From bd34c9c714ba33ea7cea72f5010687d0d3ff7350 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Sat, 22 Aug 2026 12:44:40 -0500 Subject: [PATCH] feat: add Dashboard navigation as presentation configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6D pass 1. The shell's navigation was a flat list of module ids, which is not a usable experience — and the obvious fix carries an obvious trap. Letting a module declare its folder, or modelling "Media" as something Livestream and Video belong to, turns a rendering preference into architecture: a folder becomes an owner, membership starts to look like a dependency, and the flat module graph acquires a hierarchy nobody decided on. So the rule comes first: a Dashboard group is a label on a rendering bucket. No manifest, no version, no artifact, no API, no lifecycle; nothing depends on it. Livestream depends on Video because it calls Video's functionality, and that orders startup. Livestream is shown under Media, and that orders nothing. No parent module, child module, owning module, or group-derived dependency appears anywhere in the contracts. No manifest change and no version 5. `manifest.name` has been required since version 1 and already is the module's product identity, so the shell renders "Livestream" instead of `livestream` with nothing added. `suggestedGroup` is deliberately deferred rather than shipped: it would force a hard manifest-version cutover for a hint, and its only correct semantics needs initialization logic that risks a suggestion overwriting an administrator's decision. Defaulting and ownership are different things. Two Core tables (migration 0003) hold groups and placements — server-side, because a church configures its Dashboard rather than one browser; platform-global, because no organization model exists to scope them to. `group_id` is `on delete set null`, so deleting a group ungroups its modules and a dangling reference is impossible. `module_id` carries no foreign key, because modules are packages rather than rows, so a placement for an uninstalled module is retained and restores its position on reinstall. `resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced module is ungrouped and visible with no initialization write, an absent module renders nothing, empty groups are kept, and ordering is total with the module id as final tie-break. `GET /api/v1/dashboard/navigation` returns the resolved view, layered on top of the host-neutral module UI inventory rather than merged into it — each generic descriptor is embedded untouched, so a standalone host never inherits Dashboard semantics. Reading needs the permission the inventory already needs; organizing needs the new platform permission `core.dashboard.navigation.manage` through a narrow, idempotent, closed-validation mutation contract. No editor UI. The flatness rule is executable: a test discovers real fixtures with a genuine dependency, arranges them four ways including both in one folder and the dependency inverted in presentation, and asserts discovery order, dependency declarations, and the module set stay byte-identical. The end-to-end test organizes a real installation through the real API, mounts a module from inside a group, moves it, and asserts the navigation entry is byte-identical across the move and still mounts the same artifact. The shell's client for the generic inventory became dead code and was removed rather than kept speculatively; the endpoint itself is unchanged. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 5 + README.md | 40 +- apps/api/src/app.ts | 23 + apps/api/src/dashboard-routes.ts | 310 +++++++ apps/api/src/database.ts | 6 + apps/api/src/module-ui-inventory.ts | 43 +- apps/api/src/server.ts | 3 + apps/api/tests/api-http.integration.ts | 7 +- apps/api/tests/app.test.ts | 22 + apps/api/tests/dashboard-routes.test.ts | 621 +++++++++++++ apps/api/tests/module-database.integration.ts | 5 + apps/api/tests/module-http-routes.test.ts | 2 + apps/api/tests/module-ui-host-e2e.test.ts | 224 ++++- apps/api/tests/module-ui-inventory.test.ts | 2 + apps/api/tests/module-ui-routes.test.ts | 2 + .../support/in-memory-dashboard-store.ts | 86 ++ core/drizzle.config.ts | 6 +- .../0003_dashboard_navigation_tables.sql | 22 + core/drizzle/meta/0003_snapshot.json | 870 ++++++++++++++++++ core/drizzle/meta/_ekklesia.json | 6 + core/drizzle/meta/_journal.json | 7 + core/package.json | 4 + core/src/authorization/permissions.ts | 18 + core/src/dashboard/index.ts | 3 + core/src/dashboard/navigation.ts | 156 ++++ core/src/dashboard/schema.ts | 95 ++ core/src/dashboard/store.ts | 314 +++++++ core/src/modules/ui-publication.ts | 11 + core/tests/authorization-permissions.test.ts | 19 +- core/tests/authorization-store.integration.ts | 7 +- core/tests/dashboard-flat-modules.test.ts | 247 +++++ core/tests/dashboard-navigation.test.ts | 283 ++++++ core/tests/dashboard-store.integration.ts | 415 +++++++++ core/tests/doctor-report.test.ts | 2 +- core/tests/module-permissions.test.ts | 7 +- ...7-dashboard-navigation-and-flat-modules.md | 268 ++++++ docs/architecture/decisions/README.md | 1 + docs/architecture/platform-architecture.md | 37 +- docs/architecture/repository-boundaries.md | 7 + docs/development/api.md | 90 ++ docs/development/security.md | 16 + docs/module-development/README.md | 28 +- surfaces/shell/src/browser/HostApp.tsx | 161 +++- surfaces/shell/src/host-protocol.ts | 153 ++- surfaces/shell/src/index.ts | 7 +- surfaces/shell/tests/host-app.test.tsx | 219 ++++- surfaces/shell/tests/host-protocol.test.ts | 179 ++-- surfaces/shell/tests/modularity.test.ts | 19 +- vitest.migrations.integration.config.ts | 1 + 49 files changed, 4866 insertions(+), 213 deletions(-) create mode 100644 apps/api/src/dashboard-routes.ts create mode 100644 apps/api/tests/dashboard-routes.test.ts create mode 100644 apps/api/tests/support/in-memory-dashboard-store.ts create mode 100644 core/drizzle/0003_dashboard_navigation_tables.sql create mode 100644 core/drizzle/meta/0003_snapshot.json create mode 100644 core/src/dashboard/index.ts create mode 100644 core/src/dashboard/navigation.ts create mode 100644 core/src/dashboard/schema.ts create mode 100644 core/src/dashboard/store.ts create mode 100644 core/tests/dashboard-flat-modules.test.ts create mode 100644 core/tests/dashboard-navigation.test.ts create mode 100644 core/tests/dashboard-store.integration.ts create mode 100644 docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 774b21f..21c73fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,11 @@ pnpm infrastructure:config the host application, never to a module. Ekklesia's own shell is `surfaces/shell`; it discovers installed module UIs through `GET /api/v1/module-ui` and mounts them with `@ekklesia/module-ui-host`, and it statically imports no module (ADR 0026). +- Dashboard groups are presentation configuration, never module structure. Do not add a parent + module, child module, owning module, or group-derived dependency: real module relationships are + the dependency contract's job alone, and grouping must never influence discovery, dependency + ordering, startup, or authorization (ADR 0027). A module declares its display name + (`manifest.name`); the Dashboard owns where it appears. - A surface is browser code. It may depend on browser-safe shared roles only — never `@ekklesia/contracts` or `@ekklesia/module-sdk`, whose type graphs pull Node's globals into the browser — and it reaches the server over HTTP. diff --git a/README.md b/README.md index 9d97552..2361657 100644 --- a/README.md +++ b/README.md @@ -355,10 +355,42 @@ still refused `403` when the UI calls the API anyway. The host owns the containe and every failure; a broken module costs its own panel, not the shell. See [ADR 0026](docs/architecture/decisions/0026-minimal-ekklesia-module-ui-host.md). -**This is not the Dashboard.** There is no navigation model, grouping, folders, icons, ordering, home -screen, design system, or client-side routing — the selector is a list of module ids. Sign-in UI, a -production CSP and deployment for the built shell, asset publication beyond the UI entrypoint, React -runtime sharing, the standalone host, and every real feature module also do not exist. +Phase 6D pass 1 gives that shell a real **navigation model** — and pins down the rule it could +easily have broken. A church sees `Media → Livestream, Video` and `Planning → Calendar`, while the +modules underneath stay exactly what they were: flat, independently versioned products with no +parent, no owner, and no folder-derived dependency. + +**Grouping is presentation configuration, not module structure.** A group is a label on a rendering +bucket: no manifest, no version, no artifact, no API, no lifecycle, and nothing depends on it. +Placing Livestream and Video in one folder creates no relationship between them, and moving a module +changes one row. *Livestream depends on Video because it calls Video's functionality* is a real +dependency that orders startup; *Livestream is shown under Media* orders nothing. That distinction is +executable: a test discovers real fixtures with a genuine dependency, arranges them four ways +(including both in one folder, and the dependency inverted in presentation), and asserts discovery +order, dependency declarations, and the module set come out byte-identical every time. + +**No manifest change, and no version 5.** `manifest.name` has been required since version 1 and is +already the module's product identity, so the shell simply renders "Livestream" instead of +`livestream`. `suggestedGroup` was deliberately deferred rather than shipped: it would force a hard +manifest-version cutover for a *hint*, and its only correct semantics needs initialization logic that +would risk a module's suggestion overwriting an administrator's decision. + +Two Core tables (migration `0003`) hold groups and placements — server-side, because a church +configures its Dashboard, not one browser; platform-global, because Ekklesia has no organization +model yet. `GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the +host-neutral module UI inventory rather than merged into it, embedding each generic descriptor +untouched. Reading needs the same permission the inventory does; organizing needs a new platform +permission, `core.dashboard.navigation.manage`. A newly installed module appears immediately as +ungrouped with no initialization write; an uninstalled module's placement is retained so reinstalling +restores its position; deleting a group ungroups its modules through the database rather than +application cleanup. See +[ADR 0027](docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md). + +**Still not the finished Dashboard.** There is no navigation editor or drag and drop, no nested +folders, no icons or colors, no Dashboard Home or widgets, no design system, and no client-side +routing. Sign-in UI, a production CSP and deployment for the built shell, asset publication beyond +the UI entrypoint, React runtime sharing, the standalone host, and every real feature module also do +not exist. **Security model.** Installed modules are trusted compiled backend code that runs arbitrary JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 8a51d39..e2e1baf 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -9,11 +9,13 @@ import type { PermissionRegistry, } from "@ekklesia/core/authorization"; import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity"; +import type { DashboardStore } from "@ekklesia/core/dashboard"; import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules"; import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; +import { createDashboardRoutes } from "./dashboard-routes.js"; import { createGrantsRoutes } from "./grants-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js"; @@ -47,6 +49,12 @@ export interface CreateApiAppOptions { * is: a caller that publishes no module UI must say so explicitly with an empty registry, never * fall through to an implicit one. See `./module-ui-routes.js`. */ readonly moduleUi: ModuleUiPublicationRegistry; + /** Core's persistence for how this Dashboard presents installed modules (ADR 0027). Required + * rather than defaulted, like every other module option here: a caller that exercises no + * Dashboard says so with an explicit store, never falls through to an implicit one. Grouping is + * presentation only — nothing reachable through this store influences discovery, dependency + * ordering, module startup, or any authorization decision. */ + readonly dashboard: DashboardStore; readonly runtime: EkklesiaIdentityRuntime; /** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an * exact, reflected match is ever returned in `Access-Control-Allow-Origin`. Also the trusted set @@ -193,6 +201,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono { }), ); + // Layered deliberately on top of the generic inventory above rather than merged into it: the + // inventory stays the host-neutral "what is mountable" contract a standalone host could consume, + // and this adds Ekklesia's own presentation of those same modules. See `./dashboard-routes.js`. + app.route( + "/", + createDashboardRoutes({ + evaluator: options.authorization.evaluator, + moduleUi: options.moduleUi, + registry: options.authorization.registry, + runtime, + store: options.dashboard, + trustedOrigins: allowedOrigins, + }), + ); + mountModuleHttpRoutes(app, options.moduleRoutes, { evaluator: options.authorization.evaluator, registry: options.authorization.registry, diff --git a/apps/api/src/dashboard-routes.ts b/apps/api/src/dashboard-routes.ts new file mode 100644 index 0000000..07f811c --- /dev/null +++ b/apps/api/src/dashboard-routes.ts @@ -0,0 +1,310 @@ +import { + CORE_DASHBOARD_NAVIGATION_MANAGE, + CORE_MODULES_UI_READ, +} from "@ekklesia/core/authorization"; +import { + DASHBOARD_SORT_ORDER_MAX, + DASHBOARD_SORT_ORDER_MIN, + type DashboardStore, + DashboardStoreError, + isValidDashboardIdentifier, + normalizeDashboardGroupLabel, + resolveDashboardNavigation, +} from "@ekklesia/core/dashboard"; +import type { ModuleUiPublicationRegistry } from "@ekklesia/core/modules"; +import { Hono } from "hono"; +import type { Context } from "hono"; +import { + type AuthorizationMiddlewareOptions, + getAuthorizedPrincipal, + requirePermission, + requireTrustedOrigin, +} from "./authorization-middleware.js"; +import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; +import { describeMountableModules, type HostModuleUiDescriptor } from "./module-ui-inventory.js"; + +export const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation"; +const DASHBOARD_GROUPS_PATH = `${DASHBOARD_NAVIGATION_PATH}/groups`; +const DASHBOARD_PLACEMENTS_PATH = `${DASHBOARD_NAVIGATION_PATH}/placements`; + +/** + * One module as the Dashboard presents it: the module's display name, plus the generic mount + * descriptor unchanged. + * + * The descriptor is **embedded rather than flattened**, and that is the layering made visible. + * `module` is exactly `HostModuleUiDescriptor` — the host-neutral answer to "what is mountable and + * how" (ADR 0026) — and `name` is the Dashboard's presentation concern layered on top. A standalone + * host consumes the inner object and never learns that Ekklesia has folders; Ekklesia's shell + * consumes the outer one. Neither contract is warped to serve the other, and there is one + * construction of the descriptor rather than two that could drift. + */ +export interface DashboardNavigationModuleView { + readonly name: string; + readonly module: HostModuleUiDescriptor; +} + +export interface DashboardNavigationGroupView { + /** The stable key, unchanged by a rename. Addresses the group in the mutation API; never displayed. */ + readonly id: string; + readonly label: string; + readonly order: number; + readonly modules: readonly DashboardNavigationModuleView[]; +} + +export interface DashboardNavigationResponse { + readonly groups: readonly DashboardNavigationGroupView[]; + /** Modules an administrator placed outside every group, and modules never configured at all — a + * newly installed module most importantly. Never empty merely because nothing has been arranged. */ + readonly ungrouped: readonly DashboardNavigationModuleView[]; +} + +export interface CreateDashboardRoutesOptions extends AuthorizationMiddlewareOptions { + /** The same publication registry the artifact route and the generic inventory serve from: the one + * accepted, mountable module set. Navigation never rescans, never reads a manifest, and never + * discovers anything of its own. */ + readonly moduleUi: ModuleUiPublicationRegistry; + readonly store: DashboardStore; + readonly trustedOrigins: ReadonlySet; +} + +function sanitizedFailure(c: Context, status: 400 | 404 | 503, code: string): Response { + c.header("Cache-Control", "no-store"); + return c.json({ code }, status); +} + +/** Maps the store's own sanitized codes onto HTTP without inventing a second vocabulary. A + * validation failure the route already rejects can still arrive here from a concurrent change, so + * both paths are handled rather than assumed unreachable. */ +function storeFailure(c: Context, error: unknown): Response { + if (error instanceof DashboardStoreError) { + if (error.code === "DASHBOARD_GROUP_NOT_FOUND") { + return sanitizedFailure(c, 404, error.code); + } + if (error.code === "DASHBOARD_READ_FAILED" || error.code === "DASHBOARD_WRITE_FAILED") { + return sanitizedFailure(c, 503, error.code); + } + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + return sanitizedFailure(c, 503, "DASHBOARD_WRITE_FAILED"); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parsedSortOrder(value: unknown): number | undefined { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value < DASHBOARD_SORT_ORDER_MIN || + value > DASHBOARD_SORT_ORDER_MAX + ) { + return undefined; + } + return value; +} + +const GROUP_BODY_KEYS = new Set(["label", "order"]); +const PLACEMENT_BODY_KEYS = new Set(["groupId", "order"]); + +/** + * Structured, closed validation — never a free-form configuration blob. Any key beyond the declared + * ones makes the whole body invalid rather than being silently dropped, so a caller cannot smuggle a + * field a future version might interpret, and a typo fails loudly instead of quietly doing nothing. + */ +function parseGroupBody(raw: unknown): { label: string; order: number } | undefined { + if (!isRecord(raw) || Object.keys(raw).some((key) => !GROUP_BODY_KEYS.has(key))) { + return undefined; + } + const order = parsedSortOrder(raw.order); + const label = normalizeDashboardGroupLabel(raw.label); + if (label === undefined || order === undefined) { + return undefined; + } + return { label, order }; +} + +function parsePlacementBody(raw: unknown): { groupId: string | null; order: number } | undefined { + if (!isRecord(raw) || Object.keys(raw).some((key) => !PLACEMENT_BODY_KEYS.has(key))) { + return undefined; + } + const order = parsedSortOrder(raw.order); + const groupId = raw.groupId; + if (order === undefined || (groupId !== null && !isValidDashboardIdentifier(groupId))) { + return undefined; + } + return { groupId, order }; +} + +async function readJsonBody(c: Context): Promise { + try { + return (await c.req.json()) as unknown; + } catch { + return undefined; + } +} + +/** + * The Ekklesia Dashboard's navigation: how this installation presents its mountable modules, and the + * narrow, structured API for changing that. + * + * ## Navigation is layered on the inventory, not merged into it + * + * `GET /api/v1/module-ui` (ADR 0026) answers *what may be mounted* and stays host-neutral — a + * standalone host uses it and never learns Ekklesia has folders. This route answers *how this + * Dashboard presents those modules*, and embeds each generic descriptor untouched inside a + * presentation wrapper. Keeping them separate is what stops Dashboard semantics leaking into the + * portable module-host contract. + * + * ## Grouping is presentation, and structurally cannot be anything else + * + * The resolved view is computed by `resolveDashboardNavigation`, a pure function of stored + * configuration and the current module set. It reads no manifest, no dependency, no capability, and + * no entrypoint, and it produces no relationship between two modules. Modules stay flat (ADR 0021, + * ADR 0027): two modules in one group are exactly as related as two in different groups, and moving + * one changes a single presentation row — never its artifact, its API root, its permissions, its + * dependencies, or when it starts. + * + * ## Reading versus organizing + * + * Reading requires `core.modules.ui.read`, the same permission the generic inventory requires, + * because this response *contains* that inventory and serves the same audience: any signed-in user + * needs it to see anything at all. Organizing requires `core.dashboard.navigation.manage` + * (restricted) — a platform permission, never a module's — and, like every other Core mutation, an + * exact trusted `Origin` and a bounded body before authorization work begins. + * + * Holding the manage permission grants no module authority. Every module route remains enforced + * against its own declared permission, and no arrangement of folders changes a single authorization + * decision. + */ +export function createDashboardRoutes(options: CreateDashboardRoutesOptions): Hono { + const routes = new Hono(); + const middlewareOptions: AuthorizationMiddlewareOptions = { + evaluator: options.evaluator, + registry: options.registry, + runtime: options.runtime, + }; + const requireRead = requirePermission(middlewareOptions, CORE_MODULES_UI_READ); + const requireManage = requirePermission(middlewareOptions, CORE_DASHBOARD_NAVIGATION_MANAGE); + const requireOrigin = requireTrustedOrigin(options.trustedOrigins); + const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES); + + routes.get(DASHBOARD_NAVIGATION_PATH, requireRead, async (c) => { + c.header("Cache-Control", "no-store"); + // Per-principal: the embedded descriptors carry permission hints evaluated for this caller. + c.header("Vary", "Origin, Cookie"); + const principal = getAuthorizedPrincipal(c); + try { + const [configuration, descriptors] = await Promise.all([ + options.store.readConfiguration(), + describeMountableModules(options.evaluator, principal, options.moduleUi), + ]); + const view = resolveDashboardNavigation( + configuration, + descriptors.map((entry) => ({ id: entry.descriptor.id, ...entry })), + ); + const present = (entry: { + readonly descriptor: HostModuleUiDescriptor; + readonly name: string; + }): DashboardNavigationModuleView => ({ module: entry.descriptor, name: entry.name }); + return c.json( + { + groups: view.groups.map((group) => ({ + id: group.id, + label: group.label, + modules: group.modules.map(present), + order: group.order, + })), + ungrouped: view.ungrouped.map(present), + } satisfies DashboardNavigationResponse, + 200, + ); + } catch (error) { + if (error instanceof DashboardStoreError) { + return storeFailure(c, error); + } + // An authorization-evaluation outage, exactly as the generic inventory reports it: a + // permission hint that could not be determined is never rendered as "not granted". + return sanitizedFailure(c, 503, "AUTHORIZATION_EVALUATION_FAILED"); + } + }); + + routes.put( + `${DASHBOARD_GROUPS_PATH}/:groupId`, + requireOrigin, + mutationBodyLimit, + requireManage, + async (c) => { + c.header("Cache-Control", "no-store"); + const groupId = c.req.param("groupId"); + const body = parseGroupBody(await readJsonBody(c)); + if (!body || !isValidDashboardIdentifier(groupId)) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + try { + // Idempotent by design: creating and renaming are the same operation against a + // client-chosen, stable id, so a rename can never orphan the placements pointing at it. + const group = await options.store.upsertGroup({ + id: groupId, + label: body.label, + sortOrder: body.order, + }); + return c.json({ id: group.id, label: group.label, order: group.sortOrder }, 200); + } catch (error) { + return storeFailure(c, error); + } + }, + ); + + routes.delete(`${DASHBOARD_GROUPS_PATH}/:groupId`, requireOrigin, requireManage, async (c) => { + c.header("Cache-Control", "no-store"); + const groupId = c.req.param("groupId"); + if (!isValidDashboardIdentifier(groupId)) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + try { + // Modules in the group become ungrouped through the schema's `on delete set null`, so no + // module is lost and no placement is left pointing at a group that no longer exists. + await options.store.deleteGroup(groupId); + return c.body(null, 204); + } catch (error) { + return storeFailure(c, error); + } + }); + + routes.put( + `${DASHBOARD_PLACEMENTS_PATH}/:moduleId`, + requireOrigin, + mutationBodyLimit, + requireManage, + async (c) => { + c.header("Cache-Control", "no-store"); + const moduleId = c.req.param("moduleId"); + const body = parsePlacementBody(await readJsonBody(c)); + if (!body || !isValidDashboardIdentifier(moduleId)) { + return sanitizedFailure(c, 400, "INVALID_REQUEST"); + } + try { + // A placement may be stored for a module that is not installed: that is how uninstalling + // and reinstalling preserves an arrangement. Nothing renders it until the module is back. + const placement = await options.store.setPlacement({ + groupId: body.groupId, + moduleId, + sortOrder: body.order, + }); + return c.json( + { + groupId: placement.groupId, + moduleId: placement.moduleId, + order: placement.sortOrder, + }, + 200, + ); + } catch (error) { + return storeFailure(c, error); + } + }, + ); + + return routes; +} diff --git a/apps/api/src/database.ts b/apps/api/src/database.ts index 928b531..3074a49 100644 --- a/apps/api/src/database.ts +++ b/apps/api/src/database.ts @@ -3,12 +3,17 @@ import { type AuthorizationDrizzleDatabase, createAuthorizationDrizzleView, } from "@ekklesia/core/authorization"; +import { + createDashboardDrizzleView, + type DashboardDrizzleDatabase, +} from "@ekklesia/core/dashboard"; import { createValidatedPostgresPool, type PostgresPoolOptions } from "@ekklesia/core/database"; import { createIdentityDrizzleView, type IdentityDrizzleDatabase } from "@ekklesia/core/identity"; import { createModuleDatabaseView } from "@ekklesia/core/modules"; export interface ApiDatabaseHandle { readonly authorization: AuthorizationDrizzleDatabase; + readonly dashboard: DashboardDrizzleDatabase; readonly identity: IdentityDrizzleDatabase; readonly module: ModuleDatabase; /** Closes the one shared pool exactly once, regardless of how many typed views were created @@ -35,6 +40,7 @@ export function createApiDatabase( return Object.freeze({ authorization: createAuthorizationDrizzleView(pool), close, + dashboard: createDashboardDrizzleView(pool), identity: createIdentityDrizzleView(pool), module: createModuleDatabaseView(pool), }); diff --git a/apps/api/src/module-ui-inventory.ts b/apps/api/src/module-ui-inventory.ts index 4a6575e..dbbc8fb 100644 --- a/apps/api/src/module-ui-inventory.ts +++ b/apps/api/src/module-ui-inventory.ts @@ -120,6 +120,38 @@ async function describeModule( }; } +/** One mountable module, described once: the host-neutral mount descriptor plus the module's own + * display name, kept beside it rather than inside it. */ +export interface MountableModuleDescription { + readonly descriptor: HostModuleUiDescriptor; + /** `manifest.name`. Presentation *identity* the module owns — never its placement, which the + * Dashboard owns (ADR 0027). Deliberately absent from `HostModuleUiDescriptor`, so the + * host-neutral contract a standalone host consumes stays free of presentation concerns. */ + readonly name: string; +} + +/** + * The one composition of the mountable-module set, shared by the generic inventory route and the + * Dashboard navigation route (`./dashboard-routes.js`). + * + * Both derive from the same publication registry and the same evaluator, so the two endpoints can + * never disagree about what exists, what its URLs are, or what the caller may do inside it. Sharing + * this function rather than duplicating the mapping is what makes that a structural property instead + * of a convention. + */ +export async function describeMountableModules( + evaluator: AuthorizationMiddlewareOptions["evaluator"], + principal: EkklesiaPrincipal, + registry: ModuleUiPublicationRegistry, +): Promise { + return Promise.all( + registry.list().map(async (published) => ({ + descriptor: await describeModule(evaluator, principal, published), + name: published.name, + })), + ); +} + /** Mirrors `requirePermission`'s own infrastructure-failure result exactly: a permission hint that * could not be determined is never reported as "not granted". Carries no module id, permission id, * or caught error. */ @@ -178,11 +210,14 @@ export function createModuleUiInventoryRoutes(options: CreateModuleUiInventoryRo // The `authenticated` baseline guarantees this is an authenticated principal. const principal = getAuthorizedPrincipal(c); try { - const modules = await Promise.all( - options.moduleUi - .list() - .map((published) => describeModule(options.evaluator, principal, published)), + const described = await describeMountableModules( + options.evaluator, + principal, + options.moduleUi, ); + // Only the host-neutral descriptor reaches this response: the display name stays out of the + // portable contract and is carried by the Dashboard navigation view instead. + const modules = described.map((entry) => entry.descriptor); return c.json({ modules } satisfies HostModuleUiInventory, 200); } catch { return sanitizedInventoryFailure(c); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 6ea2b3e..90b6dcf 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -6,6 +6,7 @@ import { createAuthorizationEvaluator, createAuthorizationStore, } from "@ekklesia/core/authorization"; +import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry, @@ -413,6 +414,8 @@ export async function startApi( // module, and a module that failed to load, register, or start never reaches this line at // all. Modules declaring no `entrypoints.ui` are simply absent from the registry. moduleUi: createModuleUiPublicationRegistry(inventory.modules), + // A typed view over the same single pool as identity, authorization, and the module facade. + dashboard: createDashboardStore(databaseHandle.dashboard), runtime, trustedOrigins: config.identity.trustedOrigins, }); diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 035a285..aad51e8 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -194,6 +194,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0000_identity_core_tables", "core:0001_authorization_core_tables", "core:0002_authorization_integrity", + "core:0003_dashboard_navigation_tables", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -2158,9 +2159,9 @@ describe.sequential("live API — installed module permission end-to-end", () => expect(moduleCheck).toMatchObject({ status: "pass" }); expect(moduleCheck?.details).toEqual(["calendar 0.1.0"]); expect(permissionsCheck).toMatchObject({ - // Core's own two (`core.authorization.grants.manage`, `core.modules.ui.read`) plus the - // fixture module's one. - details: ["3 permissions registered"], + // Core's own three (`core.authorization.grants.manage`, `core.modules.ui.read`, + // `core.dashboard.navigation.manage`) plus the fixture module's one. + details: ["4 permissions registered"], status: "pass", }); // Doctor and permission composition read only the manifest — the fixture's register/start diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 76a064b..a3d3e4e 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -17,6 +17,7 @@ import { } from "@ekklesia/core/modules"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js"; @@ -87,6 +88,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -105,6 +107,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -124,6 +127,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -145,6 +149,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -168,6 +173,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -191,6 +197,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -211,6 +218,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -233,6 +241,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -252,6 +261,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -273,6 +283,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -301,6 +312,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -330,6 +342,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime, @@ -364,6 +377,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -380,6 +394,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -398,6 +413,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -415,6 +431,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -430,6 +447,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -452,6 +470,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), @@ -477,6 +496,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize } }), runtime: fakeRuntime(), @@ -505,6 +525,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({ evaluator: { authorize: evaluatorAuthorize }, @@ -524,6 +545,7 @@ describe("Ekklesia API Hono application", () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), moduleUi: emptyModuleUiPublication(), + dashboard: createInMemoryDashboardStore(), moduleServices: fakeModuleServices(), authorization: fakeAuthorization({}), runtime: fakeRuntime(), diff --git a/apps/api/tests/dashboard-routes.test.ts b/apps/api/tests/dashboard-routes.test.ts new file mode 100644 index 0000000..f96d947 --- /dev/null +++ b/apps/api/tests/dashboard-routes.test.ts @@ -0,0 +1,621 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ANONYMOUS_PRINCIPAL, + createAuthenticatedPrincipal, + type EkklesiaPrincipal, + type ModuleDatabase, + type ModuleRuntimeServices, + parseEkklesiaUserId, +} from "@ekklesia/contracts"; +import { + type AuthorizationStore, + createAuthorizationEvaluator, +} from "@ekklesia/core/authorization"; +import type { DashboardConfiguration } from "@ekklesia/core/dashboard"; +import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; +import { + composePlatformPermissionRegistry, + createModuleUiPublicationRegistry, + discoverModules, +} from "@ekklesia/core/modules"; +import type { Hono } from "hono"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApiApp } from "../src/app.js"; +import { DASHBOARD_NAVIGATION_PATH } from "../src/dashboard-routes.js"; +import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; + +const trustedOrigin = "https://dashboard.example.test"; +const untrustedOrigin = "https://evil.example.test"; +const viewerId = parseEkklesiaUserId("33333333-3333-4333-8333-333333333333"); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +interface ModuleFixture { + readonly id: string; + readonly name: string; + readonly withUi?: boolean; +} + +async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { + const moduleDirectory = path.join(modulesDirectory, fixture.id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${fixture.id}`, + type: "module", + version: "1.0.0", + }), + ); + await writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { + server: "./dist/server/index.js", + ...(fixture.withUi === false ? {} : { ui: "./dist/ui/index.js" }), + }, + id: fixture.id, + manifestVersion: 4, + name: fixture.name, + version: "1.0.0", + }), + ); + await writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`, + ); + if (fixture.withUi !== false) { + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + await writeFile( + path.join(moduleDirectory, "dist/ui/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`, + ); + } +} + +interface InstallOptions { + readonly fixtures?: readonly ModuleFixture[]; + readonly principal?: EkklesiaPrincipal; + readonly platformOwner?: boolean; + readonly configuration?: Partial; +} + +const defaultFixtures: readonly ModuleFixture[] = [ + { id: "video", name: "Video" }, + { id: "livestream", name: "Livestream" }, + { id: "calendar", name: "Calendar" }, +]; + +async function install(options: InstallOptions = {}): Promise<{ app: Hono }> { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dashboard-routes-")); + temporaryDirectories.push(modulesDirectory); + for (const fixture of options.fixtures ?? defaultFixtures) { + await createModule(modulesDirectory, fixture); + } + const modules = await discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); + + const evaluator = createAuthorizationEvaluator({ + grants: { hasActiveGrant: async () => false }, + platformOwners: { isPlatformOwner: async () => options.platformOwner === true }, + }); + const runtime: EkklesiaIdentityRuntime = { + handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), + resolvePrincipal: vi.fn().mockResolvedValue(options.principal ?? ANONYMOUS_PRINCIPAL), + }; + const store = { + createGrant: vi.fn(), + grantPlatformOwner: vi.fn(), + hasActiveGrant: vi.fn().mockResolvedValue(false), + isPlatformOwner: vi.fn().mockResolvedValue(false), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), + revokeGrant: vi.fn(), + revokePlatformOwner: vi.fn(), + } as unknown as AuthorizationStore; + + const app = createApiApp({ + authorization: { evaluator, registry: composePlatformPermissionRegistry(modules), store }, + dashboard: createInMemoryDashboardStore(options.configuration ?? {}), + moduleRoutes: Object.freeze([]), + moduleServices: Object.freeze({ + database: Object.freeze({}) as unknown as ModuleDatabase, + }) as ModuleRuntimeServices, + moduleUi: createModuleUiPublicationRegistry(modules), + runtime, + trustedOrigins: [trustedOrigin], + }); + + return { app }; +} + +function signedIn(): EkklesiaPrincipal { + return createAuthenticatedPrincipal(viewerId); +} + +interface NavigationEntry { + readonly name: string; + readonly module: { + readonly id: string; + readonly version: string; + readonly ui: { readonly url: string }; + readonly api: { readonly baseUrl: string }; + readonly permissions: readonly string[]; + }; +} + +interface NavigationBody { + readonly groups: ReadonlyArray<{ + readonly id: string; + readonly label: string; + readonly order: number; + readonly modules: readonly NavigationEntry[]; + }>; + readonly ungrouped: readonly NavigationEntry[]; +} + +async function navigationOf(app: Hono): Promise { + const response = await app.request(DASHBOARD_NAVIGATION_PATH); + expect(response.status).toBe(200); + return (await response.json()) as NavigationBody; +} + +/** An administrator: a platform owner, who passes the evaluator's owner bypass for the restricted + * manage permission exactly as in production. */ +async function administrator(configuration: Partial = {}) { + return install({ configuration, platformOwner: true, principal: signedIn() }); +} + +function mutate(app: Hono, method: "PUT" | "DELETE", url: string, body?: unknown) { + return app.request(url, { + ...(body === undefined + ? {} + : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }), + headers: { + origin: trustedOrigin, + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + method, + }); +} + +describe("Dashboard navigation — reading", () => { + it("shows every mountable module as ungrouped when nothing is configured", async () => { + const { app } = await install({ principal: signedIn() }); + + const navigation = await navigationOf(app); + + expect(navigation.groups).toEqual([]); + // A fresh installation is immediately usable: no initialization write, no manual database edit. + expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([ + "Calendar", + "Livestream", + "Video", + ]); + }); + + it("renders the module's own manifest name, never its id", async () => { + const { app } = await install({ principal: signedIn() }); + + const navigation = await navigationOf(app); + + expect(navigation.ungrouped.map((entry) => entry.name)).not.toContain("livestream"); + expect(navigation.ungrouped.find((entry) => entry.module.id === "livestream")?.name).toBe( + "Livestream", + ); + }); + + it("groups and orders exactly as the stored configuration says", async () => { + const { app } = await install({ + configuration: { + groups: [ + { id: "planning", label: "Planning", sortOrder: 20 }, + { id: "media", label: "Media", sortOrder: 10 }, + ], + placements: [ + { groupId: "media", moduleId: "livestream", sortOrder: 20 }, + { groupId: "media", moduleId: "video", sortOrder: 10 }, + { groupId: "planning", moduleId: "calendar", sortOrder: 10 }, + ], + }, + principal: signedIn(), + }); + + const navigation = await navigationOf(app); + + expect( + navigation.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]), + ).toEqual([ + ["Media", ["Video", "Livestream"]], + ["Planning", ["Calendar"]], + ]); + expect(navigation.ungrouped).toEqual([]); + }); + + it("embeds the generic mount descriptor unchanged inside each entry", async () => { + const { app } = await install({ principal: signedIn() }); + + const navigation = await navigationOf(app); + const livestream = navigation.ungrouped.find((entry) => entry.module.id === "livestream"); + + // Presentation wraps the host-neutral descriptor; it never rewrites it. + expect(livestream?.module).toEqual({ + api: { baseUrl: "/api/v1/modules/livestream" }, + id: "livestream", + permissions: [], + ui: { url: "/api/v1/module-ui/livestream/1.0.0" }, + version: "1.0.0", + }); + expect(Object.keys(livestream ?? {}).sort()).toEqual(["module", "name"]); + }); + + it("agrees exactly with the generic inventory about what is mountable", async () => { + const { app } = await install({ + configuration: { + groups: [{ id: "media", label: "Media", sortOrder: 10 }], + placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], + }, + principal: signedIn(), + }); + + const navigation = await navigationOf(app); + const inventory = (await (await app.request(MODULE_UI_INVENTORY_PATH)).json()) as { + modules: ReadonlyArray<{ id: string }>; + }; + + const navigationModules = [ + ...navigation.groups.flatMap((group) => group.modules), + ...navigation.ungrouped, + ].map((entry) => entry.module); + // Both derive from one publication registry through one shared description, so they cannot + // disagree about what exists or about any URL. + expect(navigationModules.map((module) => module.id).sort()).toEqual( + inventory.modules.map((module) => module.id).sort(), + ); + }); + + it("keeps the generic inventory free of presentation metadata", async () => { + const { app } = await install({ principal: signedIn() }); + + const body = await (await app.request(MODULE_UI_INVENTORY_PATH)).text(); + + // The host-neutral contract a standalone host consumes must not learn Ekklesia has folders or + // display names. That layering is the whole reason navigation is a separate route. + for (const field of ["name", "label", "group", "order", "ungrouped"]) { + expect(body, field).not.toContain(`"${field}"`); + } + }); + + it("omits a module that is no longer installed, without breaking navigation", async () => { + const { app } = await install({ + configuration: { + groups: [{ id: "media", label: "Media", sortOrder: 10 }], + placements: [ + { groupId: "media", moduleId: "video", sortOrder: 10 }, + { groupId: "media", moduleId: "removed-module", sortOrder: 20 }, + ], + }, + fixtures: [{ id: "video", name: "Video" }], + principal: signedIn(), + }); + + const navigation = await navigationOf(app); + + expect(navigation.groups[0]?.modules.map((entry) => entry.module.id)).toEqual(["video"]); + expect(JSON.stringify(navigation)).not.toContain("removed-module"); + }); + + it("keeps a newly installed module visible alongside an existing arrangement", async () => { + const { app } = await install({ + configuration: { + groups: [{ id: "media", label: "Media", sortOrder: 10 }], + placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], + }, + principal: signedIn(), + }); + + const navigation = await navigationOf(app); + + expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]); + // Calendar and Livestream were never configured and are still reachable. + expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Livestream"]); + }); + + it("excludes a module that ships no UI, exactly like the inventory does", async () => { + const { app } = await install({ + fixtures: [ + { id: "video", name: "Video" }, + { id: "roster", name: "Roster", withUi: false }, + ], + principal: signedIn(), + }); + + const navigation = await navigationOf(app); + + expect(JSON.stringify(navigation)).not.toContain("Roster"); + }); + + it("leaks no filesystem path, package root, or manifest internal", async () => { + const { app } = await install({ principal: signedIn() }); + + const body = await (await app.request(DASHBOARD_NAVIGATION_PATH)).text(); + + expect(body).not.toContain(tmpdir()); + expect(body).not.toContain("dist"); + expect(body).not.toContain("@test/module-"); + expect(body).not.toContain("ekklesia.module.json"); + expect(body).not.toContain("entrypoints"); + expect(body).not.toContain("manifestVersion"); + }); + + it("is per-principal, uncached, and refused to anonymous callers", async () => { + const { app: anonymous } = await install(); + const { app } = await install({ principal: signedIn() }); + + const denied = await anonymous.request(DASHBOARD_NAVIGATION_PATH); + const allowed = await app.request(DASHBOARD_NAVIGATION_PATH, { + headers: { origin: trustedOrigin }, + }); + + expect(denied.status).toBe(403); + expect(await denied.text()).not.toContain("Livestream"); + expect(allowed.headers.get("cache-control")).toBe("no-store"); + const vary = allowed.headers.get("vary")?.toLowerCase() ?? ""; + expect(vary).toContain("origin"); + expect(vary).toContain("cookie"); + }); +}); + +describe("Dashboard navigation — organizing", () => { + it("creates a group and places a module, and both persist into the resolved view", async () => { + const { app } = await administrator(); + + expect( + ( + await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { + label: "Media", + order: 10, + }) + ).status, + ).toBe(200); + expect( + ( + await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, { + groupId: "media", + order: 10, + }) + ).status, + ).toBe(200); + + const navigation = await navigationOf(app); + + expect(navigation.groups.map((group) => group.label)).toEqual(["Media"]); + expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]); + expect(navigation.ungrouped.map((entry) => entry.name)).toEqual(["Calendar", "Video"]); + }); + + it("renames a group without moving anything", async () => { + const { app } = await administrator({ + groups: [{ id: "media", label: "Media", sortOrder: 10 }], + placements: [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }], + }); + + await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { + label: "Sunday Production", + order: 10, + }); + const navigation = await navigationOf(app); + + expect(navigation.groups[0]).toMatchObject({ id: "media", label: "Sunday Production" }); + expect(navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Livestream"]); + }); + + it("moves a module between groups and changes only its placement", async () => { + const { app } = await administrator({ + groups: [ + { id: "media", label: "Media", sortOrder: 10 }, + { id: "sunday-production", label: "Sunday Production", sortOrder: 5 }, + ], + placements: [ + { groupId: "media", moduleId: "livestream", sortOrder: 10 }, + { groupId: "media", moduleId: "video", sortOrder: 20 }, + ], + }); + const before = await navigationOf(app); + const livestreamBefore = before.groups + .flatMap((group) => group.modules) + .find((entry) => entry.module.id === "livestream"); + + await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, { + groupId: "sunday-production", + order: 10, + }); + const after = await navigationOf(app); + const livestreamAfter = after.groups + .flatMap((group) => group.modules) + .find((entry) => entry.module.id === "livestream"); + + // The one property this whole pass exists to guarantee: the module — its artifact URL, its API + // root, its permissions, its version, its name — is byte-identical across the move. + expect(livestreamAfter).toEqual(livestreamBefore); + expect( + after.groups.find((group) => group.id === "sunday-production")?.modules.map((e) => e.name), + ).toEqual(["Livestream"]); + expect(after.groups.find((group) => group.id === "media")?.modules.map((e) => e.name)).toEqual([ + "Video", + ]); + }); + + it("ungroups a module deliberately", async () => { + const { app } = await administrator({ + groups: [{ id: "media", label: "Media", sortOrder: 10 }], + placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], + }); + + await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { + groupId: null, + order: 5, + }); + const navigation = await navigationOf(app); + + expect(navigation.groups[0]?.modules).toEqual([]); + expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([ + "Video", + "Calendar", + "Livestream", + ]); + }); + + it("ungroups a deleted group's modules rather than losing them", async () => { + const { app } = await administrator({ + groups: [{ id: "media", label: "Media", sortOrder: 10 }], + placements: [{ groupId: "media", moduleId: "video", sortOrder: 10 }], + }); + + const deleted = await mutate(app, "DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`); + const navigation = await navigationOf(app); + + expect(deleted.status).toBe(204); + expect(navigation.groups).toEqual([]); + expect(navigation.ungrouped.map((entry) => entry.name)).toEqual([ + "Video", + "Calendar", + "Livestream", + ]); + }); + + it("refuses a placement naming a group that does not exist", async () => { + const { app } = await administrator(); + + const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { + groupId: "not-a-group", + order: 10, + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "DASHBOARD_GROUP_NOT_FOUND" }); + }); + + it("refuses a body carrying anything beyond the declared fields", async () => { + const { app } = await administrator(); + + const bodies: readonly unknown[] = [ + { label: "Media", order: 10, parentGroupId: "root" }, + { label: "Media", order: 10, ownerModuleId: "video" }, + { label: "Media" }, + { label: "", order: 10 }, + { label: "Media", order: -1 }, + { label: "Media", order: 1.5 }, + { label: 42, order: 10 }, + [], + null, + ]; + for (const body of bodies) { + const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, body); + expect(response.status, JSON.stringify(body)).toBe(400); + expect(await response.json(), JSON.stringify(body)).toEqual({ code: "INVALID_REQUEST" }); + } + }); + + it("refuses a malformed group identifier in the path", async () => { + const { app } = await administrator(); + + const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/Not%20Valid`, { + label: "Media", + order: 10, + }); + + expect(response.status).toBe(400); + }); +}); + +describe("Dashboard navigation — authority", () => { + it("refuses organizing to a signed-in user without the manage permission", async () => { + // Reading works for any signed-in user; organizing does not. + const { app } = await install({ principal: signedIn() }); + + expect((await app.request(DASHBOARD_NAVIGATION_PATH)).status).toBe(200); + const response = await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { + label: "Media", + order: 10, + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_DENIED" }); + }); + + it("refuses organizing from an untrusted origin, before authorization even runs", async () => { + const { app } = await administrator(); + + const response = await app.request(`${DASHBOARD_NAVIGATION_PATH}/groups/media`, { + body: JSON.stringify({ label: "Media", order: 10 }), + headers: { "content-type": "application/json", origin: untrustedOrigin }, + method: "PUT", + }); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" }); + }); + + it("refuses organizing to an anonymous caller", async () => { + const { app } = await install(); + + for (const [method, url, body] of [ + ["PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { label: "Media", order: 10 }], + ["DELETE", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, undefined], + ["PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/video`, { groupId: null, order: 0 }], + ] as const) { + const response = await mutate(app, method, url, body); + expect(response.status, `${method} ${url}`).toBe(403); + } + }); + + it("grants no module authority to an administrator who reorganizes the Dashboard", async () => { + // The platform owner holds `core.dashboard.navigation.manage` through the owner bypass. That + // says nothing about any module: the hints in the navigation view are still evaluated per + // module permission, and every module route stays enforced on its own. + const { app } = await administrator(); + + await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/groups/media`, { + label: "Media", + order: 10, + }); + await mutate(app, "PUT", `${DASHBOARD_NAVIGATION_PATH}/placements/livestream`, { + groupId: "media", + order: 10, + }); + const navigation = await navigationOf(app); + + const livestream = navigation.groups[0]?.modules[0]; + // The fixture modules declare no permissions at all, so an owner still holds none for them: + // moving a module into a folder cannot manufacture a permission hint, let alone a grant. + expect(livestream?.module.permissions).toEqual([]); + }); + + it("routes no method the navigation contract does not define", async () => { + const { app } = await administrator(); + + for (const method of ["POST", "PATCH"] as const) { + const response = await app.request(DASHBOARD_NAVIGATION_PATH, { + headers: { origin: trustedOrigin }, + method, + }); + expect(response.status, method).toBe(404); + } + }); +}); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 6956817..093cce0 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -220,6 +220,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0000_identity_core_tables", "core:0001_authorization_core_tables", "core:0002_authorization_integrity", + "core:0003_dashboard_navigation_tables", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -245,6 +246,7 @@ async function applyMigrationsWithModule(url: string): Promise { "core:0000_identity_core_tables", "core:0001_authorization_core_tables", "core:0002_authorization_integrity", + "core:0003_dashboard_navigation_tables", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( @@ -439,6 +441,9 @@ describe.sequential("live API — module database explicit migration ownership", expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe( 1, ); + expect( + await migrationHistoryCount(database.url, "core:0003_dashboard_navigation_tables"), + ).toBe(1); } finally { await dropDatabase(database.name); } diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index e158178..9b0a927 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -19,6 +19,7 @@ import { import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { createModuleUiPublicationRegistry } from "@ekklesia/core/modules"; import { describe, expect, it, vi } from "vitest"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; import { canonicalizeVaryHeader, @@ -108,6 +109,7 @@ function buildModuleApp(options: { moduleRoutes: options.routes, moduleServices: services, moduleUi: createModuleUiPublicationRegistry([]), + dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: options.trustedOrigins ?? [trustedOrigin], }); diff --git a/apps/api/tests/module-ui-host-e2e.test.ts b/apps/api/tests/module-ui-host-e2e.test.ts index 2a2e124..90f0731 100644 --- a/apps/api/tests/module-ui-host-e2e.test.ts +++ b/apps/api/tests/module-ui-host-e2e.test.ts @@ -28,6 +28,7 @@ import { moduleUiSourceUrl, } from "@ekklesia/shell"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; /** @@ -73,8 +74,12 @@ afterEach(async () => { * already proven end to end by `templates/module`'s own tests, and what this test needs is a byte * sequence whose journey through publication, inventory, and the loader can be asserted exactly. */ -async function createFixtureModule(modulesDirectory: string): Promise { - const moduleDirectory = path.join(modulesDirectory, "example"); +async function createFixtureModule( + modulesDirectory: string, + id = "example", + name = "Example module", +): Promise { + const moduleDirectory = path.join(modulesDirectory, id); await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); @@ -82,7 +87,7 @@ async function createFixtureModule(modulesDirectory: string): Promise { path.join(moduleDirectory, "package.json"), JSON.stringify({ ekklesia: { layer: "module" }, - name: "@test/module-example", + name: `@test/module-${id}`, type: "module", version: "0.1.0", }), @@ -92,14 +97,14 @@ async function createFixtureModule(modulesDirectory: string): Promise { JSON.stringify({ compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, - id: "example", + id, manifestVersion: 4, - name: "Example module", + name, permissions: [ { baseline: "restricted", description: "Read example records.", - id: "example.records.read", + id: `${id}.records.read`, }, ], version: "0.1.0", @@ -108,13 +113,13 @@ async function createFixtureModule(modulesDirectory: string): Promise { await writeFile( path.join(moduleDirectory, "dist/server/index.js"), `export default { - id: "example", + id: ${JSON.stringify(id)}, version: "0.1.0", register({ http }) { http.route({ method: "GET", path: "/records", - permission: "example.records.read", + permission: ${JSON.stringify(`${id}.records.read`)}, maxBodyBytes: 0, async handler() { return Response.json([{ id: "a" }, { id: "b" }]); @@ -128,10 +133,10 @@ async function createFixtureModule(modulesDirectory: string): Promise { await writeFile( path.join(moduleDirectory, "dist/ui/index.js"), `export default { - id: "example", + id: ${JSON.stringify(id)}, version: "0.1.0", async mount(container, context) { - if (!context.permissions.has("example.records.read")) { + if (!context.permissions.has(${JSON.stringify(`${id}.records.read`)})) { container.textContent = context.moduleId + ":denied"; return () => { container.textContent = ""; }; } @@ -148,6 +153,10 @@ async function createFixtureModule(modulesDirectory: string): Promise { interface HarnessOptions { readonly principal?: EkklesiaPrincipal; readonly grants?: readonly string[]; + /** Extra fixture modules, so grouping can be exercised with more than one — a folder containing a + * single module proves very little. */ + readonly extraModules?: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly platformOwner?: boolean; } interface Harness { @@ -164,6 +173,9 @@ async function startHarness(options: HarnessOptions = {}): Promise { const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-host-e2e-")); temporaryDirectories.push(modulesDirectory); await createFixtureModule(modulesDirectory); + for (const extra of options.extraModules ?? []) { + await createFixtureModule(modulesDirectory, extra.id, extra.name); + } const modules = await discoverModules({ moduleSdkVersion: "0.1.0", @@ -180,7 +192,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { const granted = new Set(options.grants ?? []); const evaluator = createAuthorizationEvaluator({ grants: { hasActiveGrant: async (query) => granted.has(query.permission) }, - platformOwners: { isPlatformOwner: async () => false }, + platformOwners: { isPlatformOwner: async () => options.platformOwner === true }, }); const runtime: EkklesiaIdentityRuntime = { handleAuthRequest: vi.fn().mockResolvedValue(new Response(null, { status: 200 })), @@ -201,6 +213,7 @@ async function startHarness(options: HarnessOptions = {}): Promise { moduleRoutes: moduleRuntime.routes, moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), + dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: ["https://dashboard.example.test"], }); @@ -250,9 +263,12 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () // 1. The shell's own bootstrap: principal, then inventory. const bootstrap = await bootstrapHost(harness.config); expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: viewerId }); - expect(bootstrap.modules).toHaveLength(1); + // A module nobody has arranged yet is ungrouped and visible — no initialization write. + expect(bootstrap.navigation.groups).toEqual([]); + expect(bootstrap.navigation.ungrouped).toHaveLength(1); + expect(bootstrap.navigation.ungrouped[0]?.name).toBe("Example module"); - const descriptor = bootstrap.modules[0]; + const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor; if (!descriptor) { throw new Error("the inventory returned no module"); } @@ -304,7 +320,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () }); try { const bootstrap = await bootstrapHost(harness.config); - const descriptor = bootstrap.modules[0]; + const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor; if (!descriptor) { throw new Error("the inventory returned no module"); } @@ -336,7 +352,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () }); try { const bootstrap = await bootstrapHost(harness.config); - const descriptor = bootstrap.modules[0]; + const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor; if (!descriptor) { throw new Error("the inventory returned no module"); } @@ -364,13 +380,187 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () } }); + it("renders a configured arrangement, and a move changes presentation only", async () => { + const harness = await startHarness({ + extraModules: [ + { id: "video", name: "Video" }, + { id: "calendar", name: "Calendar" }, + ], + grants: ["example.records.read"], + platformOwner: true, + principal: createAuthenticatedPrincipal(viewerId), + }); + const organize = (url: string, body: unknown) => + (harness.config.fetch as typeof fetch)(`https://ekklesia.example.test${url}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin: "https://dashboard.example.test" }, + method: "PUT", + }); + + try { + // An administrator organizes the Dashboard, through the real mutation API. + await organize("/api/v1/dashboard/navigation/groups/media", { label: "Media", order: 10 }); + await organize("/api/v1/dashboard/navigation/groups/planning", { + label: "Planning", + order: 20, + }); + await organize("/api/v1/dashboard/navigation/placements/video", { + groupId: "media", + order: 10, + }); + await organize("/api/v1/dashboard/navigation/placements/example", { + groupId: "media", + order: 20, + }); + await organize("/api/v1/dashboard/navigation/placements/calendar", { + groupId: "planning", + order: 10, + }); + + // The shell's own bootstrap sees exactly that arrangement. + const before = await bootstrapHost(harness.config); + expect( + before.navigation.groups.map((group) => [ + group.label, + group.modules.map((entry) => entry.name), + ]), + ).toEqual([ + ["Media", ["Video", "Example module"]], + ["Planning", ["Calendar"]], + ]); + expect(before.navigation.ungrouped).toEqual([]); + + // Mounting from inside a group uses the descriptor the group carried, untouched. + const groupedEntry = before.navigation.groups[0]?.modules[1]; + if (!groupedEntry) { + throw new Error("the arrangement did not contain the expected module"); + } + const container = containerSink(); + const mounted = await loadModuleUi({ + container: container as unknown as LoaderContainer, + context: createModuleUiContext({ + config: harness.config, + descriptor: groupedEntry.descriptor, + principal: before.principal, + }), + source: { + moduleId: groupedEntry.descriptor.id, + url: await publishedArtifactUrl( + harness, + moduleUiSourceUrl(harness.config, groupedEntry.descriptor), + ), + version: groupedEntry.descriptor.version, + }, + }); + expect(container.textContent).toBe("example:200:2"); + await mounted.unmount(); + + // Now move it somewhere else entirely. + await organize("/api/v1/dashboard/navigation/groups/sunday-production", { + label: "Sunday Production", + order: 5, + }); + await organize("/api/v1/dashboard/navigation/placements/example", { + groupId: "sunday-production", + order: 10, + }); + + const after = await bootstrapHost(harness.config); + expect( + after.navigation.groups.map((group) => [ + group.label, + group.modules.map((entry) => entry.name), + ]), + ).toEqual([ + ["Sunday Production", ["Example module"]], + ["Media", ["Video"]], + ["Planning", ["Calendar"]], + ]); + + const movedEntry = after.navigation.groups[0]?.modules[0]; + if (!movedEntry) { + throw new Error("the moved module was not found in its new group"); + } + // The whole point of the pass: the module is byte-identical across the move — same artifact + // URL, same API root, same permissions, same version, same name. + expect(movedEntry).toEqual(groupedEntry); + + // And it still mounts the same artifact and reaches the same API. + const callsBeforeRemount = harness.moduleApiCalls.length; + const movedContainer = containerSink(); + const remounted = await loadModuleUi({ + container: movedContainer as unknown as LoaderContainer, + context: createModuleUiContext({ + config: harness.config, + descriptor: movedEntry.descriptor, + principal: after.principal, + }), + source: { + moduleId: movedEntry.descriptor.id, + url: await publishedArtifactUrl( + harness, + moduleUiSourceUrl(harness.config, movedEntry.descriptor), + ), + version: movedEntry.descriptor.version, + }, + }); + expect(movedContainer.textContent).toBe("example:200:2"); + expect(harness.moduleApiCalls.slice(callsBeforeRemount)).toEqual([ + "GET /api/v1/modules/example/records", + ]); + await remounted.unmount(); + } finally { + await harness.stop(); + } + }); + + it("keeps a newly installed module visible beside an existing arrangement", async () => { + const harness = await startHarness({ + extraModules: [{ id: "video", name: "Video" }], + platformOwner: true, + principal: createAuthenticatedPrincipal(viewerId), + }); + try { + await (harness.config.fetch as typeof fetch)( + "https://ekklesia.example.test/api/v1/dashboard/navigation/groups/media", + { + body: JSON.stringify({ label: "Media", order: 10 }), + headers: { + "content-type": "application/json", + origin: "https://dashboard.example.test", + }, + method: "PUT", + }, + ); + await (harness.config.fetch as typeof fetch)( + "https://ekklesia.example.test/api/v1/dashboard/navigation/placements/video", + { + body: JSON.stringify({ groupId: "media", order: 10 }), + headers: { + "content-type": "application/json", + origin: "https://dashboard.example.test", + }, + method: "PUT", + }, + ); + + const bootstrap = await bootstrapHost(harness.config); + + expect(bootstrap.navigation.groups[0]?.modules.map((entry) => entry.name)).toEqual(["Video"]); + // `example` was never placed, and is still reachable rather than invisible. + expect(bootstrap.navigation.ungrouped.map((entry) => entry.name)).toEqual(["Example module"]); + } finally { + await harness.stop(); + } + }); + it("gives an anonymous visitor no inventory and therefore nothing to mount", async () => { const harness = await startHarness(); try { const bootstrap = await bootstrapHost(harness.config); expect(bootstrap.principal).toEqual({ kind: "anonymous" }); - expect(bootstrap.modules).toEqual([]); + expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] }); } finally { await harness.stop(); } @@ -383,7 +573,7 @@ describe("end to end: a discovered module's UI mounts in the Ekklesia host", () }); try { const bootstrap = await bootstrapHost(harness.config); - const descriptor = bootstrap.modules[0]; + const descriptor = bootstrap.navigation.ungrouped[0]?.descriptor; if (!descriptor) { throw new Error("the inventory returned no module"); } diff --git a/apps/api/tests/module-ui-inventory.test.ts b/apps/api/tests/module-ui-inventory.test.ts index 8a26714..ce3f98c 100644 --- a/apps/api/tests/module-ui-inventory.test.ts +++ b/apps/api/tests/module-ui-inventory.test.ts @@ -23,6 +23,7 @@ import { } from "@ekklesia/core/modules"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; import { moduleApiBaseUrl } from "../src/module-http-routes.js"; import { MODULE_UI_INVENTORY_PATH } from "../src/module-ui-inventory.js"; @@ -168,6 +169,7 @@ async function install(options: InstallOptions): Promise { moduleRoutes: Object.freeze([]), moduleServices: services, moduleUi: createModuleUiPublicationRegistry(modules), + dashboard: createInMemoryDashboardStore(), runtime, trustedOrigins: [trustedOrigin], }); diff --git a/apps/api/tests/module-ui-routes.test.ts b/apps/api/tests/module-ui-routes.test.ts index c8ad2ae..cabc44f 100644 --- a/apps/api/tests/module-ui-routes.test.ts +++ b/apps/api/tests/module-ui-routes.test.ts @@ -20,6 +20,7 @@ import { import { loadModuleUi } from "@ekklesia/module-ui-host"; import type { Hono } from "hono"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { createInMemoryDashboardStore } from "./support/in-memory-dashboard-store.js"; import { createApiApp } from "../src/app.js"; import { MODULE_UI_ASSET_PREFIX, @@ -168,6 +169,7 @@ async function install(fixtures: readonly ModuleFixture[]): Promise = {}, +): DashboardStore { + const groups = new Map( + (seed.groups ?? []).map((group) => [group.id, { ...group }] as const), + ); + const placements = new Map( + (seed.placements ?? []).map((placement) => [placement.moduleId, { ...placement }] as const), + ); + + const sorted = ( + records: readonly Record[], + key: (record: Record) => string, + ): readonly Record[] => + Object.freeze( + [...records].sort( + (left, right) => + left.sortOrder - right.sortOrder || + (key(left) < key(right) ? -1 : key(left) > key(right) ? 1 : 0), + ), + ); + + return Object.freeze({ + async deleteGroup(groupId: string): Promise { + if (!groups.delete(groupId)) { + throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND"); + } + for (const [moduleId, placement] of placements) { + if (placement.groupId === groupId) { + placements.set(moduleId, { ...placement, groupId: null }); + } + } + }, + + async readConfiguration(): Promise { + return Object.freeze({ + groups: sorted([...groups.values()], (group) => group.id), + placements: sorted([...placements.values()], (placement) => placement.moduleId), + }); + }, + + async setPlacement(input: SetDashboardPlacementInput): Promise { + if (input.groupId !== null && !groups.has(input.groupId)) { + throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND"); + } + const record: DashboardPlacementRecord = Object.freeze({ + groupId: input.groupId, + moduleId: input.moduleId, + sortOrder: input.sortOrder, + }); + placements.set(input.moduleId, record); + return record; + }, + + async upsertGroup(input: UpsertDashboardGroupInput): Promise { + const record: DashboardGroupRecord = Object.freeze({ + id: input.id, + label: input.label.trim(), + sortOrder: input.sortOrder, + }); + groups.set(input.id, record); + return record; + }, + }); +} diff --git a/core/drizzle.config.ts b/core/drizzle.config.ts index 77a35de..07c262f 100644 --- a/core/drizzle.config.ts +++ b/core/drizzle.config.ts @@ -2,7 +2,11 @@ import { defineConfig } from "drizzle-kit"; export default defineConfig({ dialect: "postgresql", - schema: ["./src/identity/schema.ts", "./src/authorization/schema.ts"], + schema: [ + "./src/identity/schema.ts", + "./src/authorization/schema.ts", + "./src/dashboard/schema.ts", + ], out: "./drizzle", migrations: { prefix: "index", diff --git a/core/drizzle/0003_dashboard_navigation_tables.sql b/core/drizzle/0003_dashboard_navigation_tables.sql new file mode 100644 index 0000000..5ae9e8d --- /dev/null +++ b/core/drizzle/0003_dashboard_navigation_tables.sql @@ -0,0 +1,22 @@ +CREATE TABLE "ekklesia_core_dashboard_group" ( + "id" text PRIMARY KEY NOT NULL, + "label" text NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ekklesia_core_dashboard_group_id_check" CHECK (length("ekklesia_core_dashboard_group"."id") > 0), + CONSTRAINT "ekklesia_core_dashboard_group_label_check" CHECK (length(btrim("ekklesia_core_dashboard_group"."label")) > 0) +); +--> statement-breakpoint +CREATE TABLE "ekklesia_core_dashboard_placement" ( + "module_id" text PRIMARY KEY NOT NULL, + "group_id" text, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ekklesia_core_dashboard_placement_module_check" CHECK (length("ekklesia_core_dashboard_placement"."module_id") > 0) +); +--> statement-breakpoint +ALTER TABLE "ekklesia_core_dashboard_placement" ADD CONSTRAINT "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."ekklesia_core_dashboard_group"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "ekklesia_core_dashboard_group_order_idx" ON "ekklesia_core_dashboard_group" USING btree ("sort_order","id");--> statement-breakpoint +CREATE INDEX "ekklesia_core_dashboard_placement_group_idx" ON "ekklesia_core_dashboard_placement" USING btree ("group_id","sort_order"); \ No newline at end of file diff --git a/core/drizzle/meta/0003_snapshot.json b/core/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..ff2df7b --- /dev/null +++ b/core/drizzle/meta/0003_snapshot.json @@ -0,0 +1,870 @@ +{ + "id": "fd5d1b32-781c-43ed-9d53-98bd5d395e6a", + "prevId": "fac36f3d-7551-42df-a967-aad65c2fe7f9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ekklesia_core_account": { + "name": "ekklesia_core_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_account_user_id_idx": { + "name": "ekklesia_core_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_account_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_account", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_session": { + "name": "ekklesia_core_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_session_user_id_idx": { + "name": "ekklesia_core_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_session_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_session", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_session_token_unique": { + "name": "ekklesia_core_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_user": { + "name": "ekklesia_core_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_user_email_unique": { + "name": "ekklesia_core_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_verification": { + "name": "ekklesia_core_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_verification_identifier_idx": { + "name": "ekklesia_core_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_authorization_audit": { + "name": "ekklesia_core_authorization_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_authorization_audit_subject_idx": { + "name": "ekklesia_core_authorization_audit_subject_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_authorization_audit_action_check": { + "name": "ekklesia_core_authorization_audit_action_check", + "value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')" + }, + "ekklesia_core_authorization_audit_actor_type_check": { + "name": "ekklesia_core_authorization_audit_actor_type_check", + "value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')" + }, + "ekklesia_core_authorization_audit_scope_check": { + "name": "ekklesia_core_authorization_audit_scope_check", + "value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_grant": { + "name": "ekklesia_core_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_grant_user_permission_idx": { + "name": "ekklesia_core_grant_user_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_grant", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_grant_source_kind_check": { + "name": "ekklesia_core_grant_source_kind_check", + "value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')" + }, + "ekklesia_core_grant_created_actor_type_check": { + "name": "ekklesia_core_grant_created_actor_type_check", + "value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_grant_revoked_actor_type_check": { + "name": "ekklesia_core_grant_revoked_actor_type_check", + "value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner": { + "name": "ekklesia_core_platform_owner", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_actor_type": { + "name": "granted_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_actor_id": { + "name": "granted_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_platform_owner", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_granted_actor_type_check": { + "name": "ekklesia_core_platform_owner_granted_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_platform_owner_revoked_actor_type_check": { + "name": "ekklesia_core_platform_owner_revoked_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner_bootstrap": { + "name": "ekklesia_core_platform_owner_bootstrap", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_for_user_id": { + "name": "consumed_for_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_bootstrap_singleton_check": { + "name": "ekklesia_core_platform_owner_bootstrap_singleton_check", + "value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_group": { + "name": "ekklesia_core_dashboard_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_group_order_idx": { + "name": "ekklesia_core_dashboard_group_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_group_id_check": { + "name": "ekklesia_core_dashboard_group_id_check", + "value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0" + }, + "ekklesia_core_dashboard_group_label_check": { + "name": "ekklesia_core_dashboard_group_label_check", + "value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_placement": { + "name": "ekklesia_core_dashboard_placement", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_placement_group_idx": { + "name": "ekklesia_core_dashboard_placement_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": { + "name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk", + "tableFrom": "ekklesia_core_dashboard_placement", + "tableTo": "ekklesia_core_dashboard_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_placement_module_check": { + "name": "ekklesia_core_dashboard_placement_module_check", + "value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index 4663ed3..d098641 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -19,6 +19,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0003_dashboard_navigation_tables", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index c5f1080..8124569 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785104670124, "tag": "0002_authorization_integrity", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1787408382271, + "tag": "0003_dashboard_navigation_tables", + "breakpoints": true } ] } diff --git a/core/package.json b/core/package.json index 1f46033..0b16a98 100644 --- a/core/package.json +++ b/core/package.json @@ -15,6 +15,10 @@ "types": "./dist/modules/index.d.ts", "import": "./dist/modules/index.js" }, + "./dashboard": { + "types": "./dist/dashboard/index.d.ts", + "import": "./dist/dashboard/index.js" + }, "./migrations": { "types": "./dist/migrations/index.d.ts", "import": "./dist/migrations/index.js" diff --git a/core/src/authorization/permissions.ts b/core/src/authorization/permissions.ts index bf08da1..d637e6e 100644 --- a/core/src/authorization/permissions.ts +++ b/core/src/authorization/permissions.ts @@ -98,6 +98,19 @@ export const CORE_AUTHORIZATION_GRANTS_MANAGE: PermissionId = parsePermissionId( */ export const CORE_MODULES_UI_READ: PermissionId = parsePermissionId("core.modules.ui.read"); +/** + * Restricted: reorganizing the Dashboard is an administrative action, not something every signed-in + * user may do. It is deliberately a **platform** permission and never a module's own — grouping is + * Ekklesia's presentation configuration, so no module may gate, grant, or influence it (ADR 0027). + * + * Holding it confers no module authority whatsoever. Moving a module into a folder changes a + * presentation row; it grants nobody access to that module's data, and every module route stays + * enforced against its own declared permission exactly as before. + */ +export const CORE_DASHBOARD_NAVIGATION_MANAGE: PermissionId = parsePermissionId( + "core.dashboard.navigation.manage", +); + /** * Core's own declarations, exported in deeply immutable form so a trusted composer (the module * integration layer, `core/src/modules/permissions.ts`) can build one combined registry from @@ -116,6 +129,11 @@ export const CORE_PERMISSION_DECLARATIONS: readonly PermissionDeclaration[] = Ob description: "List the installed module UIs a browser host may mount.", id: CORE_MODULES_UI_READ, }), + Object.freeze({ + baseline: "restricted" as const, + description: "Organize the Dashboard: create, rename, order, and delete navigation groups.", + id: CORE_DASHBOARD_NAVIGATION_MANAGE, + }), ]); /** diff --git a/core/src/dashboard/index.ts b/core/src/dashboard/index.ts new file mode 100644 index 0000000..acbdfa1 --- /dev/null +++ b/core/src/dashboard/index.ts @@ -0,0 +1,3 @@ +export * from "./navigation.js"; +export * from "./schema.js"; +export * from "./store.js"; diff --git a/core/src/dashboard/navigation.ts b/core/src/dashboard/navigation.ts new file mode 100644 index 0000000..85fe03c --- /dev/null +++ b/core/src/dashboard/navigation.ts @@ -0,0 +1,156 @@ +import type { DashboardConfiguration } from "./store.js"; + +/** + * One module as the Dashboard needs to present it: its identity, its administrator-visible name, + * and nothing about where it lives on disk. + * + * `name` is the module's own `manifest.name` — its product identity, which every manifest has + * already required since version 1. The Dashboard renders it rather than the module id, and a module + * declaring it is *not* the module declaring its navigation: a name answers "what is this product + * called", while a group answers "where does this church want it", and only the second is + * configuration an administrator owns. + */ +export interface DashboardNavigationModule { + readonly id: string; + readonly name: string; +} + +export interface DashboardNavigationGroupView { + /** The stable key, unchanged by a rename. Callers use it to address the group, never to display. */ + readonly id: string; + readonly label: string; + readonly order: number; + readonly modules: readonly Module[]; +} + +export interface DashboardNavigationView { + readonly groups: readonly DashboardNavigationGroupView[]; + /** + * Modules that belong to no group: those an administrator deliberately placed outside every group, + * and those never configured at all — a newly installed module, most importantly. Both render + * here, which is what guarantees a new module is visible the moment it is discovered, with no + * initialization write, no manual database edit, and no migration. + */ + readonly ungrouped: readonly Module[]; +} + +/** Placed modules first, in the administrator's order; never-configured modules after them, by id. + * A newcomer therefore appears without interrupting an ordering someone chose, and the module id is + * always the final tie-break, so two modules sharing a `sortOrder` still order identically on every + * request and in every process. */ +interface SortKey { + readonly configured: 0 | 1; + readonly order: number; + readonly id: string; +} + +function compareSortKeys(left: SortKey, right: SortKey): number { + if (left.configured !== right.configured) { + return left.configured - right.configured; + } + if (left.order !== right.order) { + return left.order - right.order; + } + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; +} + +/** + * Resolves stored Dashboard configuration against the modules that are actually mountable right now. + * + * Pure and synchronous: no database, no filesystem, no clock, no module code. It is given the stored + * arrangement and the current module set and returns what to render, so every rule below is + * directly testable without PostgreSQL. + * + * ## The rules, exactly + * + * - **A module with no placement is ungrouped and visible.** Nothing is written to make that true. + * Installing a module is enough to see it; an administrator moves it afterwards if they want to. + * - **A stored placement for a module that is not installed renders nothing, and is retained.** The + * store keeps the row, so uninstalling and reinstalling a module restores its position rather than + * silently resetting it. A dead navigation entry never appears. + * - **A placement naming a group that no longer exists cannot occur** — the schema's + * `on delete set null` makes the module ungrouped when its group is deleted — but if one somehow + * did, it is treated as ungrouped rather than dropped, so no module can be made invisible by a + * configuration inconsistency. + * - **Empty groups are kept.** A group an administrator created and has not filled yet, or whose + * only module is temporarily uninstalled, still renders; hiding it would look like the group had + * been deleted. + * - **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)`. Colliding + * order values never produce a nondeterministic result. + * + * ## What this function structurally cannot do + * + * It never reads a manifest, a dependency, a capability, an entrypoint, or a permission, and it + * returns no edge between two modules. Two modules sharing a group are exactly as related as two + * modules that do not: grouping is a rendering decision computed here, downstream of everything that + * decides what a module *is* (ADR 0027). Nothing upstream — discovery, dependency ordering, runtime + * startup, authorization — ever calls this. + */ +export function resolveDashboardNavigation( + configuration: DashboardConfiguration, + mountableModules: readonly Module[], +): DashboardNavigationView { + const placementsByModuleId = new Map( + configuration.placements.map((placement) => [placement.moduleId, placement] as const), + ); + const groupIds = new Set(configuration.groups.map((group) => group.id)); + + const modulesByGroupId = new Map>(); + const ungrouped: Array<{ key: SortKey; module: Module }> = []; + + for (const module of mountableModules) { + const placement = placementsByModuleId.get(module.id); + const key: SortKey = { + configured: placement ? 0 : 1, + id: module.id, + order: placement?.sortOrder ?? 0, + }; + const groupId = + placement?.groupId !== undefined && + placement.groupId !== null && + groupIds.has(placement.groupId) + ? placement.groupId + : undefined; + if (groupId === undefined) { + ungrouped.push({ key, module }); + continue; + } + const bucket = modulesByGroupId.get(groupId); + if (bucket) { + bucket.push({ key, module }); + } else { + modulesByGroupId.set(groupId, [{ key, module }]); + } + } + + const sortModules = (entries: Array<{ key: SortKey; module: Module }>): readonly Module[] => + Object.freeze( + [...entries] + .sort((left, right) => compareSortKeys(left.key, right.key)) + .map((entry) => entry.module), + ); + + const groups = [...configuration.groups] + .sort((left, right) => + left.sortOrder !== right.sortOrder + ? left.sortOrder - right.sortOrder + : left.id < right.id + ? -1 + : left.id > right.id + ? 1 + : 0, + ) + .map((group) => + Object.freeze({ + id: group.id, + label: group.label, + modules: sortModules(modulesByGroupId.get(group.id) ?? []), + order: group.sortOrder, + }), + ); + + return Object.freeze({ + groups: Object.freeze(groups), + ungrouped: sortModules(ungrouped), + }); +} diff --git a/core/src/dashboard/schema.ts b/core/src/dashboard/schema.ts new file mode 100644 index 0000000..252aaf8 --- /dev/null +++ b/core/src/dashboard/schema.ts @@ -0,0 +1,95 @@ +import { sql } from "drizzle-orm"; +import { check, index, integer, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +/** + * Core-owned storage for how the Ekklesia Dashboard *presents* installed modules. + * + * This is presentation configuration, and it is deliberately the only thing here. A group owns no + * tables, no migrations, no routes, and no lifecycle; it never participates in dependency + * resolution, discovery order, or module startup. **Modules stay flat** (ADR 0021): there is no + * parent module, no child module, no owning module, and nothing in this schema is ever read by + * anything that decides what a module may do or when it starts. Moving a module between groups + * changes a row here and nothing else. + * + * Core stores this; it renders nothing. `surfaces/shell` decides what the arrangement looks like. + * + * Platform-global, because Ekklesia has no organization or tenant model to scope it to. When one + * exists, these two tables gain a scope column; inventing a second notion of organization here to + * anticipate it would be worse than adding a column later. + * + * Table and index names are explicit and namespaced exactly like `core/src/authorization/schema.ts`, + * so ownership is unambiguous inside the shared public PostgreSQL schema. + */ + +/** + * A Dashboard navigation group — "Media", "Planning", "Sunday Production". + * + * `id` is an **opaque, stable key**, never displayed and never re-derived from `label`. Renaming a + * group changes only `label`, so every placement pointing at it survives untouched; that invariant + * is what keeps a rename from silently re-organizing the Dashboard. A slug is used rather than a + * generated UUID purely so the configuration API and its tests stay legible — it carries no meaning + * the platform reads. + * + * There is no parent group column. Nesting is deliberately out of scope, and leaving the shape flat + * is what keeps it out. + */ +export const ekklesiaCoreDashboardGroup = pgTable( + "ekklesia_core_dashboard_group", + { + id: text("id").primaryKey(), + label: text("label").notNull(), + sortOrder: integer("sort_order").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("ekklesia_core_dashboard_group_order_idx").on(table.sortOrder, table.id), + // Defense in depth beneath the application-layer identifier and label contracts, which never + // produce either of these: an empty id would be an unaddressable group, and an empty or + // whitespace-only label would render as a blank heading no administrator could find again. + check("ekklesia_core_dashboard_group_id_check", sql`length(${table.id}) > 0`), + check("ekklesia_core_dashboard_group_label_check", sql`length(btrim(${table.label})) > 0`), + ], +); + +/** + * Where one module sits in the Dashboard. One row per module at most — the primary key enforces it + * — so a module can never be placed in two groups, which is the database-level expression of "one + * level of grouping, and no module ownership graph." + * + * `group_id` is nullable and that is a real state, not a missing value: **ungrouped is a placement**, + * chosen by an administrator who ordered a module outside every group. A module with no row at all + * is a different state — never configured — and both render in the same ungrouped area (see + * `../dashboard/navigation.js` for the exact ordering rule). + * + * `on delete set null` is what makes group deletion safe: deleting a group cannot leave a dangling + * reference, and its modules become ungrouped rather than disappearing. That is enforced by the + * database, not by application cleanup that could be skipped. + * + * `module_id` deliberately carries **no foreign key to any module**, because modules are not rows — + * they are packages discovered on disk at startup. A placement for a module that is not installed + * is therefore normal and is retained: uninstalling and reinstalling a module keeps its position + * rather than silently resetting it. Nothing renders such a row (see `resolveDashboardNavigation`). + */ +export const ekklesiaCoreDashboardPlacement = pgTable( + "ekklesia_core_dashboard_placement", + { + moduleId: text("module_id").primaryKey(), + groupId: text("group_id").references(() => ekklesiaCoreDashboardGroup.id, { + onDelete: "set null", + }), + sortOrder: integer("sort_order").notNull().default(0), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + index("ekklesia_core_dashboard_placement_group_idx").on(table.groupId, table.sortOrder), + check("ekklesia_core_dashboard_placement_module_check", sql`length(${table.moduleId}) > 0`), + ], +); + +/** Explicit map passed to Drizzle, mirroring `authorizationSchema`'s explicit-mapping style. */ +export const dashboardSchema = { + dashboardGroup: ekklesiaCoreDashboardGroup, + dashboardPlacement: ekklesiaCoreDashboardPlacement, +} as const; diff --git a/core/src/dashboard/store.ts b/core/src/dashboard/store.ts new file mode 100644 index 0000000..69c3662 --- /dev/null +++ b/core/src/dashboard/store.ts @@ -0,0 +1,314 @@ +import { asc, eq } from "drizzle-orm"; +import type { Pool } from "pg"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { + dashboardSchema, + ekklesiaCoreDashboardGroup, + ekklesiaCoreDashboardPlacement, +} from "./schema.js"; + +export type DashboardDrizzleDatabase = NodePgDatabase; + +/** + * A typed Drizzle view over an externally owned pool, exactly like `createAuthorizationDrizzleView`: + * it never constructs, registers an error listener on, or closes a pool. The caller owns the pool + * once, however many typed views it creates over it. + */ +export function createDashboardDrizzleView(pool: Pool): DashboardDrizzleDatabase { + return drizzle(pool, { schema: dashboardSchema }); +} + +/** Bounds every stored ordering value. Wide enough that an administrator never runs out of room + * between two entries, small enough that a client cannot store a value that surprises a later + * integer column. Enforced identically by the store and by the HTTP surface, so there is one bound + * rather than two that could drift. */ +export const DASHBOARD_SORT_ORDER_MIN = 0; +export const DASHBOARD_SORT_ORDER_MAX = 1_000_000; + +/** The label an administrator sees. Bounded like every other operator-facing string in the + * platform; `moduleManifestSchema.name` uses the same ceiling. */ +export const DASHBOARD_GROUP_LABEL_MAX_LENGTH = 120; + +/** Lowercase kebab-case, matching `moduleIdSchema`'s grammar so the platform has one identifier + * shape rather than two. Opaque: never displayed, never parsed for meaning, never re-derived from a + * label. */ +const GROUP_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const GROUP_ID_MAX_LENGTH = 64; + +/** The one identifier grammar for both a group id and a placement's module id, exported so the HTTP + * boundary rejects malformed input with a request error *before* reaching the store rather than + * relying on mapping a store failure back to a status code — and so there is one grammar rather + * than two that could drift. */ +export function isValidDashboardIdentifier(value: unknown): value is string { + return ( + typeof value === "string" && value.length <= GROUP_ID_MAX_LENGTH && GROUP_ID_PATTERN.test(value) + ); +} + +/** The one label rule, exported for the same reason. Returns the trimmed value the store would + * persist, or `undefined` when the label is empty, whitespace-only, or too long. */ +export function normalizeDashboardGroupLabel(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length === 0 || trimmed.length > DASHBOARD_GROUP_LABEL_MAX_LENGTH + ? undefined + : trimmed; +} + +export type DashboardStoreErrorCode = + | "DASHBOARD_GROUP_ID_INVALID" + | "DASHBOARD_GROUP_LABEL_INVALID" + | "DASHBOARD_GROUP_NOT_FOUND" + | "DASHBOARD_MODULE_ID_INVALID" + | "DASHBOARD_SORT_ORDER_INVALID" + | "DASHBOARD_READ_FAILED" + | "DASHBOARD_WRITE_FAILED"; + +const storeErrorMessages: Readonly> = Object.freeze({ + DASHBOARD_GROUP_ID_INVALID: "A Dashboard group identifier must be lowercase kebab-case.", + DASHBOARD_GROUP_LABEL_INVALID: "A Dashboard group label must be non-empty and bounded.", + DASHBOARD_GROUP_NOT_FOUND: "The Dashboard group was not found.", + DASHBOARD_MODULE_ID_INVALID: "A Dashboard placement module identifier is invalid.", + DASHBOARD_READ_FAILED: "The Dashboard navigation configuration could not be read.", + DASHBOARD_SORT_ORDER_INVALID: "A Dashboard ordering value is out of range.", + DASHBOARD_WRITE_FAILED: "The Dashboard navigation configuration could not be saved.", +}); + +/** Never carries the underlying database error, SQL text, host, or stack — matching + * `AuthorizationStoreError` exactly. */ +export class DashboardStoreError extends Error { + readonly code: DashboardStoreErrorCode; + + constructor(code: DashboardStoreErrorCode) { + super(storeErrorMessages[code]); + this.name = "DashboardStoreError"; + this.code = code; + this.stack = `${this.name}: ${this.message}`; + Object.freeze(this); + } +} + +/** A stored group. `label` is what an administrator sees; `id` is the stable key placements point + * at, and a rename never touches it. */ +export interface DashboardGroupRecord { + readonly id: string; + readonly label: string; + readonly sortOrder: number; +} + +/** Where one module sits. `groupId: null` is a real, chosen state — deliberately ungrouped — and is + * distinct from having no record at all, which means never configured. */ +export interface DashboardPlacementRecord { + readonly moduleId: string; + readonly groupId: string | null; + readonly sortOrder: number; +} + +/** The complete stored arrangement. Contains no module facts whatsoever: resolving it against the + * modules that actually exist is `resolveDashboardNavigation`'s job, not the store's. */ +export interface DashboardConfiguration { + readonly groups: readonly DashboardGroupRecord[]; + readonly placements: readonly DashboardPlacementRecord[]; +} + +export interface UpsertDashboardGroupInput { + readonly id: string; + readonly label: string; + readonly sortOrder: number; +} + +export interface SetDashboardPlacementInput { + readonly moduleId: string; + /** `null` places the module in the ungrouped area deliberately. A group id that does not exist is + * rejected rather than stored, so a placement can never point at a group that was never created. */ + readonly groupId: string | null; + readonly sortOrder: number; +} + +export interface DashboardStore { + /** The whole stored arrangement, in deterministic order. Small by nature — one row per group and + * at most one per module — so it is read whole rather than paginated. */ + readConfiguration(): Promise; + /** Creates the group or replaces its label and order. Idempotent: the same input twice leaves the + * same single row, and placements pointing at it are untouched either way. */ + upsertGroup(input: UpsertDashboardGroupInput): Promise; + /** Deletes a group. Its placements become ungrouped through the schema's `on delete set null`, so + * no dangling reference is possible and no module is lost. */ + deleteGroup(groupId: string): Promise; + /** Creates or replaces one module's placement. */ + setPlacement(input: SetDashboardPlacementInput): Promise; +} + +function assertValidGroupId(value: string): void { + if (!isValidDashboardIdentifier(value)) { + throw new DashboardStoreError("DASHBOARD_GROUP_ID_INVALID"); + } +} + +function assertValidModuleId(value: string): void { + if (!isValidDashboardIdentifier(value)) { + throw new DashboardStoreError("DASHBOARD_MODULE_ID_INVALID"); + } +} + +function normalizedLabel(value: string): string { + const trimmed = normalizeDashboardGroupLabel(value); + if (trimmed === undefined) { + throw new DashboardStoreError("DASHBOARD_GROUP_LABEL_INVALID"); + } + return trimmed; +} + +function assertValidSortOrder(value: number): void { + if ( + !Number.isSafeInteger(value) || + value < DASHBOARD_SORT_ORDER_MIN || + value > DASHBOARD_SORT_ORDER_MAX + ) { + throw new DashboardStoreError("DASHBOARD_SORT_ORDER_INVALID"); + } +} + +function rethrowAsStoreError(error: unknown, code: DashboardStoreErrorCode): never { + if (error instanceof DashboardStoreError) { + throw error; + } + throw new DashboardStoreError(code); +} + +/** + * Core's persistence for Dashboard navigation configuration. + * + * Validation lives here rather than only at the HTTP boundary, so the invariants hold for any + * caller: identifiers are checked against one grammar, labels are trimmed and bounded, ordering is + * a bounded safe integer, and a placement may only name a group that exists. Nothing here reads, + * imports, or reasons about a module — a placement is a `module_id` string and no more — which is + * what keeps grouping structurally incapable of influencing discovery, dependency resolution, or + * startup order. + */ +export function createDashboardStore(db: DashboardDrizzleDatabase): DashboardStore { + return Object.freeze({ + async readConfiguration(): Promise { + try { + const [groups, placements] = await Promise.all([ + db + .select({ + id: ekklesiaCoreDashboardGroup.id, + label: ekklesiaCoreDashboardGroup.label, + sortOrder: ekklesiaCoreDashboardGroup.sortOrder, + }) + .from(ekklesiaCoreDashboardGroup) + .orderBy(asc(ekklesiaCoreDashboardGroup.sortOrder), asc(ekklesiaCoreDashboardGroup.id)), + db + .select({ + groupId: ekklesiaCoreDashboardPlacement.groupId, + moduleId: ekklesiaCoreDashboardPlacement.moduleId, + sortOrder: ekklesiaCoreDashboardPlacement.sortOrder, + }) + .from(ekklesiaCoreDashboardPlacement) + .orderBy( + asc(ekklesiaCoreDashboardPlacement.sortOrder), + asc(ekklesiaCoreDashboardPlacement.moduleId), + ), + ]); + return Object.freeze({ + groups: Object.freeze(groups.map((group) => Object.freeze({ ...group }))), + placements: Object.freeze(placements.map((placement) => Object.freeze({ ...placement }))), + }); + } catch (error) { + rethrowAsStoreError(error, "DASHBOARD_READ_FAILED"); + } + }, + + async upsertGroup(input: UpsertDashboardGroupInput): Promise { + assertValidGroupId(input.id); + const label = normalizedLabel(input.label); + assertValidSortOrder(input.sortOrder); + try { + // A rename updates `label` and `sort_order` only. `id` is the conflict target, so it is + // never rewritten and every placement pointing at this group keeps pointing at it. + const [row] = await db + .insert(ekklesiaCoreDashboardGroup) + .values({ id: input.id, label, sortOrder: input.sortOrder }) + .onConflictDoUpdate({ + set: { label, sortOrder: input.sortOrder, updatedAt: new Date() }, + target: ekklesiaCoreDashboardGroup.id, + }) + .returning({ + id: ekklesiaCoreDashboardGroup.id, + label: ekklesiaCoreDashboardGroup.label, + sortOrder: ekklesiaCoreDashboardGroup.sortOrder, + }); + if (!row) { + throw new DashboardStoreError("DASHBOARD_WRITE_FAILED"); + } + return Object.freeze(row); + } catch (error) { + rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED"); + } + }, + + async deleteGroup(groupId: string): Promise { + assertValidGroupId(groupId); + try { + const deleted = await db + .delete(ekklesiaCoreDashboardGroup) + .where(eq(ekklesiaCoreDashboardGroup.id, groupId)) + .returning({ id: ekklesiaCoreDashboardGroup.id }); + if (deleted.length === 0) { + throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND"); + } + } catch (error) { + rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED"); + } + }, + + async setPlacement(input: SetDashboardPlacementInput): Promise { + assertValidModuleId(input.moduleId); + assertValidSortOrder(input.sortOrder); + if (input.groupId !== null) { + assertValidGroupId(input.groupId); + } + try { + // Checked in the application rather than left to the foreign key so the caller gets + // `DASHBOARD_GROUP_NOT_FOUND` instead of a driver error. The foreign key remains the + // authority: a group deleted concurrently still fails the insert rather than storing a + // dangling reference. + if (input.groupId !== null) { + const existing = await db + .select({ id: ekklesiaCoreDashboardGroup.id }) + .from(ekklesiaCoreDashboardGroup) + .where(eq(ekklesiaCoreDashboardGroup.id, input.groupId)) + .limit(1); + if (existing.length === 0) { + throw new DashboardStoreError("DASHBOARD_GROUP_NOT_FOUND"); + } + } + const [row] = await db + .insert(ekklesiaCoreDashboardPlacement) + .values({ + groupId: input.groupId, + moduleId: input.moduleId, + sortOrder: input.sortOrder, + }) + .onConflictDoUpdate({ + set: { groupId: input.groupId, sortOrder: input.sortOrder, updatedAt: new Date() }, + target: ekklesiaCoreDashboardPlacement.moduleId, + }) + .returning({ + groupId: ekklesiaCoreDashboardPlacement.groupId, + moduleId: ekklesiaCoreDashboardPlacement.moduleId, + sortOrder: ekklesiaCoreDashboardPlacement.sortOrder, + }); + if (!row) { + throw new DashboardStoreError("DASHBOARD_WRITE_FAILED"); + } + return Object.freeze(row); + } catch (error) { + rethrowAsStoreError(error, "DASHBOARD_WRITE_FAILED"); + } + }, + }); +} diff --git a/core/src/modules/ui-publication.ts b/core/src/modules/ui-publication.ts index eb34e20..6137965 100644 --- a/core/src/modules/ui-publication.ts +++ b/core/src/modules/ui-publication.ts @@ -14,6 +14,16 @@ import type { DiscoveredModule } from "./discovery.js"; export interface PublishableModuleUi { readonly moduleId: string; readonly version: string; + /** + * The module's own `manifest.name` — its product identity, required by every manifest version and + * therefore available without any manifest change (ADR 0027). + * + * Carried here so a host can render something a person recognizes instead of a module id. It is + * presentation *identity*, not presentation *placement*: a module says what it is called, while + * where it appears in a Dashboard is configuration an administrator owns. Nothing about grouping, + * ordering, or navigation belongs on this record. + */ + readonly name: string; readonly artifactPath: string; /** * The module's own manifest-declared permissions, carried here so a publisher can compute the @@ -92,6 +102,7 @@ export function createModuleUiPublicationRegistry( Object.freeze({ artifactPath, moduleId: module.manifest.id, + name: module.manifest.name, // Already deeply frozen by discovery; copied into a fresh frozen array so a caller holding // this record can never reach the manifest object graph itself. permissions: Object.freeze([...module.manifest.permissions]), diff --git a/core/tests/authorization-permissions.test.ts b/core/tests/authorization-permissions.test.ts index a0ee44e..6074a75 100644 --- a/core/tests/authorization-permissions.test.ts +++ b/core/tests/authorization-permissions.test.ts @@ -2,6 +2,7 @@ import { parsePermissionId } from "@ekklesia/contracts"; import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, CORE_PERMISSION_REGISTRY, @@ -100,10 +101,20 @@ describe("CORE_PERMISSION_REGISTRY", () => { expect(declaration?.id).toBe("core.modules.ui.read"); }); - it("contains exactly these two permissions and no others", () => { + it("registers core.dashboard.navigation.manage as restricted", () => { + const declaration = CORE_PERMISSION_REGISTRY.get(CORE_DASHBOARD_NAVIGATION_MANAGE); + expect(declaration).toBeDefined(); + // Reorganizing the Dashboard is administrative, not something every signed-in user may do — + // unlike merely reading which module UIs exist (ADR 0027). + expect(declaration?.baseline).toBe("restricted"); + expect(declaration?.id).toBe("core.dashboard.navigation.manage"); + }); + + it("contains exactly these three permissions and no others", () => { expect(CORE_PERMISSION_REGISTRY.declarations.map((declaration) => declaration.id)).toEqual([ CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_MODULES_UI_READ, + CORE_DASHBOARD_NAVIGATION_MANAGE, ]); }); }); @@ -121,10 +132,16 @@ describe("CORE_PERMISSION_DECLARATIONS", () => { description: "List the installed module UIs a browser host may mount.", id: CORE_MODULES_UI_READ, }, + { + baseline: "restricted", + description: "Organize the Dashboard: create, rename, order, and delete navigation groups.", + id: CORE_DASHBOARD_NAVIGATION_MANAGE, + }, ]); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS)).toBe(true); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[0])).toBe(true); expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[1])).toBe(true); + expect(Object.isFrozen(CORE_PERMISSION_DECLARATIONS[2])).toBe(true); }); it("matches exactly what CORE_PERMISSION_REGISTRY exposes as its own declarations, so a composer building on top of it never diverges from what Core itself registers", () => { diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index ce28307..b57f7a5 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -173,6 +173,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0000_identity_core_tables", "core:0001_authorization_core_tables", "core:0002_authorization_integrity", + "core:0003_dashboard_navigation_tables", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -207,7 +208,7 @@ describe.sequential("live Core authorization store", () => { const scope: AuthorizationScope = parseAuthorizationScope("calendar-event:123"); const actor = createUserActor("authz_user_two"); // an operator/admin user distinct from the subject - it("applies a fresh migration and creates exactly the three authorization tables with their constraints", async () => { + it("applies a fresh migration and creates exactly the expected Core tables with their constraints", async () => { database = await createDisposableDatabase("fresh"); await applyCoreMigrations(database.url); @@ -218,9 +219,13 @@ describe.sequential("live Core authorization store", () => { where table_schema = 'public' and table_name like 'ekklesia_core_%' order by table_name`, ); + // The complete Core schema after every migration, asserted exactly so a new table is a + // deliberate change here rather than an unnoticed one. expect(tables.map((row) => row.table_name)).toEqual([ "ekklesia_core_account", "ekklesia_core_authorization_audit", + "ekklesia_core_dashboard_group", + "ekklesia_core_dashboard_placement", "ekklesia_core_grant", "ekklesia_core_migrations", "ekklesia_core_platform_owner", diff --git a/core/tests/dashboard-flat-modules.test.ts b/core/tests/dashboard-flat-modules.test.ts new file mode 100644 index 0000000..7b52bd9 --- /dev/null +++ b/core/tests/dashboard-flat-modules.test.ts @@ -0,0 +1,247 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { type DashboardConfiguration, resolveDashboardNavigation } from "../src/dashboard/index.js"; +import { + createModuleUiPublicationRegistry, + type DiscoveredModule, + discoverModules, +} from "../src/modules/index.js"; + +/** + * The executable form of the architecture's central rule: **Dashboard grouping is presentation + * configuration and can never become module structure** (ADR 0021, ADR 0027). + * + * These run real discovery over real fixtures, including a genuine module dependency, and then + * arrange those modules into folders in several different ways. Everything discovery decides — + * which modules exist, what depends on what, and the order they must start in — has to come out + * byte-identical every time. A future change that let a group influence any of that fails here. + */ +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +interface ModuleFixture { + readonly id: string; + readonly name: string; + /** A *real* technical dependency — one module calling another's functionality — expressed through + * the existing module dependency contract, which is the only kind of module relationship that + * exists. */ + readonly dependsOn?: string; +} + +async function createModule(modulesDirectory: string, fixture: ModuleFixture): Promise { + const moduleDirectory = path.join(modulesDirectory, fixture.id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await mkdir(path.join(moduleDirectory, "dist/ui"), { recursive: true }); + await writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${fixture.id}`, + type: "module", + version: "1.0.0", + }), + ); + await writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + ...(fixture.dependsOn + ? { dependencies: { modules: { required: { [fixture.dependsOn]: "^1.0.0" } } } } + : {}), + entrypoints: { server: "./dist/server/index.js", ui: "./dist/ui/index.js" }, + id: fixture.id, + manifestVersion: 4, + name: fixture.name, + version: "1.0.0", + }), + ); + await writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0" };`, + ); + await writeFile( + path.join(moduleDirectory, "dist/ui/index.js"), + `export default { id: ${JSON.stringify(fixture.id)}, version: "1.0.0", mount() {} };\n`, + ); +} + +/** Livestream genuinely depends on Video — it calls Video's functionality — so discovery must order + * Video first. Calendar depends on nothing. This is the *only* kind of relationship modules have. */ +async function discoverFixtures(): Promise { + const modulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-flat-modules-")); + temporaryDirectories.push(modulesDirectory); + await createModule(modulesDirectory, { id: "video", name: "Video" }); + await createModule(modulesDirectory, { + dependsOn: "video", + id: "livestream", + name: "Livestream", + }); + await createModule(modulesDirectory, { id: "calendar", name: "Calendar" }); + return discoverModules({ + moduleSdkVersion: "0.1.0", + modulesDirectory, + platformVersion: "0.1.0", + }); +} + +/** + * Four arrangements of the same three modules, including the one the architecture warns about: + * Livestream and Video together under "Media", which must **not** create a Media module or a + * dependency between them. + */ +const arrangements: ReadonlyArray = [ + ["nothing configured", { groups: [], placements: [] }], + [ + "Media over Planning", + { + groups: [ + { id: "media", label: "Media", sortOrder: 10 }, + { id: "planning", label: "Planning", sortOrder: 20 }, + ], + placements: [ + { groupId: "media", moduleId: "livestream", sortOrder: 10 }, + { groupId: "media", moduleId: "video", sortOrder: 20 }, + { groupId: "planning", moduleId: "calendar", sortOrder: 10 }, + ], + }, + ], + [ + "Livestream moved to Sunday Production, away from Video", + { + groups: [ + { id: "media", label: "Media", sortOrder: 10 }, + { id: "sunday-production", label: "Sunday Production", sortOrder: 5 }, + ], + placements: [ + { groupId: "sunday-production", moduleId: "livestream", sortOrder: 10 }, + { groupId: "media", moduleId: "video", sortOrder: 10 }, + ], + }, + ], + [ + "the dependency inverted in presentation: Video grouped under Livestream's folder alone", + { + groups: [{ id: "streaming", label: "Streaming", sortOrder: 1 }], + placements: [{ groupId: "streaming", moduleId: "video", sortOrder: 10 }], + }, + ], +]; + +describe("Dashboard grouping never becomes module structure", () => { + it("orders discovery by real dependencies, which is the only module relationship", async () => { + const discovered = await discoverFixtures(); + + // Video before Livestream because Livestream *depends* on it — not because of any folder. + expect(discovered.map((module) => module.manifest.id)).toEqual([ + "calendar", + "video", + "livestream", + ]); + expect( + discovered.find((module) => module.manifest.id === "livestream")?.manifest.dependencies, + ).toMatchObject({ modules: { required: { video: "^1.0.0" } } }); + }); + + it("leaves discovery, dependencies, and start order identical under every arrangement", async () => { + const discovered = await discoverFixtures(); + const registry = createModuleUiPublicationRegistry(discovered); + // The exact facts the runtime consumes, captured before any Dashboard configuration exists. + const baseline = JSON.stringify( + discovered.map((module) => ({ + capabilities: module.manifest.capabilities, + dependencies: module.manifest.dependencies, + id: module.manifest.id, + permissions: module.manifest.permissions, + version: module.manifest.version, + })), + ); + + for (const [label, configuration] of arrangements) { + const view = resolveDashboardNavigation( + configuration, + registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })), + ); + + // Discovery order — which is module *startup* order — is untouched by presentation. + expect( + discovered.map((module) => module.manifest.id), + label, + ).toEqual(["calendar", "video", "livestream"]); + // So is every fact the runtime reads. + expect( + JSON.stringify( + discovered.map((module) => ({ + capabilities: module.manifest.capabilities, + dependencies: module.manifest.dependencies, + id: module.manifest.id, + permissions: module.manifest.permissions, + version: module.manifest.version, + })), + ), + label, + ).toBe(baseline); + // And every module is still rendered exactly once, whatever the folders say. + expect( + [...view.groups.flatMap((group) => group.modules), ...view.ungrouped] + .map((entry) => entry.id) + .sort(), + label, + ).toEqual(["calendar", "livestream", "video"]); + } + }); + + it("never invents a module for a group, however the group is named", async () => { + const discovered = await discoverFixtures(); + const registry = createModuleUiPublicationRegistry(discovered); + const [, mediaArrangement] = arrangements[1] as readonly [string, DashboardConfiguration]; + + const view = resolveDashboardNavigation( + mediaArrangement, + registry.list().map((entry) => ({ id: entry.moduleId, name: entry.name })), + ); + + // "Media" and "Planning" are group labels. There is no `media` module, no manifest for one, no + // artifact, no API root, no permission, and nothing depends on one. + const moduleIds = discovered.map((module) => module.manifest.id); + expect(moduleIds).toEqual(["calendar", "video", "livestream"]); + for (const groupId of view.groups.map((group) => group.id)) { + expect(moduleIds).not.toContain(groupId); + expect(registry.resolve(groupId, "1.0.0")).toBeUndefined(); + } + }); + + it("keeps the publication record free of any grouping, ordering, or navigation field", async () => { + const registry = createModuleUiPublicationRegistry(await discoverFixtures()); + + const published = registry.resolve("livestream", "1.0.0"); + + // The record a host mounts from carries identity, the validated artifact, declared permissions, + // and the module's own name — never a group, an order, a parent, or a folder. + expect(Object.keys(published ?? {}).sort()).toEqual([ + "artifactPath", + "moduleId", + "name", + "permissions", + "version", + ]); + }); + + it("takes the display name from the module's own manifest, not from any folder", async () => { + const registry = createModuleUiPublicationRegistry(await discoverFixtures()); + + expect(registry.list().map((entry) => [entry.moduleId, entry.name])).toEqual([ + ["calendar", "Calendar"], + ["video", "Video"], + ["livestream", "Livestream"], + ]); + }); +}); diff --git a/core/tests/dashboard-navigation.test.ts b/core/tests/dashboard-navigation.test.ts new file mode 100644 index 0000000..c97bbed --- /dev/null +++ b/core/tests/dashboard-navigation.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, it } from "vitest"; +import { + type DashboardConfiguration, + type DashboardNavigationModule, + resolveDashboardNavigation, +} from "../src/dashboard/index.js"; + +function module(id: string, name = id): DashboardNavigationModule { + return { id, name }; +} + +function configuration( + groups: ReadonlyArray<{ id: string; label: string; sortOrder: number }>, + placements: ReadonlyArray<{ moduleId: string; groupId: string | null; sortOrder: number }>, +): DashboardConfiguration { + return { groups, placements }; +} + +const media = { id: "media", label: "Media", sortOrder: 10 }; +const planning = { id: "planning", label: "Planning", sortOrder: 20 }; + +describe("resolving Dashboard navigation", () => { + it("groups modules exactly where the configuration places them", () => { + const view = resolveDashboardNavigation( + configuration( + [media, planning], + [ + { groupId: "media", moduleId: "livestream", sortOrder: 20 }, + { groupId: "media", moduleId: "video", sortOrder: 10 }, + { groupId: "planning", moduleId: "calendar", sortOrder: 10 }, + ], + ), + [ + module("calendar", "Calendar"), + module("livestream", "Livestream"), + module("video", "Video"), + ], + ); + + expect( + view.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]), + ).toEqual([ + ["Media", ["Video", "Livestream"]], + ["Planning", ["Calendar"]], + ]); + expect(view.ungrouped).toEqual([]); + }); + + it("shows a newly discovered module without any stored placement", () => { + // The rule that makes installing a module sufficient to see it: no initialization write, no + // manual database edit, no migration. + const view = resolveDashboardNavigation( + configuration([media], [{ groupId: "media", moduleId: "video", sortOrder: 10 }]), + [module("video", "Video"), module("roster", "Roster")], + ); + + expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]); + expect(view.ungrouped.map((entry) => entry.id)).toEqual(["roster"]); + }); + + it("keeps a deliberately ungrouped module distinct from an unconfigured one, and orders both", () => { + const view = resolveDashboardNavigation( + configuration( + [], + [ + { groupId: null, moduleId: "video", sortOrder: 20 }, + { groupId: null, moduleId: "calendar", sortOrder: 10 }, + ], + ), + [module("roster"), module("video"), module("calendar")], + ); + + // Placed-and-ungrouped first, in the administrator's order; never-configured after, by id — so a + // newcomer never interrupts an ordering someone chose. + expect(view.ungrouped.map((entry) => entry.id)).toEqual(["calendar", "video", "roster"]); + }); + + it("omits a stored placement whose module is not installed", () => { + const view = resolveDashboardNavigation( + configuration( + [media], + [ + { groupId: "media", moduleId: "video", sortOrder: 10 }, + { groupId: "media", moduleId: "uninstalled", sortOrder: 20 }, + ], + ), + [module("video", "Video")], + ); + + // No dead entry renders, and the caller's configuration is untouched — the record survives for a + // reinstall. + expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]); + expect(view.ungrouped).toEqual([]); + }); + + it("restores a reinstalled module to the position its retained placement describes", () => { + const stored = configuration( + [media], + [ + { groupId: "media", moduleId: "livestream", sortOrder: 5 }, + { groupId: "media", moduleId: "video", sortOrder: 10 }, + ], + ); + + const whileUninstalled = resolveDashboardNavigation(stored, [module("video", "Video")]); + const afterReinstall = resolveDashboardNavigation(stored, [ + module("video", "Video"), + module("livestream", "Livestream"), + ]); + + expect(whileUninstalled.groups[0]?.modules.map((entry) => entry.id)).toEqual(["video"]); + expect(afterReinstall.groups[0]?.modules.map((entry) => entry.id)).toEqual([ + "livestream", + "video", + ]); + }); + + it("keeps an empty group rather than hiding it", () => { + const view = resolveDashboardNavigation(configuration([media, planning], []), []); + + expect(view.groups.map((group) => group.id)).toEqual(["media", "planning"]); + expect(view.groups.every((group) => group.modules.length === 0)).toBe(true); + }); + + it("treats a placement naming a missing group as ungrouped rather than losing the module", () => { + // The schema's `on delete set null` makes this unreachable in practice; the resolver still + // refuses to let a configuration inconsistency make a module invisible. + const view = resolveDashboardNavigation( + configuration([], [{ groupId: "deleted-group", moduleId: "video", sortOrder: 10 }]), + [module("video", "Video")], + ); + + expect(view.groups).toEqual([]); + expect(view.ungrouped.map((entry) => entry.id)).toEqual(["video"]); + }); + + it("orders deterministically when order values collide", () => { + const collided = configuration( + [ + { id: "zebra", label: "Zebra", sortOrder: 10 }, + { id: "alpha", label: "Alpha", sortOrder: 10 }, + ], + [ + { groupId: "alpha", moduleId: "video", sortOrder: 0 }, + { groupId: "alpha", moduleId: "calendar", sortOrder: 0 }, + { groupId: "alpha", moduleId: "roster", sortOrder: 0 }, + ], + ); + const modules = [module("roster"), module("video"), module("calendar")]; + + const first = resolveDashboardNavigation(collided, modules); + // A different input order must not change the output: the tie-break is the identifier, never + // arrival order. + const second = resolveDashboardNavigation(collided, [...modules].reverse()); + + expect(first.groups.map((group) => group.id)).toEqual(["alpha", "zebra"]); + expect(first.groups[0]?.modules.map((entry) => entry.id)).toEqual([ + "calendar", + "roster", + "video", + ]); + expect(JSON.stringify(second)).toBe(JSON.stringify(first)); + }); + + it("is empty and usable with no configuration and no modules", () => { + const view = resolveDashboardNavigation(configuration([], []), []); + + expect(view.groups).toEqual([]); + expect(view.ungrouped).toEqual([]); + }); + + it("returns a frozen view no caller can mutate", () => { + const view = resolveDashboardNavigation( + configuration([media], [{ groupId: "media", moduleId: "video", sortOrder: 10 }]), + [module("video", "Video")], + ); + + expect(Object.isFrozen(view)).toBe(true); + expect(Object.isFrozen(view.groups)).toBe(true); + expect(Object.isFrozen(view.groups[0])).toBe(true); + expect(Object.isFrozen(view.ungrouped)).toBe(true); + }); +}); + +describe("Dashboard grouping is presentation and nothing else", () => { + const modules = [ + module("livestream", "Livestream"), + module("video", "Video"), + module("calendar", "Calendar"), + ]; + + it("creates no module, no parent, and no relationship from a shared group", () => { + const view = resolveDashboardNavigation( + configuration( + [media], + [ + { groupId: "media", moduleId: "livestream", sortOrder: 10 }, + { groupId: "media", moduleId: "video", sortOrder: 20 }, + ], + ), + modules, + ); + + // "Media" is a label on a rendering bucket. It is not a module: nothing in the view carries a + // module id, version, artifact, or API root for it, and no entry names another module. + const rendered = JSON.parse(JSON.stringify(view)) as unknown; + expect(view.groups[0]?.modules.map((entry) => entry.id)).toEqual(["livestream", "video"]); + expect(modules.map((entry) => entry.id)).not.toContain("media"); + for (const entry of view.groups.flatMap((group) => group.modules)) { + // A navigation entry describes exactly one module and never references a second one. + expect(Object.keys(entry).sort()).toEqual(["id", "name"]); + } + expect(JSON.stringify(rendered)).not.toContain("parent"); + expect(JSON.stringify(rendered)).not.toContain("depends"); + }); + + it("changes only placement when a module moves to a different group", () => { + const before = resolveDashboardNavigation( + configuration( + [media], + [ + { groupId: "media", moduleId: "livestream", sortOrder: 10 }, + { groupId: "media", moduleId: "video", sortOrder: 20 }, + ], + ), + modules, + ); + const after = resolveDashboardNavigation( + configuration( + [media, { id: "sunday-production", label: "Sunday Production", sortOrder: 5 }], + [ + { groupId: "sunday-production", moduleId: "livestream", sortOrder: 10 }, + { groupId: "media", moduleId: "video", sortOrder: 20 }, + ], + ), + modules, + ); + + const findLivestream = (view: typeof before) => + [...view.groups.flatMap((group) => group.modules), ...view.ungrouped].find( + (entry) => entry.id === "livestream", + ); + + // The module object itself is identical across the move: same identity, same name, and — in the + // API layer above this — the same embedded mount descriptor. + expect(findLivestream(after)).toEqual(findLivestream(before)); + expect(before.groups.find((group) => group.id === "media")?.modules.map((m) => m.id)).toEqual([ + "livestream", + "video", + ]); + expect(after.groups.find((group) => group.id === "media")?.modules.map((m) => m.id)).toEqual([ + "video", + ]); + expect( + after.groups.find((group) => group.id === "sunday-production")?.modules.map((m) => m.id), + ).toEqual(["livestream"]); + }); + + it("renders the same module set whatever the arrangement", () => { + const arrangements: readonly DashboardConfiguration[] = [ + configuration([], []), + configuration([media], [{ groupId: "media", moduleId: "livestream", sortOrder: 10 }]), + configuration( + [media, planning], + [ + { groupId: "planning", moduleId: "livestream", sortOrder: 10 }, + { groupId: "media", moduleId: "calendar", sortOrder: 10 }, + { groupId: null, moduleId: "video", sortOrder: 10 }, + ], + ), + ]; + + for (const arrangement of arrangements) { + const view = resolveDashboardNavigation(arrangement, modules); + const rendered = [...view.groups.flatMap((group) => group.modules), ...view.ungrouped] + .map((entry) => entry.id) + .sort(); + // Every module is rendered exactly once, in every arrangement. Grouping can never add, + // remove, duplicate, or hide a module. + expect(rendered).toEqual(["calendar", "livestream", "video"]); + } + }); +}); diff --git a/core/tests/dashboard-store.integration.ts b/core/tests/dashboard-store.integration.ts new file mode 100644 index 0000000..a26558d --- /dev/null +++ b/core/tests/dashboard-store.integration.ts @@ -0,0 +1,415 @@ +import { randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client, type QueryResultRow } from "pg"; +import { afterAll, describe, expect, it } from "vitest"; +import { + createDashboardDrizzleView, + createDashboardStore, + type DashboardStore, + DashboardStoreError, + resolveDashboardNavigation, +} from "../src/dashboard/index.js"; +import { createValidatedPostgresPool } from "../src/database/index.js"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "../src/migrations/index.js"; + +/** + * The Dashboard navigation store against real PostgreSQL. + * + * The unit suites prove the resolution rules; this proves the things only a database can: that the + * migration really creates these tables, that configuration survives a completely new connection, + * that `on delete set null` really ungroups a deleted group's modules, and that a placement can + * never point at a group that does not exist. The in-memory double used by the API route tests + * deliberately reproduces only two of those behaviours, so this file is where they are actually + * established rather than assumed. + */ +const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; +const coreMigrationsDirectory = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "drizzle", +); +const trackedDatabases = new Set(); +let databaseCounter = 0; + +class IntegrationHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +function harnessError(message: string): IntegrationHarnessError { + return new IntegrationHarnessError(message); +} + +function baseDatabaseUrl(): string { + const value = process.env[integrationDatabaseUrlEnvironment]; + if (typeof value !== "string" || value.trim() === "") { + throw harnessError("The Dashboard integration database is not configured."); + } + try { + const parsed = new URL(value); + if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { + throw new TypeError("Unsupported protocol."); + } + } catch { + throw harnessError("The Dashboard integration database configuration is invalid."); + } + return value; +} + +function quoteDatabaseName(databaseName: string): string { + if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) { + throw harnessError("The generated integration database name is invalid."); + } + return `"${databaseName}"`; +} + +function makeDatabaseName(label: string): string { + const safeLabel = label + .toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, "_") + .replaceAll(/^_+|_+$/gu, "") + .slice(0, 14); + databaseCounter += 1; + return `ekk_dash_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`; +} + +function databaseUrl(databaseName: string): string { + const parsed = new URL(baseDatabaseUrl()); + parsed.pathname = `/${databaseName}`; + parsed.searchParams.delete("application_name"); + return parsed.toString(); +} + +async function openAdministrativeClient(connectionString: string): Promise { + const client = new Client({ connectionString }); + client.on("error", () => undefined); + try { + await client.connect(); + return client; + } catch { + try { + await client.end(); + } catch { + // The public harness failure stays static when connection cleanup also fails. + } + throw harnessError("The Dashboard integration database connection failed."); + } +} + +async function safeQuery( + client: Client, + text: string, + values: unknown[] = [], +): Promise { + try { + const result = await client.query(text, values); + return result.rows; + } catch { + throw harnessError("An integration database administration operation failed."); + } +} + +async function safeEnd(client: Client): Promise { + try { + await client.end(); + } catch { + throw harnessError("An integration database connection could not be closed."); + } +} + +async function dropDatabase(databaseName: string): Promise { + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery( + admin, + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()", + [databaseName], + ); + await safeQuery( + admin, + `DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`, + ); + trackedDatabases.delete(databaseName); + } finally { + await safeEnd(admin); + } +} + +async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> { + const name = makeDatabaseName(label); + const admin = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(name)} TEMPLATE template0`); + } finally { + await safeEnd(admin); + } + trackedDatabases.add(name); + return { name, url: databaseUrl(name) }; +} + +async function applyCoreMigrations(url: string): Promise { + const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-dash-no-modules-")); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModulesDirectory, + platformVersion: "0.1.0", + }, + }); + expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ + "core:0000_identity_core_tables", + "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", + "core:0003_dashboard_navigation_tables", + ]); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModulesDirectory, { force: true, recursive: true }); + } +} + +afterAll(async () => { + for (const databaseName of [...trackedDatabases]) { + await dropDatabase(databaseName).catch(() => undefined); + } +}); + +describe.sequential("live Core Dashboard navigation store", () => { + let database: { name: string; url: string }; + let admin: Client; + let store: DashboardStore; + let close: () => Promise; + + it("creates exactly the two Dashboard tables, with their constraints", async () => { + database = await createDisposableDatabase("fresh"); + await applyCoreMigrations(database.url); + admin = await openAdministrativeClient(database.url); + + const tables = await safeQuery<{ table_name: string }>( + admin, + `select table_name from information_schema.tables + where table_schema = 'public' and table_name like 'ekklesia_core_dashboard%' + order by table_name`, + ); + const constraints = await safeQuery<{ constraint_name: string; delete_rule: string }>( + admin, + `select rc.constraint_name, rc.delete_rule + from information_schema.referential_constraints rc + join information_schema.table_constraints tc + on tc.constraint_name = rc.constraint_name + where tc.table_name = 'ekklesia_core_dashboard_placement'`, + ); + + expect(tables.map((table) => table.table_name)).toEqual([ + "ekklesia_core_dashboard_group", + "ekklesia_core_dashboard_placement", + ]); + // The database, not the application, is what guarantees a deleted group leaves no dangling + // reference. + expect(constraints.map((constraint) => constraint.delete_rule)).toEqual(["SET NULL"]); + + const handle = createValidatedPostgresPool(database.url); + close = handle.close; + store = createDashboardStore(createDashboardDrizzleView(handle.pool)); + }); + + it("starts empty, so an installation with no configuration is a normal state", async () => { + expect(await store.readConfiguration()).toEqual({ groups: [], placements: [] }); + }); + + it("persists groups and placements, and reads them back in deterministic order", async () => { + await store.upsertGroup({ id: "planning", label: "Planning", sortOrder: 20 }); + await store.upsertGroup({ id: "media", label: "Media", sortOrder: 10 }); + await store.setPlacement({ groupId: "media", moduleId: "livestream", sortOrder: 20 }); + await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 10 }); + await store.setPlacement({ groupId: "planning", moduleId: "calendar", sortOrder: 10 }); + + const configuration = await store.readConfiguration(); + + expect(configuration.groups.map((group) => group.id)).toEqual(["media", "planning"]); + expect(configuration.placements.map((placement) => placement.moduleId)).toEqual([ + "calendar", + "video", + "livestream", + ]); + }); + + it("survives a completely new connection, so a church configures its Dashboard once", async () => { + // A different pool, as a restarted process would use — nothing is cached in the old one. + const reopened = createValidatedPostgresPool(database.url); + try { + const configuration = await createDashboardStore( + createDashboardDrizzleView(reopened.pool), + ).readConfiguration(); + const view = resolveDashboardNavigation(configuration, [ + { id: "livestream", name: "Livestream" }, + { id: "video", name: "Video" }, + { id: "calendar", name: "Calendar" }, + ]); + + expect( + view.groups.map((group) => [group.label, group.modules.map((entry) => entry.name)]), + ).toEqual([ + ["Media", ["Video", "Livestream"]], + ["Planning", ["Calendar"]], + ]); + } finally { + await reopened.close(); + } + }); + + it("renames a group without disturbing a single placement", async () => { + const before = await store.readConfiguration(); + + await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 10 }); + const after = await store.readConfiguration(); + + expect(after.groups.find((group) => group.id === "media")?.label).toBe("Media & Streaming"); + // The stable id is what placements point at, so a rename can never re-organize the Dashboard. + expect(after.placements).toEqual(before.placements); + }); + + it("re-orders a group and a module in place, without creating duplicates", async () => { + await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 }); + await store.setPlacement({ groupId: "media", moduleId: "video", sortOrder: 99 }); + + const configuration = await store.readConfiguration(); + + expect(configuration.groups).toHaveLength(2); + expect(configuration.groups.map((group) => group.id)).toEqual(["planning", "media"]); + expect(configuration.placements.filter((entry) => entry.moduleId === "video")).toHaveLength(1); + expect(configuration.placements.find((entry) => entry.moduleId === "video")?.sortOrder).toBe( + 99, + ); + }); + + it("moves a module between groups by changing exactly one row", async () => { + await store.upsertGroup({ id: "sunday-production", label: "Sunday Production", sortOrder: 5 }); + await store.setPlacement({ + groupId: "sunday-production", + moduleId: "livestream", + sortOrder: 10, + }); + + const configuration = await store.readConfiguration(); + + expect(configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId).toBe( + "sunday-production", + ); + // Video did not move. + expect(configuration.placements.find((entry) => entry.moduleId === "video")?.groupId).toBe( + "media", + ); + }); + + it("ungroups a module deliberately, which is distinct from having no record", async () => { + await store.setPlacement({ groupId: null, moduleId: "calendar", sortOrder: 7 }); + + const configuration = await store.readConfiguration(); + const calendar = configuration.placements.find((entry) => entry.moduleId === "calendar"); + + expect(calendar).toEqual({ groupId: null, moduleId: "calendar", sortOrder: 7 }); + }); + + it("ungroups a deleted group's modules instead of leaving a dangling reference", async () => { + await store.deleteGroup("sunday-production"); + + const configuration = await store.readConfiguration(); + const groupIds = new Set(configuration.groups.map((group) => group.id)); + + expect(groupIds.has("sunday-production")).toBe(false); + expect( + configuration.placements.find((entry) => entry.moduleId === "livestream")?.groupId, + ).toBeNull(); + // No placement anywhere names a group that no longer exists. + for (const placement of configuration.placements) { + if (placement.groupId !== null) { + expect(groupIds.has(placement.groupId)).toBe(true); + } + } + }); + + it("refuses a placement naming a group that was never created", async () => { + await expect( + store.setPlacement({ groupId: "not-a-group", moduleId: "video", sortOrder: 10 }), + ).rejects.toMatchObject({ code: "DASHBOARD_GROUP_NOT_FOUND" }); + }); + + it("refuses to delete a group that does not exist", async () => { + await expect(store.deleteGroup("not-a-group")).rejects.toMatchObject({ + code: "DASHBOARD_GROUP_NOT_FOUND", + }); + }); + + it("stores a placement for a module that is not installed, and renders nothing for it", async () => { + await store.upsertGroup({ id: "media", label: "Media & Streaming", sortOrder: 30 }); + await store.setPlacement({ groupId: "media", moduleId: "uninstalled", sortOrder: 1 }); + + const configuration = await store.readConfiguration(); + const view = resolveDashboardNavigation(configuration, [{ id: "video", name: "Video" }]); + + // Retained, so a reinstall restores the position... + expect(configuration.placements.map((entry) => entry.moduleId)).toContain("uninstalled"); + // ...and never rendered while the module is absent. + expect( + [...view.groups.flatMap((group) => group.modules), ...view.ungrouped].map( + (entry) => entry.id, + ), + ).toEqual(["video"]); + }); + + it("rejects malformed input before it reaches the database", async () => { + await expect( + store.upsertGroup({ id: "Not Kebab Case", label: "Nope", sortOrder: 0 }), + ).rejects.toBeInstanceOf(DashboardStoreError); + await expect( + store.upsertGroup({ id: "media", label: " ", sortOrder: 0 }), + ).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" }); + await expect( + store.upsertGroup({ id: "media", label: "x".repeat(121), sortOrder: 0 }), + ).rejects.toMatchObject({ code: "DASHBOARD_GROUP_LABEL_INVALID" }); + await expect( + store.upsertGroup({ id: "media", label: "Media", sortOrder: -1 }), + ).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" }); + await expect( + store.setPlacement({ groupId: null, moduleId: "Not Valid", sortOrder: 0 }), + ).rejects.toMatchObject({ code: "DASHBOARD_MODULE_ID_INVALID" }); + await expect( + store.setPlacement({ groupId: null, moduleId: "video", sortOrder: 1.5 }), + ).rejects.toMatchObject({ code: "DASHBOARD_SORT_ORDER_INVALID" }); + }); + + it("trims a stored label rather than persisting surrounding whitespace", async () => { + await store.upsertGroup({ id: "planning", label: " Planning ", sortOrder: 20 }); + + const configuration = await store.readConfiguration(); + + expect(configuration.groups.find((group) => group.id === "planning")?.label).toBe("Planning"); + }); + + it("leaks no database detail in a sanitized failure", async () => { + await close(); + + const error = await store.readConfiguration().catch((caught: unknown) => caught); + const text = `${String(error)}\n${(error as Error).stack ?? ""}`; + + expect(error).toMatchObject({ code: "DASHBOARD_READ_FAILED" }); + expect(text).not.toContain(database.name); + expect(text).not.toContain("select"); + expect(text).not.toContain("ekklesia_core_dashboard"); + await safeEnd(admin); + await dropDatabase(database.name); + }); +}); diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 4745f4a..c5c1dc3 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -78,7 +78,7 @@ Modules ✓ Dependency graph valid Dependency order is empty. ✓ Permission registry composed - 2 permissions registered + 3 permissions registered Data definitions ✓ Schemas validated diff --git a/core/tests/module-permissions.test.ts b/core/tests/module-permissions.test.ts index 4c505f6..1c41f17 100644 --- a/core/tests/module-permissions.test.ts +++ b/core/tests/module-permissions.test.ts @@ -2,6 +2,7 @@ import { type PermissionBaseline, parseModuleManifest } from "@ekklesia/contract import { describe, expect, it } from "vitest"; import { CORE_AUTHORIZATION_GRANTS_MANAGE, + CORE_DASHBOARD_NAVIGATION_MANAGE, CORE_MODULES_UI_READ, CORE_PERMISSION_DECLARATIONS, PermissionRegistryError, @@ -67,8 +68,9 @@ describe("composePlatformPermissionRegistry", () => { expect(registry.has(CORE_MODULES_UI_READ)).toBe(true); expect(registry.has("calendar.events.read")).toBe(true); expect(registry.has("people.directory.read")).toBe(true); - // Core's own two declarations plus one from each module. - expect(registry.declarations).toHaveLength(4); + expect(registry.has(CORE_DASHBOARD_NAVIGATION_MANAGE)).toBe(true); + // Core's own three declarations plus one from each module. + expect(registry.declarations).toHaveLength(5); }); it("preserves deterministic order: Core first, then modules in the supplied order, then declarations in manifest order", () => { @@ -85,6 +87,7 @@ describe("composePlatformPermissionRegistry", () => { expect(registry.declarations.map((declaration) => declaration.id)).toEqual([ CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_MODULES_UI_READ, + CORE_DASHBOARD_NAVIGATION_MANAGE, "calendar.events.read", "calendar.events.write", "people.directory.read", diff --git a/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md b/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md new file mode 100644 index 0000000..48ec85e --- /dev/null +++ b/docs/architecture/decisions/0027-dashboard-navigation-and-flat-modules.md @@ -0,0 +1,268 @@ +# 0027: Dashboard navigation is presentation configuration, and modules stay flat + +- Status: Accepted +- Date: 2026-08-22 +- Extends: [0021](0021-modules-as-hostable-products.md), + [0026](0026-minimal-ekklesia-module-ui-host.md) + +## Context + +ADR 0026 gave Ekklesia a browser shell that mounts discovered module UI. Its navigation was a flat +list of **module ids** — `livestream`, `announcements` — which is not a usable long-term experience, +and it had no notion of organizing anything. + +The obvious next step carries an equally obvious trap. A church wants to see: + +```text +Media Planning Communication +├── Livestream ├── Calendar └── Announcements +└── Video └── Roster +``` + +and the tempting way to build that is to let a module say which folder it belongs to, or worse, to +model "Media" as something Livestream and Video belong *to*. Either turns a rendering preference +into architecture: a folder becomes an owner, membership starts to look like a dependency, and the +flat, independently versioned module graph ADR 0021 established quietly acquires a hierarchy nobody +decided to add. + +Two facts from the repository shaped what was actually needed. + +**A display name already exists.** `moduleManifestSchema` has required `name` (trimmed, 1–120 +characters) since version 1, and every fixture and the template already set it — `"Example Module"`. +Nothing needed adding to the manifest to stop rendering raw ids. + +**Nothing else exists.** There is no settings or configuration table, no key-value store, no +organization or tenant model, and no client-side routing anywhere in the repository. Whatever stores +an arrangement had to be built, and could not be scoped to an organization that does not exist. + +## Decision + +### Grouping is presentation configuration. It is never module structure. + +A Dashboard group is a label on a rendering bucket. It owns no tables, no migrations, no routes, and +no lifecycle; it has no manifest, no version, and no artifact; nothing depends on it and it depends +on nothing. **It is not a module**, and "Media" existing does not make Livestream and Video related +in any way the platform can observe. + +The distinction the architecture has to keep sharp: + +- *Livestream depends on Video because it calls Video's functionality* — a real technical + dependency, declared through the existing module dependency contract, which orders discovery and + startup. +- *Livestream is shown under Media* — a rendering preference an administrator can change at any time, + which orders nothing. + +Nothing in this pass introduces a parent module, a child module, an owning module, a container +module, or a group-derived dependency edge. The words do not appear in the contracts, and the +absence is enforced by tests rather than left to discipline (see **Consequences**). + +### No manifest change, and no version 5 + +`manifest.name` is the display name. It is the module's **product identity** — what the product is +called — and a module is exactly the right owner of that. Adding a `presentation.name` beside it +would have created two names for one thing and forced `manifestVersion: 5`, a hard cutover for every +installed module (ADR 0006's convention: each version rejects all others) in exchange for nothing. + +`PublishableModuleUi` gained `name`, carried from the manifest through the existing publication +registry. That is a Core-internal record, not a manifest change. + +### `suggestedGroup` is deferred, deliberately + +A module suggesting a default folder is a reasonable idea and was rejected **for this pass** on three +grounds, in order of weight. + +It would force `manifestVersion: 5` — the most disruptive change available — for a *hint*. Its only +correct semantics ("used when a placement is first materialized, never re-applied over an +administrator's decision") requires configuration-initialization logic that writes rows on first +sight of a module; without that machinery the field would be either inert or authoritative, and +authoritative is the outcome this ADR exists to prevent. And it is not needed: an unplaced module is +simply ungrouped, which is deterministic, requires no write at all, and is strictly simpler than +initializing state. + +Shipping the field now with semantics we could not yet honour would be adding a misleading contract +because we know we want the idea eventually. When there is a reason to bump the manifest version, +this can travel with it. + +### Platform-owned, server-side, platform-global persistence + +Two Core tables (`core/src/dashboard/schema.ts`), following the `ekklesia_core_*` conventions +exactly, in migration `0003_dashboard_navigation_tables`: + +- `ekklesia_core_dashboard_group` — `id` (opaque stable key), `label`, `sort_order`. +- `ekklesia_core_dashboard_placement` — `module_id` (primary key: at most one placement per module), + nullable `group_id`, `sort_order`. + +A church configures its Dashboard, not one browser, so this is server state rather than +`localStorage`. It is **platform-global**, because Ekklesia has no organization or tenant model; +inventing a second notion of organization to anticipate one would be worse than adding a scope column +when a real one exists. That limitation is recorded rather than hidden. + +Three schema decisions carry invariants rather than convenience: + +`group_id` is `on delete set null`. Deleting a group cannot leave a dangling reference and cannot +lose a module — its modules become ungrouped, enforced by the database rather than by application +cleanup that could be skipped. + +`module_id` carries **no foreign key**, because modules are packages discovered on disk, not rows. A +placement for an uninstalled module is therefore normal and is retained, so uninstall-and-reinstall +preserves an arrangement instead of silently resetting it. + +The group `id` is opaque and never re-derived from the label. Renaming "Media" to "Sunday Production" +changes `label` alone, so every placement survives — a rename can never re-organize the Dashboard. + +### The resolution rules, stated exactly + +`resolveDashboardNavigation` is a pure function of stored configuration and the currently mountable +module set. Every rule is directly testable without PostgreSQL: + +- **A module with no placement is ungrouped and visible.** Installing a module is sufficient to see + it: no initialization write, no manual database edit, no migration. This is the rule that stops a + newly installed module from disappearing. +- **A stored placement for an absent module renders nothing and is retained.** No dead entry; no lost + position on reinstall. +- **A placement naming a missing group is treated as ungrouped**, not dropped. `on delete set null` + makes this unreachable in practice; a configuration inconsistency must still never make a module + invisible. +- **Empty groups are kept.** Hiding one would look like it had been deleted. +- **Ordering is total.** Groups by `(order, id)`; modules by `(configured, order, id)` — placed + modules in the administrator's order, then never-configured ones by id, so a newcomer never + interrupts an ordering someone chose. Colliding order values can never produce a + nondeterministic result; the identifier is always the final tie-break. + +Simple integers, not fractional ordering or CRDTs. Reordering rewrites the values it needs to. + +### Navigation is layered on the inventory, not merged into it + +`GET /api/v1/module-ui` (ADR 0026) still answers *what is mountable*, host-neutrally, and is +unchanged — no display name, no group, no order. A standalone host consumes it and never learns +Ekklesia has folders. + +`GET /api/v1/dashboard/navigation` answers *how this Dashboard presents those modules*, and +**embeds** each generic descriptor untouched inside a presentation wrapper: + +```json +{ "groups": [ { "id": "media", "label": "Media", "order": 10, + "modules": [ { "name": "Livestream", "module": { "id": "livestream", "…": "…" } } ] } ], + "ungrouped": [] } +``` + +Embedding rather than flattening is the layering made visible in the payload, and both endpoints +compose the descriptor through one shared function, so they cannot disagree about what exists or +about any URL. The server is authoritative for the resolved view: the browser does no grouping, no +sorting, and no joining, and reads no manifest. + +### Reading versus organizing + +Reading requires `core.modules.ui.read` — the same permission the inventory requires, because this +response *contains* that inventory and serves the same audience: any signed-in user needs it to see +anything at all. A second read permission for the same data would be ceremony. + +Organizing requires a new **platform** permission, `core.dashboard.navigation.manage` +(baseline `restricted`), never a module's — grouping is Ekklesia's configuration, so no module may +gate, grant, or influence it. Mutations additionally require an exact trusted `Origin` and a bounded +body before authorization work begins, exactly like every other Core mutation. + +Holding it grants no module authority. Moving a module into a folder changes a presentation row; it +grants nobody access to that module's data, and every module route stays enforced against its own +declared permission. + +### A narrow, structured mutation contract — no configuration blob + +Three idempotent operations, each with closed validation that rejects any unexpected key rather than +dropping it: + +```text +PUT /api/v1/dashboard/navigation/groups/:groupId { label, order } +DELETE /api/v1/dashboard/navigation/groups/:groupId +PUT /api/v1/dashboard/navigation/placements/:moduleId { groupId | null, order } +``` + +`PUT` against a client-chosen stable id makes create and rename the same operation, so a rename can +never orphan a placement. A "save the whole dashboard config" blob was rejected: it has no +invariants, no per-operation authority, and no way to reject a field nobody meant to send. + +No editor UI ships in this pass. The persistence model is proven through the real API instead, which +is the repository's normal pattern. + +### One level of grouping + +`Group → Modules`. No nesting, no subgroups. The schema has no parent-group column, so the shape +itself forecloses it rather than a rule asking developers not to. Nested navigation can be +reconsidered when a real use case appears. + +## Alternatives + +- **A `presentation.name` manifest field.** Rejected: `manifest.name` already is the display name, + and adding a second would mean two names for one product and a version 5 cutover for nothing. +- **`suggestedGroup` now.** Deferred; see above. It buys a default at the cost of a manifest version + and initialization machinery, and risks becoming authoritative. +- **A module declaring its own folder authoritatively.** Rejected outright — it is the mistake this + ADR exists to prevent. An administrator must be able to move a module without touching it. +- **Re-applying a module's suggestion on every startup.** Rejected for the same reason: defaulting + and ownership are different, and a suggestion that overwrites a decision is ownership. +- **Modelling a group as a module.** Rejected: it would give a rendering bucket a manifest, a + version, a lifecycle, and a place in the dependency graph, and would make "Livestream belongs under + Media" indistinguishable from "Livestream depends on Media." +- **Browser `localStorage`.** Rejected: a church configures its Dashboard, not one browser on one + device. +- **A generalized platform settings framework.** Rejected: two purpose-built tables with real + constraints beat a key-value blob whose invariants live only in application code, and nothing else + needs settings yet. +- **Extending the module UI inventory with groups.** Rejected: it would push Dashboard semantics into + the host-neutral contract a standalone host is meant to consume. +- **Making the browser join inventory and configuration.** Rejected: it would put grouping logic — + and therefore the ordering rules — in the client, where a second implementation could drift. +- **Deleting placements for uninstalled modules.** Rejected: retaining them makes reinstall restore + an arrangement, at the cost of a few harmless rows. +- **Hiding empty groups.** Rejected: indistinguishable from deletion, from the administrator's side. +- **Fractional or CRDT ordering.** Rejected as unjustified for a list a person arranges by hand. +- **Nested folders, icons, drag and drop, a navigation editor, client routing.** All deferred; each + needs a design decision this pass should not make by accident. Icons in particular would + immediately require an icon vocabulary, an asset-publication story, and a trust decision about + arbitrary SVG. + +## Consequences + +Ekklesia has a real Dashboard navigation model: persistent, platform-owned, administrator-editable +through a structured API, rendered by the shell, mounting flat modules through the unchanged generic +loader. A module now shows as "Livestream" rather than `livestream`. + +**The flatness invariant is executable, not aspirational.** `core/tests/dashboard-flat-modules.test.ts` +discovers real fixtures with a genuine dependency (Livestream depends on Video), then arranges them +four ways — including both modules under one "Media" folder, and the dependency deliberately inverted +in presentation — and asserts that discovery order, dependency declarations, capabilities, +permissions, and the module set come out byte-identical every time, and that no group id is ever a +module. A future change that let grouping influence any of that fails there. + +The end-to-end test goes further: it organizes a real installation through the real mutation API, +bootstraps the real shell code against it, mounts a module from inside a group, moves that module to +a different group, and asserts the navigation entry is **byte-identical** across the move — same +artifact URL, same API root, same permissions, same version, same name — and still mounts and reaches +the same API. + +Four things changed outside the new code. `createApiApp` takes a required `dashboard` store. +`PublishableModuleUi` carries `name`. Core has a third permission, which moved five existing +assertions (each updated to name all three permissions rather than to loosen a count). And the shell's +bootstrap now resolves navigation instead of the raw inventory, which made its client for +`GET /api/v1/module-ui` dead code; that client was **removed** rather than kept speculatively, and +its descriptor-validation coverage moved onto the navigation parser. The endpoint itself is unchanged +and remains fully covered by `apps/api`'s tests. + +Two costs worth naming. Navigation configuration is platform-global and will need a scope column when +an organization model exists. And the navigation endpoint evaluates every listed module's declared +permissions per request, inheriting ADR 0026's per-request evaluation cost; for a handful of modules +that is unremarkable, and it is measured work rather than hidden. + +## Deferred + +Not implemented or promised here: the navigation **editor** — drag and drop, a management UI, and any +administrator-facing affordance beyond the API; nested folders and subgroups; icons, colors, badges, +and module descriptions in navigation; `suggestedGroup` and any configuration-initialization or +defaulting logic; per-module administrator label overrides; Dashboard Home, widgets, summaries, and +quick actions; permission-aware navigation visibility (whether to hide a module a viewer can do +nothing in remains an open UX policy); client-side routing and deep links to a selected module; +organization or tenant scoping; a design system, theme tokens, and CSS asset publication; and every +real feature module. + +The permission-id typing gap carried since ADR 0022 is unchanged: `permissions.has` takes a plain +string, and the wire arrays are `string[]`, so a typo still silently hides a control. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index 4443777..c9086ea 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -35,3 +35,4 @@ ADRs capture durable choices that affect more than one package or phase. - [0024: Browser-safe module UI SDK and the generic host loader](0024-browser-safe-ui-sdk-and-host-loader.md) — Accepted - [0025: Trusted module UI asset publication](0025-trusted-module-ui-asset-publication.md) — Accepted - [0026: The minimal Ekklesia module UI host](0026-minimal-ekklesia-module-ui-host.md) — Accepted +- [0027: Dashboard navigation is presentation configuration, and modules stay flat](0027-dashboard-navigation-and-flat-modules.md) — Accepted diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index 50abee7..ae9ce22 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -267,9 +267,40 @@ checked to contain exactly one dynamic import with no module identity baked in. from Core's own evaluator and stay presentation-only — server authorization is proven unchanged by a test in which a UI holding no grant calls its API anyway and is refused. -**This is not the Dashboard.** No navigation, grouping, icons, ordering, design system, or routing; -the selector lists module ids. A production CSP and a deployment for the built shell, sign-in UI, -general asset publication, and the standalone host remain future work. +**Phase 6D pass 1 adds the Dashboard navigation model, and fixes the rule it could have broken.** +[ADR 0027](decisions/0027-dashboard-navigation-and-flat-modules.md) establishes that a Dashboard +group is **presentation configuration and never module structure**. A group is a label on a rendering +bucket: no manifest, version, artifact, API, permission, or lifecycle, and nothing depends on it. +Placing two modules in one folder creates no relationship between them; *Livestream depends on Video +because it calls Video's functionality* orders startup, while *Livestream is shown under Media* +orders nothing. Modules stay flat, with no parent, child, owner, or group-derived dependency. + +No manifest change was needed: `manifest.name` has been required since version 1 and already is the +module's product identity, so the shell renders it instead of the module id. `suggestedGroup` was +deferred rather than shipped — it would force a hard manifest-version cutover for a hint, and its +only correct semantics needs initialization logic that risks a suggestion overwriting an +administrator's decision. + +Core owns the persistence: two `ekklesia_core_dashboard_*` tables (migration `0003`) holding groups +and placements, platform-global because no organization model exists yet. `group_id` is +`on delete set null`, so deleting a group ungroups its modules and a dangling reference is +impossible; `module_id` carries no foreign key, because modules are packages rather than rows, so a +placement for an uninstalled module is retained and restores its position on reinstall. +`resolveDashboardNavigation` is a pure function of configuration plus the mountable set: an unplaced +module is ungrouped and visible with no initialization write, an absent module renders nothing, +empty groups are kept, and ordering is total with the identifier as final tie-break. + +`GET /api/v1/dashboard/navigation` returns the resolved view, layered **on top of** the host-neutral +module UI inventory rather than merged into it — it embeds each generic descriptor untouched, so a +standalone host consuming the inventory never inherits Dashboard semantics. Reading requires the same +permission the inventory does; organizing requires the new platform permission +`core.dashboard.navigation.manage` through a narrow, structured, idempotent mutation contract. The +shell renders that arrangement and computes none of it. + +**Still not the finished Dashboard.** No editor or drag and drop, no nested folders, no icons, no +Dashboard Home or widgets, no design system, and no client routing. A production CSP and a deployment +for the built shell, sign-in UI, general asset publication, and the standalone host remain future +work. Restart-based discovery, explicit operator-invoked migrations, and the trusted compiled-module model are all unchanged. diff --git a/docs/architecture/repository-boundaries.md b/docs/architecture/repository-boundaries.md index 68ebfdf..3b94130 100644 --- a/docs/architecture/repository-boundaries.md +++ b/docs/architecture/repository-boundaries.md @@ -58,6 +58,13 @@ These rules map platform ownership to filesystem and package boundaries. The exe import from `@ekklesia/module-ui-sdk` to close the browser boundary. `module-sdk` re-exports the same barrel and is excluded for the same reason. A surface declares its own types for the wire and validates what arrives ([ADR 0026](decisions/0026-minimal-ekklesia-module-ui-host.md)). +- Dashboard navigation configuration is **Core-owned platform state**, never module-owned: Core + stores groups and placements and resolves the view, `apps/api` exposes it, and `surfaces/shell` + renders it. A group is presentation configuration and never module structure — it has no manifest, + version, artifact, or lifecycle, and nothing in discovery, dependency resolution, or module startup + may ever read it + ([ADR 0027](decisions/0027-dashboard-navigation-and-flat-modules.md)). A module's manifest declares + its own display name and nothing about where it is presented. - The browser host (`surfaces/shell`) statically imports no module. Every installed module UI it can mount arrives at run time as a URL from the module UI inventory, is fetched by the generic loader, and is never bundled into the shell. Its own build (esbuild, owned by that package) is a separate diff --git a/docs/development/api.md b/docs/development/api.md index b94e195..006f7eb 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -77,6 +77,8 @@ No real values are shown here — configure per environment and never commit the | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). | | `/api/v1/modules//*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). | | `GET /api/v1/module-ui` | Ekklesia | The browser host's module UI inventory: which module UIs may be mounted, where to fetch each, where each module's API lives, and the viewer's granted permissions for it. Requires `core.modules.ui.read` (baseline `authenticated`). See [Module UI inventory](#module-ui-inventory). | +| `GET /api/v1/dashboard/navigation` | Ekklesia | The resolved Dashboard navigation: the same mountable modules, grouped, labelled, and ordered as this installation has configured them. Requires `core.modules.ui.read`. See [Dashboard navigation](#dashboard-navigation). | +| `PUT\|DELETE /api/v1/dashboard/navigation/{groups,placements}/*` | Ekklesia | Organize the Dashboard: create/rename/order/delete a group, and place a module. Requires `core.dashboard.navigation.manage` (baseline `restricted`) and a trusted `Origin`. See [Dashboard navigation](#dashboard-navigation). | | `GET /api/v1/module-ui//` | Ekklesia | Publishes an installed module's validated `entrypoints.ui` artifact at a browser-loadable URL. No authentication; only the declared artifact, never another file. See [Module UI asset publication](#module-ui-asset-publication). | Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a @@ -452,6 +454,94 @@ are mounted under `/api/v1/modules/`, in the module runtime's publish registry: installing, removing, or editing a module's routes takes effect only on the next API restart. Removing a module makes its former routes return the ordinary `404 {"code":"NOT_FOUND"}`. +## Dashboard navigation + +```text +GET /api/v1/dashboard/navigation +PUT /api/v1/dashboard/navigation/groups/ { "label": "Media", "order": 10 } +DELETE /api/v1/dashboard/navigation/groups/ +PUT /api/v1/dashboard/navigation/placements/ { "groupId": "media"|null, "order": 10 } +``` + +How this installation presents its mountable modules +([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). + +```json +{ + "groups": [ + { + "id": "media", + "label": "Media", + "order": 10, + "modules": [ + { + "name": "Livestream", + "module": { + "id": "livestream", + "version": "1.0.0", + "ui": { "url": "/api/v1/module-ui/livestream/1.0.0" }, + "api": { "baseUrl": "/api/v1/modules/livestream" }, + "permissions": ["livestream.streams.read"] + } + } + ] + } + ], + "ungrouped": [] +} +``` + +### Navigation versus inventory + +They are deliberately separate, and this route is layered **on top of** the inventory rather than +merged into it: + +- `GET /api/v1/module-ui` answers *what may be mounted*, host-neutrally. A standalone host consumes + it and never learns Ekklesia has folders. It carries no display name, group, or order. +- `GET /api/v1/dashboard/navigation` answers *how this Dashboard presents those modules*, and + **embeds** each generic descriptor untouched under `module`. + +Both compose that descriptor through one shared function, so they cannot disagree about what exists +or about any URL. + +### Grouping is presentation, never module structure + +A group is a label on a rendering bucket. It is **not a module**: it has no manifest, version, +artifact, API, permission, or lifecycle, and nothing depends on it. Placing Livestream and Video in +one folder creates no relationship between them, and moving a module changes a single presentation +row — never its artifact, API root, permissions, dependencies, or startup order. Real technical +dependencies remain the module dependency contract's job and are the only relationship modules have. + +### Resolution rules + +- A module with **no placement is ungrouped and visible** — installing it is enough, with no + initialization write and no manual database edit. +- A stored placement for a module that is **not installed renders nothing and is retained**, so + uninstall-and-reinstall preserves an arrangement. +- **Empty groups are kept** (hiding one would look like deletion), and a placement naming a missing + group renders as ungrouped rather than disappearing. +- Deleting a group **ungroups its modules** through the schema's `on delete set null`; a dangling + reference is impossible. +- Ordering is total: groups by `(order, id)`, modules by `(configured, order, id)` — placed modules + in the administrator's order, then never-configured ones by id, with the identifier always the + final tie-break, so colliding order values are still deterministic. + +### Authority + +Reading requires `core.modules.ui.read` — the same permission the inventory requires, because this +response contains it and serves the same audience. Organizing requires +`core.dashboard.navigation.manage` (baseline `restricted`), a **platform** permission and never a +module's, plus an exact trusted `Origin` and a bounded body. Holding it grants no module authority: +every module route stays enforced against its own declared permission, and no arrangement of folders +changes an authorization decision. + +Group ids are opaque, stable, lowercase kebab-case keys, never displayed and never re-derived from a +label — so renaming a group cannot re-organize the Dashboard. Bodies are closed: any key beyond the +declared ones makes the whole request invalid rather than being silently dropped. Configuration is +platform-global; Ekklesia has no organization or tenant model to scope it to yet. + +Responses are per-principal, so `Cache-Control: no-store` and `Vary: Origin, Cookie`. + ## Module UI inventory ```text diff --git a/docs/development/security.md b/docs/development/security.md index 1960926..e3bde3b 100644 --- a/docs/development/security.md +++ b/docs/development/security.md @@ -152,6 +152,22 @@ shell owns the container, the loading state, and every error, so a failing modul rather than the shell. None of that is containment: once imported, a module UI is ordinary same-realm JavaScript, exactly as described above. +### Dashboard arrangement is not an access control + +Dashboard groups and placements are presentation configuration +([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). Organizing the +Dashboard requires the platform permission `core.dashboard.navigation.manage` (baseline +`restricted`), and holding it **grants no module authority whatsoever**: moving a module into a +folder changes one presentation row, and every module route stays enforced against its own declared +permission exactly as before. Nothing in the navigation configuration is read by discovery, +dependency resolution, module startup, or the authorization evaluator. + +Navigation is also not a way to hide anything. A module absent from a viewer's navigation is a +rendering decision, never a restriction — server authorization remains the only thing that decides +what a request may do. Reading navigation requires the same `core.modules.ui.read` the module UI +inventory requires, so it discloses the same installed-module enumeration and nothing more; it +carries no filesystem path, package root, or manifest internal. + ### Client-side permission checks are not a security boundary A host may tell a module UI what permissions it believes the viewer holds diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 6fd0940..d382c8e 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -155,6 +155,27 @@ module's own HTTP routes, and creating relationship-derived grants automatically own business logic, are both still later work; this only makes a declared permission *usable* through Core's existing, generic surface. +### Your module's display name + +`name` — required by the manifest since version 1, trimmed, 1–120 characters — is what a person sees +in Ekklesia's Dashboard navigation. It is your module's **product identity**: what the product is +called, which you own. Set it to something a church staff member would recognize ("Livestream"), not +your package name or module id. + +**You do not declare where your module appears.** Dashboard groups — "Media", "Planning", "Sunday +Production" — are presentation configuration an administrator owns, stored by the platform and +changed at any time without touching your module +([ADR 0027](../architecture/decisions/0027-dashboard-navigation-and-flat-modules.md)). There is no +manifest field for a folder, a group, an order, an icon, or a category, and adding one would make a +rendering preference into module structure. + +That separation also means grouping never affects you. Two modules sharing a folder have no +relationship: no dependency, no ordering, no shared anything. If your module genuinely needs another +module's functionality, declare it in `dependencies.modules` — that is the only kind of module +relationship the platform has, and it is what orders discovery and startup. An administrator moving +your module into a different folder changes nothing about your artifact, your API root, your +permissions, your dependencies, or when you start. + Every entrypoint except `migrations` must be a compiled ESM `.js` or `.mjs` file — `server`, `schema`, `jobs`, and `ui` alike. `migrations` is the one directory entrypoint. All entrypoints begin with `./`, remain inside the package, and exist. Production never imports source TypeScript directly. @@ -499,9 +520,10 @@ Three things follow for you: API root; a path that escapes it (`"../other-module/records"`, an absolute URL) is refused before it becomes a request. Keep asking for relative paths and your artifact stays hostable elsewhere. -> **This is not the Dashboard.** The shell is a module list and a mount target. There is no -> navigation, grouping, icons, ordering, theme, or design system yet, and the selector shows your -> module's id — the inventory deliberately carries no display metadata until that design exists. +> **This is not the finished Dashboard.** The shell renders one level of grouping and mounts the +> selected module. Your module appears under whatever group an administrator placed it in — by its +> `name`, never its id — and under "Other" until someone arranges it. There is no navigation editor, +> no nested folders, no icons, no theme, and no Dashboard Home yet. ### What a module UI will never own diff --git a/surfaces/shell/src/browser/HostApp.tsx b/surfaces/shell/src/browser/HostApp.tsx index 1afce5e..5ce46ab 100644 --- a/surfaces/shell/src/browser/HostApp.tsx +++ b/surfaces/shell/src/browser/HostApp.tsx @@ -2,6 +2,7 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host"; import { type ReactElement, useEffect, useState } from "react"; import { bootstrapHost, + type DashboardNavigationEntry, type EkklesiaHostConfig, type HostBootstrap, HostProtocolError, @@ -24,7 +25,14 @@ export interface HostAppProps { } const shellStyles = { - aside: { borderRight: "1px solid #e7e5e4", minWidth: "12rem", padding: "1rem" }, + aside: { borderRight: "1px solid #e7e5e4", minWidth: "13rem", padding: "1rem" }, + groupLabel: { + color: "#78716c", + fontSize: "0.75rem", + letterSpacing: "0.05em", + margin: "0 0 0.25rem", + textTransform: "uppercase", + }, header: { borderBottom: "1px solid #e7e5e4", fontWeight: 600, @@ -42,24 +50,66 @@ const shellStyles = { message: { color: "#57534e", padding: "1rem" }, } as const; -function moduleKey(id: string, version: string): string { - return `${id}@${version}`; +/** Identity, not placement: a module keeps the same selection key wherever it is arranged, so moving + * it between groups never resets what is open or remounts what is showing. */ +function moduleKey(entry: DashboardNavigationEntry): string { + return `${entry.descriptor.id}@${entry.descriptor.version}`; +} + +function allEntries(bootstrap: HostBootstrap): readonly DashboardNavigationEntry[] { + return [ + ...bootstrap.navigation.groups.flatMap((group) => group.modules), + ...bootstrap.navigation.ungrouped, + ]; +} + +function ModuleButton(props: { + readonly entry: DashboardNavigationEntry; + readonly selected: boolean; + readonly onSelect: () => void; +}): ReactElement { + return ( + + ); } /** - * The minimal Ekklesia browser shell: sign-in awareness, a module list, and one mount target. + * The Ekklesia browser shell: sign-in awareness, the Dashboard's configured navigation, and one + * mount target. * - * **This is not the Dashboard.** There is no navigation model, no grouping or folders, no icons, no - * ordering, no home screen, no design system, and no routing — a selected-module state is the whole - * of it, and the selector renders module ids because the inventory deliberately carries no display - * metadata yet (ADR 0021 keeps that an administrator's presentation configuration, not a module - * declaration). What this proves is the architecture underneath: that a module discovered on the - * server, published as an artifact, and described by the inventory can be mounted here without the - * shell knowing anything about it in advance. + * ## The shell renders the arrangement; it never computes one * - * Nothing about any specific module appears in this file, or anywhere in this package. There is no - * import of a module, no module id, no registry, no allowlist, and no fallback bundle. Every module - * the shell can mount arrives at run time from the inventory the server returned. + * Groups, their order, their labels, which module sits where, and the order within each group all + * arrive already resolved from the server (ADR 0027). This component reads no manifest, performs no + * sorting, and knows nothing about how a placement is decided — so changing the Dashboard's ordering + * rules, or eventually where configuration is scoped, needs no change here at all. The module id is + * never displayed: the module's own manifest name is. + * + * ## Still not the finished Dashboard + * + * There is one level of grouping and no nesting, no icons, no drag and drop, no editor, no home + * screen or widgets, no theme, and no client routing — selection is state. What this proves is the + * layer beneath a Dashboard: persistent, platform-owned presentation configuration driving a real + * host that mounts flat, independent modules. + * + * Nothing about any specific module appears in this file, or anywhere in this package. */ export function HostApp(props: HostAppProps): ReactElement { const [state, setState] = useState({ status: "loading" }); @@ -79,11 +129,8 @@ export function HostApp(props: HostAppProps): ReactElement { ? { status: "signed-out" } : { bootstrap, status: "ready" }, ); - setSelectedKey( - bootstrap.modules[0] - ? moduleKey(bootstrap.modules[0].id, bootstrap.modules[0].version) - : undefined, - ); + const first = allEntries(bootstrap)[0]; + setSelectedKey(first ? moduleKey(first) : undefined); }) .catch((error: unknown) => { if (abort.signal.aborted) { @@ -118,54 +165,68 @@ export function HostApp(props: HostAppProps): ReactElement { } const { bootstrap } = state; - const selected = bootstrap.modules.find( - (module) => moduleKey(module.id, module.version) === selectedKey, - ); + const entries = allEntries(bootstrap); + const selected = entries.find((entry) => moduleKey(entry) === selectedKey); + const { groups, ungrouped } = bootstrap.navigation; return (
Ekklesia
{selected ? ( - // Keyed by module identity, so switching selection discards this frame — and its container - // element — entirely rather than reusing one across two modules. See `ModuleUiFrame` for - // why that is what makes a slow, stale load harmless. + // Keyed by module identity — not by group — so switching selection discards this frame and + // its container entirely, while re-grouping a module never remounts it. See + // `ModuleUiFrame` for why that is what makes a slow, stale load harmless. ) : undefined} diff --git a/surfaces/shell/src/host-protocol.ts b/surfaces/shell/src/host-protocol.ts index d4101d6..47ac300 100644 --- a/surfaces/shell/src/host-protocol.ts +++ b/surfaces/shell/src/host-protocol.ts @@ -17,10 +17,15 @@ export interface EkklesiaHostConfig { } /** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API - * URL — the server produces both and hands them over in the inventory — so these two are the only - * URLs it knows, and they are the two that describe the platform itself rather than any module. */ + * URL — the server produces both and hands them over in the navigation view — so these two are the + * only URLs it knows, and they describe the platform itself rather than any module. + * + * `/api/v1/module-ui` is deliberately absent: that host-neutral inventory (ADR 0026) remains the + * endpoint a standalone host would consume, but Ekklesia's own shell needs the arrangement too, so + * it reads the navigation view instead. Keeping an unused client for it here would be speculative + * code for a consumer that does not exist. */ const PRINCIPAL_PATH = "/api/v1/principal"; -const MODULE_UI_INVENTORY_PATH = "/api/v1/module-ui"; +const DASHBOARD_NAVIGATION_PATH = "/api/v1/dashboard/navigation"; export type HostProtocolErrorCode = /** The caller is not signed in, or lacks `core.modules.ui.read`. */ @@ -112,37 +117,99 @@ function rootRelativePath(value: unknown, key: string): string | undefined { * yielding a half-built descriptor, because a descriptor with a missing URL would surface much later * as an inexplicable mount failure. */ -export function parseHostModuleUiInventory(payload: unknown): readonly HostModuleUiDescriptor[] { - const modules = isRecord(payload) ? payload.modules : undefined; - if (!Array.isArray(modules)) { +function parseDescriptor(entry: unknown): HostModuleUiDescriptor { + const id = nestedString(entry, "id"); + const version = nestedString(entry, "version"); + const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined; + const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined; + const permissions = isRecord(entry) ? entry.permissions : undefined; + if ( + id === undefined || + version === undefined || + uiUrl === undefined || + apiBaseUrl === undefined || + !Array.isArray(permissions) || + permissions.some((permission) => typeof permission !== "string") + ) { throw new HostProtocolError("HOST_RESPONSE_INVALID"); } - return Object.freeze( - modules.map((entry): HostModuleUiDescriptor => { - const id = nestedString(entry, "id"); - const version = nestedString(entry, "version"); - const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined; - const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined; - const permissions = isRecord(entry) ? entry.permissions : undefined; - if ( - id === undefined || - version === undefined || - uiUrl === undefined || - apiBaseUrl === undefined || - !Array.isArray(permissions) || - permissions.some((permission) => typeof permission !== "string") - ) { - throw new HostProtocolError("HOST_RESPONSE_INVALID"); - } - return Object.freeze({ - apiBaseUrl, - id, - permissions: Object.freeze([...(permissions as string[])]), - uiUrl, - version, - }); - }), - ); + return Object.freeze({ + apiBaseUrl, + id, + permissions: Object.freeze([...(permissions as string[])]), + uiUrl, + version, + }); +} + +/** + * One module as the Dashboard presents it. + * + * The mount descriptor is nested exactly as the server sends it, not flattened into this object. + * That keeps the two concerns visibly separate in the browser as well as on the wire: `descriptor` + * is the host-neutral "what and how to mount", and `name` is Ekklesia's presentation of it. Nothing + * about a group is stored on the module — a module does not know, and must not know, where it has + * been placed. + */ +export interface DashboardNavigationEntry { + readonly name: string; + readonly descriptor: HostModuleUiDescriptor; +} + +export interface DashboardNavigationGroup { + /** Opaque and stable; the shell uses it as a React key and never displays it. */ + readonly id: string; + readonly label: string; + readonly modules: readonly DashboardNavigationEntry[]; +} + +/** + * The whole navigation the shell renders, already resolved and ordered by the server. + * + * The browser performs no grouping, no sorting, and no joining of its own: it does not read + * manifests, does not know which module belongs where, and could not compute this view if it wanted + * to. Ordering arrives as array order, so a change to the server's ordering rules needs no shell + * change at all. + */ +export interface DashboardNavigation { + readonly groups: readonly DashboardNavigationGroup[]; + readonly ungrouped: readonly DashboardNavigationEntry[]; +} + +function parseNavigationEntry(entry: unknown): DashboardNavigationEntry { + const name = nestedString(entry, "name"); + if (name === undefined || !isRecord(entry)) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ descriptor: parseDescriptor(entry.module), name }); +} + +/** Strict, total validation, exactly like the inventory parser: a malformed group or entry fails + * the whole document rather than rendering a half-built navigation. */ +export function parseDashboardNavigation(payload: unknown): DashboardNavigation { + const groups = isRecord(payload) ? payload.groups : undefined; + const ungrouped = isRecord(payload) ? payload.ungrouped : undefined; + if (!Array.isArray(groups) || !Array.isArray(ungrouped)) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + groups: Object.freeze( + groups.map((group): DashboardNavigationGroup => { + const id = nestedString(group, "id"); + const label = nestedString(group, "label"); + const modules = isRecord(group) ? group.modules : undefined; + if (id === undefined || label === undefined || !Array.isArray(modules)) { + throw new HostProtocolError("HOST_RESPONSE_INVALID"); + } + return Object.freeze({ + id, + label, + modules: Object.freeze(modules.map((entry) => parseNavigationEntry(entry))), + }); + }), + ), + ungrouped: Object.freeze(ungrouped.map((entry) => parseNavigationEntry(entry))), + }); } /** `/api/v1/principal`'s two documented shapes, narrowed to the browser-owned `ModuleUiPrincipal` @@ -214,21 +281,27 @@ export async function loadHostPrincipal( return parseHostPrincipal(await requestPlatformJson(config, PRINCIPAL_PATH, signal)); } -/** Which module UIs this viewer may mount, where to fetch each, and what they may do inside it. */ -export async function loadModuleUiInventory( +/** The resolved Dashboard navigation: the same mountable modules, arranged as this installation has + * configured them (ADR 0027). */ +export async function loadDashboardNavigation( config: EkklesiaHostConfig, signal?: AbortSignal, -): Promise { - return parseHostModuleUiInventory( - await requestPlatformJson(config, MODULE_UI_INVENTORY_PATH, signal), +): Promise { + return parseDashboardNavigation( + await requestPlatformJson(config, DASHBOARD_NAVIGATION_PATH, signal), ); } export interface HostBootstrap { readonly principal: ModuleUiPrincipal; - readonly modules: readonly HostModuleUiDescriptor[]; + readonly navigation: DashboardNavigation; } +const EMPTY_NAVIGATION: DashboardNavigation = Object.freeze({ + groups: Object.freeze([]), + ungrouped: Object.freeze([]), +}); + /** * The shell's whole startup sequence, in one explicit function: resolve who is viewing, then ask * what they may mount. Sequential rather than parallel on purpose — an anonymous viewer is told to @@ -241,7 +314,7 @@ export async function bootstrapHost( ): Promise { const principal = await loadHostPrincipal(config, signal); if (principal.kind === "anonymous") { - return { modules: Object.freeze([]), principal }; + return { navigation: EMPTY_NAVIGATION, principal }; } - return { modules: await loadModuleUiInventory(config, signal), principal }; + return { navigation: await loadDashboardNavigation(config, signal), principal }; } diff --git a/surfaces/shell/src/index.ts b/surfaces/shell/src/index.ts index 02952f1..df2766f 100644 --- a/surfaces/shell/src/index.ts +++ b/surfaces/shell/src/index.ts @@ -11,14 +11,17 @@ */ export { bootstrapHost, + type DashboardNavigation, + type DashboardNavigationEntry, + type DashboardNavigationGroup, type EkklesiaHostConfig, type HostBootstrap, type HostModuleUiDescriptor, HostProtocolError, type HostProtocolErrorCode, + loadDashboardNavigation, loadHostPrincipal, - loadModuleUiInventory, - parseHostModuleUiInventory, + parseDashboardNavigation, parseHostPrincipal, resolveApiUrl, } from "./host-protocol.js"; diff --git a/surfaces/shell/tests/host-app.test.tsx b/surfaces/shell/tests/host-app.test.tsx index e769829..5f154bb 100644 --- a/surfaces/shell/tests/host-app.test.tsx +++ b/surfaces/shell/tests/host-app.test.tsx @@ -34,25 +34,36 @@ afterEach(async () => { consoleError.mockRestore(); }); -function descriptor(id: string, version = "0.1.0", permissions: readonly string[] = []) { +/** One navigation entry, in the wire shape the Dashboard navigation endpoint produces: the module's + * display name beside the untouched mount descriptor. */ +function entry(id: string, name: string, version = "0.1.0", permissions: readonly string[] = []) { return { - api: { baseUrl: `/api/v1/modules/${id}` }, - id, - permissions, - ui: { url: `/api/v1/module-ui/${id}/${version}` }, - version, + module: { + api: { baseUrl: `/api/v1/modules/${id}` }, + id, + permissions, + ui: { url: `/api/v1/module-ui/${id}/${version}` }, + version, + }, + name, }; } +function group(id: string, label: string, modules: ReturnType[], order = 10) { + return { id, label, modules, order }; +} + interface PlatformStub { - readonly modules?: ReadonlyArray>; + readonly groups?: ReadonlyArray>; + readonly ungrouped?: ReadonlyArray>; readonly principal?: unknown; - readonly inventoryStatus?: number; - readonly inventoryThrows?: boolean; + readonly navigationStatus?: number; + readonly navigationThrows?: boolean; } function platformFetch(stub: PlatformStub = {}): typeof fetch { - const modules = stub.modules ?? [descriptor("example")]; + const groups = stub.groups ?? []; + const ungrouped = stub.ungrouped ?? (stub.groups ? [] : [entry("example", "Example Module")]); return vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.endsWith("/api/v1/principal")) { @@ -61,13 +72,13 @@ function platformFetch(stub: PlatformStub = {}): typeof fetch { { headers: { "content-type": "application/json" }, status: 200 }, ); } - if (url.endsWith("/api/v1/module-ui")) { - if (stub.inventoryThrows) { + if (url.endsWith("/api/v1/dashboard/navigation")) { + if (stub.navigationThrows) { throw new TypeError("network down"); } - return new Response(JSON.stringify({ modules }), { + return new Response(JSON.stringify({ groups, ungrouped }), { headers: { "content-type": "application/json" }, - status: stub.inventoryStatus ?? 200, + status: stub.navigationStatus ?? 200, }); } return new Response("{}", { status: 404 }); @@ -123,10 +134,12 @@ function query(testId: string): Element | null { } describe("Ekklesia shell — bootstrap", () => { - it("renders the module list the inventory returned", async () => { + it("renders the module list the navigation returned", async () => { await render({ apiBaseUrl, - fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + fetch: platformFetch({ + ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], + }), }); expect(query("select-example")).not.toBeNull(); @@ -140,26 +153,174 @@ describe("Ekklesia shell — bootstrap", () => { expect(query("module-container")).toBeNull(); }); - it("treats a refused inventory as signed out rather than as a crash", async () => { - await render({ apiBaseUrl, fetch: platformFetch({ inventoryStatus: 403 }) }); + it("treats refused navigation as signed out rather than as a crash", async () => { + await render({ apiBaseUrl, fetch: platformFetch({ navigationStatus: 403 }) }); expect(query("host-signed-out")).not.toBeNull(); }); it("renders a host-owned error when the platform cannot be reached", async () => { - await render({ apiBaseUrl, fetch: platformFetch({ inventoryThrows: true }) }); + await render({ apiBaseUrl, fetch: platformFetch({ navigationThrows: true }) }); expect(query("host-error")).not.toBeNull(); expect(text()).not.toContain("network down"); }); it("says so plainly when no installed module provides a UI", async () => { - await render({ apiBaseUrl, fetch: platformFetch({ modules: [] }) }); + await render({ apiBaseUrl, fetch: platformFetch({ groups: [], ungrouped: [] }) }); expect(query("host-empty")).not.toBeNull(); }); }); +describe("Ekklesia shell — Dashboard navigation", () => { + const mediaGroup = () => + group("media", "Media", [entry("video", "Video"), entry("livestream", "Livestream", "2.0.0")]); + const planningGroup = () => group("planning", "Planning", [entry("calendar", "Calendar")], 20); + + it("renders groups with their labels and the modules' display names", async () => { + await render({ + apiBaseUrl, + fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }), + }); + + expect(query("group-media")?.textContent).toBe("MediaVideoLivestream"); + expect(query("group-planning")?.textContent).toBe("PlanningCalendar"); + // The module id is never shown; the manifest name is. + expect(text()).not.toContain("livestream"); + }); + + it("renders groups and modules in exactly the server's order", async () => { + // The shell sorts nothing: whatever order arrives is the order shown, so the server stays the + // single authority on arrangement. + await render({ + apiBaseUrl, + fetch: platformFetch({ + groups: [ + group("planning", "Planning", [entry("calendar", "Calendar")]), + group("media", "Media", [ + entry("livestream", "Livestream", "2.0.0"), + entry("video", "Video"), + ]), + ], + }), + }); + + const labels = [...container.querySelectorAll("h2")].map((heading) => heading.textContent); + expect(labels).toEqual(["Planning", "Media"]); + const buttons = [...container.querySelectorAll("button")].map((button) => button.textContent); + expect(buttons).toEqual(["Calendar", "Livestream", "Video"]); + }); + + it("renders ungrouped modules alongside groups, under a heading", async () => { + await render({ + apiBaseUrl, + fetch: platformFetch({ + groups: [mediaGroup()], + ungrouped: [entry("roster", "Roster")], + }), + }); + + expect(query("group-ungrouped")?.textContent).toBe("OtherRoster"); + }); + + it("omits the heading when nothing is grouped, so an unconfigured install reads as a list", async () => { + await render({ + apiBaseUrl, + fetch: platformFetch({ ungrouped: [entry("roster", "Roster"), entry("video", "Video")] }), + }); + + expect(query("group-ungrouped")?.textContent).toBe("RosterVideo"); + expect(container.querySelectorAll("h2")).toHaveLength(0); + }); + + it("keeps an empty group visible", async () => { + await render({ + apiBaseUrl, + fetch: platformFetch({ + groups: [group("media", "Media", [])], + ungrouped: [entry("video", "Video")], + }), + }); + + // The administrator created it; hiding it would look like it had been deleted. + expect(query("group-media")?.textContent).toBe("Media"); + }); + + it("selects the first module in the arranged order and marks it active", async () => { + await render( + { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, + async (url) => artifact(url.includes("/video/") ? "video" : "other", "0.1.0"), + ); + + expect(query("select-video")?.getAttribute("aria-current")).toBe("page"); + expect(query("select-livestream")?.getAttribute("aria-current")).toBeNull(); + }); + + it("mounts a module selected from inside a group, using the descriptor the group carried", async () => { + const requested: string[] = []; + await render( + { apiBaseUrl, fetch: platformFetch({ groups: [mediaGroup(), planningGroup()] }) }, + async (url) => { + requested.push(url); + return artifact(url.includes("/calendar/") ? "calendar" : "video", "0.1.0"); + }, + ); + + await act(async () => { + (query("select-calendar") as HTMLButtonElement).click(); + }); + + // The nested descriptor is used unchanged: grouping altered nothing about the artifact URL. + expect(requested).toEqual([ + `${apiBaseUrl}/api/v1/module-ui/video/0.1.0`, + `${apiBaseUrl}/api/v1/module-ui/calendar/0.1.0`, + ]); + expect(query("module-container")?.textContent).toBe("calendar mounted"); + }); + + it("mounts the identical artifact whichever group a module is presented in", async () => { + const requestedWhenGrouped: string[] = []; + await render( + { + apiBaseUrl, + fetch: platformFetch({ + groups: [group("media", "Media", [entry("livestream", "Livestream")])], + }), + }, + async (url) => { + requestedWhenGrouped.push(url); + return artifact("livestream", "0.1.0"); + }, + ); + await act(async () => { + root.unmount(); + }); + + // The same module, presented somewhere else entirely. + root = createRoot(container); + const requestedWhenMoved: string[] = []; + await render( + { + apiBaseUrl, + fetch: platformFetch({ + groups: [ + group("sunday-production", "Sunday Production", [entry("livestream", "Livestream")], 5), + ], + }), + }, + async (url) => { + requestedWhenMoved.push(url); + return artifact("livestream", "0.1.0"); + }, + ); + + // Presentation changed; the module did not. + expect(requestedWhenMoved).toEqual(requestedWhenGrouped); + expect(query("group-sunday-production")?.textContent).toBe("Sunday ProductionLivestream"); + }); +}); + describe("Ekklesia shell — mount lifecycle", () => { it("mounts the first module into the host's own container", async () => { const mountedInto: Element[] = []; @@ -175,7 +336,7 @@ describe("Ekklesia shell — mount lifecycle", () => { expect(mountedInto[0]).not.toBe(document.body); }); - it("imports exactly the URL the inventory published, resolved against the API origin", async () => { + it("imports exactly the URL the navigation published, resolved against the API origin", async () => { const requested: string[] = []; await render({ apiBaseUrl, fetch: platformFetch() }, async (url) => { requested.push(url); @@ -229,7 +390,9 @@ describe("Ekklesia shell — mount lifecycle", () => { await render( { apiBaseUrl, - fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + fetch: platformFetch({ + ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], + }), }, importModule, ); @@ -275,7 +438,9 @@ describe("Ekklesia shell — mount lifecycle", () => { await render( { apiBaseUrl, - fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + fetch: platformFetch({ + ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], + }), }, importModule, ); @@ -352,7 +517,9 @@ describe("Ekklesia shell — failure is host-owned", () => { await render( { apiBaseUrl, - fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + fetch: platformFetch({ + ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], + }), }, importModule, ); @@ -389,7 +556,9 @@ describe("Ekklesia shell — failure is host-owned", () => { await render( { apiBaseUrl, - fetch: platformFetch({ modules: [descriptor("example"), descriptor("calendar", "2.0.0")] }), + fetch: platformFetch({ + ungrouped: [entry("example", "Example Module"), entry("calendar", "Calendar", "2.0.0")], + }), }, importModule, ); diff --git a/surfaces/shell/tests/host-protocol.test.ts b/surfaces/shell/tests/host-protocol.test.ts index 1b930b1..edf75f7 100644 --- a/surfaces/shell/tests/host-protocol.test.ts +++ b/surfaces/shell/tests/host-protocol.test.ts @@ -1,11 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import { bootstrapHost, - type EkklesiaHostConfig, type HostModuleUiDescriptor, + loadDashboardNavigation, loadHostPrincipal, - loadModuleUiInventory, - parseHostModuleUiInventory, + parseDashboardNavigation, parseHostPrincipal, } from "../src/host-protocol.js"; import { createModuleUiContext, moduleUiSourceUrl } from "../src/module-context.js"; @@ -47,6 +46,37 @@ const inventoryPayload = { ], }; +const calendarDescriptor: HostModuleUiDescriptor = { + apiBaseUrl: "/api/v1/modules/calendar", + id: "calendar", + permissions: [], + uiUrl: "/api/v1/module-ui/calendar/2.0.0", + version: "2.0.0", +}; + +const navigationPayload = { + groups: [ + { + id: "media", + label: "Media", + modules: [{ module: inventoryPayload.modules[0], name: "Example Module" }], + order: 10, + }, + ], + ungrouped: [ + { + module: { + api: { baseUrl: "/api/v1/modules/calendar" }, + id: "calendar", + permissions: [], + ui: { url: "/api/v1/module-ui/calendar/2.0.0" }, + version: "2.0.0", + }, + name: "Calendar", + }, + ], +}; + const exampleDescriptor: HostModuleUiDescriptor = { apiBaseUrl: "/api/v1/modules/example", id: "example", @@ -55,69 +85,57 @@ const exampleDescriptor: HostModuleUiDescriptor = { version: "0.1.0", }; -describe("host protocol — inventory", () => { - it("parses the server's descriptor into exactly what a host context needs", () => { - expect(parseHostModuleUiInventory(inventoryPayload)).toEqual([exampleDescriptor]); +function navigationWithModule(module: unknown): unknown { + return { groups: [], ungrouped: [{ module, name: "Example Module" }] }; +} + +describe("host protocol — descriptor validation", () => { + it("parses a well-formed descriptor out of the navigation view", () => { + const navigation = parseDashboardNavigation(navigationWithModule(inventoryPayload.modules[0])); + + expect(navigation.ungrouped[0]?.descriptor).toEqual(exampleDescriptor); }); - it("rejects a malformed document rather than yielding a half-built descriptor", () => { + it("rejects a malformed descriptor rather than yielding a half-built one", () => { + const base = inventoryPayload.modules[0] as Record; const malformed: unknown[] = [ {}, - { modules: {} }, - { modules: [{ id: "example", version: "0.1.0" }] }, - { modules: [{ ...inventoryPayload.modules[0], ui: {} }] }, - { modules: [{ ...inventoryPayload.modules[0], api: { baseUrl: "" } }] }, - { modules: [{ ...inventoryPayload.modules[0], permissions: "example.records.read" }] }, - { modules: [{ ...inventoryPayload.modules[0], permissions: [42] }] }, + { id: "example", version: "0.1.0" }, + { ...base, ui: {} }, + { ...base, api: { baseUrl: "" } }, + { ...base, permissions: "example.records.read" }, + { ...base, permissions: [42] }, // Both URLs must be root-relative, so a value that would resolve against another origin is // refused rather than silently used to fetch browser code. - { - modules: [ - { ...inventoryPayload.modules[0], ui: { url: "https://evil.example.test/x.js" } }, - ], - }, - { modules: [{ ...inventoryPayload.modules[0], ui: { url: "//evil.example.test/x.js" } }] }, - { modules: [{ ...inventoryPayload.modules[0], ui: { url: "relative/x.js" } }] }, - { - modules: [ - { ...inventoryPayload.modules[0], api: { baseUrl: "https://evil.example.test/api" } }, - ], - }, + { ...base, ui: { url: "https://evil.example.test/x.js" } }, + { ...base, ui: { url: "//evil.example.test/x.js" } }, + { ...base, ui: { url: "relative/x.js" } }, + { ...base, api: { baseUrl: "https://evil.example.test/api" } }, ]; - for (const payload of malformed) { - expect(() => parseHostModuleUiInventory(payload)).toThrowError( - expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }), - ); + for (const module of malformed) { + expect( + () => parseDashboardNavigation(navigationWithModule(module)), + JSON.stringify(module), + ).toThrowError(expect.objectContaining({ code: "HOST_RESPONSE_INVALID" })); } }); - it("fetches the inventory from the platform path with credentials", async () => { - const { calls, implementation } = stubFetch({ - "/api/v1/module-ui": () => jsonResponse(inventoryPayload), - }); - const config: EkklesiaHostConfig = { apiBaseUrl, fetch: implementation }; - - await loadModuleUiInventory(config); - - expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/module-ui`); - expect(calls[0]?.init?.credentials).toBe("include"); - }); - it("reports an unauthenticated caller distinctly from any other failure", async () => { for (const status of [401, 403]) { const { implementation } = stubFetch({ - "/api/v1/module-ui": () => jsonResponse({ code: "AUTHORIZATION_DENIED" }, status), + "/api/v1/dashboard/navigation": () => + jsonResponse({ code: "AUTHORIZATION_DENIED" }, status), }); await expect( - loadModuleUiInventory({ apiBaseUrl, fetch: implementation }), + loadDashboardNavigation({ apiBaseUrl, fetch: implementation }), ).rejects.toMatchObject({ code: "HOST_UNAUTHENTICATED" }); } const { implementation } = stubFetch({ - "/api/v1/module-ui": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500), + "/api/v1/dashboard/navigation": () => jsonResponse({ code: "INTERNAL_ERROR" }, 500), }); await expect( - loadModuleUiInventory({ apiBaseUrl, fetch: implementation }), + loadDashboardNavigation({ apiBaseUrl, fetch: implementation }), ).rejects.toMatchObject({ code: "HOST_REQUEST_FAILED" }); }); @@ -126,7 +144,7 @@ describe("host protocol — inventory", () => { throw new Error("connect ECONNREFUSED /run/postgres/.s.PGSQL.5432"); }) as unknown as typeof fetch; - const error = await loadModuleUiInventory({ apiBaseUrl, fetch: failing }).catch( + const error = await loadDashboardNavigation({ apiBaseUrl, fetch: failing }).catch( (caught: unknown) => caught, ); @@ -167,10 +185,63 @@ describe("host protocol — principal", () => { }); }); -describe("host protocol — bootstrap", () => { - it("resolves the principal and then the inventory", async () => { +describe("host protocol — Dashboard navigation", () => { + it("parses the server's resolved arrangement, keeping the mount descriptor nested", () => { + const navigation = parseDashboardNavigation(navigationPayload); + + expect(navigation.groups.map((group) => [group.id, group.label])).toEqual([["media", "Media"]]); + expect(navigation.groups[0]?.modules).toEqual([ + { descriptor: exampleDescriptor, name: "Example Module" }, + ]); + expect(navigation.ungrouped).toEqual([{ descriptor: calendarDescriptor, name: "Calendar" }]); + }); + + it("rejects a malformed navigation document rather than rendering half of it", () => { + const malformed: unknown[] = [ + {}, + { groups: [] }, + { groups: {}, ungrouped: [] }, + { groups: [{ id: "media", label: "Media" }], ungrouped: [] }, + { groups: [{ id: "media", modules: [] }], ungrouped: [] }, + { groups: [], ungrouped: [{ name: "Example" }] }, + { groups: [], ungrouped: [{ module: inventoryPayload.modules[0] }] }, + // An absolute artifact URL must be refused here exactly as in the inventory. + { + groups: [], + ungrouped: [ + { + module: { + ...inventoryPayload.modules[0], + ui: { url: "https://evil.example.test/x.js" }, + }, + name: "Example", + }, + ], + }, + ]; + for (const payload of malformed) { + expect(() => parseDashboardNavigation(payload), JSON.stringify(payload)).toThrowError( + expect.objectContaining({ code: "HOST_RESPONSE_INVALID" }), + ); + } + }); + + it("fetches navigation from the platform path with credentials", async () => { const { calls, implementation } = stubFetch({ - "/api/v1/module-ui": () => jsonResponse(inventoryPayload), + "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), + }); + + await loadDashboardNavigation({ apiBaseUrl, fetch: implementation }); + + expect(calls[0]?.url).toBe(`${apiBaseUrl}/api/v1/dashboard/navigation`); + expect(calls[0]?.init?.credentials).toBe("include"); + }); +}); + +describe("host protocol — bootstrap", () => { + it("resolves the principal and then the Dashboard navigation", async () => { + const { calls, implementation } = stubFetch({ + "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), "/api/v1/principal": () => jsonResponse({ kind: "authenticated", userId: "user-1" }), }); @@ -178,22 +249,22 @@ describe("host protocol — bootstrap", () => { expect(calls.map((call) => call.url)).toEqual([ `${apiBaseUrl}/api/v1/principal`, - `${apiBaseUrl}/api/v1/module-ui`, + `${apiBaseUrl}/api/v1/dashboard/navigation`, ]); expect(bootstrap.principal).toEqual({ kind: "authenticated", userId: "user-1" }); - expect(bootstrap.modules).toEqual([exampleDescriptor]); + expect(bootstrap.navigation.groups[0]?.modules[0]?.descriptor).toEqual(exampleDescriptor); }); - it("never requests the inventory for an anonymous viewer", async () => { + it("never requests navigation for an anonymous viewer", async () => { const { calls, implementation } = stubFetch({ - "/api/v1/module-ui": () => jsonResponse(inventoryPayload), + "/api/v1/dashboard/navigation": () => jsonResponse(navigationPayload), "/api/v1/principal": () => jsonResponse({ kind: "anonymous" }), }); const bootstrap = await bootstrapHost({ apiBaseUrl, fetch: implementation }); expect(bootstrap.principal).toEqual({ kind: "anonymous" }); - expect(bootstrap.modules).toEqual([]); + expect(bootstrap.navigation).toEqual({ groups: [], ungrouped: [] }); expect(calls.map((call) => call.url)).toEqual([`${apiBaseUrl}/api/v1/principal`]); }); diff --git a/surfaces/shell/tests/modularity.test.ts b/surfaces/shell/tests/modularity.test.ts index acf732a..fe48af3 100644 --- a/surfaces/shell/tests/modularity.test.ts +++ b/surfaces/shell/tests/modularity.test.ts @@ -144,9 +144,24 @@ describe("the shell builds no module knowledge into its bundle", () => { expect(bundle).not.toContain("entrypoints"); }); - it("spells only the two platform paths it genuinely owns", () => { + it("spells only the platform paths it genuinely owns", () => { + // The platform's own endpoints — never a module's. Everything module-specific arrives at run + // time from the navigation the server resolved. expect(bundle).toContain('"/api/v1/principal"'); - expect(bundle).toContain('"/api/v1/module-ui"'); + expect(bundle).toContain('"/api/v1/dashboard/navigation"'); + // The shell reads the navigation view, which already contains the inventory; it never calls + // the host-neutral inventory endpoint itself. + expect(bundle).not.toContain('"/api/v1/module-ui"'); + }); + + it("bakes in no group, label, or placement", () => { + // Grouping is server-resolved configuration. A group id or label appearing in the bundle would + // mean the shell had an opinion about an arrangement it must only render. + // Deliberately distinctive terms: a generic word like "media" appears inside React's own + // bundled source and would make this assertion meaningless rather than strict. + for (const term of ["planning", "sunday-production", "livestream", "calendar", "ungrouped-"]) { + expect(bundle.toLowerCase(), term).not.toContain(term); + } }); it("uses neither eval nor a blob URL to load module code", () => { diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 5e414f1..33ded6c 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "core/tests/database-tls.integration.ts", "core/tests/authorization-store.integration.ts", "core/tests/authorization-bootstrap.integration.ts", + "core/tests/dashboard-store.integration.ts", "apps/api/tests/api-http.integration.ts", "apps/api/tests/module-database.integration.ts", ], -- 2.54.0