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]>
337 lines
13 KiB
TypeScript
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;
|
|
}
|