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; }