Files
Ekklesia/apps/api/src/dashboard-routes.ts
T
ekklesiaandClaude Opus 5.5 4671622b9d Add the module navigation contract: manifest v9, module routes, return-to-origin
Stage 1 PR 7 (ADR 0063). A module becomes a first-class part of the Dashboard.

- Manifest v9 = v8 plus optional `navigation`: a Lucide icon from the curated
  MODULE_NAVIGATION_ICONS set, and up to 12 primary pages (stable id, safe
  label, kebab-case path relative to the module root; no traversal, schemes,
  queries, duplicates, or nesting). Validated before activation.
- Version 8 stays accepted beside version 9, each read strictly as itself
  (discriminated union); no released manifest is rewritten. The template
  moves to v9; the released Signage 0.1.5 manifest is a test fixture.
- Real module routes: /<id> is the landing view, /<id>/<page> a declared
  page, anything beneath a page a module-owned detail route; anything else
  is not-found and mounts nothing. api/assets/display are never pages.
- Sidebar: declared icons, pages nested under an open module, one
  aria-current, collapsed and drawer behaviour; v8 modules unchanged.
- module-ui-sdk: ModuleUiHostContext.navigation (location, subscribe,
  navigate, link, settings), refusing anything outside the module's routes;
  undefined on the display host. Mount ABI unchanged.
- Return-to-origin: one `?return=` carrier on Settings URLs, validated by
  parseReturnTarget and against running modules' declared routes; Settings
  Back returns to exactly the originating module page, across refreshes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 08:21:50 -05:00

337 lines
13 KiB
TypeScript

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,
type MountableModuleDescription,
} 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;
/**
* What the module declares about its own pages (manifest v9, ADR 0063): the Lucide icon the
* Dashboard draws it with (`null` for the generic one) and its primary pages, in declared order —
* empty for a single-page module, which every manifest v8 module is. The module's identity, never
* its placement: where it sits in the sidebar stays the administrator's (ADR 0027).
*/
readonly navigation: {
readonly icon: string | null;
readonly pages: readonly {
readonly id: string;
readonly label: string;
readonly path: string;
}[];
};
}
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<string>;
}
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<string, unknown> {
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<unknown> {
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: MountableModuleDescription): DashboardNavigationModuleView => ({
module: entry.descriptor,
name: entry.name,
navigation: {
icon: entry.navigation.icon ?? null,
pages: entry.navigation.pages.map((page) => ({
id: page.id,
label: page.label,
path: page.path,
})),
},
});
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;
}