diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index bc777b9..d243d3b 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -70,6 +70,15 @@ class FrontendRoute: order: int = 100 +@dataclass(frozen=True, slots=True) +class PublicFrontendRoute: + """Explicitly allowlisted route that can render without authentication.""" + + path: str + component: str + order: int = 100 + + @dataclass(frozen=True, slots=True) class FrontendModule: module_id: str @@ -80,6 +89,7 @@ class FrontendModule: asset_manifest_integrity: str | None = None asset_manifest_contract_version: str = "1" routes: tuple[FrontendRoute, ...] = () + public_routes: tuple[PublicFrontendRoute, ...] = () nav_items: tuple[NavItem, ...] = () settings_routes: tuple[FrontendRoute, ...] = () diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index 6a81587..2339344 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -16,6 +16,7 @@ from govoplan_core.core.modules import ( ModuleManifest, NavItem, PermissionDefinition, + PublicFrontendRoute, ResourceAclProvider, RoleTemplate, SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION, @@ -195,6 +196,7 @@ class PlatformRegistry: available_capabilities=available_capabilities, ) permissions = _collect_manifest_permissions(ordered) + _validate_public_frontend_route_uniqueness(ordered) _validate_interface_closure(ordered) _validate_role_template_scopes(ordered, known_scopes=set(permissions)) @@ -434,7 +436,7 @@ def _validate_manifest_frontend(manifest: ModuleManifest) -> None: ) if frontend.package_name is not None and not _NPM_PACKAGE_RE.match(frontend.package_name): raise RegistryError(f"Module {manifest.id!r} has invalid frontend package name {frontend.package_name!r}") - for route in (*frontend.routes, *frontend.settings_routes): + for route in (*frontend.routes, *frontend.settings_routes, *frontend.public_routes): _validate_frontend_route(manifest.id, route.path, route.component) for item in frontend.nav_items: _validate_nav_item(manifest.id, item) @@ -447,6 +449,24 @@ def _validate_frontend_route(module_id: str, path: str, component: str) -> None: raise RegistryError(f"Frontend route {path!r} for module {module_id!r} must declare a component") +def _validate_public_frontend_route_uniqueness( + manifests: tuple[ModuleManifest, ...], +) -> None: + owners: dict[str, tuple[str, PublicFrontendRoute]] = {} + for manifest in manifests: + if manifest.frontend is None: + continue + for route in manifest.frontend.public_routes: + previous = owners.get(route.path) + if previous is not None: + previous_module, _previous_route = previous + raise RegistryError( + f"Duplicate public frontend route {route.path!r} in modules " + f"{previous_module!r} and {manifest.id!r}" + ) + owners[route.path] = (manifest.id, route) + + def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None: providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list) for manifest in manifests: diff --git a/src/govoplan_core/server/platform.py b/src/govoplan_core/server/platform.py index 45ac7b1..1883767 100644 --- a/src/govoplan_core/server/platform.py +++ b/src/govoplan_core/server/platform.py @@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError from govoplan_core.admin.models import SystemSettings from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID from govoplan_core.core.maintenance import saved_maintenance_mode -from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem +from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem, PublicFrontendRoute from govoplan_core.core.registry import PlatformRegistry from govoplan_core.db.session import get_database from govoplan_core.i18n import system_i18n_payload @@ -41,6 +41,14 @@ def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]: } +def _public_frontend_route_payload(route: PublicFrontendRoute) -> dict[str, object]: + return { + "path": route.path, + "component": route.component, + "order": route.order, + } + + def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None: if frontend is None: return None @@ -53,11 +61,31 @@ def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | No "asset_manifest_integrity": frontend.asset_manifest_integrity, "asset_manifest_contract_version": frontend.asset_manifest_contract_version, "routes": [_frontend_route_payload(route) for route in frontend.routes], + "public_routes": [ + _public_frontend_route_payload(route) for route in frontend.public_routes + ], "nav": [_nav_item_payload(item) for item in frontend.nav_items], "settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes], } +def _public_frontend_payload(frontend: FrontendModule) -> dict[str, object]: + """Return only assets and routes explicitly approved for signed-out use.""" + + return { + "module_id": frontend.module_id, + "package_name": frontend.package_name, + "asset_manifest": frontend.asset_manifest, + "asset_manifest_signature": frontend.asset_manifest_signature, + "asset_manifest_public_key_id": frontend.asset_manifest_public_key_id, + "asset_manifest_integrity": frontend.asset_manifest_integrity, + "asset_manifest_contract_version": frontend.asset_manifest_contract_version, + "public_routes": [ + _public_frontend_route_payload(route) for route in frontend.public_routes + ], + } + + def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]: capabilities: list[str] = [] if manifest_id == "mail": @@ -104,6 +132,23 @@ def create_platform_router(settings: object | None = None) -> APIRouter: ] } + @router.get("/public-modules") + def public_modules(request: Request): + registry = _registry(request) + return { + "modules": [ + { + "id": manifest.id, + "name": manifest.name, + "version": manifest.version, + "frontend": _public_frontend_payload(manifest.frontend), + } + for manifest in registry.manifests() + if manifest.frontend is not None + and manifest.frontend.public_routes + ] + } + @router.get("/permissions") def permissions(request: Request): registry = _registry(request) diff --git a/tests/test_conditional_requests.py b/tests/test_conditional_requests.py index 45aecec..cb89214 100644 --- a/tests/test_conditional_requests.py +++ b/tests/test_conditional_requests.py @@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase): ) with TestClient(app) as client: - for path in ("/api/v1/platform/status", "/api/v1/platform/modules"): + for path in ( + "/api/v1/platform/status", + "/api/v1/platform/modules", + "/api/v1/platform/public-modules", + ): first = client.get(path) self.assertEqual(200, first.status_code, first.text) etag = first.headers.get("etag") diff --git a/tests/test_module_system.py b/tests/test_module_system.py index 59d3bc4..9d7823a 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -30,7 +30,7 @@ os.environ.setdefault("DATABASE_URL", f"sqlite:///{_TEST_ROOT / 'module-test.db' os.environ.setdefault("DEV_BOOTSTRAP_ENABLED", "false") os.environ.setdefault("CELERY_ENABLED", "false") -from fastapi import APIRouter +from fastapi import APIRouter, FastAPI from fastapi.testclient import TestClient from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey @@ -99,7 +99,7 @@ from govoplan_core.core.module_package_catalog import ( sign_module_package_catalog, validate_module_package_catalog, ) -from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult +from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleMigrationTask, ModuleMigrationTaskContext, ModuleMigrationTaskResult, ModuleUninstallGuardResult, PublicFrontendRoute from govoplan_core.core.module_guards import drop_table_retirement_provider from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate from govoplan_core.core.registry import PlatformRegistry, RegistryError @@ -112,6 +112,7 @@ from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.permissions import scope_grants from govoplan_core.server.app import create_app from govoplan_core.server.config import GovoplanServerConfig +from govoplan_core.server.platform import create_platform_router from govoplan_core.server.registry import available_module_manifests, build_platform_registry from govoplan_core.server.route_validation import RouteCollisionError from govoplan_core.tenancy.scope import Tenant, create_scope_tables @@ -338,7 +339,7 @@ class ModuleSystemTests(unittest.TestCase): self.assertEqual(manifest.id, manifest.frontend.module_id) if manifest.frontend.package_name is not None: self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE) - for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes): + for route in (*manifest.frontend.routes, *manifest.frontend.settings_routes, *manifest.frontend.public_routes): self.assertTrue(route.path.startswith("/")) self.assertTrue(route.component.strip()) for item in (*manifest.nav_items, *manifest.frontend.nav_items): @@ -457,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase): with self.assertRaisesRegex(RegistryError, "Frontend route"): registry.validate() + def test_public_frontend_routes_are_explicitly_allowlisted(self) -> None: + registry = PlatformRegistry() + registry.register(ModuleManifest( + id="example", + name="Example", + version="test", + frontend=FrontendModule( + module_id="example", + package_name="@govoplan/example-webui", + routes=(FrontendRoute(path="/example", component="ExamplePage"),), + public_routes=( + PublicFrontendRoute( + path="/example/public/:token", + component="ExamplePublicPage", + ), + ), + ), + )) + registry.register(ModuleManifest( + id="private", + name="Private", + version="test", + frontend=FrontendModule( + module_id="private", + package_name="@govoplan/private-webui", + routes=(FrontendRoute(path="/private", component="PrivatePage"),), + ), + )) + registry.validate() + + app = FastAPI() + app.state.govoplan_registry = registry + app.include_router(create_platform_router(), prefix="/api/v1") + with TestClient(app) as client: + response = client.get("/api/v1/platform/public-modules") + + self.assertEqual(response.status_code, 200, response.text) + self.assertEqual(["example"], [item["id"] for item in response.json()["modules"]]) + public_module = response.json()["modules"][0] + self.assertNotIn("dependencies", public_module) + self.assertNotIn("nav", public_module["frontend"]) + self.assertNotIn("routes", public_module["frontend"]) + self.assertEqual( + ["/example/public/:token"], + [item["path"] for item in public_module["frontend"]["public_routes"]], + ) + + def test_registry_rejects_duplicate_public_frontend_routes(self) -> None: + registry = PlatformRegistry() + for module_id in ("first", "second"): + registry.register(ModuleManifest( + id=module_id, + name=module_id.title(), + version="test", + frontend=FrontendModule( + module_id=module_id, + public_routes=( + PublicFrontendRoute( + path="/shared/public/:token", + component=f"{module_id.title()}PublicPage", + ), + ), + ), + )) + + with self.assertRaisesRegex(RegistryError, "Duplicate public frontend route"): + registry.validate() + def test_server_startup_rejects_duplicate_configured_routes(self) -> None: first = APIRouter() second = APIRouter() diff --git a/webui/src/App.tsx b/webui/src/App.tsx index cfd19f9..50f70a1 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -1,14 +1,14 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { lazy, Suspense, useEffect, useMemo, useState } from "react"; import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth"; -import { fetchPlatformModules, fetchPlatformStatus } from "./api/platform"; +import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform"; import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client"; -import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformWebModule, UserUiPreferences } from "./types"; +import type { ApiSettings, AuthInfo, AuthSessionInfo, AuthUpdate, AuthUser, LoginResponse, PlatformModuleInfo, PlatformPublicModuleInfo, PlatformWebModule, UserUiPreferences } from "./types"; import AppShell from "./layout/AppShell"; import PublicLandingPage from "./features/auth/PublicLandingPage"; import LoginModal from "./features/auth/LoginModal"; import { PermissionBoundary } from "./components/AccessBoundary"; -import { firstAccessibleRoute, loadRemoteWebModules, moduleInstalled, navItemsForModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules"; +import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules } from "./platform/modules"; import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents"; import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard"; @@ -30,7 +30,9 @@ export default function App() { const [auth, setAuth] = useState(null); const [checkingSession, setCheckingSession] = useState(true); const [platformModules, setPlatformModules] = useState(null); + const [platformPublicModules, setPlatformPublicModules] = useState(null); const [remoteWebModules, setRemoteWebModules] = useState([]); + const [remotePublicWebModules, setRemotePublicWebModules] = useState([]); const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null }); const [backendReachable, setBackendReachable] = useState(true); const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null); @@ -38,9 +40,13 @@ export default function App() { const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]); const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]); + const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]); + const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]); const navItems = useMemo(() => navItemsForModules(webModules), [webModules]); const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]); - const moduleTranslations = useMemo(() => webModules.map((module) => module.translations).filter(Boolean), [webModules]); + const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]); + const contextModules = auth ? webModules : publicWebModules; + const moduleTranslations = useMemo(() => contextModules.map((module) => module.translations).filter(Boolean), [contextModules]); const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]); function updateSettings(next: ApiSettings) { @@ -119,6 +125,14 @@ export default function App() { }; }, [settings.apiBaseUrl]); + useEffect(() => { + let cancelled = false; + fetchPlatformPublicModules(settings). + then((response) => {if (!cancelled) setPlatformPublicModules(response.modules);}). + catch(() => {if (!cancelled) setPlatformPublicModules(null);}); + return () => {cancelled = true;}; + }, [settings.apiBaseUrl, settings.apiKey]); + useEffect(() => { let cancelled = false; setCheckingSession(true); @@ -233,6 +247,18 @@ export default function App() { return () => {cancelled = true;}; }, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]); + useEffect(() => { + let cancelled = false; + if (!platformPublicModules?.length) { + setRemotePublicWebModules([]); + return () => {cancelled = true;}; + } + loadRemotePublicWebModules(platformPublicModules, localPublicWebModules). + then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}). + catch(() => {if (!cancelled) setRemotePublicWebModules([]);}); + return () => {cancelled = true;}; + }, [platformPublicModules, localPublicWebModules]); + useEffect(() => { if (!auth) return; @@ -284,7 +310,7 @@ export default function App() { if (checkingSession) { return ( - +
@@ -304,10 +330,21 @@ export default function App() { if (!auth) { return ( - + - +

i18n:govoplan-core.loading_module.50161f3c

}> + + {publicRoutes.map((route) => + + )} + } /> + +
@@ -342,6 +379,13 @@ export default function App() { } /> {!dashboardModuleInstalled && } />} + {publicRoutes.map((route) => + + )} {moduleRoutes.map((route) => (settings, "/api/v1/platform/modules"); } +export async function fetchPlatformPublicModules(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/platform/public-modules"); +} + export async function fetchPlatformStatus(settings: ApiSettings): Promise { return apiFetch(settings, "/api/v1/platform/status"); } diff --git a/webui/src/platform/moduleLogic.ts b/webui/src/platform/moduleLogic.ts index e009b1d..06ca646 100644 --- a/webui/src/platform/moduleLogic.ts +++ b/webui/src/platform/moduleLogic.ts @@ -1,4 +1,4 @@ -import type { PlatformRouteContribution, PlatformWebModule } from "../types"; +import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types"; export function uiCapability(capabilityName: string, modules: PlatformWebModule[]): T | null { for (const module of modules) { @@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] { return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); } + +export function publicRouteContributionsForModules(modules: PlatformWebModule[]): PlatformPublicRouteContribution[] { + return modules.flatMap((module) => module.publicRoutes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100)); +} diff --git a/webui/src/platform/modules.ts b/webui/src/platform/modules.ts index a2d91b2..180b7c1 100644 --- a/webui/src/platform/modules.ts +++ b/webui/src/platform/modules.ts @@ -1,10 +1,11 @@ import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, Folder, Form, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, Users, type LucideIcon } from "lucide-react"; import installedWebModules from "virtual:govoplan-installed-modules"; -import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, PlatformModuleInfo, PlatformNavItem, PlatformWebModule } from "../types"; +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 @@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo 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) @@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo }; } +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; @@ -294,6 +346,10 @@ 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 ?? []). diff --git a/webui/src/types.ts b/webui/src/types.ts index 2704251..062596a 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -245,6 +245,12 @@ export type PlatformRouteContext = { onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void; }; +export type PlatformPublicRouteContext = { + settings: ApiSettings; + auth: AuthInfo | null; + onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void; +}; + export type AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string; export type AdminSectionRenderContext = PlatformRouteContext & { @@ -298,6 +304,12 @@ export type PlatformRouteContribution = { render: (context: PlatformRouteContext) => ReactNode; }; +export type PlatformPublicRouteContribution = { + path: string; + order?: number; + render: (context: PlatformPublicRouteContext) => ReactNode; +}; + export type PlatformUiCapabilities = Record; export type PlatformTranslationDictionary = Record; @@ -317,6 +329,7 @@ export type PlatformWebModule = { remoteAssetContractVersion?: string | null; navItems?: PlatformNavItem[]; routes?: PlatformRouteContribution[]; + publicRoutes?: PlatformPublicRouteContribution[]; translations?: PlatformTranslations; uiCapabilities?: PlatformUiCapabilities; runtimeUiCapabilities?: PlatformUiCapabilities; @@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = { asset_manifest_integrity?: string | null; asset_manifest_contract_version?: string | null; routes: PlatformFrontendRouteInfo[]; + public_routes: Array<{ + path: string; + component: string; + order: number; + }>; nav: Array<{ path: string; label: string; @@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = { settings_routes: PlatformFrontendRouteInfo[]; }; +export type PlatformPublicModuleInfo = { + id: string; + name: string; + version: string; + frontend: Pick< + PlatformFrontendModuleInfo, + | "module_id" + | "package_name" + | "asset_manifest" + | "asset_manifest_signature" + | "asset_manifest_public_key_id" + | "asset_manifest_integrity" + | "asset_manifest_contract_version" + | "public_routes" + >; +}; + export type PlatformModuleInfo = { id: string; name: string;