import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react"; import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types"; import { hasUiCapability as hasUiCapabilityForModules, moduleInstalled as moduleInstalledForModules, moduleIntegrationEnabled as moduleIntegrationEnabledForModules, publicRouteContributionsForModules as publicRouteContributionsForModuleList, routeContributionsForModules as routeContributionsForModuleList, uiCapabilities as uiCapabilitiesForModules, uiCapability as uiCapabilityForModules } from "./moduleLogic"; import { hasAnyScope, hasScope } from "../utils/permissions"; import { isViewSurfaceVisible, navigationViewSurfaceId, routeViewSurfaceId, viewSurfaceCatalogueForModules } from "./views"; export const fallbackDashboardNavItem: PlatformNavItem = { to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 }; type InstalledWebModuleLoader = { packageName: string; load: () => Promise<{ default: PlatformWebModule }>; }; const installedWebModuleLoaders = installedWebModuleLoaderSource as unknown as InstalledWebModuleLoader[]; export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] { return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem]; } const localModuleCache = new Map>(); const loadedLocalModules = new Map(); const remoteModuleCache = new Map>(); declare global { interface Window { __GOVOPLAN_REMOTE_MODULE_KEYS__?: Record; } } type RemoteAssetManifest = { contractVersion?: string; moduleId?: string; entry?: string; entryIntegrity?: string; moduleExport?: string; }; const iconByName: Record = { activity: Activity, admin: Shield, bell: Bell, "book-user": BookUser, building: Building2, "building-2": Building2, calendar: CalendarDays, "calendar-clock": CalendarClock, campaign: Mails, "clipboard-pen-line": ClipboardPenLine, dashboard: LayoutDashboard, "database-zap": DatabaseZap, file: Folder, files: Folder, folder: Folder, form: Form, inbox: Inbox, "layout-template": LayoutTemplate, mail: Mail, notifications: Bell, organizations: Building2, operator: RadioTower, "radio-tower": RadioTower, reports: ClipboardPenLine, "shield-check": ShieldCheck, templates: LayoutTemplate, users: Users, waypoints: Waypoints, workflow: WorkflowIcon }; export function iconComponentForName(iconName: string | null | undefined): LucideIcon | undefined { if (!iconName) return undefined; return iconByName[iconName]; } function resolveNavItemIcon(item: PlatformNavItem): PlatformNavItem { return { ...item, icon: item.icon ?? iconComponentForName(item.iconName) }; } function navFromMetadata(item: PlatformModuleInfo["nav"][number]): PlatformNavItem { return { to: item.path, label: item.label, iconName: item.icon ?? null, allOf: item.required_all, anyOf: item.required_any, order: item.order, surfaceId: item.surface_id ?? undefined }; } function viewSurfaceFromMetadata( item: NonNullable["view_surfaces"]>[number] ): PlatformViewSurface { return { id: item.id, moduleId: item.module_id, kind: item.kind, label: item.label, parentId: item.parent_id, description: item.description, order: item.order, defaultVisible: item.default_visible, required: item.required }; } function mergeViewSurfaces( module: PlatformWebModule, info: PlatformModuleInfo ): PlatformViewSurface[] | undefined { const surfaces = new Map(); for (const surface of info.frontend?.view_surfaces ?? []) { const normalized = viewSurfaceFromMetadata(surface); surfaces.set(normalized.id, normalized); } for (const surface of module.viewSurfaces ?? []) { surfaces.set(surface.id, { ...surfaces.get(surface.id), ...surface, required: Boolean(surfaces.get(surface.id)?.required || surface.required) }); } return surfaces.size ? [...surfaces.values()] : undefined; } function routesWithServerMetadata( module: PlatformWebModule, info: PlatformModuleInfo ) { const routesByPath = new Map( (info.frontend?.routes ?? []).map((route) => [route.path, route]) ); return (module.routes ?? []).map((route) => { const metadata = routesByPath.get(route.path); return { ...route, surfaceId: metadata?.surface_id ?? route.surfaceId ?? routeViewSurfaceId(module.id, route.path) }; }); } function runtimeUiCapabilitiesForModule(module: PlatformWebModule, info: PlatformModuleInfo) { const enabledNames = new Set(info.runtime_ui_capabilities ?? []); if (enabledNames.size === 0 || !module.runtimeUiCapabilities) return {}; return Object.fromEntries( Object.entries(module.runtimeUiCapabilities).filter(([name]) => enabledNames.has(name)) ); } function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo): PlatformWebModule { const backendNav = info.frontend?.nav.length ? info.frontend.nav : info.nav; return { ...module, label: info.name, version: info.version, dependencies: info.dependencies, optionalDependencies: info.optional_dependencies, remoteAssetManifest: info.frontend?.asset_manifest ?? module.remoteAssetManifest, remoteAssetSignature: info.frontend?.asset_manifest_signature ?? module.remoteAssetSignature, remoteAssetPublicKeyId: info.frontend?.asset_manifest_public_key_id ?? module.remoteAssetPublicKeyId, remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity, remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion, navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems, routes: routesWithServerMetadata(module, info), publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes), viewSurfaces: mergeViewSurfaces(module, info), uiCapabilities: { ...(module.uiCapabilities ?? {}), ...runtimeUiCapabilitiesForModule(module, info) } }; } function filterPublicRoutes( module: PlatformWebModule, routes: NonNullable["public_routes"] | undefined ) { if (!routes?.length) return []; const allowedPaths = new Set(routes.map((route) => route.path)); return (module.publicRoutes ?? []).filter((route) => allowedPaths.has(route.path)); } function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo { return { id: info.id, name: info.name, version: info.version, dependencies: [], optional_dependencies: [], enabled: true, runtime_ui_capabilities: [], nav: [], frontend: { ...info.frontend, routes: [], nav: [], settings_routes: [] } }; } export async function loadInstalledPublicWebModules( platformModules: PlatformPublicModuleInfo[] | null | undefined ): Promise { if (!platformModules?.length) return []; const loaded = await loadInstalledWebModules(platformModules.map(publicModuleInfo)); return loaded.filter((module) => module.publicRoutes?.length); } export async function loadRemotePublicWebModules( platformModules: PlatformPublicModuleInfo[] | null | undefined, alreadyLoaded: PlatformWebModule[] = [] ): Promise { if (!platformModules?.length) return []; const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded); return loaded.filter((module) => module.publicRoutes?.length); } export async function loadInstalledWebModules( platformModules: PlatformModuleInfo[] | null | undefined ): Promise { if (!platformModules?.length) return []; const enabledModules = platformModules.filter((module) => module.enabled); const resolved = await Promise.all(enabledModules.map(async (info) => { const local = await loadInstalledWebModule(info); return local ? applyServerMetadata(local, info) : null; })); return resolved.filter((module): module is PlatformWebModule => module !== null); } export async function loadRemoteWebModules( platformModules: PlatformModuleInfo[] | null | undefined, alreadyLoaded: PlatformWebModule[] = []) : Promise { if (!platformModules?.length) return []; const loadedIds = new Set(alreadyLoaded.map((module) => module.id)); const candidates = platformModules.filter((module) => { return module.enabled && !loadedIds.has(module.id) && Boolean(module.frontend?.asset_manifest); }); if (candidates.length === 0) return []; const loaded = await Promise.all(candidates.map((module) => { const cacheKey = `${module.id}:${module.frontend?.asset_manifest ?? ""}:${module.version}`; let promise = remoteModuleCache.get(cacheKey); if (!promise) { promise = loadRemoteWebModule(module).catch((error) => { console.warn("GovOPlaN remote module was not loaded:", module.id, error); return null; }); remoteModuleCache.set(cacheKey, promise); } return promise; })); return loaded.filter((module): module is PlatformWebModule => module !== null); } async function loadInstalledWebModule(info: PlatformModuleInfo): Promise { if (!info.frontend) return null; const packageName = info.frontend?.package_name; if (packageName) { const loader = installedWebModuleLoaders.find((candidate) => candidate.packageName === packageName); return loader ? loadInstalledWebModulePackage(loader, info.id) : null; } // Legacy manifests may omit package_name. Keep compatibility without making // every modern module eager: only this fallback imports all installed descriptors. const candidates = await Promise.all( installedWebModuleLoaders.map((loader) => loadInstalledWebModulePackage(loader)) ); return candidates.find((candidate) => candidate?.id === info.id) ?? null; } async function loadInstalledWebModulePackage( loader: InstalledWebModuleLoader, expectedModuleId?: string ): Promise { let promise = localModuleCache.get(loader.packageName); if (!promise) { promise = loader.load(). then((imported) => { const module = imported.default; if (!isPlatformWebModule(module)) { throw new Error(`${loader.packageName} does not export a PlatformWebModule`); } loadedLocalModules.set(loader.packageName, module); return module; }). catch((error) => { localModuleCache.delete(loader.packageName); console.warn("GovOPlaN installed WebUI module was not loaded:", loader.packageName, error); return null; }); localModuleCache.set(loader.packageName, promise); } const module = await promise; if (module && expectedModuleId && module.id !== expectedModuleId) { console.warn( "GovOPlaN installed WebUI package id mismatch:", loader.packageName, `expected ${expectedModuleId}, received ${module.id}` ); return null; } return module; } async function loadRemoteWebModule(info: PlatformModuleInfo): Promise { const frontend = info.frontend; if (!frontend?.asset_manifest) return null; const manifestUrl = absoluteModuleAssetUrl(frontend.asset_manifest); const manifestResponse = await fetch(manifestUrl, { credentials: "same-origin" }); if (!manifestResponse.ok) throw new Error(`manifest fetch failed with HTTP ${manifestResponse.status}`); const manifestBuffer = await manifestResponse.arrayBuffer(); if (frontend.asset_manifest_integrity) { const ok = await verifyIntegrity(manifestBuffer, frontend.asset_manifest_integrity); if (!ok) throw new Error("i18n:govoplan-core.manifest_integrity_check_failed.6e3913bc"); } const manifestText = new TextDecoder().decode(manifestBuffer); if (frontend.asset_manifest_signature && frontend.asset_manifest_public_key_id) { const signatureOk = await verifyManifestSignature( manifestBuffer, frontend.asset_manifest_signature, frontend.asset_manifest_public_key_id ); if (signatureOk === false) throw new Error("i18n:govoplan-core.manifest_signature_check_failed.a9860176"); if (signatureOk === null && !frontend.asset_manifest_integrity) throw new Error("i18n:govoplan-core.manifest_signature_key_is_not_trusted_by_this_sh.3bb2f629"); } else if (!frontend.asset_manifest_integrity) { throw new Error("i18n:govoplan-core.remote_manifests_need_an_integrity_hash_or_a_ver.bfbb7daa"); } const manifest = JSON.parse(manifestText) as RemoteAssetManifest; const contractVersion = manifest.contractVersion ?? frontend.asset_manifest_contract_version ?? "1"; if (contractVersion !== "1") throw new Error(`unsupported remote asset contract ${contractVersion}`); if (manifest.moduleId && manifest.moduleId !== info.id) throw new Error(`manifest module id ${manifest.moduleId} does not match ${info.id}`); if (!manifest.entry) throw new Error("i18n:govoplan-core.remote_manifest_has_no_entry.8220dbc9"); if (!manifest.entryIntegrity) throw new Error("i18n:govoplan-core.remote_entry_needs_an_integrity_hash.3b69ae0a"); const entryUrl = absoluteModuleAssetUrl(manifest.entry, manifestUrl); const entryResponse = await fetch(entryUrl, { credentials: "same-origin" }); if (!entryResponse.ok) throw new Error(`entry fetch failed with HTTP ${entryResponse.status}`); const entryBuffer = await entryResponse.arrayBuffer(); if (!(await verifyIntegrity(entryBuffer, manifest.entryIntegrity))) throw new Error("i18n:govoplan-core.entry_integrity_check_failed.1d8f6b89"); const blobUrl = URL.createObjectURL(new Blob([entryBuffer], { type: "text/javascript" })); try { const imported = (await import(/* @vite-ignore */blobUrl)) as Record; const exported = manifest.moduleExport ? imported[manifest.moduleExport] : imported.default ?? imported.module; if (!isPlatformWebModule(exported)) throw new Error("i18n:govoplan-core.entry_did_not_export_a_platformwebmodule.9a157741"); if (exported.id !== info.id) throw new Error(`entry module id ${exported.id} does not match ${info.id}`); return applyServerMetadata(exported, info); } finally { URL.revokeObjectURL(blobUrl); } } function absoluteModuleAssetUrl(value: string, base = window.location.origin): string { return new URL(value, base).toString(); } async function verifyIntegrity(buffer: ArrayBuffer, integrity: string): Promise { const separator = integrity.indexOf("-"); const algorithm = separator > 0 ? integrity.slice(0, separator) : ""; const expected = separator > 0 ? integrity.slice(separator + 1) : ""; if (!algorithm || !expected) return false; const digestName = { "SHA-256": "SHA-256", "SHA-384": "SHA-384", "SHA-512": "SHA-512", SHA256: "SHA-256", SHA384: "SHA-384", SHA512: "SHA-512" }[algorithm.toUpperCase()]; if (!digestName) return false; const digest = await crypto.subtle.digest(digestName, buffer); return constantTimeEqual(base64FromBuffer(digest), expected) || constantTimeEqual(hexFromBuffer(digest), expected.toLowerCase()); } async function verifyManifestSignature(buffer: ArrayBuffer, signature: string, keyId: string): Promise { const jwk = window.__GOVOPLAN_REMOTE_MODULE_KEYS__?.[keyId]; if (!jwk) return null; const isOkp = jwk.kty === "OKP"; const importAlgorithm = isOkp ? { name: "Ed25519" } : { name: "ECDSA", namedCurve: jwk.crv ?? "P-256" }; const verifyAlgorithm = isOkp ? { name: "Ed25519" } : { name: "ECDSA", hash: "SHA-256" }; const key = await crypto.subtle.importKey("jwk", jwk, importAlgorithm as AlgorithmIdentifier, false, ["verify"]); return crypto.subtle.verify(verifyAlgorithm as AlgorithmIdentifier, key, base64ToBuffer(signature), buffer); } function isPlatformWebModule(value: unknown): value is PlatformWebModule { if (!value || typeof value !== "object") return false; const item = value as Partial; return typeof item.id === "string" && typeof item.label === "string" && typeof item.version === "string"; } function base64FromBuffer(buffer: ArrayBuffer): string { let binary = ""; for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte); return btoa(binary); } function base64ToBuffer(value: string): ArrayBuffer { const normalized = value.trim().replace(/-/g, "+").replace(/_/g, "/"); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); const binary = atob(padded); const buffer = new ArrayBuffer(binary.length); const bytes = new Uint8Array(buffer); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return buffer; } function hexFromBuffer(buffer: ArrayBuffer): string { return Array.from(new Uint8Array(buffer)).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } function constantTimeEqual(left: string, right: string): boolean { if (left.length !== right.length) return false; let result = 0; for (let index = 0; index < left.length; index += 1) result |= left.charCodeAt(index) ^ right.charCodeAt(index); return result === 0; } export function installedLocalWebModules(): PlatformWebModule[] { return [...loadedLocalModules.values()]; } export function uiCapability(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T | null { return uiCapabilityForModules(capabilityName, modules); } export function uiCapabilities(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T[] { return uiCapabilitiesForModules(capabilityName, modules); } export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean { return hasUiCapabilityForModules(capabilityName, modules); } export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean { return moduleInstalledForModules(moduleId, modules); } export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean { return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules); } export function routeContributionsForModules(modules: PlatformWebModule[]) { return routeContributionsForModuleList(modules); } export function publicRouteContributionsForModules(modules: PlatformWebModule[]) { return publicRouteContributionsForModuleList(modules); } export function dashboardWidgetsForModules( modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null ): DashboardWidgetContribution[] { const catalogue = viewSurfaceCatalogueForModules(modules); return uiCapabilitiesForModules("dashboard.widgets", modules). flatMap((capability) => capability.widgets ?? []). filter((widget) => isViewSurfaceVisible(projection, widget.surfaceId, catalogue)). sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } export function navItemsForModules( modules: PlatformWebModule[], projection?: EffectiveViewProjection | null ): PlatformNavItem[] { const catalogue = viewSurfaceCatalogueForModules(modules); const moduleItems = modules.flatMap((module) => (module.navItems ?? []).map((item) => ({ ...item, surfaceId: item.surfaceId ?? navigationViewSurfaceId(module.id, item.to) })) ); return [...shellNavItemsForModules(modules), ...moduleItems]. map(resolveNavItemIcon). filter((item) => isViewSurfaceVisible(projection, item.surfaceId, catalogue)). sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] { return navItemsForModules(modules, projection).filter((item) => { if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false; if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false; return true; }); } export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): string { return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard"; }