Release v0.1.6
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import type { PlatformWebModule } from "../types";
|
||||
import { moduleInstalled, uiCapability } from "./modules";
|
||||
import { moduleInstalled, uiCapabilities, uiCapability } from "./modules";
|
||||
|
||||
const PlatformModulesContext = createContext<PlatformWebModule[]>([]);
|
||||
|
||||
@@ -19,3 +19,7 @@ export function usePlatformModuleInstalled(moduleId: string): boolean {
|
||||
export function usePlatformUiCapability<T = unknown>(capabilityName: string): T | null {
|
||||
return uiCapability<T>(capabilityName, usePlatformModules());
|
||||
}
|
||||
|
||||
export function usePlatformUiCapabilities<T = unknown>(capabilityName: string): T[] {
|
||||
return uiCapabilities<T>(capabilityName, usePlatformModules());
|
||||
}
|
||||
|
||||
5
webui/src/platform/moduleEvents.ts
Normal file
5
webui/src/platform/moduleEvents.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const PLATFORM_MODULES_CHANGED_EVENT = "govoplan:platform-modules-changed";
|
||||
|
||||
export function dispatchPlatformModulesChanged() {
|
||||
window.dispatchEvent(new CustomEvent(PLATFORM_MODULES_CHANGED_EVENT));
|
||||
}
|
||||
@@ -8,6 +8,13 @@ export function uiCapability<T = unknown>(capabilityName: string, modules: Platf
|
||||
return null;
|
||||
}
|
||||
|
||||
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T[] {
|
||||
return modules.flatMap((module) => {
|
||||
const value = module.uiCapabilities?.[capabilityName];
|
||||
return value === undefined ? [] : [value as T];
|
||||
});
|
||||
}
|
||||
|
||||
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[]): boolean {
|
||||
return uiCapability(capabilityName, modules) !== null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Users, type LucideIcon } from "lucide-react";
|
||||
import { Activity, CalendarDays, FileText, Folder, Form, LayoutDashboard, Mail, Mails, Shield, Users, type LucideIcon } from "lucide-react";
|
||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
||||
import type { AuthInfo, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types";
|
||||
import {
|
||||
@@ -6,18 +6,36 @@ import {
|
||||
moduleInstalled as moduleInstalledForModules,
|
||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||
routeContributionsForModules as routeContributionsForModuleList,
|
||||
uiCapabilities as uiCapabilitiesForModules,
|
||||
uiCapability as uiCapabilityForModules
|
||||
} from "./moduleLogic";
|
||||
import { adminReadScopes, hasAnyScope, hasScope } from "../utils/permissions";
|
||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||
|
||||
export const shellNavItems: PlatformNavItem[] = [
|
||||
{ to: "/dashboard", label: "Dashboard", iconName: "dashboard", order: 10 }
|
||||
];
|
||||
|
||||
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,
|
||||
calendar: CalendarDays,
|
||||
campaign: Mails,
|
||||
dashboard: LayoutDashboard,
|
||||
file: Folder,
|
||||
@@ -69,6 +87,11 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
||||
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,
|
||||
uiCapabilities: {
|
||||
...(module.uiCapabilities ?? {}),
|
||||
@@ -90,6 +113,142 @@ export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[]
|
||||
.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 ${module.id} was not loaded:`, 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("manifest integrity check failed");
|
||||
}
|
||||
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("manifest signature check failed");
|
||||
if (signatureOk === null && !frontend.asset_manifest_integrity) throw new Error("manifest signature key is not trusted by this shell");
|
||||
} else if (!frontend.asset_manifest_integrity) {
|
||||
throw new Error("remote manifests need an integrity hash or a verifiable signature");
|
||||
}
|
||||
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("remote manifest has no entry");
|
||||
if (!manifest.entryIntegrity) throw new Error("remote entry needs an integrity hash");
|
||||
|
||||
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("entry integrity check failed");
|
||||
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("entry did not export a PlatformWebModule");
|
||||
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;
|
||||
@@ -99,6 +258,10 @@ export function uiCapability<T = unknown>(capabilityName: string, modules: Platf
|
||||
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);
|
||||
}
|
||||
@@ -132,6 +295,5 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat
|
||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules): string {
|
||||
const preferred = visibleNavItems(auth, modules).find((item) => item.to !== "/dashboard");
|
||||
if (preferred) return preferred.to;
|
||||
if (hasAnyScope(auth, adminReadScopes)) return "/admin";
|
||||
return "/dashboard";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user