feat(core): support explicit public module routes
This commit is contained in:
@@ -70,6 +70,15 @@ class FrontendRoute:
|
|||||||
order: int = 100
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class FrontendModule:
|
class FrontendModule:
|
||||||
module_id: str
|
module_id: str
|
||||||
@@ -80,6 +89,7 @@ class FrontendModule:
|
|||||||
asset_manifest_integrity: str | None = None
|
asset_manifest_integrity: str | None = None
|
||||||
asset_manifest_contract_version: str = "1"
|
asset_manifest_contract_version: str = "1"
|
||||||
routes: tuple[FrontendRoute, ...] = ()
|
routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
public_routes: tuple[PublicFrontendRoute, ...] = ()
|
||||||
nav_items: tuple[NavItem, ...] = ()
|
nav_items: tuple[NavItem, ...] = ()
|
||||||
settings_routes: tuple[FrontendRoute, ...] = ()
|
settings_routes: tuple[FrontendRoute, ...] = ()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from govoplan_core.core.modules import (
|
|||||||
ModuleManifest,
|
ModuleManifest,
|
||||||
NavItem,
|
NavItem,
|
||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
|
PublicFrontendRoute,
|
||||||
ResourceAclProvider,
|
ResourceAclProvider,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
|
||||||
@@ -195,6 +196,7 @@ class PlatformRegistry:
|
|||||||
available_capabilities=available_capabilities,
|
available_capabilities=available_capabilities,
|
||||||
)
|
)
|
||||||
permissions = _collect_manifest_permissions(ordered)
|
permissions = _collect_manifest_permissions(ordered)
|
||||||
|
_validate_public_frontend_route_uniqueness(ordered)
|
||||||
_validate_interface_closure(ordered)
|
_validate_interface_closure(ordered)
|
||||||
_validate_role_template_scopes(ordered, known_scopes=set(permissions))
|
_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):
|
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}")
|
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)
|
_validate_frontend_route(manifest.id, route.path, route.component)
|
||||||
for item in frontend.nav_items:
|
for item in frontend.nav_items:
|
||||||
_validate_nav_item(manifest.id, item)
|
_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")
|
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:
|
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||||
for manifest in manifests:
|
for manifest in manifests:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
|||||||
from govoplan_core.admin.models import SystemSettings
|
from govoplan_core.admin.models import SystemSettings
|
||||||
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
from govoplan_core.admin.settings import SYSTEM_SETTINGS_ID
|
||||||
from govoplan_core.core.maintenance import saved_maintenance_mode
|
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.core.registry import PlatformRegistry
|
||||||
from govoplan_core.db.session import get_database
|
from govoplan_core.db.session import get_database
|
||||||
from govoplan_core.i18n import system_i18n_payload
|
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:
|
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||||
if frontend is None:
|
if frontend is None:
|
||||||
return 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_integrity": frontend.asset_manifest_integrity,
|
||||||
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
"asset_manifest_contract_version": frontend.asset_manifest_contract_version,
|
||||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
"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],
|
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
"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]:
|
def _runtime_ui_capabilities(manifest_id: str, settings: object | None, registry: PlatformRegistry) -> list[str]:
|
||||||
capabilities: list[str] = []
|
capabilities: list[str] = []
|
||||||
if manifest_id == "mail":
|
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")
|
@router.get("/permissions")
|
||||||
def permissions(request: Request):
|
def permissions(request: Request):
|
||||||
registry = _registry(request)
|
registry = _registry(request)
|
||||||
|
|||||||
@@ -87,7 +87,11 @@ class ConditionalRequestTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
with TestClient(app) as client:
|
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)
|
first = client.get(path)
|
||||||
self.assertEqual(200, first.status_code, first.text)
|
self.assertEqual(200, first.status_code, first.text)
|
||||||
etag = first.headers.get("etag")
|
etag = first.headers.get("etag")
|
||||||
|
|||||||
@@ -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("DEV_BOOTSTRAP_ENABLED", "false")
|
||||||
os.environ.setdefault("CELERY_ENABLED", "false")
|
os.environ.setdefault("CELERY_ENABLED", "false")
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from cryptography.hazmat.primitives import serialization
|
from cryptography.hazmat.primitives import serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
@@ -99,7 +99,7 @@ from govoplan_core.core.module_package_catalog import (
|
|||||||
sign_module_package_catalog,
|
sign_module_package_catalog,
|
||||||
validate_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.module_guards import drop_table_retirement_provider
|
||||||
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest, RoleTemplate
|
||||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
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.security.permissions import scope_grants
|
||||||
from govoplan_core.server.app import create_app
|
from govoplan_core.server.app import create_app
|
||||||
from govoplan_core.server.config import GovoplanServerConfig
|
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.registry import available_module_manifests, build_platform_registry
|
||||||
from govoplan_core.server.route_validation import RouteCollisionError
|
from govoplan_core.server.route_validation import RouteCollisionError
|
||||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
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)
|
self.assertEqual(manifest.id, manifest.frontend.module_id)
|
||||||
if manifest.frontend.package_name is not None:
|
if manifest.frontend.package_name is not None:
|
||||||
self.assertRegex(manifest.frontend.package_name, _PACKAGE_NAME_RE)
|
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.path.startswith("/"))
|
||||||
self.assertTrue(route.component.strip())
|
self.assertTrue(route.component.strip())
|
||||||
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
for item in (*manifest.nav_items, *manifest.frontend.nav_items):
|
||||||
@@ -457,6 +458,74 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
with self.assertRaisesRegex(RegistryError, "Frontend route"):
|
||||||
registry.validate()
|
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:
|
def test_server_startup_rejects_duplicate_configured_routes(self) -> None:
|
||||||
first = APIRouter()
|
first = APIRouter()
|
||||||
second = APIRouter()
|
second = APIRouter()
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
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 { 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 AppShell from "./layout/AppShell";
|
||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
import LoginModal from "./features/auth/LoginModal";
|
import LoginModal from "./features/auth/LoginModal";
|
||||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
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 { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||||
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||||
@@ -30,7 +30,9 @@ export default function App() {
|
|||||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||||
|
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
|
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||||
const [backendReachable, setBackendReachable] = useState(true);
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
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 localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
||||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
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 navItems = useMemo(() => navItemsForModules(webModules), [webModules]);
|
||||||
const moduleRoutes = useMemo(() => routeContributionsForModules(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]);
|
const dashboardModuleInstalled = useMemo(() => moduleInstalled("dashboard", webModules), [webModules]);
|
||||||
|
|
||||||
function updateSettings(next: ApiSettings) {
|
function updateSettings(next: ApiSettings) {
|
||||||
@@ -119,6 +125,14 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [settings.apiBaseUrl]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setCheckingSession(true);
|
setCheckingSession(true);
|
||||||
@@ -233,6 +247,18 @@ export default function App() {
|
|||||||
return () => {cancelled = true;};
|
return () => {cancelled = true;};
|
||||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
|
|
||||||
@@ -284,7 +310,7 @@ export default function App() {
|
|||||||
if (checkingSession) {
|
if (checkingSession) {
|
||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<div className="public-landing">
|
<div className="public-landing">
|
||||||
@@ -304,10 +330,21 @@ export default function App() {
|
|||||||
if (!auth) {
|
if (!auth) {
|
||||||
return (
|
return (
|
||||||
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
<PlatformLanguageProvider systemAvailableLanguages={systemLanguages?.available} systemEnabledLanguageCodes={systemLanguages?.enabled} defaultLanguage={systemLanguages?.defaultLanguage} moduleTranslations={moduleTranslations}>
|
||||||
<PlatformModulesProvider modules={webModules}>
|
<PlatformModulesProvider modules={publicWebModules}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />
|
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
||||||
|
<Routes>
|
||||||
|
{publicRoutes.map((route) =>
|
||||||
|
<Route
|
||||||
|
key={`public:${route.path}`}
|
||||||
|
path={route.path}
|
||||||
|
element={route.render({ settings, auth: null, onAuthChange: updateAuth })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</PlatformModulesProvider>
|
</PlatformModulesProvider>
|
||||||
@@ -342,6 +379,13 @@ export default function App() {
|
|||||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||||
|
{publicRoutes.map((route) =>
|
||||||
|
<Route
|
||||||
|
key={`public:${route.path}`}
|
||||||
|
path={route.path}
|
||||||
|
element={route.render({ settings, auth, onAuthChange: updateAuth })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{moduleRoutes.map((route) =>
|
{moduleRoutes.map((route) =>
|
||||||
<Route
|
<Route
|
||||||
key={route.path}
|
key={route.path}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { ApiSettings } from "../types";
|
import type { ApiSettings } from "../types";
|
||||||
import type { PlatformModuleInfo } from "../types";
|
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
|
||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
|
||||||
|
export type PlatformPublicModulesResponse = { modules: PlatformPublicModuleInfo[] };
|
||||||
|
|
||||||
export type PlatformStatusResponse = {
|
export type PlatformStatusResponse = {
|
||||||
maintenance_mode: {
|
maintenance_mode: {
|
||||||
@@ -38,6 +39,10 @@ export async function fetchPlatformModules(settings: ApiSettings): Promise<Platf
|
|||||||
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
return apiFetch<PlatformModulesResponse>(settings, "/api/v1/platform/modules");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPlatformPublicModules(settings: ApiSettings): Promise<PlatformPublicModulesResponse> {
|
||||||
|
return apiFetch<PlatformPublicModulesResponse>(settings, "/api/v1/platform/public-modules");
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
export async function fetchPlatformStatus(settings: ApiSettings): Promise<PlatformStatusResponse> {
|
||||||
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
return apiFetch<PlatformStatusResponse>(settings, "/api/v1/platform/status");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PlatformRouteContribution, PlatformWebModule } from "../types";
|
import type { PlatformPublicRouteContribution, PlatformRouteContribution, PlatformWebModule } from "../types";
|
||||||
|
|
||||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[]): T | null {
|
||||||
for (const module of modules) {
|
for (const module of modules) {
|
||||||
@@ -32,3 +32,7 @@ export function moduleIntegrationEnabled(moduleId: string, dependencyId: string,
|
|||||||
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
export function routeContributionsForModules(modules: PlatformWebModule[]): PlatformRouteContribution[] {
|
||||||
return modules.flatMap((module) => module.routes ?? []).sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { 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 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 {
|
import {
|
||||||
hasUiCapability as hasUiCapabilityForModules,
|
hasUiCapability as hasUiCapabilityForModules,
|
||||||
moduleInstalled as moduleInstalledForModules,
|
moduleInstalled as moduleInstalledForModules,
|
||||||
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
moduleIntegrationEnabled as moduleIntegrationEnabledForModules,
|
||||||
|
publicRouteContributionsForModules as publicRouteContributionsForModuleList,
|
||||||
routeContributionsForModules as routeContributionsForModuleList,
|
routeContributionsForModules as routeContributionsForModuleList,
|
||||||
uiCapabilities as uiCapabilitiesForModules,
|
uiCapabilities as uiCapabilitiesForModules,
|
||||||
uiCapability as uiCapabilityForModules } from
|
uiCapability as uiCapabilityForModules } from
|
||||||
@@ -109,6 +110,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
remoteAssetIntegrity: info.frontend?.asset_manifest_integrity ?? module.remoteAssetIntegrity,
|
||||||
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
remoteAssetContractVersion: info.frontend?.asset_manifest_contract_version ?? module.remoteAssetContractVersion,
|
||||||
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
navItems: backendNav.length ? backendNav.map(navFromMetadata) : module.navItems,
|
||||||
|
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
|
||||||
uiCapabilities: {
|
uiCapabilities: {
|
||||||
...(module.uiCapabilities ?? {}),
|
...(module.uiCapabilities ?? {}),
|
||||||
...runtimeUiCapabilitiesForModule(module, info)
|
...runtimeUiCapabilitiesForModule(module, info)
|
||||||
@@ -116,6 +118,56 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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[] {
|
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
||||||
if (!platformModules?.length) return localModules;
|
if (!platformModules?.length) return localModules;
|
||||||
|
|
||||||
@@ -294,6 +346,10 @@ export function routeContributionsForModules(modules: PlatformWebModule[]) {
|
|||||||
return routeContributionsForModuleList(modules);
|
return routeContributionsForModuleList(modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function publicRouteContributionsForModules(modules: PlatformWebModule[]) {
|
||||||
|
return publicRouteContributionsForModuleList(modules);
|
||||||
|
}
|
||||||
|
|
||||||
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
export function dashboardWidgetsForModules(modules: PlatformWebModule[] = localModules): DashboardWidgetContribution[] {
|
||||||
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
return uiCapabilitiesForModules<DashboardWidgetsUiCapability>("dashboard.widgets", modules).
|
||||||
flatMap((capability) => capability.widgets ?? []).
|
flatMap((capability) => capability.widgets ?? []).
|
||||||
|
|||||||
@@ -245,6 +245,12 @@ export type PlatformRouteContext = {
|
|||||||
onAuthChange?: (auth: AuthUpdate | null, accessToken?: string) => void;
|
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 AdminSectionGroup = "ROOT" | "SYSTEM" | "TENANT" | "GROUP" | "USER" | string;
|
||||||
|
|
||||||
export type AdminSectionRenderContext = PlatformRouteContext & {
|
export type AdminSectionRenderContext = PlatformRouteContext & {
|
||||||
@@ -298,6 +304,12 @@ export type PlatformRouteContribution = {
|
|||||||
render: (context: PlatformRouteContext) => ReactNode;
|
render: (context: PlatformRouteContext) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PlatformPublicRouteContribution = {
|
||||||
|
path: string;
|
||||||
|
order?: number;
|
||||||
|
render: (context: PlatformPublicRouteContext) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
export type PlatformUiCapabilities = Record<string, unknown>;
|
export type PlatformUiCapabilities = Record<string, unknown>;
|
||||||
|
|
||||||
export type PlatformTranslationDictionary = Record<string, string>;
|
export type PlatformTranslationDictionary = Record<string, string>;
|
||||||
@@ -317,6 +329,7 @@ export type PlatformWebModule = {
|
|||||||
remoteAssetContractVersion?: string | null;
|
remoteAssetContractVersion?: string | null;
|
||||||
navItems?: PlatformNavItem[];
|
navItems?: PlatformNavItem[];
|
||||||
routes?: PlatformRouteContribution[];
|
routes?: PlatformRouteContribution[];
|
||||||
|
publicRoutes?: PlatformPublicRouteContribution[];
|
||||||
translations?: PlatformTranslations;
|
translations?: PlatformTranslations;
|
||||||
uiCapabilities?: PlatformUiCapabilities;
|
uiCapabilities?: PlatformUiCapabilities;
|
||||||
runtimeUiCapabilities?: PlatformUiCapabilities;
|
runtimeUiCapabilities?: PlatformUiCapabilities;
|
||||||
@@ -751,6 +764,11 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
asset_manifest_integrity?: string | null;
|
asset_manifest_integrity?: string | null;
|
||||||
asset_manifest_contract_version?: string | null;
|
asset_manifest_contract_version?: string | null;
|
||||||
routes: PlatformFrontendRouteInfo[];
|
routes: PlatformFrontendRouteInfo[];
|
||||||
|
public_routes: Array<{
|
||||||
|
path: string;
|
||||||
|
component: string;
|
||||||
|
order: number;
|
||||||
|
}>;
|
||||||
nav: Array<{
|
nav: Array<{
|
||||||
path: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -763,6 +781,23 @@ export type PlatformFrontendModuleInfo = {
|
|||||||
settings_routes: PlatformFrontendRouteInfo[];
|
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 = {
|
export type PlatformModuleInfo = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user