import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, Waypoints, type LucideIcon } from "lucide-react"; import installedWebModules from "virtual:govoplan-installed-modules"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, 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"; export const fallbackDashboardNavItem: PlatformNavItem = { to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 }; export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] { return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem]; } const localModules: PlatformWebModule[] = installedWebModules; 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, file: Folder, files: Folder, folder: Folder, form: Form, "layout-template": LayoutTemplate, mail: Mail, notifications: Bell, organizations: Building2, operator: RadioTower, "radio-tower": RadioTower, reports: ClipboardPenLine, templates: LayoutTemplate, users: Users, waypoints: Waypoints }; 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 }; } 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, publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes), 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 function resolveInstalledPublicWebModules( platformModules: PlatformPublicModuleInfo[] | null | undefined ): PlatformWebModule[] { if (!platformModules?.length) return []; const localById = new Map(localModules.map((module) => [module.id, module])); return platformModules.flatMap((info) => { const local = localById.get(info.id); if (!local) return []; const resolved = applyServerMetadata(local, publicModuleInfo(info)); return resolved.publicRoutes?.length ? [resolved] : []; }); } export async function loadRemotePublicWebModules( platformModules: PlatformPublicModuleInfo[] | null | undefined, alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules) ): Promise { if (!platformModules?.length) return []; const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded); return loaded.filter((module) => module.publicRoutes?.length); } export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] { if (!platformModules?.length) return localModules; const localById = new Map(localModules.map((module) => [module.id, module])); return platformModules. filter((module) => module.enabled). map((module) => { const local = localById.get(module.id); return local ? applyServerMetadata(local, module) : null; }). filter((module): module is PlatformWebModule => module !== null); } export async function loadRemoteWebModules( platformModules: PlatformModuleInfo[] | null | undefined, alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)) : 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 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 localModules; } export function uiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): T | null { return uiCapabilityForModules(capabilityName, modules); } export function uiCapabilities(capabilityName: string, modules: PlatformWebModule[] = localModules): T[] { return uiCapabilitiesForModules(capabilityName, modules); } export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): boolean { return hasUiCapabilityForModules(capabilityName, modules); } export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = localModules): boolean { return moduleInstalledForModules(moduleId, modules); } export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = localModules): 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[] = localModules): DashboardWidgetContribution[] { return uiCapabilitiesForModules("dashboard.widgets", modules). flatMap((capability) => capability.widgets ?? []). sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } export function navItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] { return [...shellNavItemsForModules(modules), ...modules.flatMap((module) => module.navItems ?? [])]. map(resolveNavItemIcon). sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules): PlatformNavItem[] { return navItemsForModules(modules).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[] = localModules): string { return visibleNavItems(auth, modules)[0]?.to ?? "/dashboard"; }