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", ],