import type { EkklesiaPrincipal, PermissionId } from "@ekklesia/contracts"; import { type AuthorizationPlatformOwnerPort, CORE_DEVICES_MANAGE, CORE_MODULES_CONFIGURATION_MANAGE, CORE_MODULES_LIFECYCLE_MANAGE, CORE_MODULES_UI_READ, } from "@ekklesia/core/authorization"; import type { ModuleLifecycleSnapshot } from "@ekklesia/core/modules"; import { SETTINGS_SECTIONS, type SettingsSection } from "@ekklesia/shell/dashboard-routes"; import { Hono } from "hono"; import { type AuthorizationMiddlewareOptions, getAuthorizedPrincipal, requirePermission, } from "./authorization-middleware.js"; export const DASHBOARD_SETTINGS_PATH = "/api/v1/dashboard/settings"; /** One module's settings destination: its id (the `/settings/` segment), its display * name, and whether it is running. A disabled or blocked module is listed so its address can say it * is unavailable, and flagged so the Dashboard never offers it in navigation. */ export interface DashboardSettingsModuleView { readonly id: string; readonly name: string; readonly available: boolean; } export interface DashboardSettingsResponse { /** The platform sections this viewer may see, in navigation order. */ readonly sections: readonly SettingsSection[]; /** Modules whose settings this viewer may see, by name. */ readonly modules: readonly DashboardSettingsModuleView[]; } /** * Who may see each platform section (ADR 0061). * * A section whose administration already has a Core permission uses it, so it is delegable today. * General, Branding, and System have no permission yet — nothing in them exists to authorize — so * they are shown only to the **platform owner**, the one principal who already holds every * permission; each gains its own delegable permission in the work that builds it. */ const SECTION_ACCESS: Readonly> = Object.freeze({ branding: "platform-owner", // Finding, installing, turning on and off, and updating modules: deciding which modules run. discover: CORE_MODULES_LIFECYCLE_MANAGE, displays: CORE_DEVICES_MANAGE, general: "platform-owner", system: "platform-owner", }); export interface CreateDashboardSettingsRoutesOptions extends AuthorizationMiddlewareOptions { /** The boot snapshot: every module this process discovered, and which it started. A module's * settings exist when its manifest declares configuration (ADR 0029). */ readonly snapshot: ModuleLifecycleSnapshot; readonly platformOwners: AuthorizationPlatformOwnerPort; } function compareText(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } /** * The Dashboard's Settings navigation, evaluated for the viewer. * * Like the module permission hints in the navigation (ADR 0026), these are **presentation hints, * not authority**: they decide which Settings destinations the Dashboard offers, so nobody is shown * administration they cannot carry out (DESIGN.md §9.2). Every route behind a section keeps its own * enforcement, unchanged. Each answer goes through Core's one evaluator — baselines, grants, expiry, * and the owner bypass behave exactly as on a real request — and an evaluation failure is a `503`, * never an empty answer that would look like a permissions problem. * * Reading requires `core.modules.ui.read`, the same permission the navigation it sits beside * requires: any signed-in person may ask, and most will be told there is nothing for them. */ export function createDashboardSettingsRoutes(options: CreateDashboardSettingsRoutesOptions): Hono { const routes = new Hono(); const requireRead = requirePermission(options, CORE_MODULES_UI_READ); const started = new Set(options.snapshot.startedModuleIds); const modulesWithSettings = options.snapshot.discoveredModules .filter((module) => module.manifest.configuration.length > 0) .map((module) => ({ id: module.manifest.id, name: module.manifest.name })) .sort((left, right) => compareText(left.name, right.name) || compareText(left.id, right.id)); const holds = async ( principal: EkklesiaPrincipal, permission: PermissionId, ): Promise => { const declaration = options.registry.get(permission); if (!declaration) { return false; } return ( await options.evaluator.authorize({ baseline: declaration.baseline, permission, principal, }) ).allowed; }; routes.get(DASHBOARD_SETTINGS_PATH, requireRead, async (c) => { c.header("Cache-Control", "no-store"); c.header("Vary", "Origin, Cookie"); const principal = getAuthorizedPrincipal(c); try { let owner: boolean | undefined; const isOwner = async (): Promise => { owner ??= principal.kind === "authenticated" && (await options.platformOwners.isPlatformOwner(principal.userId)); return owner; }; const sections: SettingsSection[] = []; for (const section of SETTINGS_SECTIONS) { const access = SECTION_ACCESS[section]; if (access === "platform-owner" ? await isOwner() : await holds(principal, access)) { sections.push(section); } } const modules = (await holds(principal, CORE_MODULES_CONFIGURATION_MANAGE)) ? modulesWithSettings.map((module) => ({ ...module, available: started.has(module.id) })) : []; return c.json({ modules, sections } satisfies DashboardSettingsResponse, 200); } catch { return c.json({ code: "AUTHORIZATION_EVALUATION_FAILED" }, 503); } }); return routes; }