Add governed module and interface controls

This commit is contained in:
2026-08-04 05:20:47 +02:00
parent 5bc7d748f8
commit d6e7c8b0b1
27 changed files with 1700 additions and 74 deletions
+52
View File
@@ -834,6 +834,24 @@ the shared loading and retryable error state around route rendering. The
initial static import closure and largest asynchronous chunk are enforced by initial static import closure and largest asynchronous chunk are enforced by
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md). the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
Every public platform interface has a stable declaration identity. Backend
routes, capabilities, interfaces, search providers/sources, permissions,
frontend routes/navigation, and View surfaces derive that identity from typed
`ModuleManifest` values. Typed WebUI capabilities declare IDs for settings,
admin sections, widgets, search contexts, and extension actions. Shared form
and action controls accept `interfaceId` and `helpTopicId`; use module-namespaced
values when another contract, documentation topic, or automated check must
refer to the control across source changes. The static inventory assigns a
line-independent source anchor when an explicit ID is absent and reports that
fact for later review.
Core exposes the sanitized runtime declaration set at
`GET /api/v1/platform/interface-catalog`. The endpoint is read-only, requires
`admin:module:read` or `system:settings:read`, and includes only modules
effective in the caller's active tenant context. It never serializes factories,
credentials, executable callbacks, or mutable module state. Registry validation
rejects conflicting declaration IDs before startup.
WebUI modules receive only the core route context: WebUI modules receive only the core route context:
- `settings` - `settings`
@@ -1239,6 +1257,40 @@ devserver, development bootstrap, background worker registry, and migration
metadata plan all read the saved desired state from `system_settings` before metadata plan all read the saved desired state from `system_settings` before
building their module registry. building their module registry.
### Tenant entitlement and personal visibility
Deployment activation remains process-wide: one installed and active registry
is shared by every tenant served by that process. Tenant module selection is a
separate entitlement document in `core_scopes.settings.module_entitlements`:
- a system policy marks each installed module `unavailable`, `available`, or
`forced` for one tenant;
- the tenant selection may enable or disable only available modules;
- protected platform modules, forced modules, and transitive dependencies stay
effective;
- malformed explicit entitlement fails closed to protected modules, while an
absent document preserves the pre-entitlement behavior for upgraded tenants;
- an optimistic revision prevents concurrent system and tenant administrators
from silently replacing each other's changes.
The authenticated platform metadata and module route guard intersect global
runtime activation with the active tenant's effective entitlement. Entitlement
does not grant a permission. Access authorization must still allow every API
operation and resource.
Users and groups do not own another module-runtime state. Every WebUI module
already contributes a root `<module>.module` View surface, so personal and
group module visibility is expressed through Views. View policy controls who
may select, assign, edit, derive, or workflow-activate those projections;
required View assignments can retain required UI. Thus tenant entitlement owns
operational availability, Views own presentation, and Access owns authority.
Capability-style modules such as Encryption must keep activation separate from
domain data state. Making Encryption effective only exposes its capability and
administration surfaces. Encrypting, rekeying, decrypting, or migrating data is
an explicit versioned protection-policy operation owned by Encryption and the
module that owns the data.
Hot enable/disable is a core design principle for every module: Hot enable/disable is a core design principle for every module:
- Core keeps one mutable active `PlatformRegistry` object and swaps its manifest - Core keeps one mutable active `PlatformRegistry` object and swaps its manifest
+5
View File
@@ -50,6 +50,11 @@ contestability, responsibility, and traceability at the point of action.
| UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces | | UX-024 | Explicit `Discard` actions and dirty in-application navigation use the shared `UnsavedChangesProvider` dialog. A page registers save/discard behavior with `useUnsavedDraftGuard`; its Discard button calls `requestDiscard`, and route changes use `useGuardedNavigate` or `requestNavigation`. | Accepted | All create/edit surfaces |
| UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code | | UX-025 | `window.alert` and the global `alert` function are prohibited. A narrowly necessary exception requires product-owner authorization and an entry in the alert exception register before implementation. | Accepted | All WebUI code |
| UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables | | UX-026 | A table defines one stable ordered action set. A row-level unavailable action remains in its normal position and is disabled, preferably with `disabledReason`; structurally irrelevant actions are omitted for the entire table. Empty rows reserve the same slots so their Add action stays in the normal left-most action position. | Accepted | All structured tables |
| UX-027 | The platform icon rail keeps its brand header and utility footer visible. Only the module-navigation region scrolls when installed and permitted modules exceed the available viewport height. | Accepted | Core WebUI shell |
| UX-028 | Maintenance and offline state change the titlebar surface and repeat a quiet status label behind its controls. They must not replace, cover, or intercept the centered global-search surface; an accessible status control remains in the leading titlebar area. | Accepted | Core WebUI shell |
| UX-029 | Recoverable page and module errors use the central compact `DismissibleAlert` presentation with an explicit recovery action where one exists. Full-height workspaces must overlay page feedback instead of allowing an alert to become a stretched workspace row. | Accepted | Core and module WebUIs |
| UX-030 | At narrow widths, the titlebar uses separate context and command rows. Context selectors remain horizontally reachable, search retains its compact trigger, and language/help/notification/account commands remain fixed icon controls without overlap. Shared content padding contracts so domain workspaces retain usable width. | Accepted | Core WebUI shell and all module workspaces |
| UX-031 | Public controls and extension contributions use stable, module-namespaced interface identities. Shared controls expose `interfaceId` and `helpTopicId`; generated source anchors are inventory evidence, not a substitute for an explicit ID when documentation, policy, or automation refers to the control. | Accepted | Core and module WebUIs |
## Confirmed Implementation Decisions ## Confirmed Implementation Decisions
+4
View File
@@ -135,6 +135,9 @@ def get_api_principal(
authorization: str | None = Header(default=None), authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"), x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> ApiPrincipal: ) -> ApiPrincipal:
cached = getattr(request.state, "govoplan_api_principal", None)
if isinstance(cached, ApiPrincipal):
return cached
principal = _api_principal_provider_from_request(request).resolve_api_principal( principal = _api_principal_provider_from_request(request).resolve_api_principal(
request, request,
session, session,
@@ -143,6 +146,7 @@ def get_api_principal(
) )
if not isinstance(principal, ApiPrincipal): if not isinstance(principal, ApiPrincipal):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Invalid API principal")
request.state.govoplan_api_principal = principal
return principal return principal
+59 -4
View File
@@ -4,9 +4,13 @@ from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from threading import RLock from threading import RLock
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, status from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException, Request, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules from govoplan_core.core.module_management import ModuleManagementError, REQUIRED_PLATFORM_MODULES, plan_desired_enabled_modules
from govoplan_core.core.module_entitlements import tenant_module_entitlement_state
from govoplan_core.core.module_lifecycle_recovery import ( from govoplan_core.core.module_lifecycle_recovery import (
ModuleLifecycleRecovery, ModuleLifecycleRecovery,
begin_runtime_graph_recovery, begin_runtime_graph_recovery,
@@ -18,7 +22,9 @@ from govoplan_core.core.runtime import configure_runtime
from govoplan_core.core.workflows import ( from govoplan_core.core.workflows import (
workflow_definition_contribution_provider, workflow_definition_contribution_provider,
) )
from govoplan_core.db.session import get_session
from govoplan_core.server.route_validation import validate_router_can_mount from govoplan_core.server.route_validation import validate_router_can_mount
from govoplan_core.tenancy.scope import Tenant
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -31,12 +37,61 @@ class ModuleLifecycleResult:
def require_module_active(module_id: str): def require_module_active(module_id: str):
def dependency(request: Request) -> None: def dependency(
request: Request,
session: Session = Depends(get_session),
authorization: str | None = Header(default=None),
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
) -> None:
registry = getattr(request.app.state, "govoplan_registry", None) registry = getattr(request.app.state, "govoplan_registry", None)
if isinstance(registry, PlatformRegistry) and registry.has_module(module_id): if not isinstance(registry, PlatformRegistry) or not registry.has_module(module_id):
return
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Module is disabled: {module_id}")
# Public module routes must remain reachable without Access. When an
# authenticated request is present, cache its principal and apply the
# active tenant's module entitlement before the owning route executes.
if not authorization and not x_api_key and not request.cookies:
return
try:
principal = get_api_principal(
request,
session,
authorization=authorization,
x_api_key=x_api_key,
)
except HTTPException as exc:
if exc.status_code in {
status.HTTP_401_UNAUTHORIZED,
status.HTTP_403_FORBIDDEN,
}:
return
raise
if not isinstance(principal, ApiPrincipal) or principal.principal.tenant_id is None:
return
try:
tenant = session.get(Tenant, principal.principal.tenant_id)
except (RuntimeError, SQLAlchemyError) as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Tenant module entitlement could not be resolved.",
) from exc
if tenant is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The active tenant is unavailable.",
)
manifests = {manifest.id: manifest for manifest in registry.manifests()}
entitlement = tenant_module_entitlement_state(
tenant.settings or {},
manifests,
runtime_active_modules=manifests,
)
if module_id not in entitlement.effective_modules:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Module is unavailable in the active tenant: {module_id}",
)
return dependency return dependency
@@ -0,0 +1,483 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from typing import Any
from govoplan_core.core.modules import ModuleManifest
MODULE_ENTITLEMENTS_KEY = "module_entitlements"
MODULE_ENTITLEMENT_SCHEMA_VERSION = 1
TENANT_PROTECTED_MODULES = ("access", "admin")
class ModuleEntitlementError(ValueError):
pass
class ModuleEntitlementConflict(ModuleEntitlementError):
pass
@dataclass(frozen=True, slots=True)
class TenantModuleItem:
id: str
name: str
dependencies: tuple[str, ...]
runtime_active: bool
availability: str
selected: bool
effective: bool
forced: bool
derived_dependency: bool
tenant_can_toggle: bool
reason: str | None = None
@dataclass(frozen=True, slots=True)
class TenantModuleEntitlementState:
revision: int
configured: bool
available_modules: tuple[str, ...]
forced_modules: tuple[str, ...]
selected_modules: tuple[str, ...]
effective_modules: tuple[str, ...]
derived_dependencies: tuple[str, ...]
modules: tuple[TenantModuleItem, ...]
diagnostics: tuple[dict[str, str], ...] = ()
def tenant_module_entitlement_state(
settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest],
*,
runtime_active_modules: Iterable[str] | None = None,
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
) -> TenantModuleEntitlementState:
module_ids = tuple(sorted(manifests))
known = set(module_ids)
runtime_active = (
known
if runtime_active_modules is None
else known.intersection(_normalized_ids(runtime_active_modules))
)
protected = known.intersection(_normalized_ids(protected_modules))
raw_document = (settings or {}).get(MODULE_ENTITLEMENTS_KEY)
configured = isinstance(raw_document, Mapping)
diagnostics: list[dict[str, str]] = []
if not configured:
revision = 0
requested_available = set(known)
requested_forced = set(protected)
requested_selected = set(known)
else:
document = raw_document
revision = _revision(document.get("revision"), diagnostics)
system_policy = document.get("system_policy")
tenant_selection = document.get("tenant_selection")
if not isinstance(system_policy, Mapping) or not isinstance(
tenant_selection, Mapping
):
diagnostics.append(
_diagnostic(
"module_entitlements.invalid_document",
"The tenant module entitlement document is malformed and was restricted to protected modules.",
)
)
requested_available = set(protected)
requested_forced = set(protected)
requested_selected = set()
else:
requested_available = _configured_ids(
system_policy.get("available_modules"),
field="system_policy.available_modules",
known=known,
fallback=protected,
diagnostics=diagnostics,
)
requested_forced = _configured_ids(
system_policy.get("forced_modules"),
field="system_policy.forced_modules",
known=known,
fallback=protected,
diagnostics=diagnostics,
)
requested_selected = _configured_ids(
tenant_selection.get("enabled_modules"),
field="tenant_selection.enabled_modules",
known=known,
fallback=(),
diagnostics=diagnostics,
)
available, missing_available = _dependency_closure(
requested_available | requested_forced | protected,
manifests,
)
forced, missing_forced = _dependency_closure(
requested_forced | protected,
manifests,
)
selected = requested_selected.intersection(available)
effective_candidates, missing_selected = _dependency_closure(
selected | forced,
manifests,
)
effective_candidates.intersection_update(available)
effective = effective_candidates.intersection(runtime_active)
derived = effective_candidates - selected - forced
for module_id in sorted(
missing_available | missing_forced | missing_selected
):
diagnostics.append(
_diagnostic(
"module_entitlements.missing_dependency",
f"A selected module requires unavailable dependency {module_id}.",
)
)
items: list[TenantModuleItem] = []
for module_id in module_ids:
manifest = manifests[module_id]
is_available = module_id in available
is_forced = module_id in forced
is_selected = module_id in selected
is_derived = module_id in derived
is_runtime_active = module_id in runtime_active
is_effective = module_id in effective
reason: str | None = None
if not is_available:
reason = "Unavailable by system policy."
elif is_forced:
reason = "Required by system policy or a protected platform dependency."
elif is_derived:
reason = "Required by another selected module."
elif not is_runtime_active and (is_selected or is_forced):
reason = "Selected for this tenant, but the module is not active in the deployment."
items.append(
TenantModuleItem(
id=module_id,
name=manifest.name,
dependencies=tuple(manifest.dependencies),
runtime_active=is_runtime_active,
availability=(
"forced" if is_forced else "available" if is_available else "unavailable"
),
selected=is_selected,
effective=is_effective,
forced=is_forced,
derived_dependency=is_derived,
tenant_can_toggle=is_available and not is_forced and not is_derived,
reason=reason,
)
)
return TenantModuleEntitlementState(
revision=revision,
configured=configured,
available_modules=tuple(sorted(available)),
forced_modules=tuple(sorted(forced)),
selected_modules=tuple(sorted(selected)),
effective_modules=tuple(sorted(effective)),
derived_dependencies=tuple(sorted(derived)),
modules=tuple(items),
diagnostics=tuple(diagnostics),
)
def update_system_tenant_module_policy(
settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest],
*,
available_modules: Iterable[str],
forced_modules: Iterable[str],
enabled_modules: Iterable[str],
expected_revision: int | None,
runtime_active_modules: Iterable[str] | None = None,
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
current = tenant_module_entitlement_state(
settings,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
_check_revision(current.revision, expected_revision)
known = set(manifests)
available_requested = _validated_requested_ids(
available_modules, known=known, field="available_modules"
)
forced_requested = _validated_requested_ids(
forced_modules, known=known, field="forced_modules"
)
enabled_requested = _validated_requested_ids(
enabled_modules, known=known, field="enabled_modules"
)
protected = known.intersection(_normalized_ids(protected_modules))
available, missing = _dependency_closure(
available_requested | forced_requested | protected,
manifests,
)
forced, forced_missing = _dependency_closure(
forced_requested | protected,
manifests,
)
if missing or forced_missing:
missing_text = ", ".join(sorted(missing | forced_missing))
raise ModuleEntitlementError(
f"Module policy references dependencies that are not installed: {missing_text}"
)
unavailable_enabled = enabled_requested - available
if unavailable_enabled:
raise ModuleEntitlementError(
"Tenant selection contains modules unavailable by system policy: "
+ ", ".join(sorted(unavailable_enabled))
)
_validate_enabled_dependencies(enabled_requested | forced, available, manifests)
updated = _write_document(
settings,
revision=current.revision + 1,
available_modules=available,
forced_modules=forced,
enabled_modules=enabled_requested,
)
return updated, tenant_module_entitlement_state(
updated,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
def update_tenant_module_selection(
settings: Mapping[str, object] | None,
manifests: Mapping[str, ModuleManifest],
*,
enabled_modules: Iterable[str],
expected_revision: int | None,
runtime_active_modules: Iterable[str] | None = None,
protected_modules: Iterable[str] = TENANT_PROTECTED_MODULES,
) -> tuple[dict[str, object], TenantModuleEntitlementState]:
current = tenant_module_entitlement_state(
settings,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
_check_revision(current.revision, expected_revision)
enabled = _validated_requested_ids(
enabled_modules,
known=set(manifests),
field="enabled_modules",
)
unavailable = enabled - set(current.available_modules)
if unavailable:
raise ModuleEntitlementError(
"Tenant selection contains modules unavailable by system policy: "
+ ", ".join(sorted(unavailable))
)
_validate_enabled_dependencies(
enabled | set(current.forced_modules),
set(current.available_modules),
manifests,
)
updated = _write_document(
settings,
revision=current.revision + 1,
available_modules=current.available_modules,
forced_modules=current.forced_modules,
enabled_modules=enabled,
)
return updated, tenant_module_entitlement_state(
updated,
manifests,
runtime_active_modules=runtime_active_modules,
protected_modules=protected_modules,
)
def module_entitlement_payload(
tenant_id: str,
state: TenantModuleEntitlementState,
) -> dict[str, Any]:
return {
"tenant_id": tenant_id,
"revision": state.revision,
"configured": state.configured,
"available_modules": list(state.available_modules),
"forced_modules": list(state.forced_modules),
"selected_modules": list(state.selected_modules),
"effective_modules": list(state.effective_modules),
"derived_dependencies": list(state.derived_dependencies),
"modules": [
{
"id": item.id,
"name": item.name,
"dependencies": list(item.dependencies),
"runtime_active": item.runtime_active,
"availability": item.availability,
"selected": item.selected,
"effective": item.effective,
"forced": item.forced,
"derived_dependency": item.derived_dependency,
"tenant_can_toggle": item.tenant_can_toggle,
"reason": item.reason,
}
for item in state.modules
],
"diagnostics": [dict(item) for item in state.diagnostics],
}
def _write_document(
settings: Mapping[str, object] | None,
*,
revision: int,
available_modules: Iterable[str],
forced_modules: Iterable[str],
enabled_modules: Iterable[str],
) -> dict[str, object]:
updated = dict(settings or {})
updated[MODULE_ENTITLEMENTS_KEY] = {
"schema_version": MODULE_ENTITLEMENT_SCHEMA_VERSION,
"revision": revision,
"system_policy": {
"available_modules": sorted(set(available_modules)),
"forced_modules": sorted(set(forced_modules)),
},
"tenant_selection": {
"enabled_modules": sorted(set(enabled_modules)),
},
}
return updated
def _validate_enabled_dependencies(
enabled: set[str],
available: set[str],
manifests: Mapping[str, ModuleManifest],
) -> None:
closure, missing = _dependency_closure(enabled, manifests)
if missing:
raise ModuleEntitlementError(
"Selected modules require dependencies that are not installed: "
+ ", ".join(sorted(missing))
)
unavailable = closure - available
if unavailable:
raise ModuleEntitlementError(
"Selected modules require dependencies unavailable by system policy: "
+ ", ".join(sorted(unavailable))
)
def _dependency_closure(
requested: Iterable[str],
manifests: Mapping[str, ModuleManifest],
) -> tuple[set[str], set[str]]:
closure: set[str] = set()
missing: set[str] = set()
pending = list(_normalized_ids(requested))
while pending:
module_id = pending.pop()
if module_id in closure:
continue
manifest = manifests.get(module_id)
if manifest is None:
missing.add(module_id)
continue
closure.add(module_id)
pending.extend(manifest.dependencies)
return closure, missing
def _configured_ids(
value: object,
*,
field: str,
known: set[str],
fallback: Iterable[str],
diagnostics: list[dict[str, str]],
) -> set[str]:
if not isinstance(value, list | tuple):
diagnostics.append(
_diagnostic(
"module_entitlements.invalid_field",
f"{field} is malformed and was evaluated with a restrictive fallback.",
)
)
return set(fallback)
values = _normalized_ids(value)
unknown = values - known
if unknown:
diagnostics.append(
_diagnostic(
"module_entitlements.unknown_module",
f"{field} references unknown modules: {', '.join(sorted(unknown))}.",
)
)
return values.intersection(known)
def _validated_requested_ids(
values: Iterable[str],
*,
known: set[str],
field: str,
) -> set[str]:
normalized = _normalized_ids(values)
unknown = normalized - known
if unknown:
raise ModuleEntitlementError(
f"{field} contains unknown modules: {', '.join(sorted(unknown))}"
)
return normalized
def _normalized_ids(values: Iterable[object]) -> set[str]:
return {
clean
for value in values
if (clean := str(value).strip())
}
def _revision(value: object, diagnostics: list[dict[str, str]]) -> int:
if isinstance(value, int) and value >= 0:
return value
diagnostics.append(
_diagnostic(
"module_entitlements.invalid_revision",
"The module entitlement revision is invalid; concurrent updates will require a reload.",
)
)
return 0
def _check_revision(current: int, expected: int | None) -> None:
if expected is not None and expected != current:
raise ModuleEntitlementConflict(
f"Module entitlement revision changed from {expected} to {current}; reload before saving."
)
def _diagnostic(code: str, message: str) -> dict[str, str]:
return {"code": code, "message": message, "severity": "warning"}
__all__ = [
"MODULE_ENTITLEMENTS_KEY",
"MODULE_ENTITLEMENT_SCHEMA_VERSION",
"TENANT_PROTECTED_MODULES",
"ModuleEntitlementConflict",
"ModuleEntitlementError",
"TenantModuleEntitlementState",
"TenantModuleItem",
"module_entitlement_payload",
"tenant_module_entitlement_state",
"update_system_tenant_module_policy",
"update_tenant_module_selection",
]
@@ -0,0 +1,303 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass, field
import hashlib
import json
import re
from typing import Any, Literal, Mapping, Sequence
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.views import (
navigation_view_surface_id,
route_view_surface_id,
)
PLATFORM_INTERFACE_CONTRACT_VERSION = "1"
PlatformInterfaceKind = Literal[
"backend_capability",
"frontend_route",
"navigation",
"permission",
"provided_interface",
"public_route",
"search_provider",
"search_source",
"settings_route",
"view_surface",
]
@dataclass(frozen=True, slots=True)
class PlatformInterfaceDeclaration:
"""A sanitized, stable declaration from a module manifest.
The declaration contains identifiers and authorization metadata only. It
deliberately excludes factories, executable callbacks, credentials, and
mutable module state.
"""
id: str
module_id: str
kind: PlatformInterfaceKind
label: str | None = None
path: str | None = None
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
metadata: Mapping[str, Any] = field(default_factory=dict)
@property
def key(self) -> str:
return f"{self.kind}:{self.id}"
def to_dict(self) -> dict[str, Any]:
return {
"key": self.key,
"id": self.id,
"module_id": self.module_id,
"kind": self.kind,
"label": self.label,
"path": self.path,
"required_all": list(self.required_all),
"required_any": list(self.required_any),
"metadata": dict(self.metadata),
}
def manifest_interface_declarations(
manifest: ModuleManifest,
) -> tuple[PlatformInterfaceDeclaration, ...]:
"""Normalize the typed public declarations owned by one module manifest."""
declarations: list[PlatformInterfaceDeclaration] = []
for capability_name in sorted(manifest.capability_factories):
documentation = manifest.capability_documentation.get(capability_name)
declarations.append(
PlatformInterfaceDeclaration(
id=capability_name,
module_id=manifest.id,
kind="backend_capability",
label=documentation.label if documentation is not None else None,
metadata={
"contract_version": (
documentation.contract_version
if documentation is not None
else None
),
},
)
)
for interface in manifest.provides_interfaces:
declarations.append(
PlatformInterfaceDeclaration(
id=interface.name,
module_id=manifest.id,
kind="provided_interface",
metadata={"version": interface.version},
)
)
for permission in manifest.permissions:
declarations.append(
PlatformInterfaceDeclaration(
id=permission.scope,
module_id=manifest.id,
kind="permission",
label=permission.label,
metadata={
"category": permission.category,
"level": permission.level,
"deprecated": permission.deprecated,
},
)
)
for registration in manifest.search_providers:
declarations.append(
PlatformInterfaceDeclaration(
id=registration.id,
module_id=manifest.id,
kind="search_provider",
metadata={
"role": "provider",
"resource_types": list(registration.resource_types),
},
)
)
for registration in manifest.search_sources:
declarations.append(
PlatformInterfaceDeclaration(
id=registration.id,
module_id=manifest.id,
kind="search_source",
metadata={"role": "source"},
)
)
frontend = manifest.frontend
if frontend is not None:
for route in frontend.routes:
declarations.append(
PlatformInterfaceDeclaration(
id=route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="frontend_route",
path=route.path,
required_all=route.required_all,
required_any=route.required_any,
metadata={
"component": route.component,
"order": route.order,
"surface_id": route.surface_id,
},
)
)
for route in frontend.public_routes:
declarations.append(
PlatformInterfaceDeclaration(
id=f"{manifest.id}.public.{_path_slug(route.path)}",
module_id=manifest.id,
kind="public_route",
path=route.path,
metadata={"component": route.component, "order": route.order},
)
)
for route in frontend.settings_routes:
declarations.append(
PlatformInterfaceDeclaration(
id=route_view_surface_id(manifest.id, route.path),
module_id=manifest.id,
kind="settings_route",
path=route.path,
required_all=route.required_all,
required_any=route.required_any,
metadata={
"component": route.component,
"order": route.order,
"surface_id": route.surface_id,
},
)
)
for item in frontend.nav_items:
declarations.append(
PlatformInterfaceDeclaration(
id=navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
path=item.path,
required_all=item.required_all,
required_any=item.required_any,
metadata={
"icon": item.icon,
"section": item.section,
"order": item.order,
"surface_id": item.surface_id,
},
)
)
for surface in frontend.view_surfaces:
declarations.append(
PlatformInterfaceDeclaration(
id=surface.id,
module_id=manifest.id,
kind="view_surface",
label=surface.label,
metadata={
"surface_kind": surface.kind,
"parent_id": surface.parent_id,
"description": surface.description,
"order": surface.order,
"default_visible": surface.default_visible,
"required": surface.required,
},
)
)
frontend_navigation = {
declaration.id: declaration
for declaration in declarations
if declaration.kind == "navigation"
}
for item in manifest.nav_items:
declaration = PlatformInterfaceDeclaration(
id=navigation_view_surface_id(manifest.id, item.path),
module_id=manifest.id,
kind="navigation",
label=item.label,
path=item.path,
required_all=item.required_all,
required_any=item.required_any,
metadata={
"icon": item.icon,
"section": item.section,
"order": item.order,
"surface_id": item.surface_id,
},
)
frontend_declaration = frontend_navigation.get(declaration.id)
if frontend_declaration is not None and frontend_declaration == declaration:
continue
declarations.append(declaration)
return tuple(sorted(declarations, key=lambda item: (item.kind, item.id)))
def validate_manifest_interface_declarations(manifest: ModuleManifest) -> None:
seen: set[str] = set()
for declaration in manifest_interface_declarations(manifest):
if declaration.key in seen:
raise ValueError(
f"Module {manifest.id!r} declares duplicate platform interface "
f"{declaration.key!r}"
)
seen.add(declaration.key)
def manifest_interface_catalog(manifest: ModuleManifest) -> dict[str, Any]:
declarations = manifest_interface_declarations(manifest)
serialized = [item.to_dict() for item in declarations]
canonical = json.dumps(
serialized,
ensure_ascii=True,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return {
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
"module_id": manifest.id,
"module_version": manifest.version,
"digest": f"sha256:{hashlib.sha256(canonical).hexdigest()}",
"counts": dict(sorted(Counter(item.kind for item in declarations).items())),
"declarations": serialized,
}
def platform_interface_catalog(
manifests: Sequence[ModuleManifest],
) -> dict[str, Any]:
modules = [manifest_interface_catalog(manifest) for manifest in manifests]
return {
"contract_version": PLATFORM_INTERFACE_CONTRACT_VERSION,
"modules": modules,
}
def _path_slug(path: str) -> str:
slug = re.sub(r"[^a-z0-9]+", ".", path.lower()).strip(".")
return slug or "root"
__all__ = [
"PLATFORM_INTERFACE_CONTRACT_VERSION",
"PlatformInterfaceDeclaration",
"PlatformInterfaceKind",
"manifest_interface_catalog",
"manifest_interface_declarations",
"platform_interface_catalog",
"validate_manifest_interface_declarations",
]
+7
View File
@@ -29,6 +29,9 @@ from govoplan_core.core.ownership import (
OwnershipProviderRegistration, OwnershipProviderRegistration,
ResourceOwnershipProvider, ResourceOwnershipProvider,
) )
from govoplan_core.core.platform_interfaces import (
validate_manifest_interface_declarations,
)
from govoplan_core.core.provider_governance import ( from govoplan_core.core.provider_governance import (
ExternalProviderDeclaration, ExternalProviderDeclaration,
ExternalProviderStateProviderRegistration, ExternalProviderStateProviderRegistration,
@@ -646,6 +649,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
_validate_manifest_overlaps(manifest) _validate_manifest_overlaps(manifest)
_validate_manifest_migration_spec(manifest) _validate_manifest_migration_spec(manifest)
_validate_manifest_frontend(manifest) _validate_manifest_frontend(manifest)
try:
validate_manifest_interface_declarations(manifest)
except ValueError as exc:
raise RegistryError(str(exc)) from exc
for item in manifest.nav_items: for item in manifest.nav_items:
_validate_nav_item(manifest.id, item) _validate_nav_item(manifest.id, item)
for topic in manifest.documentation: for topic in manifest.documentation:
@@ -69,6 +69,8 @@ TENANT_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"), PermissionDefinition("admin:settings:write", "Manage tenant settings", "Change tenant defaults and non-policy settings.", "Tenant administration"),
PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"), PermissionDefinition("admin:policies:read", "View tenant policies", "Read tenant policy and governance settings.", "Tenant administration"),
PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"), PermissionDefinition("admin:policies:write", "Manage tenant policies", "Change tenant policy and governance settings where system policy permits it.", "Tenant administration"),
PermissionDefinition("admin:module:read", "View tenant modules", "Inspect module availability, requirements, and effective state for the active tenant.", "Tenant administration"),
PermissionDefinition("admin:module:write", "Manage tenant modules", "Enable or disable modules for the active tenant within system policy.", "Tenant administration"),
) )
SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = ( SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
+85 -5
View File
@@ -5,9 +5,17 @@ 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.auth import ApiPrincipal, get_api_principal from govoplan_core.auth import ApiPrincipal, get_api_principal, require_any_scope
from govoplan_core.core.maintenance import saved_maintenance_mode from govoplan_core.core.maintenance import saved_maintenance_mode
from govoplan_core.core.module_entitlements import (
module_entitlement_payload,
tenant_module_entitlement_state,
)
from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute from govoplan_core.core.modules import FrontendModule, FrontendRoute, ModuleManifest, NavItem, PublicFrontendRoute
from govoplan_core.core.platform_interfaces import (
manifest_interface_catalog,
platform_interface_catalog,
)
from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces from govoplan_core.core.registry import PlatformRegistry, manifest_view_surfaces
from govoplan_core.core.views import ( from govoplan_core.core.views import (
VIEW_SURFACE_CONTRACT_VERSION, VIEW_SURFACE_CONTRACT_VERSION,
@@ -17,6 +25,7 @@ from govoplan_core.core.views import (
) )
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
from govoplan_core.tenancy.scope import Tenant
def _registry(request: Request) -> PlatformRegistry: def _registry(request: Request) -> PlatformRegistry:
@@ -26,6 +35,49 @@ def _registry(request: Request) -> PlatformRegistry:
return registry return registry
def _effective_manifest_state(
request: Request,
principal: ApiPrincipal,
) -> tuple[PlatformRegistry, tuple[ModuleManifest, ...], object | None]:
"""Resolve only manifests available in the principal's active context."""
registry = _registry(request)
manifests = tuple(registry.manifests())
entitlement = None
principal_ref = getattr(principal, "principal", None)
tenant_id = getattr(principal_ref, "tenant_id", None)
if tenant_id is not None:
try:
with get_database().session() as session:
tenant = session.get(Tenant, tenant_id)
if tenant is None:
raise HTTPException(
status_code=403,
detail="The active tenant is unavailable.",
)
manifest_map = {manifest.id: manifest for manifest in manifests}
entitlement = tenant_module_entitlement_state(
tenant.settings or {},
manifest_map,
runtime_active_modules=manifest_map,
)
except (RuntimeError, SQLAlchemyError) as exc:
raise HTTPException(
status_code=503,
detail="Tenant module entitlement could not be resolved.",
) from exc
effective_ids = (
set(entitlement.effective_modules)
if entitlement is not None
else {manifest.id for manifest in manifests}
)
return (
registry,
tuple(manifest for manifest in manifests if manifest.id in effective_ids),
entitlement,
)
def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]: def _nav_item_payload(item: NavItem, module_id: str | None = None) -> dict[str, object]:
return { return {
"path": item.path, "path": item.path,
@@ -156,9 +208,14 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
@router.get("/modules") @router.get("/modules")
def modules( def modules(
request: Request, request: Request,
_principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
): ):
registry = _registry(request) registry, manifests, entitlement = _effective_manifest_state(
request,
principal,
)
principal_ref = getattr(principal, "principal", None)
tenant_id = getattr(principal_ref, "tenant_id", None)
return { return {
"modules": [ "modules": [
{ {
@@ -178,13 +235,36 @@ def create_platform_router(settings: object | None = None) -> APIRouter:
for declaration in manifest.external_providers for declaration in manifest.external_providers
], ],
"runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry), "runtime_ui_capabilities": _runtime_ui_capabilities(manifest.id, settings, registry),
"interface_catalog": {
key: value
for key, value in manifest_interface_catalog(manifest).items()
if key != "declarations"
},
"nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items], "nav": [_nav_item_payload(item, manifest.id) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest), "frontend": _frontend_payload(manifest),
} }
for manifest in registry.manifests() for manifest in manifests
] ],
"module_entitlement": (
module_entitlement_payload(tenant_id, entitlement)
if tenant_id is not None and entitlement is not None
else None
),
} }
@router.get("/interface-catalog")
def interface_catalog(
request: Request,
principal: ApiPrincipal = Depends(
require_any_scope("admin:module:read", "system:settings:read")
),
):
_registry_item, manifests, _entitlement = _effective_manifest_state(
request,
principal,
)
return platform_interface_catalog(manifests)
@router.get("/public-modules") @router.get("/public-modules")
def public_modules(request: Request): def public_modules(request: Request):
registry = _registry(request) registry = _registry(request)
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
import unittest
from pathlib import Path
import tempfile
from unittest.mock import patch
from fastapi import APIRouter, Depends, FastAPI
from fastapi.testclient import TestClient
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.lifecycle import require_module_active
from govoplan_core.core.module_entitlements import (
ModuleEntitlementConflict,
ModuleEntitlementError,
tenant_module_entitlement_state,
update_system_tenant_module_policy,
update_tenant_module_selection,
)
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.session import configure_database, get_database
from govoplan_core.server.platform import create_platform_router
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
class TenantModuleEntitlementTests(unittest.TestCase):
def setUp(self) -> None:
self.manifests = {
"access": ModuleManifest(id="access", name="Access", version="test"),
"admin": ModuleManifest(
id="admin",
name="Admin",
version="test",
dependencies=("access",),
),
"files": ModuleManifest(
id="files",
name="Files",
version="test",
dependencies=("access",),
),
"campaigns": ModuleManifest(
id="campaigns",
name="Campaigns",
version="test",
dependencies=("access", "files"),
),
"encryption": ModuleManifest(
id="encryption",
name="Encryption",
version="test",
),
}
def test_unconfigured_tenant_preserves_current_module_visibility(self) -> None:
state = tenant_module_entitlement_state({}, self.manifests)
self.assertFalse(state.configured)
self.assertEqual(set(self.manifests), set(state.effective_modules))
self.assertEqual({"access", "admin"}, set(state.forced_modules))
def test_system_policy_closes_dependencies_and_tenant_selection(self) -> None:
settings, state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("campaigns", "encryption"),
forced_modules=("campaigns",),
enabled_modules=("encryption",),
expected_revision=0,
)
self.assertEqual(1, state.revision)
self.assertEqual(
{"access", "admin", "files", "campaigns", "encryption"},
set(state.available_modules),
)
self.assertEqual(
{"access", "admin", "files", "campaigns"},
set(state.forced_modules),
)
self.assertEqual({"encryption"}, set(state.selected_modules))
self.assertEqual(set(self.manifests), set(state.effective_modules))
self.assertIn("module_entitlements", settings)
def test_tenant_cannot_enable_system_unavailable_module(self) -> None:
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("files",),
forced_modules=(),
enabled_modules=(),
expected_revision=0,
)
with self.assertRaisesRegex(
ModuleEntitlementError,
"unavailable by system policy: encryption",
):
update_tenant_module_selection(
settings,
self.manifests,
enabled_modules=("encryption",),
expected_revision=1,
)
def test_forced_modules_remain_effective_when_tenant_selection_is_empty(self) -> None:
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("campaigns",),
forced_modules=("campaigns",),
enabled_modules=(),
expected_revision=0,
)
_settings, state = update_tenant_module_selection(
settings,
self.manifests,
enabled_modules=(),
expected_revision=1,
)
self.assertEqual(
{"access", "admin", "files", "campaigns"},
set(state.effective_modules),
)
self.assertTrue(
all(
not item.tenant_can_toggle
for item in state.modules
if item.id in state.forced_modules
)
)
def test_inactive_runtime_module_is_selected_but_not_effective(self) -> None:
settings, state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("files", "encryption"),
forced_modules=(),
enabled_modules=("encryption",),
expected_revision=0,
runtime_active_modules=("access", "admin", "files"),
)
self.assertIn("encryption", state.selected_modules)
self.assertNotIn("encryption", state.effective_modules)
encryption = next(item for item in state.modules if item.id == "encryption")
self.assertIn("not active in the deployment", encryption.reason or "")
self.assertIn("module_entitlements", settings)
def test_stale_revision_is_rejected(self) -> None:
settings, _state = update_system_tenant_module_policy(
{},
self.manifests,
available_modules=("files",),
forced_modules=(),
enabled_modules=("files",),
expected_revision=0,
)
with self.assertRaises(ModuleEntitlementConflict):
update_tenant_module_selection(
settings,
self.manifests,
enabled_modules=(),
expected_revision=0,
)
def test_malformed_document_fails_closed_to_protected_modules(self) -> None:
state = tenant_module_entitlement_state(
{"module_entitlements": {"revision": "invalid"}},
self.manifests,
)
self.assertEqual({"access", "admin"}, set(state.effective_modules))
self.assertTrue(state.diagnostics)
class TenantModuleEntitlementRouteTests(unittest.TestCase):
def setUp(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-entitlement-test-"))
configure_database(f"sqlite:///{root / 'test.db'}")
create_scope_tables(get_database().engine)
self.manifests = (
ModuleManifest(id="access", name="Access", version="test"),
ModuleManifest(
id="admin",
name="Admin",
version="test",
dependencies=("access",),
),
ModuleManifest(
id="files",
name="Files",
version="test",
dependencies=("access",),
),
)
self.registry = PlatformRegistry()
for manifest in self.manifests:
self.registry.register(manifest)
settings, _state = update_system_tenant_module_policy(
{},
{manifest.id: manifest for manifest in self.manifests},
available_modules=(),
forced_modules=(),
enabled_modules=(),
expected_revision=0,
)
with get_database().session() as session:
session.add(
Tenant(
id="tenant-1",
slug="tenant-1",
name="Tenant 1",
settings=settings,
)
)
session.commit()
self.principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
),
account=object(),
user=object(),
)
def test_platform_metadata_excludes_tenant_unavailable_module(self) -> None:
app = FastAPI()
app.state.govoplan_registry = self.registry
app.include_router(create_platform_router(), prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: self.principal
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(
{"access", "admin"},
{item["id"] for item in response.json()["modules"]},
)
self.assertNotIn(
"files",
response.json()["module_entitlement"]["effective_modules"],
)
def test_authenticated_module_route_is_hidden_when_tenant_unavailable(self) -> None:
app = FastAPI()
app.state.govoplan_registry = self.registry
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
@guarded.get("/files")
def files_route():
return {"ok": True}
app.include_router(guarded)
with patch(
"govoplan_core.core.lifecycle.get_api_principal",
return_value=self.principal,
), TestClient(app) as client:
response = client.get(
"/files",
headers={"Authorization": "Bearer test"},
)
self.assertEqual(404, response.status_code, response.text)
self.assertEqual(
"Module is unavailable in the active tenant: files",
response.json()["detail"],
)
def test_unauthenticated_public_route_is_not_turned_into_login(self) -> None:
app = FastAPI()
app.state.govoplan_registry = self.registry
guarded = APIRouter(dependencies=[Depends(require_module_active("files"))])
@guarded.get("/public-files")
def public_files_route():
return {"ok": True}
app.include_router(guarded)
with TestClient(app) as client:
response = client.get("/public-files")
self.assertEqual(200, response.status_code, response.text)
if __name__ == "__main__":
unittest.main()
+131
View File
@@ -0,0 +1,131 @@
from __future__ import annotations
import unittest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.modules import (
FrontendModule,
FrontendRoute,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
)
from govoplan_core.core.platform_interfaces import (
manifest_interface_catalog,
manifest_interface_declarations,
)
from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.server.platform import create_platform_router
def _principal(*scopes: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id=None,
tenant_id=None,
scopes=frozenset(scopes),
),
account=object(),
user=object(),
)
def _manifest() -> ModuleManifest:
navigation = NavItem(path="/example", label="Example", icon="box")
return ModuleManifest(
id="example",
name="Example",
version="1.2.3",
provides_interfaces=(
ModuleInterfaceProvider(name="example.reader", version="1.0.0"),
),
capability_factories={"example.reader": lambda _context: object()},
nav_items=(navigation,),
frontend=FrontendModule(
module_id="example",
routes=(
FrontendRoute(path="/example", component="ExamplePage"),
),
nav_items=(navigation,),
),
)
class PlatformInterfaceCatalogTests(unittest.TestCase):
def test_manifest_declarations_have_stable_typed_keys(self) -> None:
declarations = manifest_interface_declarations(_manifest())
keys = {item.key for item in declarations}
self.assertIn("backend_capability:example.reader", keys)
self.assertIn("provided_interface:example.reader", keys)
self.assertIn("frontend_route:example.route.example", keys)
self.assertIn("navigation:example.nav.example", keys)
self.assertEqual(
1,
sum(item.key == "navigation:example.nav.example" for item in declarations),
)
def test_catalog_digest_is_deterministic(self) -> None:
first = manifest_interface_catalog(_manifest())
second = manifest_interface_catalog(_manifest())
self.assertEqual(first["digest"], second["digest"])
self.assertEqual("1", first["contract_version"])
def test_registry_rejects_conflicting_duplicate_navigation(self) -> None:
manifest = _manifest()
manifest = ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
nav_items=manifest.nav_items,
frontend=FrontendModule(
module_id="example",
nav_items=(NavItem(path="/example", label="Other"),),
),
)
registry = PlatformRegistry()
registry.register(manifest)
with self.assertRaisesRegex(RegistryError, "duplicate platform interface"):
registry.validate()
def test_read_only_endpoint_requires_administrator_scope(self) -> None:
registry = PlatformRegistry()
registry.register(_manifest())
registry.validate()
app = FastAPI()
app.state.govoplan_registry = registry
app.include_router(create_platform_router(), prefix="/api/v1")
app.dependency_overrides[get_api_principal] = lambda: _principal()
with TestClient(app) as client:
denied = client.get("/api/v1/platform/interface-catalog")
self.assertEqual(403, denied.status_code)
app.dependency_overrides[get_api_principal] = lambda: _principal(
"admin:module:read"
)
with TestClient(app) as client:
response = client.get("/api/v1/platform/interface-catalog")
self.assertEqual(200, response.status_code)
payload = response.json()
self.assertEqual("1", payload["contract_version"])
self.assertEqual(["example"], [item["module_id"] for item in payload["modules"]])
self.assertIn(
"frontend_route:example.route.example",
{
item["key"]
for item in payload["modules"][0]["declarations"]
},
)
if __name__ == "__main__":
unittest.main()
+11 -1
View File
@@ -40,7 +40,7 @@ function scanStructuralSourcePositions(roots) {
} }
if (ts.isPropertyAssignment(node)) { if (ts.isPropertyAssignment(node)) {
const name = nodeName(node.name); const name = nodeName(node.name);
if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer)) report(node, `object ${name}`); if ((isStructuralName(name) || isAlgorithmNameProperty(node) || isListOptionValueProperty(node)) && hasI18nLiteral(node.initializer) && !isPresentationalBlockerProperty(node)) report(node, `object ${name}`);
if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key"); if (hasI18nLiteral(node.name) && !isAllowedStructuralStringPosition(node.name, file)) report(node, "object key");
} }
if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key"); if (ts.isShorthandPropertyAssignment(node) && node.name.text.includes("i18n:")) report(node, "object key");
@@ -221,6 +221,16 @@ function isStructuralJsxValue(node) {
return parent.tagName.getText() === "option"; return parent.tagName.getText() === "option";
} }
function isPresentationalBlockerProperty(node) {
if (nodeName(node.name) !== "target" || !ts.isObjectLiteralExpression(node.parent)) return false;
const propertyNames = new Set(
node.parent.properties
.filter((property) => ts.isPropertyAssignment(property))
.map((property) => nodeName(property.name))
);
return propertyNames.has("actor") && propertyNames.has("requiredAction");
}
function hasAncestorVariable(node, variableName) { function hasAncestorVariable(node, variableName) {
let current = node.parent; let current = node.parent;
while (current) { while (current) {
@@ -9,6 +9,10 @@ const read = (path) => readFileSync(resolve(webuiRoot, path), "utf8");
const settings = read("src/features/settings/SettingsPage.tsx"); const settings = read("src/features/settings/SettingsPage.tsx");
const retention = read("src/features/privacy/RetentionPolicyManagement.tsx"); const retention = read("src/features/privacy/RetentionPolicyManagement.tsx");
const credentials = read("src/components/CredentialEnvelopeManager.tsx"); const credentials = read("src/components/CredentialEnvelopeManager.tsx");
const iconRail = read("src/layout/IconRail.tsx");
const moduleLoadBoundary = read("src/components/ModuleLoadBoundary.tsx");
const titlebar = read("src/layout/Titlebar.tsx");
const layoutStyles = read("src/styles/layout.css");
assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation"); assert.match(settings, /contextId: "core\.settings"/, "settings expose stable contextual documentation");
assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state"); assert.match(settings, /There are no unsaved profile changes\./, "profile save explains its clean state");
@@ -24,4 +28,18 @@ assert.match(credentials, /contextId: "access\.credentials"/, "credentials expos
assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons"); assert.match(credentials, /disabledReason: writeDisabledReason/, "credential row actions retain actionable disabled reasons");
assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor"); assert.doesNotMatch(credentials, /<textarea/, "credentials use typed controls rather than a primary JSON editor");
assert.match(iconRail, /<div className="icon-rail-scroll">\s*<nav className="icon-nav">/, "the module navigation has a dedicated scroll viewport");
assert.match(layoutStyles, /\.icon-rail-scroll \{[^}]*min-height: 0;[^}]*flex: 1 1 auto;[^}]*overflow-y: auto;/, "only the middle rail region scrolls");
assert.match(layoutStyles, /\.icon-rail-header \{[^}]*flex: 0 0 auto;/, "the rail logo remains fixed");
assert.match(layoutStyles, /\.icon-rail-bottom \{[^}]*flex: 0 0 auto;/, "the rail utility controls remain fixed");
assert.match(titlebar, /className="titlebar-status-pattern"/, "shell state uses a titlebar background pattern");
assert.match(titlebar, /className="titlebar-global-search"/, "global search retains its dedicated titlebar grid cell");
assert.match(titlebar, /className="account-pill"[\s\S]*aria-label=\{displayUserName\}[\s\S]*aria-haspopup="menu"/, "the compact account menu retains an accessible name and menu state");
assert.doesNotMatch(layoutStyles, /\.maintenance-topbar-link[^}]*position: absolute;/, "maintenance state does not occupy the centered search position");
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.app-main \{[\s\S]*grid-template-rows: 104px 51px minmax\(0, 1fr\);/, "the narrow shell reserves two non-overlapping titlebar rows");
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.titlebar-context-selectors \{[\s\S]*overflow-x: auto;/, "narrow context selectors remain reachable without covering titlebar actions");
assert.match(layoutStyles, /@media \(max-width: 600px\)[\s\S]*\.account-pill span \{[\s\S]*display: none;/, "narrow account controls retain the icon while removing collision-prone text");
assert.match(moduleLoadBoundary, /<DismissibleAlert tone="danger" compact/, "module failures use the compact shared alert");
console.log("Core interface-pattern contracts passed."); console.log("Core interface-pattern contracts passed.");
+5 -1
View File
@@ -1,5 +1,5 @@
import type { ApiSettings } from "../types"; import type { ApiSettings } from "../types";
import type { PlatformModuleInfo, PlatformPublicModuleInfo } from "../types"; import type { PlatformInterfaceCatalog, PlatformModuleInfo, PlatformPublicModuleInfo } from "../types";
import { apiFetch } from "./client"; import { apiFetch } from "./client";
export type PlatformModulesResponse = { modules: PlatformModuleInfo[] }; export type PlatformModulesResponse = { modules: PlatformModuleInfo[] };
@@ -50,3 +50,7 @@ export async function fetchPlatformStatus(settings: ApiSettings): Promise<Platfo
export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> { export async function fetchPlatformPermissions(settings: ApiSettings): Promise<PlatformPermissionsResponse> {
return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions"); return apiFetch<PlatformPermissionsResponse>(settings, "/api/v1/platform/permissions");
} }
export async function fetchPlatformInterfaceCatalog(settings: ApiSettings): Promise<PlatformInterfaceCatalog> {
return apiFetch<PlatformInterfaceCatalog>(settings, "/api/v1/platform/interface-catalog");
}
+4 -3
View File
@@ -1,12 +1,13 @@
import type { ButtonHTMLAttributes, ReactNode } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react";
import DisabledActionTooltip from "./DisabledActionTooltip"; import DisabledActionTooltip from "./DisabledActionTooltip";
import type { PlatformInterfaceIdentityProps } from "../types";
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & PlatformInterfaceIdentityProps & {
variant?: "primary" | "secondary" | "ghost" | "danger"; variant?: "primary" | "secondary" | "ghost" | "danger";
disabledReason?: ReactNode; disabledReason?: ReactNode;
}; };
export default function Button({ variant = "secondary", className = "", disabledReason, disabled, ...props }: ButtonProps) { export default function Button({ variant = "secondary", className = "", disabledReason, disabled, interfaceId, helpTopicId, ...props }: ButtonProps) {
const button = <button className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />; const button = <button data-interface-id={interfaceId} data-help-topic-id={helpTopicId} className={`btn btn-${variant} ${className}`} disabled={disabled || Boolean(disabledReason)} {...props} />;
return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>; return <DisabledActionTooltip reason={disabledReason}>{button}</DisabledActionTooltip>;
} }
+8 -7
View File
@@ -1,8 +1,9 @@
import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react"; import { CalendarDays, ChevronLeft, ChevronRight, Clock } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react"; import { useEffect, useMemo, useRef, useState, type InputHTMLAttributes } from "react";
import useOutsideDismiss from "../hooks/useOutsideDismiss"; import useOutsideDismiss from "../hooks/useOutsideDismiss";
import type { PlatformInterfaceIdentityProps } from "../types";
type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & { type BaseProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange" | "min" | "max"> & PlatformInterfaceIdentityProps & {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
min?: string; min?: string;
@@ -49,7 +50,7 @@ function combineDateTime(date: string, time: string): string {
return `${date || dateString(new Date())}T${time || "00:00"}`; return `${date || dateString(new Date())}T${time || "00:00"}`;
} }
export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", ...props }: BaseProps) { export function DateField({ value, onChange, min, max, disabled, className = "", placeholder = "i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8", interfaceId, helpTopicId, ...props }: BaseProps) {
const selectedDate = parseDate(value); const selectedDate = parseDate(value);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date()); const [visibleMonth, setVisibleMonth] = useState<Date>(() => selectedDate ?? new Date());
@@ -93,7 +94,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
} }
return ( return (
<div ref={rootRef} className={`date-field ${className}`.trim()}> <div ref={rootRef} className={`date-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<input <input
{...props} {...props}
ref={inputRef} ref={inputRef}
@@ -145,7 +146,7 @@ export function DateField({ value, onChange, min, max, disabled, className = "",
} }
export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", ...props }: BaseProps) { export function TimeField({ value, onChange, min, max, className = "", placeholder = "i18n:govoplan-core.hh_mm.a4c7ee9b", interfaceId, helpTopicId, ...props }: BaseProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
const input = inputRef.current; const input = inputRef.current;
@@ -158,7 +159,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
}, [value, min, max]); }, [value, min, max]);
return ( return (
<div className={`time-field ${className}`.trim()}> <div className={`time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<input <input
{...props} {...props}
ref={inputRef} ref={inputRef}
@@ -174,7 +175,7 @@ export function TimeField({ value, onChange, min, max, className = "", placehold
} }
export function DateTimeField({ value, onChange, min, max, disabled, className = "", ...props }: BaseProps) { export function DateTimeField({ value, onChange, min, max, disabled, className = "", interfaceId, helpTopicId, ...props }: BaseProps) {
const parts = datePartsFromDateTime(value); const parts = datePartsFromDateTime(value);
const minParts = datePartsFromDateTime(min || ""); const minParts = datePartsFromDateTime(min || "");
const maxParts = datePartsFromDateTime(max || ""); const maxParts = datePartsFromDateTime(max || "");
@@ -188,7 +189,7 @@ export function DateTimeField({ value, onChange, min, max, disabled, className =
} }
return ( return (
<div className={`date-time-field ${className}`.trim()}> <div className={`date-time-field ${className}`.trim()} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<DateField <DateField
{...props} {...props}
value={parts.date} value={parts.date}
+10 -2
View File
@@ -3,12 +3,20 @@ import FieldLabel from "./help/FieldLabel";
import type { DocumentationHelpReference } from "./help/documentationHelp"; import type { DocumentationHelpReference } from "./help/documentationHelp";
import { helpForFieldLabel } from "../utils/fieldHelp"; import { helpForFieldLabel } from "../utils/fieldHelp";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export default function FormField({ label, help, documentation, children }: { label: ReactNode; help?: ReactNode; documentation?: DocumentationHelpReference; children: ReactNode }) { type FormFieldProps = PlatformInterfaceIdentityProps & {
label: ReactNode;
help?: ReactNode;
documentation?: DocumentationHelpReference;
children: ReactNode;
};
export default function FormField({ label, help, documentation, children, interfaceId, helpTopicId }: FormFieldProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const renderedLabel = typeof label === "string" ? translateText(label) : label; const renderedLabel = typeof label === "string" ? translateText(label) : label;
return ( return (
<label className="form-field"> <label className="form-field" data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel> <FieldLabel className="form-label" help={help ?? helpForFieldLabel(label)} documentation={documentation}>{renderedLabel}</FieldLabel>
{children} {children}
</label> </label>
+1 -1
View File
@@ -43,7 +43,7 @@ export default class ModuleLoadBoundary extends Component<
if (this.state.error) { if (this.state.error) {
return ( return (
<div className="content-pad module-load-error"> <div className="content-pad module-load-error">
<DismissibleAlert tone="danger" dismissible={false}> <DismissibleAlert tone="danger" compact resetKey={`${this.props.resetKey}:${this.state.error.message}`}>
<p>i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf</p> <p>i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf</p>
<Button type="button" onClick={() => window.location.reload()}> <Button type="button" onClick={() => window.location.reload()}>
i18n:govoplan-core.reload.cce71553 i18n:govoplan-core.reload.cce71553
+7 -2
View File
@@ -9,6 +9,7 @@ import {
} from "react"; } from "react";
import { ChevronDown, Search } from "lucide-react"; import { ChevronDown, Search } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export type SearchableSelectOption = { export type SearchableSelectOption = {
value: string; value: string;
@@ -27,7 +28,7 @@ export type SearchableSelectCreateCustomOption = (
value: string value: string
) => SearchableSelectOption | null; ) => SearchableSelectOption | null;
export type SearchableSelectProps = { export type SearchableSelectProps = PlatformInterfaceIdentityProps & {
id?: string; id?: string;
value: string; value: string;
onChange: ( onChange: (
@@ -95,7 +96,9 @@ export default function SearchableSelect({
minQueryLength = 0, minQueryLength = 0,
searchLimit = 50, searchLimit = 50,
debounceMs = 200, debounceMs = 200,
className = "" className = "",
interfaceId,
helpTopicId
}: SearchableSelectProps) { }: SearchableSelectProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-"); const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
@@ -294,6 +297,8 @@ export default function SearchableSelect({
<div <div
ref={rootRef} ref={rootRef}
className={rootClassName} className={rootClassName}
data-interface-id={interfaceId}
data-help-topic-id={helpTopicId}
onBlur={closeOnFocusLeave} onBlur={closeOnFocusLeave}
> >
<div className="searchable-select-control"> <div className="searchable-select-control">
+4 -3
View File
@@ -2,8 +2,9 @@ import type { ReactNode } from "react";
import FieldLabel from "./help/FieldLabel"; import FieldLabel from "./help/FieldLabel";
import { helpForFieldLabel } from "../utils/fieldHelp"; import { helpForFieldLabel } from "../utils/fieldHelp";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
type ToggleSwitchProps = { type ToggleSwitchProps = PlatformInterfaceIdentityProps & {
label: ReactNode; label: ReactNode;
activeLabel?: ReactNode; activeLabel?: ReactNode;
inactiveLabel?: ReactNode; inactiveLabel?: ReactNode;
@@ -13,7 +14,7 @@ type ToggleSwitchProps = {
help?: ReactNode; help?: ReactNode;
}; };
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) { export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help, interfaceId, helpTopicId }: ToggleSwitchProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined; const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
const renderedLabel = typeof label === "string" ? translateText(label) : label; const renderedLabel = typeof label === "string" ? translateText(label) : label;
@@ -21,7 +22,7 @@ export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checke
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel; const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined; const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
return ( return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}> <label className={`toggle-switch-row ${disabled ? "disabled" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<input <input
className="toggle-switch-input" className="toggle-switch-input"
type="checkbox" type="checkbox"
@@ -1,6 +1,7 @@
import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext"; import { i18nMessage, usePlatformLanguage } from "../../i18n/LanguageContext";
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent } from "react"; import type { CSSProperties, KeyboardEvent } from "react";
import type { PlatformInterfaceIdentityProps } from "../../types";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Button from "../Button"; import Button from "../Button";
import { import {
@@ -12,7 +13,7 @@ import {
type MailboxAddress } from type MailboxAddress } from
"../../utils/emailAddresses"; "../../utils/emailAddresses";
type EmailAddressInputProps = { type EmailAddressInputProps = PlatformInterfaceIdentityProps & {
value: MailboxAddress[]; value: MailboxAddress[];
onChange?: (value: MailboxAddress[]) => void; onChange?: (value: MailboxAddress[]) => void;
onAddressAdded?: (address: MailboxAddress) => void; onAddressAdded?: (address: MailboxAddress) => void;
@@ -43,7 +44,9 @@ export default function EmailAddressInput({
emailPlaceholder = "email@example.org", emailPlaceholder = "email@example.org",
emptyText = "i18n:govoplan-core.no_address_added_yet.809c4247", emptyText = "i18n:govoplan-core.no_address_added_yet.809c4247",
compact = false, compact = false,
showAddButton showAddButton,
interfaceId,
helpTopicId
}: EmailAddressInputProps) { }: EmailAddressInputProps) {
const { translateText } = usePlatformLanguage(); const { translateText } = usePlatformLanguage();
const inputId = useId(); const inputId = useId();
@@ -199,7 +202,7 @@ export default function EmailAddressInput({
) : null; ) : null;
return ( return (
<div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`}> <div className={`email-address-input ${compact ? "compact" : ""} ${disabled ? "disabled" : ""} ${canUseAddButton ? "has-add-button" : ""}`} data-interface-id={interfaceId} data-help-topic-id={helpTopicId}>
<div className={`email-address-editor ${error ? "has-error" : ""}`}> <div className={`email-address-editor ${error ? "has-error" : ""}`}>
<div className="email-chip-list" aria-live="polite"> <div className="email-chip-list" aria-live="polite">
{normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>} {normalizedValue.length === 0 && !entryText && <span className="email-chip-empty">{translateText(emptyText)}</span>}
+15 -6
View File
@@ -66,21 +66,29 @@ export default function IconRail({
<div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div> <div className="brand-mark" title="i18n:govoplan-core.govoplan.a84c0a85">i18n:govoplan-core.g.a36a6718</div>
</div> </div>
{!compact && {!compact && (
<> <>
<div className="icon-rail-scroll">
<nav className="icon-nav"> <nav className="icon-nav">
{items.map(({ to, label, icon: Icon }) => { {items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to; const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to); const active = modulePathActive(location.pathname, to);
const renderedLabel = translateText(label); const renderedLabel = translateText(label);
return ( return (
<NavLink key={to} to={target} className={`icon-nav-item ${active ? "active" : ""}`} title={renderedLabel} onClick={(event) => handleNavClick(event, target)}> <NavLink
key={to}
to={target}
className={`icon-nav-item ${active ? "active" : ""}`}
title={renderedLabel}
onClick={(event) => handleNavClick(event, target)}
>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>} {Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span> <span className="icon-nav-label">{renderedLabel}</span>
</NavLink>); </NavLink>
);
})} })}
</nav> </nav>
</div>
<div className="icon-rail-bottom"> <div className="icon-rail-bottom">
<NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}> <NavLink to="/settings" className={({ isActive }) => `icon-nav-item ${isActive ? "active" : ""}`} title={translateText("i18n:govoplan-core.settings.c7f73bb5")} onClick={(event) => handleNavClick(event, "/settings")}>
<Settings size={20} /> <Settings size={20} />
@@ -97,8 +105,9 @@ export default function IconRail({
</button> </button>
</div> </div>
</> </>
} )}
</aside>); </aside>
);
} }
+30 -13
View File
@@ -1,5 +1,5 @@
import { useRef, useState, useEffect } from "react"; import { useRef, useState, useEffect } from "react";
import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react"; import { Bell, Check, LogOut, Settings, TriangleAlert, UserCircle, WifiOff } from "lucide-react";
import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types"; import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types";
import HelpMenu from "./HelpMenu"; import HelpMenu from "./HelpMenu";
import LanguageMenu from "./LanguageMenu"; import LanguageMenu from "./LanguageMenu";
@@ -75,6 +75,10 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
0 : 0 :
Math.max(0, Number(notificationSummary?.unread) || 0); Math.max(0, Number(notificationSummary?.unread) || 0);
const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount); const notificationBadgeLabel = unreadNotificationCount > 99 ? "99+" : String(unreadNotificationCount);
const titlebarState = !backendReachable ? "offline" : maintenanceMode?.enabled ? "maintenance" : null;
const titlebarStateLabel = titlebarState === "offline"
? "System not reachable / offline!"
: translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d");
useEffect(() => { useEffect(() => {
function onPointerDown(event: MouseEvent) { function onPointerDown(event: MouseEvent) {
@@ -148,27 +152,34 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
} }
return ( return (
<header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}`}> <header className={`titlebar${showGlobalSearch ? " has-global-search" : ""}${titlebarState ? ` is-${titlebarState}` : ""}`}>
{!backendReachable ? {titlebarState &&
<div className="titlebar-status-pattern" aria-hidden="true">
{Array.from({ length: 10 }, (_, index) => <span key={index}>{titlebarStateLabel}</span>)}
</div>
}
<div className="titlebar-leading">
{titlebarState === "offline" &&
<div <div
className="backend-offline-topbar-alert" className="backend-offline-topbar-alert"
role="status" role="status"
aria-live="polite" aria-live="polite"
title="System not reachable / offline!"> aria-label={titlebarStateLabel}
System not reachable / offline! title={titlebarStateLabel}>
</div> : <WifiOff size={18} aria-hidden="true" />
maintenanceMode?.enabled && </div>
}
{titlebarState === "maintenance" &&
<button <button
type="button" type="button"
className="maintenance-topbar-link" className="maintenance-topbar-link"
title={maintenanceMode.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")} aria-label={translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
title={maintenanceMode?.message || translateText("i18n:govoplan-core.open_maintenance_mode_settings.99b41249")}
onClick={openMaintenanceSettings}> onClick={openMaintenanceSettings}>
<TriangleAlert size={18} aria-hidden="true" />
{translateText("i18n:govoplan-core.maintenance_mode_enabled.4fb4a37d")}
</button> </button>
} }
<div className="titlebar-leading">
{auth && showContextSelectors && {auth && showContextSelectors &&
<div className="titlebar-context-selectors"> <div className="titlebar-context-selectors">
{activeTenant && showTenantControl && {activeTenant && showTenantControl &&
@@ -237,7 +248,13 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode
} }
<div className="context-menu-wrap" ref={accountRef}> <div className="context-menu-wrap" ref={accountRef}>
<button className="account-pill" onClick={() => setAccountOpen(!accountOpen)}> <button
className="account-pill"
aria-label={displayUserName}
aria-haspopup="menu"
aria-expanded={accountOpen}
title={displayUserName}
onClick={() => setAccountOpen(!accountOpen)}>
<UserCircle size={22} /> <UserCircle size={22} />
<span>{displayUserName}</span> <span>{displayUserName}</span>
<span className="tenant-caret"></span> <span className="tenant-caret"></span>
+2
View File
@@ -2235,7 +2235,9 @@
} }
.module-load-error .alert { .module-load-error .alert {
width: min(640px, 100%);
max-width: 640px; max-width: 640px;
margin: 0;
} }
.module-load-error .alert-message { .module-load-error .alert-message {
+99 -5
View File
@@ -1,7 +1,7 @@
.app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; } .app-shell { height: 100vh; min-height: 0; display: grid; grid-template-columns: auto 1fr; overflow: hidden; }
.icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; } .icon-rail { width: 58px; background: var(--rail-bg); color: var(--rail-text); display: flex; flex-direction: column; align-items: center; height: 100vh; min-height: 0; box-shadow: var(--shadow-rail); z-index: 1000; transition: width .16s ease; }
.icon-rail.expanded { width: 208px; align-items: stretch; } .icon-rail.expanded { width: 208px; align-items: stretch; }
.icon-rail-header { width: 100%; min-height: 63px; display: flex; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; } .icon-rail-header { width: 100%; min-height: 63px; display: flex; flex: 0 0 auto; align-items: center; justify-content: center; padding: 0 10px; box-sizing: border-box; }
.brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; } .brand-mark { width: 34px; height: 34px; flex: 0 0 auto; border-radius: 50%; background: conic-gradient(var(--accent) 0 20%, var(--amber) 0 40%, var(--green) 0 60%, var(--blue) 0 80%, var(--muted) 0); color: transparent; font-size: 0; position: relative; }
.brand-mark::after { position: absolute; .brand-mark::after { position: absolute;
top: 9px; top: 9px;
@@ -16,6 +16,7 @@
.icon-rail-toggle:hover, .icon-rail-toggle:hover,
.icon-rail-toggle:focus-visible { background: var(--rail-bg-active); color: var(--on-accent); outline: none; } .icon-rail-toggle:focus-visible { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
.icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); } .icon-rail-toggle:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.icon-rail-scroll { width: 100%; min-width: 0; min-height: 0; flex: 1 1 auto; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: var(--rail-text-muted) transparent; scrollbar-width: thin; }
.icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; } .icon-nav { width: 100%; display: flex; flex-direction: column; min-width: 0; }
.icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; } .icon-nav-item { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; color: var(--rail-text-muted); border-left: 3px solid transparent; text-decoration: none; box-sizing: border-box; }
.icon-nav-item svg, .icon-nav-item svg,
@@ -27,6 +28,12 @@
.icon-rail.compact { width: 58px; } .icon-rail.compact { width: 58px; }
.app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); } .app-main { min-width: 0; min-height: 0; height: 100vh; display: grid; grid-template-rows: 64px 51px minmax(0, 1fr); }
.titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); } .titlebar { position: relative; background: var(--titlebar-bg); border-bottom: var(--border-line); display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; padding: 0 18px; gap: 18px; z-index: 100; box-shadow: var(--shadow-chrome); }
.titlebar.is-maintenance { background: var(--warning-bg); border-bottom-color: var(--warning-border-soft); }
.titlebar.is-offline { background: var(--danger-bg); border-bottom-color: var(--danger-border-deep); }
.titlebar > :not(.titlebar-status-pattern) { position: relative; z-index: 1; }
.titlebar-status-pattern { position: absolute; inset: 0; display: flex; align-items: center; gap: 34px; overflow: hidden; padding: 0 14px; pointer-events: none; white-space: nowrap; }
.titlebar-status-pattern span { flex: 0 0 auto; color: var(--warning-text); font-size: 11px; font-weight: 800; opacity: .16; }
.titlebar.is-offline .titlebar-status-pattern span { color: var(--danger-text); opacity: .18; }
.titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); } .titlebar.has-global-search { grid-template-columns: minmax(0, 1fr) minmax(190px, min(360px, 28vw)) minmax(0, 1fr); }
.titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; } .titlebar-leading { grid-column: 1; display: flex; align-items: center; min-width: 0; }
.titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; } .titlebar-global-search { grid-column: 2; position: relative; width: 100%; min-width: 0; height: 34px; }
@@ -50,10 +57,10 @@
.titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); } .titlebar-notification-badge { position: absolute; top: 4px; right: 3px; min-width: 16px; height: 16px; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; padding: 0 4px; border: 2px solid var(--titlebar-bg); border-radius: 999px; background: var(--red); color: var(--on-accent); font-size: 10px; font-weight: 800; line-height: 1; transform: translate(35%, -35%); }
.account-pill { color: var(--text); } .account-pill { color: var(--text); }
.maintenance-topbar-link, .maintenance-topbar-link,
.backend-offline-topbar-alert { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); border-radius: 6px; min-height: 32px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; font: inherit; font-weight: 800; box-shadow: 0 1px 2px var(--hover-tint); z-index: 1; white-space: nowrap; } .backend-offline-topbar-alert { width: 34px; height: 34px; flex: 0 0 auto; box-sizing: border-box; border-radius: 4px; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; padding: 0; font: inherit; box-shadow: 0 1px 2px var(--hover-tint); }
.maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--warning-bg); color: var(--warning-text); cursor: pointer; } .maintenance-topbar-link { border: 1px solid var(--warning-border-soft); background: var(--surface); color: var(--warning-text); cursor: pointer; }
.maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); } .maintenance-topbar-link:hover { background: var(--warning-bg-hover); color: var(--warning-text-hover); }
.backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--red); color: var(--on-accent); } .backend-offline-topbar-alert { border: 1px solid var(--danger-border-deep); background: var(--surface); color: var(--danger-text); }
.language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; } .language-menu-button { min-width: 54px; justify-content: center; font-weight: 800; }
.language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; } .language-menu-code, .language-option-code { font-size: 12px; letter-spacing: .06em; text-transform: uppercase; }
.language-menu { min-width: 210px; } .language-menu { min-width: 210px; }
@@ -322,7 +329,7 @@
/* Side rail: settings lives as the bottom utility entry. */ /* Side rail: settings lives as the bottom utility entry. */
.icon-rail-bottom { .icon-rail-bottom {
width: 100%; width: 100%;
margin-top: auto; flex: 0 0 auto;
padding: 12px 0 20px; padding: 12px 0 20px;
} }
.icon-rail-bottom .icon-nav-item { .icon-rail-bottom .icon-nav-item {
@@ -331,3 +338,90 @@
.icon-rail-bottom .icon-rail-toggle { .icon-rail-bottom .icon-rail-toggle {
border-top: 1px solid var(--rail-bg-active); border-top: 1px solid var(--rail-bg-active);
} }
@media (max-width: 600px) {
.app-main {
grid-template-rows: 104px 51px minmax(0, 1fr);
}
.titlebar,
.titlebar.has-global-search {
grid-template-columns: 34px minmax(0, 1fr);
grid-template-rows: 42px 42px;
column-gap: 6px;
row-gap: 4px;
padding: 6px 8px;
}
.titlebar-leading {
grid-column: 1 / -1;
grid-row: 1;
overflow: hidden;
}
.titlebar-context-selectors {
width: 100%;
overflow-x: auto;
overflow-y: hidden;
gap: 8px;
scrollbar-width: thin;
}
.titlebar-global-search {
grid-column: 1;
grid-row: 2;
}
.titlebar-actions,
.titlebar.has-global-search .titlebar-actions {
grid-column: 2;
grid-row: 2;
gap: 2px;
}
.titlebar:not(.has-global-search) .titlebar-actions {
grid-column: 1 / -1;
}
.language-menu-button {
min-width: 40px;
padding-inline: 4px;
}
.titlebar-actions > .context-menu-wrap:not(.language-menu-wrap) > .titlebar-link {
width: 34px;
height: 34px;
justify-content: center;
padding: 0;
font-size: 0;
}
.account-pill {
width: 34px;
height: 34px;
justify-content: center;
padding: 0;
}
.account-pill span {
display: none;
}
.breadcrumb-bar {
min-width: 0;
padding-inline: 12px;
overflow: hidden;
}
.breadcrumbs {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
scrollbar-width: thin;
}
.content-pad {
padding: 18px 14px;
}
}
+34
View File
@@ -385,6 +385,13 @@ export type PlatformPublicRouteContribution = {
export type PlatformUiCapabilities = Record<string, unknown>; export type PlatformUiCapabilities = Record<string, unknown>;
export type PlatformInterfaceIdentityProps = {
/** Stable control-plane identity; use a module-namespaced value. */
interfaceId?: string;
/** Optional stable documentation/help topic associated with the control. */
helpTopicId?: string;
};
export type PlatformTranslationDictionary = Record<string, string>; export type PlatformTranslationDictionary = Record<string, string>;
export type PlatformTranslations = Record<string, PlatformTranslationDictionary>; export type PlatformTranslations = Record<string, PlatformTranslationDictionary>;
@@ -1050,6 +1057,32 @@ export type PlatformPublicModuleInfo = {
>; >;
}; };
export type PlatformInterfaceDeclarationInfo = {
key: string;
id: string;
module_id: string;
kind: string;
label?: string | null;
path?: string | null;
required_all: string[];
required_any: string[];
metadata: Record<string, unknown>;
};
export type PlatformModuleInterfaceCatalog = {
contract_version: string;
module_id: string;
module_version: string;
digest: string;
counts: Record<string, number>;
declarations: PlatformInterfaceDeclarationInfo[];
};
export type PlatformInterfaceCatalog = {
contract_version: string;
modules: PlatformModuleInterfaceCatalog[];
};
export type PlatformModuleInfo = { export type PlatformModuleInfo = {
id: string; id: string;
name: string; name: string;
@@ -1083,6 +1116,7 @@ export type PlatformModuleInfo = {
behavior: Record<string, unknown>; behavior: Record<string, unknown>;
}>; }>;
runtime_ui_capabilities?: string[]; runtime_ui_capabilities?: string[];
interface_catalog?: Omit<PlatformModuleInterfaceCatalog, "declarations">;
nav: Array<{ nav: Array<{
path: string; path: string;
label: string; label: string;
+4
View File
@@ -146,6 +146,8 @@ export const adminReadScopes = [
"admin:api_keys:read", "admin:api_keys:read",
"admin:settings:read", "admin:settings:read",
"admin:policies:read", "admin:policies:read",
"admin:module:read",
"admin:module:write",
"mail:profile:read", "mail:profile:read",
"mail_servers:read", "mail_servers:read",
"audit:read", "audit:read",
@@ -163,6 +165,8 @@ export const adminReadScopes = [
"access:system_setting:read", "access:system_setting:read",
"access:system_credential:read", "access:system_credential:read",
"access:credential:read", "access:credential:read",
"access:service_account:read",
"approvals:workspace:admin",
"access:governance:read", "access:governance:read",
"views:definition:read", "views:definition:read",
"views:assignment:read", "views:assignment:read",