Files
govoplan-core/webui/src/platform/modules.ts

378 lines
16 KiB
TypeScript

import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, 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<string, Promise<PlatformWebModule | null>>();
declare global {
interface Window {
__GOVOPLAN_REMOTE_MODULE_KEYS__?: Record<string, JsonWebKey>;
}
}
type RemoteAssetManifest = {
contractVersion?: string;
moduleId?: string;
entry?: string;
entryIntegrity?: string;
moduleExport?: string;
};
const iconByName: Record<string, LucideIcon> = {
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,
"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<PlatformModuleInfo["frontend"]>["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<PlatformWebModule[]> {
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<PlatformWebModule[]> {
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<PlatformWebModule | null> {
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<string, unknown>;
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<boolean> {
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<boolean | null> {
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<PlatformWebModule>;
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<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T | null {
return uiCapabilityForModules<T>(capabilityName, modules);
}
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T[] {
return uiCapabilitiesForModules<T>(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<DashboardWidgetsUiCapability>("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";
}