13 Commits

15 changed files with 1889 additions and 166 deletions

View File

@@ -22,6 +22,14 @@ This repository owns:
Core owns module discovery, configuration package loading, route registry, RBAC evaluation, capability registry, and shared WebUI shell behavior.
While Docs is installed, its managed `docs_reader` tenant role is an automatic
authenticated-member baseline. Access derives its narrow read grant from the
active Docs manifest without per-user assignments or authorization-time writes,
so ordinary users can open their configured handbook without an administrator
assigning documentation access one account at a time.
Administrative documentation remains separately protected by
`docs:documentation:admin` or the applicable administration scope.
## Documentation model
The docs module should render documentation in three layers:

View File

@@ -26,7 +26,7 @@ It should not start from a full product manual. The current tenant, installed
modules, enabled routes, permissions, and configuration decide what is shown as
the default path.
Tracking issue: `add-ideas/govoplan-docs#15`.
Tracking issue: `GovOPlaN/govoplan-docs#15`.
## Editorial Pillars
@@ -255,6 +255,9 @@ Workflow topics:
- keep each step actionable
- mention blockers where the user would encounter them
- link to field/reference topics instead of repeating field tables
- for every user-facing workflow, declare one or more conditions and put
`required_scopes` or `any_scopes` on every condition alternative; the release
gate rejects an unscoped alternative
Reference topics:

View File

@@ -100,17 +100,17 @@ database session when available. The module can then describe the effective
state without the docs module importing feature internals.
For example, `govoplan-mail` contributes static documentation for reusable mail
profiles and a runtime tenant policy topic. If the effective tenant mail policy
limits sending to approved profile ids and disables user, group, and
campaign-local profiles, the runtime docs state that users can choose approved
profiles but cannot bring arbitrary SMTP or IMAP servers at lower scopes. If the
policy allows lower scopes, the same topic explains which scopes may define
profiles and whether credential inheritance is locked.
profiles and runtime effective-policy topics. The provider evaluates the
current actor and scope. A custom-profile task appears only when that actor has
the relevant authority and the effective user policy permits it; the task then
states the host/domain bounds and separate credential authority needed to
complete it.
Runtime providers must avoid leaking secrets. They should summarize posture,
counts, source provenance, and enabling conditions rather than exposing
credentials, hostnames, profile ids, or raw policy payloads unless the route is
explicitly intended for that level of detail.
counts, source provenance, and enabling conditions. A provider may disclose a
host or domain pattern only when the actor is authorized and that exact value is
necessary to complete the documented task. Credentials, usernames, profile
ids, source ids, unrelated topology, and raw policy payloads remain excluded.
### Conditions And Unlocks
@@ -123,9 +123,10 @@ Documentation topics can declare conditions:
- required scopes or one-of scope alternatives
- configuration keys that influence the topic
The docs API classifies satisfied topics into their requested layer. Unsatisfied
topics remain visible as available or evidence documentation when safe, with a
reason such as a missing module, capability, or scope.
The docs API classifies satisfied topics into their requested layer.
Unsatisfied topics can remain visible in the administrator projection when
safe. The user projection omits them so a protected body or metadata payload is
not disclosed merely because a condition failed.
Topics can also declare related modules and unlock notes. This lets a module
state that additional behavior becomes available when another module is
@@ -136,7 +137,7 @@ then to public module documentation for broader examples.
GovOPlaN has two documentation presentations over the same source model.
Admin documentation is allowed to be technical. It can expose installed module
Admin documentation is a separately authorized technical projection. It can expose installed module
ids, route contributions, API paths, permissions, capability names, server
configuration keys, policy source chains, migration notes, and operator
evidence. This is the right place to explain exactly why a setting is available,
@@ -157,8 +158,10 @@ whether the user may choose an approved mail profile or add a local mail server.
The first presentation rule is:
- admin docs show technical context and evidence tables
- user docs show plain-language topics, examples, limits, and escalation paths
- admin docs require `docs:documentation:admin` or the compatible settings
administration authority and show technical context and evidence tables
- user docs are the default and show only active plain-language topics,
examples, limits, and safe escalation paths
- both presentations are filtered by installed modules, active configuration,
permissions, and safe disclosure rules

View File

@@ -23,9 +23,24 @@ The documentation context is built from:
| `available` | Installed features hidden by missing permissions or unmet conditions. |
| `evidence` | Hints for unavailable optional modules, missing capabilities, or external evidence sources. |
Normal user documentation should emphasize `always` and `configured` content.
Admin documentation may show all layers plus route, permission, module, and
capability diagnostics.
Normal user documentation returns only active, safely projected topics. Admin
documentation may show all layers plus route, permission, module, and capability
diagnostics, but it requires the separate administrative documentation
authority.
The managed `docs_reader` role grants `docs:documentation:read` automatically
to every authenticated tenant membership while Docs is installed. This only
opens the user projection: each contributed workflow still needs its own
module, capability, permission, and runtime-policy conditions so baseline Help
Center access does not imply authority to perform every documented task.
This permission binding is a strict source contract. Every user workflow topic
must declare at least one `DocumentationCondition`, and every alternative in
its `conditions` tuple must include `required_scopes` or `any_scopes`. Module,
capability, or configuration conditions alone are not sufficient because topic
conditions are alternatives: one unscoped alternative would bypass all scoped
ones. Manifest validation blocks a release containing such a topic, and Docs
omits a non-compliant runtime-provider topic from the user projection.
## Conditions
@@ -37,7 +52,11 @@ Documentation topics can declare:
- required capabilities
- required scopes
- any-of scope sets
- related configuration keys
- related configuration keys as technical provenance
Configuration keys are descriptive metadata; they are not evaluated as
conditions. Configuration-dependent guidance belongs in an owning module's
runtime provider.
The API returns both a human-readable `reason` and structured `blockers`:
@@ -52,10 +71,27 @@ The API returns both a human-readable `reason` and structured `blockers`:
}
```
The WebUI must display the reason and, when present, the blocker lists. This
prevents dead-end instructions: users see whether a feature is unavailable
because a module is missing, a capability is not registered, or their current
permissions do not expose it.
Raw blockers and scope names are part of the administrator projection. The user
projection omits inactive topics instead of returning their protected body,
metadata, and blocker identifiers. A module can contribute a separate, safe
plain-language escalation topic when users need to know that an administrator
must enable something.
## Task metadata
Workflow topics can supply `outcome`, `prerequisites`, `steps`, `result`, and
`verification`. Runtime providers can additionally supply:
- `current_configuration`: bounded plain-language facts for this actor and
installation;
- `limitations`: bounded, actionable caveats; and
- `constraints`: records with `id`, `label`, `description`, and optional
user-safe `values` needed to complete the task.
The Docs API validates and whitelists those fields for the user projection.
Modules remain responsible for authorization and for preserving policy
semantics. Constraints must never contain secrets, internal policy-source ids,
unrelated topology, or raw policy payloads.
## Module Guidance
@@ -90,9 +126,9 @@ Runtime docs may link to issues when an unavailable feature is planned or a
known limitation is relevant, but the UI must label that link as active work.
It must not treat open issues as shipped behavior.
Safe disclosure is evaluated before a topic is returned. Missing permission,
missing module, and missing capability explanations should be useful but
minimal. User docs explain the blocker and who can resolve it. Admin docs can
Safe disclosure is evaluated before a topic is returned. User docs contain
only active topics, runtime and HTTPS public links, and a bounded metadata
whitelist. Admin docs can
include route ids, scopes, capability names, configuration keys, and policy
provenance only when the actor has permission to inspect those details. Secrets,
tokens, private keys, credentials, raw policy payloads, and unrelated personal

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/docs-webui",
"version": "0.1.8",
"version": "0.1.10",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -17,7 +17,7 @@
"README.md"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-docs"
version = "0.1.8"
version = "0.1.10"
description = "GovOPlaN documentation module for configured-system, available, and evidence documentation."
readme = "README.md"
requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.8",
"govoplan-access>=0.1.8",
"govoplan-core>=0.1.10",
"govoplan-access>=0.1.10",
]
[tool.setuptools.packages.find]

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.8"
__version__ = "0.1.10"

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,9 @@ from __future__ import annotations
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationSourceDefinition,
DocumentationTopic,
FrontendModule,
FrontendRoute,
@@ -14,7 +16,9 @@ from govoplan_core.core.modules import (
)
DOCS_READ_SCOPE = "docs:documentation:read"
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, "system:settings:read", "admin:settings:read")
DOCS_ADMIN_READ_SCOPE = "docs:documentation:admin"
DOCS_ADMIN_READ_SCOPES = (DOCS_ADMIN_READ_SCOPE, "system:settings:read", "admin:settings:read")
DOCS_READ_SCOPES = (DOCS_READ_SCOPE, *DOCS_ADMIN_READ_SCOPES)
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -41,22 +45,34 @@ def _route_factory(context: ModuleContext):
manifest = ModuleManifest(
id="docs",
name="Docs",
version="0.1.8",
version="0.1.10",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("policy", "audit", "ops", "workflow", "search"),
permissions=(
_permission(
DOCS_READ_SCOPE,
"View configured documentation",
"Read documentation generated from installed modules, visible routes, permissions, and evidence sources.",
"Read user documentation generated for the current actor from installed modules and effective configuration.",
),
_permission(
DOCS_ADMIN_READ_SCOPE,
"View administrative documentation",
"Read technical module, route, permission, configuration, and evidence documentation.",
),
),
role_templates=(
RoleTemplate(
slug="docs_reader",
name="Documentation reader",
description="Read the configured-system documentation browser.",
description="Read the configured-system documentation browser. This role is granted automatically to every authenticated tenant member while Docs is installed.",
permissions=(DOCS_READ_SCOPE,),
default_authenticated=True,
),
RoleTemplate(
slug="docs_admin",
name="Documentation administrator",
description="Read user guidance and the technical configured-system documentation projection.",
permissions=(DOCS_READ_SCOPE, DOCS_ADMIN_READ_SCOPE),
),
),
route_factory=_route_factory,
@@ -155,6 +171,11 @@ manifest = ModuleManifest(
audience=("tenant_admin", "access_admin", "operator", "user"),
related_modules=("organizations", "identity", "idm", "access"),
order=21,
conditions=(
DocumentationCondition(
required_modules=("organizations", "identity", "idm", "access"),
),
),
links=(
DocumentationLink(label="Organizations", href="/organizations", kind="runtime"),
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
@@ -173,6 +194,39 @@ manifest = ModuleManifest(
},
),
),
documentation_sources=(
DocumentationSourceDefinition(
id="docs.configuration.packages",
kind="configuration_package",
label="Configuration package catalog",
condition=DocumentationCondition(
any_scopes=("system:settings:read", "admin:settings:read"),
),
provenance={
"source": "core_configuration_package_contract",
"version": "1",
},
inspection={
"package_id": "govoplan.configuration-packages",
"schema_version": "1",
"description": "Signed, preflighted platform configuration packages.",
},
),
DocumentationSourceDefinition(
id="docs.project.wiki",
kind="wiki",
label="GovOPlaN Docs wiki",
provenance={"source": "gitea_wiki"},
link=DocumentationLink(
label="GovOPlaN Docs wiki",
href="https://git.add-ideas.de/GovOPlaN/govoplan-docs/wiki",
kind="wiki",
),
inspection={
"href": "https://git.add-ideas.de/GovOPlaN/govoplan-docs/wiki",
},
),
),
)

View File

@@ -0,0 +1,391 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Annotated, Any, Literal, Mapping
from pydantic import BaseModel, ConfigDict, Field
from govoplan_core.core.modules import (
DocumentationCondition,
DocumentationLink,
DocumentationSourceDefinition,
DocumentationType,
ModuleManifest,
)
from govoplan_core.security.redaction import redact_secret_values
class _SourceModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class DocumentationSourceProvenance(_SourceModel):
source: str
version: str | None = None
revision: str | None = None
published_at: str | None = None
checksum: str | None = None
class DocumentationSourceVisibility(_SourceModel):
documentation_types: list[DocumentationType]
required_modules: list[str] = Field(default_factory=list)
any_modules: list[str] = Field(default_factory=list)
missing_modules: list[str] = Field(default_factory=list)
required_capabilities: list[str] = Field(default_factory=list)
required_scopes: list[str] = Field(default_factory=list)
any_scopes: list[str] = Field(default_factory=list)
configuration_keys: list[str] = Field(default_factory=list)
class ManifestInspection(_SourceModel):
kind: Literal["manifest"] = "manifest"
module_id: str
name: str
version: str
dependencies: list[str]
optional_dependencies: list[str]
class RouteInspection(_SourceModel):
kind: Literal["route"] = "route"
path: str
component: str | None = None
order: int
class CapabilityInspection(_SourceModel):
kind: Literal["capability"] = "capability"
capability: str
class PolicyInspection(_SourceModel):
kind: Literal["policy"] = "policy"
capability: str
class ConfigurationPackageInspection(_SourceModel):
kind: Literal["configuration_package"] = "configuration_package"
package_id: str
schema_version: str | None = None
description: str | None = None
class WikiInspection(_SourceModel):
kind: Literal["wiki"] = "wiki"
href: str
published_at: str | None = None
revision: str | None = None
class RepositoryInspection(_SourceModel):
kind: Literal["repository"] = "repository"
href: str
revision: str | None = None
DocumentationSourceInspection = Annotated[
ManifestInspection
| RouteInspection
| CapabilityInspection
| PolicyInspection
| ConfigurationPackageInspection
| WikiInspection
| RepositoryInspection,
Field(discriminator="kind"),
]
class DocumentationSourceItem(_SourceModel):
id: str
kind: str
owner_module_id: str
label: str
state: Literal["configured", "disabled", "unavailable"]
state_reason: str | None = None
inspection_url: str
provenance: DocumentationSourceProvenance
visibility: DocumentationSourceVisibility
inspection: DocumentationSourceInspection
def summary(self) -> dict[str, Any]:
return self.model_dump(mode="json", exclude={"inspection"})
@dataclass(frozen=True, slots=True)
class RegisteredDocumentationSource:
item: DocumentationSourceItem
condition: DocumentationCondition
documentation_types: tuple[DocumentationType, ...]
configuration_key: str | None = None
class DocumentationSourceRegistry:
def __init__(self) -> None:
self._sources: dict[str, RegisteredDocumentationSource] = {}
def register(self, source: RegisteredDocumentationSource) -> None:
if source.item.id in self._sources:
raise ValueError(f"Duplicate documentation source id: {source.item.id}")
self._sources[source.item.id] = source
def sources(self) -> tuple[RegisteredDocumentationSource, ...]:
return tuple(
self._sources[source_id]
for source_id in sorted(self._sources)
)
def get(self, source_id: str) -> RegisteredDocumentationSource | None:
return self._sources.get(source_id)
def build_documentation_source_registry(
manifests: tuple[ModuleManifest, ...],
) -> DocumentationSourceRegistry:
registry = DocumentationSourceRegistry()
for manifest in manifests:
registry.register(_manifest_source(manifest))
for source in _route_sources(manifest):
registry.register(source)
for source in _capability_sources(manifest):
registry.register(source)
for definition in manifest.documentation_sources:
registry.register(_defined_source(manifest, definition))
for source in _linked_sources(manifest):
if registry.get(source.item.id) is None:
registry.register(source)
return registry
def _manifest_source(manifest: ModuleManifest) -> RegisteredDocumentationSource:
return _registered_source(
source_id=f"{manifest.id}.manifest",
kind="manifest",
owner_module_id=manifest.id,
label=f"{manifest.name} module manifest",
provenance={"source": "module_manifest", "version": manifest.version},
inspection=ManifestInspection(
module_id=manifest.id,
name=manifest.name,
version=manifest.version,
dependencies=list(manifest.dependencies),
optional_dependencies=list(manifest.optional_dependencies),
),
)
def _route_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
if manifest.frontend is None:
return ()
return tuple(
_registered_source(
source_id=_derived_source_id(manifest.id, "route", route.path),
kind="route",
owner_module_id=manifest.id,
label=f"{manifest.name} route {route.path}",
provenance={"source": "frontend_manifest", "version": manifest.version},
inspection=RouteInspection(
path=route.path,
component=route.component,
order=route.order,
),
condition=DocumentationCondition(
required_modules=(manifest.id,),
required_scopes=route.required_all,
any_scopes=route.required_any,
),
)
for route in manifest.frontend.routes
)
def _capability_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
sources: list[RegisteredDocumentationSource] = []
for capability in sorted(manifest.capability_factories):
is_policy = capability.startswith("policy.")
kind = "policy" if is_policy else "capability"
inspection: DocumentationSourceInspection = (
PolicyInspection(capability=capability)
if is_policy
else CapabilityInspection(capability=capability)
)
sources.append(_registered_source(
source_id=_derived_source_id(manifest.id, kind, capability),
kind=kind,
owner_module_id=manifest.id,
label=f"{manifest.name} {kind} {capability}",
provenance={"source": "module_manifest", "version": manifest.version},
inspection=inspection,
condition=DocumentationCondition(required_modules=(manifest.id,)),
))
return tuple(sources)
def _defined_source(
manifest: ModuleManifest,
definition: DocumentationSourceDefinition,
) -> RegisteredDocumentationSource:
inspection = _defined_inspection(definition)
return _registered_source(
source_id=definition.id,
kind=definition.kind,
owner_module_id=manifest.id,
label=definition.label,
provenance={
"source": str(definition.provenance.get("source") or "module_manifest"),
**_safe_source_fields(definition.provenance),
},
inspection=inspection,
condition=definition.condition,
documentation_types=definition.documentation_types,
state=definition.state,
configuration_key=definition.configuration_key,
)
def _defined_inspection(definition: DocumentationSourceDefinition) -> DocumentationSourceInspection:
safe = _safe_source_fields(definition.inspection)
if definition.kind == "configuration_package":
return ConfigurationPackageInspection(
package_id=str(safe.get("package_id") or definition.id),
schema_version=_optional_text(safe.get("schema_version")),
description=_optional_text(safe.get("description")),
)
href = definition.link.href if definition.link else str(safe.get("href") or "")
if definition.kind == "wiki":
return WikiInspection(
href=href,
published_at=_optional_text(safe.get("published_at")),
revision=_optional_text(safe.get("revision")),
)
if definition.kind == "repository":
return RepositoryInspection(
href=href,
revision=_optional_text(safe.get("revision")),
)
if definition.kind == "route":
return RouteInspection(
path=str(safe.get("path") or ""),
component=_optional_text(safe.get("component")),
order=int(safe.get("order") or 0),
)
if definition.kind == "policy":
return PolicyInspection(capability=str(safe.get("capability") or definition.id))
if definition.kind == "capability":
return CapabilityInspection(capability=str(safe.get("capability") or definition.id))
return ManifestInspection(
module_id=str(safe.get("module_id") or definition.id.split(".", 1)[0]),
name=str(safe.get("name") or definition.label),
version=str(safe.get("version") or ""),
dependencies=_string_list(safe.get("dependencies")),
optional_dependencies=_string_list(safe.get("optional_dependencies")),
)
def _linked_sources(manifest: ModuleManifest) -> tuple[RegisteredDocumentationSource, ...]:
sources: list[RegisteredDocumentationSource] = []
for topic in manifest.documentation:
for link in topic.links:
if link.kind not in {"wiki", "repository"}:
continue
source_id = _derived_source_id(manifest.id, link.kind, link.href)
inspection: DocumentationSourceInspection = (
WikiInspection(href=link.href)
if link.kind == "wiki"
else RepositoryInspection(href=link.href)
)
sources.append(_registered_source(
source_id=source_id,
kind=link.kind,
owner_module_id=manifest.id,
label=link.label,
provenance={"source": "documentation_link", "version": manifest.version},
inspection=inspection,
condition=topic.conditions[0] if len(topic.conditions) == 1 else DocumentationCondition(),
documentation_types=topic.documentation_types,
))
return tuple(sources)
def _registered_source(
*,
source_id: str,
kind: str,
owner_module_id: str,
label: str,
provenance: Mapping[str, Any],
inspection: DocumentationSourceInspection,
condition: DocumentationCondition | None = None,
documentation_types: tuple[DocumentationType, ...] = ("admin",),
state: Literal["configured", "disabled", "unavailable"] = "configured",
configuration_key: str | None = None,
) -> RegisteredDocumentationSource:
clean_provenance = _safe_source_fields(provenance)
return RegisteredDocumentationSource(
item=DocumentationSourceItem(
id=source_id,
kind=kind,
owner_module_id=owner_module_id,
label=label,
state=state,
inspection_url=f"/api/v1/docs/sources/{source_id}",
provenance=DocumentationSourceProvenance(
source=str(clean_provenance.get("source") or "unknown"),
version=_optional_text(clean_provenance.get("version")),
revision=_optional_text(clean_provenance.get("revision")),
published_at=_optional_text(clean_provenance.get("published_at")),
checksum=_optional_text(clean_provenance.get("checksum")),
),
visibility=_visibility_payload(condition or DocumentationCondition(), documentation_types),
inspection=inspection,
),
condition=condition or DocumentationCondition(),
documentation_types=documentation_types,
configuration_key=configuration_key,
)
def _visibility_payload(
condition: DocumentationCondition,
documentation_types: tuple[DocumentationType, ...],
) -> DocumentationSourceVisibility:
return DocumentationSourceVisibility(
documentation_types=list(documentation_types),
required_modules=list(condition.required_modules),
any_modules=list(condition.any_modules),
missing_modules=list(condition.missing_modules),
required_capabilities=list(condition.required_capabilities),
required_scopes=list(condition.required_scopes),
any_scopes=list(condition.any_scopes),
configuration_keys=list(condition.configuration_keys),
)
def _derived_source_id(module_id: str, kind: str, value: str) -> str:
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
return f"{module_id}.{kind}.{digest}"
def _safe_source_fields(value: Mapping[str, Any]) -> dict[str, Any]:
redacted = redact_secret_values(dict(value))
if not isinstance(redacted, Mapping):
return {}
return {
str(key): item
for key, item in redacted.items()
if isinstance(item, (str, int, float, bool, list, tuple)) or item is None
}
def _optional_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text[:2_000] if text else None
def _string_list(value: object) -> list[str]:
if not isinstance(value, (list, tuple)):
return []
return [str(item)[:500] for item in value[:100]]

View File

@@ -1,10 +1,21 @@
from __future__ import annotations
import unittest
from inspect import signature
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from govoplan_access.backend.manifest import get_manifest as get_access_manifest
from govoplan_core.core.modules import DocumentationCondition
from govoplan_core.core.modules import DocumentationCondition, DocumentationLink, DocumentationTopic, ModuleManifest, NavItem
from govoplan_core.core.modules import (
DocumentationConfigurationDecision,
DocumentationConfigurationProviderRegistration,
DocumentationSourceDefinition,
FrontendModule,
FrontendRoute,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_tenancy.backend.manifest import get_manifest as get_tenancy_manifest
from govoplan_docs.backend.api.v1.routes import (
@@ -12,8 +23,11 @@ from govoplan_docs.backend.api.v1.routes import (
_condition_visibility,
_documentation_topic_anchor,
_documentation_topic_groups,
_visible_documentation_sources,
docs_context,
)
from govoplan_docs.backend.manifest import get_manifest as get_docs_manifest
from govoplan_docs.backend.sources import build_documentation_source_registry
class FakePrincipal:
@@ -28,6 +42,18 @@ class FakePrincipal:
class DocsContextTests(unittest.TestCase):
def test_docs_reader_is_the_managed_authenticated_tenant_default(self) -> None:
manifest = get_docs_manifest()
roles = {template.slug: template for template in manifest.role_templates}
self.assertTrue(roles["docs_reader"].default_authenticated)
self.assertTrue(roles["docs_reader"].managed)
self.assertEqual(roles["docs_reader"].level, "tenant")
self.assertEqual(
roles["docs_reader"].permissions,
("docs:documentation:read",),
)
def test_topic_groups_preserve_layer_classification(self) -> None:
registry = PlatformRegistry()
registry.register(get_tenancy_manifest())
@@ -60,7 +86,7 @@ class DocsContextTests(unittest.TestCase):
self.assertIn("access.workflow.grant-user-access", configured_ids)
self.assertIn("docs.pattern.field-help", always_ids)
def test_unavailable_topic_still_appears_in_kind_group(self) -> None:
def test_user_projection_omits_topics_without_required_access(self) -> None:
registry = PlatformRegistry()
registry.register(get_tenancy_manifest())
registry.register(get_access_manifest())
@@ -76,11 +102,212 @@ class DocsContextTests(unittest.TestCase):
locale="en",
)
groups = _documentation_topic_groups(layers)
workflows = {topic["id"]: topic for topic in groups["workflow"]}
workflow_ids = {topic["id"] for topic in groups["workflow"]}
self.assertIn("access.workflow.grant-user-access", workflows)
self.assertFalse(workflows["access.workflow.grant-user-access"]["active"])
self.assertIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
self.assertNotIn("access.workflow.grant-user-access", workflow_ids)
self.assertNotIn("access.workflow.grant-user-access", {topic["id"] for topic in layers["available"]})
def test_user_projection_fails_closed_for_unscoped_workflow_topics(self) -> None:
unscoped_static_topic = DocumentationTopic(
id="example.workflow.unscoped-static",
title="Unscoped static task",
summary="This topic must not be rendered.",
documentation_types=("user",),
metadata={"kind": "workflow"},
)
unscoped_runtime_topic = DocumentationTopic(
id="example.workflow.unscoped-runtime",
title="Unscoped runtime task",
summary="This provider topic must not be rendered.",
documentation_types=("user",),
conditions=(DocumentationCondition(required_modules=("example",)),),
metadata={"kind": "workflow"},
)
scoped_runtime_topic = DocumentationTopic(
id="example.workflow.scoped-runtime",
title="Scoped runtime task",
summary="This provider topic remains visible.",
documentation_types=("user",),
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
metadata={"kind": "workflow"},
)
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="9.9.9",
documentation=(unscoped_static_topic,),
documentation_providers=(
lambda context: (unscoped_runtime_topic, scoped_runtime_topic),
),
))
layers = _classify_documentation(
registry,
FakePrincipal({"docs:documentation:read"}),
settings=None,
session=None,
documentation_type="user",
locale="en",
)
self.assertEqual(
[topic["id"] for topic in _documentation_topic_groups(layers)["workflow"]],
["example.workflow.scoped-runtime"],
)
def test_user_projection_is_a_bounded_safe_whitelist(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="9.9.9",
nav_items=(NavItem(path="/example", label="Example", required_any=("docs:documentation:read",)),),
documentation=(
DocumentationTopic(
id="example.workflow.safe",
title="Safe task",
summary="A task the actor may perform.",
documentation_types=("user",),
conditions=(DocumentationCondition(required_scopes=("docs:documentation:read",)),),
links=(
DocumentationLink(label="Open task", href="/example", kind="runtime"),
DocumentationLink(label="Unsafe runtime", href="javascript:alert(1)", kind="runtime"),
DocumentationLink(label="Backslash runtime", href="/\\evil.invalid", kind="runtime"),
DocumentationLink(label="Invisible runtime", href="/hidden", kind="runtime"),
DocumentationLink(label="Shell settings", href="/settings?section=example", kind="runtime"),
DocumentationLink(label="Public help", href="https://example.invalid/help", kind="public"),
DocumentationLink(label="Malformed public", href="https://[", kind="public"),
DocumentationLink(label="Repository", href="example/docs/SECRET.md", kind="repository"),
DocumentationLink(label="API", href="/api/v1/example", kind="api"),
),
metadata={
"kind": "workflow",
"screen": "Example",
"steps": ["Do the safe thing."],
"help_contexts": ["example.list"],
"current_configuration": ["The bounded option is enabled."],
"constraints": [{
"id": "domain",
"label": "Domain",
"description": "Use an approved domain.",
"values": ["*.example.invalid"],
"secret": "must-not-pass",
}],
"raw_policy": {"secret": "must-not-pass"},
"api_path": "/api/v1/internal",
},
),
),
))
layers = _classify_documentation(
registry,
FakePrincipal({"docs:documentation:read"}),
settings=None,
session=None,
documentation_type="user",
locale="en",
)
topic = layers["configured"][0]
self.assertEqual(
[link["href"] for link in topic["links"]],
["/example", "/settings?section=example", "https://example.invalid/help"],
)
self.assertEqual(topic["conditions"], [])
self.assertEqual(topic["configuration_keys"], [])
self.assertEqual(topic["blockers"], {
"modules": [],
"capabilities": [],
"scopes": [],
"configuration": [],
})
self.assertNotIn("raw_policy", topic["metadata"])
self.assertNotIn("api_path", topic["metadata"])
self.assertEqual(topic["metadata"]["help_contexts"], ["example.list"])
self.assertEqual(topic["metadata"]["constraints"][0], {
"id": "domain",
"label": "Domain",
"description": "Use an approved domain.",
"values": ["*.example.invalid"],
})
def test_runtime_provider_failure_is_diagnostic_only(self) -> None:
def failing_provider(_context):
raise RuntimeError("sensitive provider detail")
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="private-module",
name="Private module",
version="1.0.0",
documentation_providers=(failing_provider,),
))
principal = FakePrincipal({"docs:documentation:read"})
user_layers = _classify_documentation(
registry,
principal,
settings=None,
session=None,
documentation_type="user",
locale="en",
)
admin_layers = _classify_documentation(
registry,
principal,
settings=None,
session=None,
documentation_type="admin",
locale="en",
)
self.assertFalse(any(user_layers.values()))
self.assertEqual(
[topic["id"] for topic in admin_layers["evidence"]],
["private-module.runtime-documentation-unavailable"],
)
def test_docs_default_to_user_and_admin_projection_requires_admin_authority(self) -> None:
query_default = signature(docs_context).parameters["documentation_type"].default
self.assertEqual(query_default.default, "user")
with self.assertRaises(HTTPException) as raised:
docs_context(
SimpleNamespace(),
documentation_type="admin",
locale="en",
principal=FakePrincipal({"docs:documentation:read"}),
)
self.assertEqual(raised.exception.status_code, 403)
def test_user_actor_projection_does_not_disclose_identity_or_scope_count(self) -> None:
registry = PlatformRegistry()
registry.register(get_docs_manifest())
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(govoplan_registry=registry)))
empty_layers = {"always": [], "configured": [], "available": [], "evidence": []}
with patch("govoplan_docs.backend.api.v1.routes._documentation_layers", return_value=empty_layers):
payload = docs_context(
request,
documentation_type="user",
locale="en",
principal=FakePrincipal({"docs:documentation:read"}),
)
self.assertEqual(payload["actor"]["available_documentation_types"], ["user"])
self.assertNotIn("tenant_id", payload["actor"])
self.assertNotIn("user_id", payload["actor"])
self.assertNotIn("scope_count", payload["actor"])
self.assertEqual(payload["layers"]["configured"]["permissions"], [])
self.assertEqual(payload["layers"]["available"]["routes"], [])
self.assertEqual(payload["layers"]["evidence"]["sources"], [])
self.assertEqual(
[(item["module_id"], item["path"]) for item in payload["layers"]["configured"]["routes"]],
[("docs", "/docs")],
)
self.assertEqual(payload["summary"]["visible_route_count"], 1)
def test_topic_anchor_is_stable(self) -> None:
self.assertEqual(
@@ -114,6 +341,7 @@ class DocsContextTests(unittest.TestCase):
self.assertEqual(blockers["modules"], ["mail", "files", "campaigns", "legacy"])
self.assertEqual(blockers["capabilities"], ["mail.delivery"])
self.assertEqual(blockers["scopes"], ["mail:profile:read", "admin:policies:read"])
self.assertEqual(blockers["configuration"], [])
def test_condition_visibility_accepts_any_module_scope_and_capability(self) -> None:
registry = PlatformRegistry()
@@ -129,7 +357,213 @@ class DocsContextTests(unittest.TestCase):
self.assertTrue(active)
self.assertEqual(reason, "conditions satisfied")
self.assertEqual(blockers, {"modules": [], "capabilities": [], "scopes": []})
self.assertEqual(blockers, {
"modules": [],
"capabilities": [],
"scopes": [],
"configuration": [],
})
def test_configuration_states_gate_conditions_without_disclosing_values(self) -> None:
states = {
"feature.enabled": "enabled",
"feature.inherited": "inherited",
"feature.disabled": "disabled",
"feature.unavailable": "unavailable",
}
def resolve(_context, keys):
return {
key: DocumentationConfigurationDecision(
key=key,
state=states[key], # type: ignore[arg-type]
source="tenant" if states[key] == "enabled" else "system",
reason="State metadata only.",
)
for key in keys
}
topics = tuple(
DocumentationTopic(
id=f"example.{key}",
title=key,
summary="Configuration-aware topic.",
conditions=(
DocumentationCondition(
required_scopes=("docs:documentation:read",),
configuration_keys=(key,),
),
),
configuration_keys=(key,),
)
for key in states
)
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=topics,
documentation_configuration_providers=(
DocumentationConfigurationProviderRegistration(
keys=tuple(states),
resolve=resolve,
),
),
))
layers = _classify_documentation(
registry,
FakePrincipal({"docs:documentation:read"}),
settings=None,
session=None,
documentation_type="admin",
locale="en",
)
self.assertEqual(
{topic["id"] for topic in layers["configured"]},
{"example.feature.enabled", "example.feature.inherited"},
)
self.assertEqual(
[topic["id"] for topic in layers["available"]],
["example.feature.disabled"],
)
self.assertEqual(
[topic["id"] for topic in layers["evidence"]],
["example.feature.unavailable"],
)
rendered = repr(layers)
self.assertNotIn("raw value", rendered.casefold())
self.assertNotIn("password", rendered.casefold())
def test_source_registry_covers_every_kind_and_redacts_explicit_payloads(self) -> None:
manifest = ModuleManifest(
id="example",
name="Example",
version="1.2.3",
capability_factories={
"example.lookup": lambda _context: object(),
"policy.example": lambda _context: object(),
},
frontend=FrontendModule(
module_id="example",
routes=(
FrontendRoute(
path="/example",
component="ExamplePage",
required_all=("example:item:read",),
),
),
),
documentation_sources=(
DocumentationSourceDefinition(
id="example.configuration.package",
kind="configuration_package",
label="Example package",
inspection={
"package_id": "example.package",
"schema_version": "2",
"password": "must-not-leak",
},
),
DocumentationSourceDefinition(
id="example.project.wiki",
kind="wiki",
label="Example wiki",
link=DocumentationLink(
label="Wiki",
href="https://example.invalid/wiki",
kind="wiki",
),
),
DocumentationSourceDefinition(
id="example.repository.handbook",
kind="repository",
label="Example handbook",
link=DocumentationLink(
label="Handbook",
href="example/docs/HANDBOOK.md",
kind="repository",
),
),
),
)
sources = build_documentation_source_registry((manifest,)).sources()
self.assertEqual(
{source.item.kind for source in sources},
{
"manifest",
"route",
"capability",
"policy",
"configuration_package",
"wiki",
"repository",
},
)
self.assertNotIn("must-not-leak", repr(sources))
for source in sources:
self.assertTrue(source.item.id.startswith("example."))
self.assertEqual("example", source.item.owner_module_id)
self.assertEqual(
f"/api/v1/docs/sources/{source.item.id}",
source.item.inspection_url,
)
def test_source_visibility_hides_unauthorized_ids(self) -> None:
manifest = ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation_sources=(
DocumentationSourceDefinition(
id="example.protected.handbook",
kind="repository",
label="Protected handbook",
condition=DocumentationCondition(
required_scopes=("example:handbook:read",),
),
link=DocumentationLink(
label="Protected",
href="example/docs/PROTECTED.md",
kind="repository",
),
),
),
)
registry = PlatformRegistry()
registry.register(manifest)
sources = build_documentation_source_registry((manifest,)).sources()
denied = _visible_documentation_sources(
sources,
registry,
FakePrincipal({"docs:documentation:admin"}),
settings=None,
session=None,
documentation_type="admin",
locale="en",
)
allowed = _visible_documentation_sources(
sources,
registry,
FakePrincipal({
"docs:documentation:admin",
"example:handbook:read",
}),
settings=None,
session=None,
documentation_type="admin",
locale="en",
)
denied_ids = {source.item.id for source in denied}
allowed_ids = {source.item.id for source in allowed}
self.assertNotIn("example.protected.handbook", denied_ids)
self.assertIn("example.protected.handbook", allowed_ids)
if __name__ == "__main__":

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/docs-webui",
"version": "0.1.8",
"version": "0.1.10",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -13,11 +13,11 @@
}
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.8",
"@govoplan/core-webui": "^0.1.10",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-router-dom": ">=7.18.2 <8",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.6"

View File

@@ -54,9 +54,20 @@ export type DocsOptionalModuleEvidence = {
};
export type DocsSource = {
id: string;
kind: string;
owner_module_id: string;
label: string;
source: string;
layer: string;
state: "configured" | "disabled" | "unavailable";
state_reason?: string | null;
inspection_url: string;
provenance: {
source: string;
version?: string | null;
revision?: string | null;
published_at?: string | null;
checksum?: string | null;
};
};
export type DocsDocumentationCondition = {
@@ -94,6 +105,7 @@ export type DocsDocumentationTopic = {
modules: string[];
capabilities: string[];
scopes: string[];
configuration: string[];
};
audience: string[];
order: number;
@@ -105,16 +117,23 @@ export type DocsDocumentationTopic = {
related_modules: string[];
unlocks: string[];
configuration_keys: string[];
configuration_states: Array<{
key: string;
state: "enabled" | "disabled" | "inherited" | "unavailable";
source?: string | null;
reason?: string | null;
}>;
metadata: Record<string, unknown>;
};
export type DocsContext = {
actor: {
tenant_id: string;
user_id: string;
scope_count: number;
tenant_id?: string;
user_id?: string;
scope_count?: number;
documentation_type: "admin" | "user";
locale: string;
available_documentation_types: Array<"admin" | "user">;
};
summary: {
module_count: number;

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import {
@@ -67,20 +67,29 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
const [context, setContext] = useState<DocsContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const loadSequence = useRef(0);
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
const locale = localeFromSearch(location.search) ?? language;
const adminDocs = documentationType === "admin";
async function load() {
const sequence = ++loadSequence.current;
setLoading(true);
setError("");
setContext(null);
try {
setContext(await fetchDocsContext(settings, { documentationType, locale }));
const nextContext = await fetchDocsContext(settings, { documentationType, locale });
if (sequence !== loadSequence.current) return;
if (nextContext.actor.documentation_type !== documentationType) {
throw new Error("Documentation response type did not match the requested projection.");
}
setContext(nextContext);
} catch (err) {
if (sequence !== loadSequence.current) return;
setError(adminErrorMessage(err));
} finally {
setLoading(false);
if (sequence === loadSequence.current) setLoading(false);
}
}
@@ -123,7 +132,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
<aside className="section-sidebar docs-outline" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<div className="docs-sidebar-header">
<div className="section-title">i18n:govoplan-docs.help_center.f3f3a34b</div>
<AudienceToggle selected={documentationType} onSelect={selectDocumentationType} />
<AudienceToggle
selected={documentationType}
onSelect={selectDocumentationType}
canViewAdmin={context?.actor.available_documentation_types.includes("admin") ?? documentationType === "admin"}
/>
</div>
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<ExplorerTree
@@ -211,7 +224,13 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
}
}
function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void }) {
function AudienceToggle({ selected, onSelect, canViewAdmin }: { selected: DocumentationType; onSelect: (type: DocumentationType) => void; canViewAdmin: boolean }) {
const options: Array<{ id: DocumentationType; label: string }> = [
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
];
if (canViewAdmin) {
options.unshift({ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" });
}
return (
<SegmentedControl
className="docs-audience-toggle"
@@ -221,10 +240,7 @@ function AudienceToggle({ selected, onSelect }: { selected: DocumentationType; o
ariaLabel="i18n:govoplan-docs.documentation_type.5a66690c"
value={selected}
onChange={onSelect}
options={[
{ id: "admin", label: "i18n:govoplan-docs.admin_docs.bf504a56" },
{ id: "user", label: "i18n:govoplan-docs.user_docs.1e38e8d3" }
]}
options={options}
/>
);
}
@@ -353,10 +369,26 @@ function DocumentationTopicList({ topics, emptyText, showTechnical = false, docu
}
function TopicMetadata({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
if (topic.kind === "workflow") return <WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} />;
if (topic.kind === "reference") return <ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
if (topic.kind === "pattern") return <PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} />;
return <RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} />;
const configuredDetails = <ConfiguredDetails topic={topic} />;
if (topic.kind === "workflow") return <>{configuredDetails}<WorkflowDetails topic={topic} documentationType={documentationType} topicById={topicById} /></>;
if (topic.kind === "reference") return <>{configuredDetails}<ReferenceDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
if (topic.kind === "pattern") return <>{configuredDetails}<PatternDetails topic={topic} showTechnical={showTechnical} documentationType={documentationType} topicById={topicById} /></>;
return <>{configuredDetails}<RelatedTopics topic={topic} documentationType={documentationType} topicById={topicById} /></>;
}
function ConfiguredDetails({ topic }: { topic: DocsDocumentationTopic }) {
const currentConfiguration = metadataList(topic.metadata, "current_configuration");
const limitations = metadataList(topic.metadata, "limitations");
const constraints = metadataRecords(topic.metadata, "constraints");
const prefix = topicAnchorId(topic);
if (!currentConfiguration.length && !limitations.length && !constraints.length) return null;
return (
<div className="docs-topic-details">
{!!currentConfiguration.length && <DetailList id={`${prefix}-current-configuration`} title="i18n:govoplan-docs.this_system.b13a51ad" items={currentConfiguration} />}
{!!constraints.length && <ConstraintDetails id={`${prefix}-constraints`} constraints={constraints} />}
{!!limitations.length && <DetailList id={`${prefix}-limitations`} title="i18n:govoplan-docs.details.a6b3c45f" items={limitations} />}
</div>
);
}
function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsDocumentationTopic; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
@@ -385,6 +417,30 @@ function WorkflowDetails({ topic, documentationType, topicById }: { topic: DocsD
);
}
function ConstraintDetails({ id, constraints }: { id: string; constraints: Record<string, unknown>[] }) {
return (
<div className="docs-detail-block" id={id}>
<h4>i18n:govoplan-docs.requirements.09a428f9</h4>
<dl className="detail-list compact">
{constraints.map((constraint, index) => {
const label = metadataString(constraint, "label");
const description = metadataString(constraint, "description");
const values = metadataList(constraint, "values");
return (
<div key={metadataString(constraint, "id") || `${label}-${index}`}>
<dt>{label}</dt>
<dd>
{description}
{!!values.length && <span className="muted block">{values.join(", ")}</span>}
</dd>
</div>
);
})}
</dl>
</div>
);
}
function ReferenceDetails({ topic, showTechnical, documentationType, topicById }: { topic: DocsDocumentationTopic; showTechnical: boolean; documentationType: DocumentationType; topicById: Map<string, DocsDocumentationTopic> }) {
const route = metadataString(topic.metadata, "route");
const screen = metadataString(topic.metadata, "screen");
@@ -503,7 +559,8 @@ function UnavailableDocumentationReason({ topic }: { topic: DocsDocumentationTop
const rows = [
["i18n:govoplan-docs.modules.04e9462c", topic.blockers.modules],
["i18n:govoplan-docs.capabilities.ca09c54b", topic.blockers.capabilities],
["i18n:govoplan-docs.permissions.d06d5557", topic.blockers.scopes]
["i18n:govoplan-docs.permissions.d06d5557", topic.blockers.scopes],
["Configuration", topic.blockers.configuration]
].filter(([, values]) => Array.isArray(values) && values.length);
return (
<div className="docs-unavailable-reason">
@@ -757,8 +814,25 @@ function uniqueTopics(topics: DocsDocumentationTopic[]): DocsDocumentationTopic[
function selectedPageFromSearch(search: string, pages: DocsPageNode[]): DocsPageNode | null {
if (!pages.length) return null;
const requested = new URLSearchParams(search).get("topic") || "";
return pages.find((page) => page.id === requested) ?? pages[0];
const params = new URLSearchParams(search);
const requested = params.get("topic") || "";
if (requested) return pages.find((page) => page.id === requested) ?? pages[0];
const helpContext = params.get("context") || "";
if (helpContext) {
const exact = pages.find((page) => page.kind === "topic" && metadataList(page.topic.metadata, "help_contexts").includes(helpContext));
if (exact) return exact;
const moduleId = helpContextModuleId(helpContext);
const moduleTopic = pages.find((page) => page.kind === "topic" && page.topic.source_module_id === moduleId);
if (moduleTopic) return moduleTopic;
}
return pages[0];
}
function helpContextModuleId(value: string): string {
const prefix = value.split(".", 1)[0];
if (prefix === "campaign") return "campaigns";
if (prefix === "address-book") return "addresses";
return prefix;
}
function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): OutlineItem[] {
@@ -781,6 +855,9 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
const prefix = topicAnchorId(topic);
const items: OutlineItem[] = [{ id: prefix, label: topic.title }];
if (topic.summary) items.push({ id: `${prefix}-summary`, label: "i18n:govoplan-docs.summary.d6b9936d" });
if (metadataList(topic.metadata, "current_configuration").length) items.push({ id: `${prefix}-current-configuration`, label: "i18n:govoplan-docs.this_system.b13a51ad" });
if (metadataRecords(topic.metadata, "constraints").length) items.push({ id: `${prefix}-constraints`, label: "i18n:govoplan-docs.requirements.09a428f9" });
if (metadataList(topic.metadata, "limitations").length) items.push({ id: `${prefix}-limitations`, label: "i18n:govoplan-docs.details.a6b3c45f" });
if (topic.kind === "workflow") {
if (metadataString(topic.metadata, "outcome")) items.push({ id: `${prefix}-outcome`, label: "i18n:govoplan-docs.outcome.10172bd3" });
if (metadataList(topic.metadata, "prerequisites").length) items.push({ id: `${prefix}-prerequisites`, label: "i18n:govoplan-docs.prerequisites.fdf2407f" });
@@ -800,7 +877,7 @@ function outlineForPage(page: DocsPageNode | null, showTechnical: boolean): Outl
}
function documentationTypeFromSearch(search: string): DocumentationType {
return new URLSearchParams(search).get("type") === "user" ? "user" : "admin";
return new URLSearchParams(search).get("type") === "admin" ? "admin" : "user";
}
function localeFromSearch(search: string): string | null {
@@ -899,9 +976,9 @@ function EvidenceList({ modules, sources }: { modules: DocsOptionalModuleEvidenc
</div>
)}
{sources.map((item) =>
<div key={item.source}>
<dt>{item.layer}</dt>
<dd><strong>{item.label}</strong><span className="muted"> · {item.source}</span></dd>
<div key={item.id}>
<dt><StatusBadge status={item.state === "configured" ? "success" : item.state === "disabled" ? "inactive" : "warning"} label={item.state} /></dt>
<dd><strong>{item.label}</strong><span className="muted"> · {item.owner_module_id} · {item.kind} · {item.provenance.source}</span></dd>
</div>
)}
</dl>

View File

@@ -4,7 +4,7 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
const DocsPage = lazy(() => import("./features/docs/DocsPage"));
const docsReadScopes = ["docs:documentation:read", "system:settings:read", "admin:settings:read"];
const docsReadScopes = ["docs:documentation:read", "docs:documentation:admin", "system:settings:read", "admin:settings:read"];
const translations = {
en: generatedTranslations.en,
@@ -14,7 +14,7 @@ const translations = {
export const docsModule: PlatformWebModule = {
id: "docs",
label: "i18n:govoplan-docs.docs.68a41942",
version: "1.0.0",
version: "0.1.10",
dependencies: ["access"],
optionalDependencies: ["policy", "audit", "ops", "workflow", "search"],
translations,