Compare commits

...
17 Commits
Author SHA1 Message Date
zemion ac40774785 feat(webui): expose stable product destinations
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 18:06:52 +02:00
zemion 9a3008002d feat: define governed tenant erasure contracts
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 16:00:06 +02:00
zemion 9cb2080938 feat: collect infrastructure dependency inventories
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 15:00:08 +02:00
zemion 08c3e47b6d Exercise accessible resident permit journeys
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 14:03:43 +02:00
zemion 6e518fa6a2 feat: compose stable product surfaces
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 13:21:24 +02:00
zemion f98cf9ced8 feat(help): enforce owner-aware high-risk contexts
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 11:48:23 +02:00
zemion d2e491348d feat(docs): validate owner structured translations
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 01:15:30 +02:00
zemion 562d278f60 feat(core): add structured documentation localization contract
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 20:25:12 +02:00
zemion c6f6faf64f feat(core): add datasource lifecycle governance contracts
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 19:37:44 +02:00
zemion 1c3ee9e8c7 Release Core v0.1.35 configuration package safeguards
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 18:05:33 +02:00
zemion aa91063211 Release Core v0.1.34 with JMAP mail contracts
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 17:08:54 +02:00
zemion fa2d5d40dd Release Core v0.1.33 with redirect-sensitive headers
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 16:11:02 +02:00
zemion 6ccef162f6 Release Core v0.1.32 with bounded HTTP request bodies
Module Package Release / publish-packages (push) Successful in 14s
2026-08-22 14:32:24 +02:00
zemion 48dac139a5 feat(wiki): compose governed Wiki WebUI
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 13:32:20 +02:00
zemion a090e5af20 feat(tickets): add optional integration contracts and WebUI composition
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 12:06:04 +02:00
zemion 0c1358b862 feat(policy): add delegation and escalation contracts
Module Package Release / publish-packages (push) Failing after 6s
2026-08-22 03:12:30 +02:00
zemion a9035c4c3b feat: add Campaign work orchestration contract
Module Package Release / publish-packages (push) Failing after 6s
2026-08-22 02:15:32 +02:00
78 changed files with 5361 additions and 601 deletions
+1
View File
@@ -142,6 +142,7 @@ system:tenants:read
system:tenants:create system:tenants:create
system:tenants:update system:tenants:update
system:tenants:suspend system:tenants:suspend
system:tenants:erase
system:accounts:read system:accounts:read
system:accounts:create system:accounts:create
+36
View File
@@ -157,6 +157,16 @@ The initial implementation includes provider-neutral orchestration helpers:
- `apply_configuration_package(...)` - `apply_configuration_package(...)`
- `export_configuration_package(...)` - `export_configuration_package(...)`
Portable fragments may bind deployment-specific operator input without placing
that value in the signed reusable definition. A payload value of
`{"$data": "requirement_key"}` references a key declared in the manifest's
`data_requirements`. Preflight fails before invoking the owning provider when a
reference is malformed, undeclared, or unresolved. Once supplied, Core replaces
the reference in memory and passes only the resolved fragment to the provider.
This mechanism is for deployment bindings and wording, not plaintext secrets:
credential-envelope or environment references remain the normal portable
boundary.
The first concrete provider is `govoplan_access.backend.configuration_provider`. The first concrete provider is `govoplan_access.backend.configuration_provider`.
It supports access-owned `roles`, `groups`, and `group_role_assignments` It supports access-owned `roles`, `groups`, and `group_role_assignments`
fragments and applies them idempotently. Mail and Files also register providers fragments and applies them idempotently. Mail and Files also register providers
@@ -190,6 +200,19 @@ Feature providers remain responsible for their own semantics:
Ops projects the same Core-validated receipt. It must not maintain a second Ops projects the same Core-validated receipt. It must not maintain a second
parser with different validation or secret-handling rules. parser with different validation or secret-handling rules.
Core also defines the inverse, read-only dependency-inventory contract used
before the installer changes one of those infrastructure capabilities. An
enabled module registers
`infrastructure.dependency_inventory.<module_id>` and returns bounded, stable
references to its persisted configuration or data, a lifecycle state, scope,
numeric metrics, and a required operator action. Providers must not return
secrets or use this read to migrate state. The Core collector validates provider
identity and capability coverage, orders records deterministically, and marks
the complete inventory failed when any provider raises or violates the
contract. Ops is the authorized projection boundary; the installer remains the
consumer and must match installation id, freshness, completion and impacted
capability coverage before apply.
The admin wizard backend starts with these routes: The admin wizard backend starts with these routes:
- `GET /api/v1/admin/configuration-packages/catalog` - `GET /api/v1/admin/configuration-packages/catalog`
@@ -212,6 +235,14 @@ The admin wizard backend starts with these routes:
10. Store import provenance, package version, supplied non-secret metadata, and 10. Store import provenance, package version, supplied non-secret metadata, and
audit events. audit events.
Provider applies may commit independently. Core therefore stops at the first
apply or health blocker and reports an explicit rollback state. A blocked
preflight or a no-op needs no recovery; a successful multi-provider mutation
retains the reviewed pre-apply database snapshot as its generic rollback path;
a later-provider failure is reported as a partial apply that requires snapshot
recovery or an explicitly supported module-owned compensation. The generic
wizard never claims atomic cross-module undo.
The wizard should display everything necessary and nothing unnecessary. Generic The wizard should display everything necessary and nothing unnecessary. Generic
sections should cover package trust, dependency plan, required data, conflicts, sections should cover package trust, dependency plan, required data, conflicts,
review, and result. Module-specific fields should appear only when the selected review, and result. Module-specific fields should appear only when the selected
@@ -262,6 +293,11 @@ Exported packages should record provenance: source GovOPlaN version, module
versions, exporter identity, timestamp, selected scope, redactions, and versions, exporter identity, timestamp, selected scope, redactions, and
validation status. validation status.
The orchestrator emits this provenance independently of provider payloads and
lists secret requirement keys as redacted without serializing their supplied
values. Providers still own the deeper rule that credentials, tokens, and
decrypted envelope contents must never appear in exported fragments.
## Catalogs And Trust ## Catalogs And Trust
Configuration catalogs should follow the existing module package catalog model: Configuration catalogs should follow the existing module package catalog model:
+11
View File
@@ -58,6 +58,17 @@ than adding custom `F1` listeners:
headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its headed pages. `WorkspaceLayout` owns the full-canvas workspace scope and its
labelled primary/content panes; pages inside it use `PageLayout` in labelled primary/content panes; pages inside it use `PageLayout` in
`workspace` mode and retain their own route-level help identity. `workspace` mode and retain their own route-level help identity.
- `PasswordField` passes its owner context and module through reveal/generate
actions and the shared generator dialog. Credential consumers must supply an
exact owner context; the generic component does not own credential policy.
High-risk controls use one of the source-inventory risk classes (`authority`,
`credential`, `disclosure`, `encryption`, `external-effect`, `irreversible`,
`policy`, or `retention`) and require exact F1 help. The extractor infers
obvious cases conservatively; components may declare `data-help-risk`
explicitly or mark a reviewed ordinary control with
`data-help-risk-reviewed="standard"`. The strict workspace gate rejects new
unresolved high-risk debt.
Module routes, public routes, settings sections, and administration sections Module routes, public routes, settings sections, and administration sections
may also declare `helpContextId` and `helpTopicId`. Each module must keep a may also declare `helpContextId` and `helpTopicId`. Each module must keep a
+1
View File
@@ -18,6 +18,7 @@ operator, and roadmap pages.
| External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. | | External references and integration maturity | `EXTERNAL_REFERENCES_AND_INTEGRATION_MATURITY.md` | Stable external identity and cumulative connector maturity; configured source authority is defined by the meta target architecture. |
| Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. | | Institutional context and governed references | `INSTITUTIONAL_CONTEXT_CONTRACT.md` | Shared temporal, actor/representation, institution, mandate, service, party, decision, evidence, legal-basis, information-governance, presentation, and geo DTO/provider contracts. |
| Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. | | Provider-neutral record filing | `RECORDS_FILING_CONTRACT.md` | Exact source-revision identity, current source authorization, idempotent filing, capability discovery, and ownership boundary. |
| Ticket routing and Case escalation | `TICKET_INTEGRATION_CONTRACTS.md` | Optional fail-open routing, replay-safe Case handoff, authorization, evidence, and ownership boundaries. |
| Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. | | Temporal data read context | `TEMPORAL_DATA_CONTEXT.md` | Valid-time and recorded-time titlebar selection, HTTP/cache contract, security boundary, and module-adoption rule. |
| Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. | | Cross-module information governance adoption | `INFORMATION_GOVERNANCE_ADOPTION.md` | Manifest evidence and enforcement rules for temporal browsing, purpose-aware access, retention, and institutional context. |
| Data-subject access and erasure requests | `DATA_SUBJECT_REQUESTS.md` | Provider-owned search and mutation, explicit coverage, governed export, retained evidence, permissions, and idempotent execution. | | Data-subject access and erasure requests | `DATA_SUBJECT_REQUESTS.md` | Provider-owned search and mutation, explicit coverage, governed export, retained evidence, permissions, and idempotent execution. |
+42
View File
@@ -18,6 +18,27 @@ The platform inventory recognizes both inline locale objects and generated
catalogs declared as `const de` / `const en`. Its strict mode requires both catalogs declared as `const de` / `const en`. Its strict mode requires both
locales and reports `de` explicitly as the reference locale. locales and reports `de` explicitly as the reference locale.
## Structured Documentation Localization
`DocumentationTopic.translations` continues to own localized title, summary,
and body prose. Topics whose metadata contains rendered prose opt into the
separate `structured_translation_version="1"` contract and provide a complete
same-shape value for each translated metadata key in
`structured_translations`. Version 1 covers workflow prerequisites, steps,
outcome, result and verification; reference fields; limitations, constraints,
consequences and consequence classes; and the other rendered explanation
fields declared by Core.
The registry rejects an unversioned translation, an unsupported contract
version, missing structured keys, changed object keys or list lengths, empty
translated strings, and changed non-text values. Stable field IDs, routes,
permission scopes, and other technical leaves therefore remain structurally
bound to the source metadata. The Docs module overlays only a validated locale
at response time and reports the selected structured locale separately from the
title/body locale. Missing structured translations fall back to source content
and remain visible in public coverage until the owning module adopts the
contract.
## Help Resolution ## Help Resolution
Every focusable field and action receives a stable derived F1 identity from the Every focusable field and action receives a stable derived F1 identity from the
@@ -54,6 +75,17 @@ native control nested in `FormField`. Dynamic context expressions remain
separate evidence and generic derived fallbacks remain in the richer-help separate evidence and generic derived fallbacks remain in the richer-help
candidate queue. candidate queue.
The same inventory classifies controls whose labels, identities, component
context, or explicit `data-help-risk` indicate authority, credentials,
disclosure, encryption, external effects, irreversible changes, policy, or
retention. These controls require an exact context rather than relying only on
page fallback. Reviewed false positives carry
`data-help-risk-reviewed="standard"`. Invalid risk classes and any increase
above the versioned `tools/inventory/high-risk-help-baseline.json` ceiling fail
strict declaration checks; the ceiling is lowered as the finite queue is
resolved. Password fields and their generator dialog propagate the owning
field's context so shared credential controls never invent a Core-owned topic.
The generated `help_review_candidates` list is therefore a content-depth queue, The generated `help_review_candidates` list is therefore a content-depth queue,
not a list of controls on which F1 cannot work. It should prioritize: not a list of controls on which F1 cannot work. It should prioritize:
@@ -71,6 +103,14 @@ modal at narrow widths, closes with Escape, and restores focus to the triggering
control. Module journeys should add their own exact high-risk mappings; they do control. Module journeys should add their own exact high-risk mappings; they do
not need to reimplement the keyboard or dialog mechanics. not need to reimplement the keyboard or dialog mechanics.
The same conformance suite mounts the production Forms Runtime self-service and
assisted Anwohnerparkausweis surfaces with German module translations. Desktop
and mobile runs traverse native controls by keyboard, inspect accessible names
and landmarks, run WCAG 2.1 A/AA automation, verify responsive overflow, and
retain independent per-field assisted provenance. Physical assistive-technology
spot checks remain release evidence rather than being represented as browser
automation.
## Verification ## Verification
```bash ```bash
@@ -88,6 +128,8 @@ The check must report:
- no duplicate stable IDs; - no duplicate stable IDs;
- no undeclared public WebUI surface; - no undeclared public WebUI surface;
- no stale runtime route or endpoint declaration. - no stale runtime route or endpoint declaration.
- no invalid high-risk help annotation or regression above the recorded
exact-context debt ceiling.
Browser acceptance is part of the focused workspace gate and can be run alone: Browser acceptance is part of the focused workspace gate and can be run alone:
+56
View File
@@ -128,6 +128,8 @@ The following contracts are the baseline API that modules can rely on:
- bounded reference-option search provider contract - bounded reference-option search provider contract
- single-tenant and optional batched tenant summary provider contracts - single-tenant and optional batched tenant summary provider contracts
- tenant delete-veto provider contract - tenant delete-veto provider contract
- provider-neutral tenant-erasure preview, step, idempotency, and
reconciliation contracts in `govoplan_core.core.tenant_erasure`
- WebUI module contribution contract - WebUI module contribution contract
- navigation metadata contract - navigation metadata contract
- command/event envelope contract - command/event envelope contract
@@ -149,6 +151,17 @@ Destructive tenant lifecycle planning deliberately continues to use the
single-tenant path so it invokes every registered provider for the target single-tenant path so it invokes every registered provider for the target
tenant, independent of ordinary list-page projections. tenant, independent of ordinary list-page projections.
Governed populated-tenant erasure is separate from ordinary delete vetoes.
Modules contribute `tenancy.erasure_provider.<module_id>` capabilities with a
bounded resource inventory, explicit erase/retain/legal-hold/external/key/
backup dispositions, ordered destructive warnings, idempotent step execution,
and reconciliation. The collector fails closed when a provider is invalid or
fails. A module with nonzero tenant summary counts and no erasure capability is
reported as unsupported and blocks execution; modules with neither contract
are explicitly projected as outside tenant-persistence scope. Provider
evidence contains counts and stable references only and must never contain
secrets or erased subject data.
This list is the Milestone A kernel-contract freeze baseline. New module work This list is the Milestone A kernel-contract freeze baseline. New module work
may extend the kernel by adding explicit contracts, but existing contracts must may extend the kernel by adding explicit contracts, but existing contracts must
remain source-compatible through the 0.1.x split line unless a migration shim remain source-compatible through the 0.1.x split line unless a migration shim
@@ -1030,6 +1043,49 @@ Any future exception is extraction debt and must be temporary, documented in the
script with a reason, and removed when a capability/API/event contract replaces script with a reason, and removed when a capability/API/event contract replaces
it. it.
## Product Surface Contributions
`FrontendModule.product_surfaces` is the versioned product-composition contract
for stable identities that may have one or more technical owners. A contribution
declares contract version 1, a product identity, common label/icon/description,
stable entry path, owner route and View surfaces, supported task/reader/admin/
operator presentations, authorization requirements, capabilities, search
sources, help contexts, documentation topics, migration aliases, and standard
unavailable/degraded explanations.
Core validates every reference against the owning manifest. Contributors that
share an identity must agree on its common product metadata and entry path;
entry and alias paths cannot belong to another product identity. The WebUI
composes valid owners by product id, filters them through authorization and the
effective View, and resolves the stable entry or migration alias to the first
available owner route. It emits `govoplan:product-surface-route-resolved` before
the redirect so migration telemetry can observe alias use without making the
technical module part of the ordinary label.
The shell projects every authorized, View-visible owner route with a product
contribution into one stable product navigation item. The product label and
entry path replace package topology in the primary rail; every contributing
owner path still marks that item active. `All available tools` is a collapsed,
permission-derived catalogue built independently of the active View, so a
focused workflow cannot remove the explicit escape. It may reveal an
authorized owner route that a View omitted, but never an unauthorized route.
Navigation visibility preferences do not delete catalogue entries, and the
original owner routes remain compatible deep links.
The initial promoted destinations are `work.items` at `/work`,
`meetings.calendar` at `/agenda`, `communication.messages` at `/messages`
(with `/inbox` as an alias), and `records.files` at `/documents`. Their labels
and availability language are centralized in Core while Tasks, Calendar,
Mail/Postbox, and Files retain route, command, search, help, documentation,
authorization, and data ownership.
Use `ProductAvailabilityState` for unavailable and degraded outcomes. The
ordinary state explains the attempted outcome, consequence, recovery path and
responsible role. Exact module, capability, provider and correlation values may
be supplied as a collapsed technical detail; they are not the primary error.
The state is presentation only and never grants authority or changes provider
health.
## Boundary Decision Register ## Boundary Decision Register
These durable decisions close older exploratory core issues. Implementation These durable decisions close older exploratory core issues. Implementation
+17
View File
@@ -14,6 +14,7 @@ consistent while each module still owns its domain rules.
| Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. | | Governance defaults | `govoplan-admin` plus `govoplan-access` materializer | admin settings, governance template routes, access materialization capability | System governance can block tenant-local groups, roles, and API keys. |
| Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. | | Delegation and ownership policy | access/campaign/mail/files modules | capability checks and owner-scoped APIs | Source provenance should use this contract when policies become externally explainable. |
| Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. | | Definition governance | `govoplan-policy` | capability `policy.definitionGovernance` | Resolves view, edit, run/start, reuse, derive, and automate for system, tenant, group, and user Dataflow/Workflow definitions. |
| Function assignment governance | `govoplan-policy` | capability `policy.functionAssignmentGovernance` | Returns current review steps, delegation depth/validity ceilings, and explicit timed-escalation targets consumed by IDM. |
## Policy Decision ## Policy Decision
@@ -126,6 +127,22 @@ When the capability is absent, modules must not silently emulate cross-scope
inheritance. Their conservative fallback is limited to local tenant inheritance. Their conservative fallback is limited to local tenant
definitions and disables reuse, derivation, and automation. definitions and disables reuse, derivation, and automation.
## Function Assignment Delegation And Escalation
`FunctionAssignmentGovernanceDecision` is the versioned cross-module contract
for request/grant review. In addition to the required holder, authority, and
recipient steps, it returns `delegation_allowed`,
`maximum_delegation_depth`, `maximum_delegated_validity_days`, and typed
`FunctionAssignmentEscalationRule` entries. Each escalation entry binds one
review step to an exact target function and timeout.
The decision is a current ceiling, not durable authorization. IDM must recheck
the complete assignment-source chain and all recorded decisions before final
application. An elapsed timeout creates explicit state and evidence; it must
never be interpreted as approval or as permission to silently substitute an
approver. Missing providers, malformed rules, invalid chains, or tightened
limits fail closed with an explainable reason.
## Bounded Impact-Subject Providers ## Bounded Impact-Subject Providers
Policy impact previews discover optional subject providers through capability Policy impact previews discover optional subject providers through capability
+59
View File
@@ -0,0 +1,59 @@
# Ticket Integration Capability Contracts
Core owns two narrow, optional contracts that let the Tickets module compose
with policy and formal-procedure modules without importing either one. Tickets
remains the authority for operational ticket identity, lifecycle, assignment,
comments, links, and immutable history.
## Capability Names
- `tickets.routing` optionally supplies a `TicketRoutingProvider`.
- `tickets.case_escalation` optionally supplies a
`TicketCaseEscalationProvider`.
Both contracts are version 1 and are defined in
`govoplan_core.core.tickets`. Registry helpers return `None` when a capability
is absent or has the wrong shape, so optional-module absence is normal runtime
state rather than a startup failure.
## Routing
Tickets sends a bounded, tenant-scoped `TicketRoutingRequest` containing the
ticket reference, type, priority, title, receive time, optional queue hint, and
non-secret attributes. The provider returns its identity and may return a queue
reference, timezone-aware service target, human-readable explanation, and
bounded metadata.
The provider is advisory. Tickets snapshots any returned queue and target into
its own record and history. An absent provider, a no-match plan, or an absent
queue must not prevent ticket intake; authorized staff can route manually.
Providers must not persist a second ticket lifecycle.
## Case Escalation
Tickets sends a `TicketCaseEscalationCommand` with stable tenant, ticket, and
display references, the requested Case type, actor-visible handoff note,
timezone-aware occurrence time, and an idempotency key. The provider returns a
stable Case identifier, number, bounded application-relative URL, replay flag,
and bounded metadata.
Providers must:
- recheck tenant and Case-creation authorization;
- reject an absent or inactive requested Case type;
- make identical retries resolve the same Case;
- preserve the Ticket reference in governed Case context; and
- return only an application-relative path, never an untrusted external URL.
Tickets records the result and its own escalation evidence. Cases remains the
authority for the formal procedure; Tickets remains the authority for the
operational request. Creating a Case does not merge or silently close either
lifecycle.
## Failure And Transaction Semantics
Capability calls receive the caller's active persistence session so a concrete
provider can participate in the same unit of work. Authorization and validation
errors fail the requested routing/escalation mutation explicitly. The caller
must still apply its own permission checks, tenant boundary, replay protection,
and immutable evidence rules.
+3 -1
View File
@@ -236,7 +236,9 @@ instead of reproducing their behavior.
not self-explanatory. not self-explanatory.
- `help` content is contextual guidance, not the accessible name. The persisted - `help` content is contextual guidance, not the accessible name. The persisted
`show_inline_help_hints` user preference hides only the `InlineHelp` marker by `show_inline_help_hints` user preference hides only the `InlineHelp` marker by
applying `ui-hide-help-hints` at the document root. applying `ui-hide-help-hints` at the document root. When shown, the shared
marker is a labelled, keyboard-focusable help control and exposes its tooltip
on focus as well as pointer hover.
- Shared action-bearing components accept an optional disabled reason. In - Shared action-bearing components accept an optional disabled reason. In
particular, `MailServerSettingsPanel` forwards protocol-specific test particular, `MailServerSettingsPanel` forwards protocol-specific test
blockers into the shared focusable disabled-action tooltip; modules provide blockers into the shared focusable disabled-action tooltip; modules provide
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-core" name = "govoplan-core"
version = "0.1.27" version = "0.1.44"
description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
+139 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Protocol, runtime_checkable from typing import Literal, Protocol, runtime_checkable
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext" CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT = "campaigns.mailPolicyContext"
@@ -12,6 +12,20 @@ CAPABILITY_CAMPAIGNS_POLICY_CONTEXT = "campaigns.policyContext"
CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks" CAPABILITY_CAMPAIGNS_DELIVERY_TASKS = "campaigns.deliveryTasks"
CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules" CAPABILITY_CAMPAIGNS_SCHEDULES = "campaigns.schedules"
CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention" CAPABILITY_CAMPAIGNS_RETENTION = "campaigns.retention"
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION = "campaigns.workOrchestration"
CampaignWorkAssigneeKind = Literal[
"account",
"group",
"organization_function",
]
CampaignWorkHandoffStatus = Literal[
"open",
"in_progress",
"completed",
"rejected",
"cancelled",
]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -32,6 +46,88 @@ class CampaignPolicyContext:
settings: Mapping[str, object] = field(default_factory=dict) settings: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CampaignWorkHandoffRequest:
"""Typed request used by Workflow to open accountable Campaign work."""
tenant_id: str
idempotency_key: str
purpose: str
assignee_kind: CampaignWorkAssigneeKind
assignee_id: str
campaign_id: str | None = None
create_external_id: str | None = None
create_name: str | None = None
create_description: str | None = None
expected_campaign_revision: int | None = None
due_at: datetime | None = None
mirror_to_tasks: bool = True
correlation_id: str | None = None
workflow_instance_id: str | None = None
workflow_step_id: str | None = None
def __post_init__(self) -> None:
for value, label in (
(self.tenant_id, "Campaign hand-off tenant"),
(self.idempotency_key, "Campaign hand-off idempotency key"),
(self.purpose, "Campaign hand-off purpose"),
(self.assignee_id, "Campaign hand-off assignee"),
):
if not value.strip():
raise ValueError(f"{label} is required")
references_existing = bool(self.campaign_id and self.campaign_id.strip())
creates_new = bool(
self.create_external_id
and self.create_external_id.strip()
and self.create_name
and self.create_name.strip()
)
if references_existing == creates_new:
raise ValueError(
"Campaign hand-offs must either reference one campaign or "
"declare one new campaign."
)
if self.expected_campaign_revision is not None and (
self.expected_campaign_revision < 1
):
raise ValueError("Expected Campaign revisions start at one")
if self.due_at is not None and self.due_at.tzinfo is None:
raise ValueError("Campaign hand-off due dates require a timezone")
@dataclass(frozen=True, slots=True)
class CampaignWorkHandoffRef:
"""Stable, revision-bearing reference returned to the Workflow instance."""
tenant_id: str
campaign_id: str
campaign_version_id: str
campaign_revision: int
assignment_id: str
assignment_revision: int
status: CampaignWorkHandoffStatus
action_url: str
campaign_ref: str
assignment_ref: str
event_type: str = "campaign.work.changed"
replayed: bool = False
optional_capabilities: Mapping[str, bool] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class CampaignWorkHandoffInspection:
"""Current authorization and revision check before Workflow continuation."""
allowed: bool
status: CampaignWorkHandoffStatus | None = None
assignment_revision: int | None = None
action_url: str | None = None
assignment_ref: str | None = None
reason: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable @runtime_checkable
class CampaignMailPolicyContextProvider(Protocol): class CampaignMailPolicyContextProvider(Protocol):
def get_campaign_mail_policy_context( def get_campaign_mail_policy_context(
@@ -132,3 +228,45 @@ class CampaignRetentionProvider(Protocol):
policy_for_campaign_id: Callable[[str | None], object], policy_for_campaign_id: Callable[[str | None], object],
) -> Mapping[str, Mapping[str, int]]: ) -> Mapping[str, Mapping[str, int]]:
... ...
@runtime_checkable
class CampaignWorkOrchestrationProvider(Protocol):
"""Optional Campaign boundary for durable Workflow-owned hand-offs."""
def prepare_handoff(
self,
session: object,
principal: object,
*,
request: CampaignWorkHandoffRequest,
) -> CampaignWorkHandoffRef:
...
def inspect_handoff(
self,
session: object,
principal: object,
*,
tenant_id: str,
assignment_id: str,
expected_revision: int | None = None,
) -> CampaignWorkHandoffInspection:
...
def campaign_work_orchestration_provider(
registry: object | None,
) -> CampaignWorkOrchestrationProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
):
return None
capability = registry.capability(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION)
return (
capability
if isinstance(capability, CampaignWorkOrchestrationProvider)
else None
)
@@ -3,6 +3,8 @@ from __future__ import annotations
import base64 import base64
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime
from importlib.metadata import PackageNotFoundError, version as package_version
from pathlib import Path from pathlib import Path
import json import json
import os import os
@@ -38,6 +40,12 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider"
DiagnosticSeverity = Literal["blocker", "warning", "info"] DiagnosticSeverity = Literal["blocker", "warning", "info"]
PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"] PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"]
ConfigurationRollbackStatus = Literal[
"blocked_before_apply",
"not_required",
"database_restore_required",
"partial_apply_requires_recovery",
]
ConfigurationPackageClass = Literal[ ConfigurationPackageClass = Literal[
"reference", "reference",
"product", "product",
@@ -461,6 +469,21 @@ class ConfigurationApplyResult:
diagnostics: tuple[ConfigurationDiagnostic, ...] = () diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
created_refs: Mapping[str, str] = field(default_factory=dict) created_refs: Mapping[str, str] = field(default_factory=dict)
updated_refs: Mapping[str, str] = field(default_factory=dict) updated_refs: Mapping[str, str] = field(default_factory=dict)
rollback: "ConfigurationRollbackState | None" = None
@dataclass(frozen=True, slots=True)
class ConfigurationRollbackState:
status: ConfigurationRollbackStatus
summary: str
recovery_action: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"status": self.status,
"summary": self.summary,
"recovery_action": self.recovery_action,
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -471,11 +494,40 @@ class ConfigurationExportSelection:
object_refs: tuple[str, ...] = () object_refs: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ConfigurationExportProvenance:
exported_at: str
source_core_version: str
module_versions: Mapping[str, str]
tenant_id: str | None
exporter_id: str | None
scopes: tuple[str, ...] = ()
module_ids: tuple[str, ...] = ()
object_refs: tuple[str, ...] = ()
redacted_secret_keys: tuple[str, ...] = ()
def to_dict(self) -> dict[str, object]:
return {
"exported_at": self.exported_at,
"source_core_version": self.source_core_version,
"module_versions": dict(self.module_versions),
"tenant_id": self.tenant_id,
"exporter_id": self.exporter_id,
"selection": {
"scopes": list(self.scopes),
"module_ids": list(self.module_ids),
"object_refs": list(self.object_refs),
},
"redacted_secret_keys": list(self.redacted_secret_keys),
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ConfigurationExportResult: class ConfigurationExportResult:
fragments: tuple[ConfigurationPackageFragment, ...] = () fragments: tuple[ConfigurationPackageFragment, ...] = ()
data_requirements: tuple[ConfigurationRequiredData, ...] = () data_requirements: tuple[ConfigurationRequiredData, ...] = ()
diagnostics: tuple[ConfigurationDiagnostic, ...] = () diagnostics: tuple[ConfigurationDiagnostic, ...] = ()
provenance: ConfigurationExportProvenance | None = None
@runtime_checkable @runtime_checkable
@@ -508,6 +560,7 @@ def dry_run_configuration_package(
diagnostics: list[ConfigurationDiagnostic] = [] diagnostics: list[ConfigurationDiagnostic] = []
required_data: list[ConfigurationRequiredData] = [] required_data: list[ConfigurationRequiredData] = []
plan: list[ConfigurationPlanItem] = [] plan: list[ConfigurationPlanItem] = []
declared_data: dict[str, ConfigurationRequiredData] = {}
diagnostics.extend(_module_requirement_diagnostics(manifest, context)) diagnostics.extend(_module_requirement_diagnostics(manifest, context))
diagnostics.extend(_capability_requirement_diagnostics(manifest, context)) diagnostics.extend(_capability_requirement_diagnostics(manifest, context))
@@ -515,6 +568,7 @@ def dry_run_configuration_package(
for item in manifest.data_requirements: for item in manifest.data_requirements:
requirement = ConfigurationRequiredData.from_mapping(item) requirement = ConfigurationRequiredData.from_mapping(item)
required_data.append(requirement) required_data.append(requirement)
declared_data[requirement.key] = requirement
if requirement.required and requirement.key not in context.supplied_data: if requirement.required and requirement.key not in context.supplied_data:
diagnostics.append(ConfigurationDiagnostic( diagnostics.append(ConfigurationDiagnostic(
severity="blocker", severity="blocker",
@@ -525,6 +579,25 @@ def dry_run_configuration_package(
)) ))
for fragment in manifest.fragments: for fragment in manifest.fragments:
data_ref_diagnostics = _fragment_data_reference_diagnostics(
fragment,
declared_data=declared_data,
supplied_data=context.supplied_data,
)
if data_ref_diagnostics:
diagnostics.extend(data_ref_diagnostics)
plan.append(ConfigurationPlanItem(
action="blocked",
module_id=fragment.module_id,
fragment_type=fragment.fragment_type,
fragment_id=fragment.fragment_id,
summary="Fragment needs declared deployment data before provider preflight.",
))
continue
resolved_fragment = _resolve_fragment_data_references(
fragment,
context.supplied_data,
)
provider = provider_map.get(fragment.module_id) provider = provider_map.get(fragment.module_id)
if provider is None: if provider is None:
diagnostics.append(ConfigurationDiagnostic( diagnostics.append(ConfigurationDiagnostic(
@@ -550,7 +623,7 @@ def dry_run_configuration_package(
plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported.")) plan.append(ConfigurationPlanItem(action="blocked", module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=fragment.fragment_id, summary="Fragment type is unsupported."))
continue continue
try: try:
result = provider.preflight(fragment, context) result = provider.preflight(resolved_fragment, context)
except Exception as exc: except Exception as exc:
diagnostics.append(ConfigurationDiagnostic( diagnostics.append(ConfigurationDiagnostic(
severity="blocker", severity="blocker",
@@ -605,19 +678,41 @@ def apply_configuration_package(
preflight = dry_run_configuration_package(manifest, providers, apply_context) preflight = dry_run_configuration_package(manifest, providers, apply_context)
blockers = [item for item in preflight.diagnostics if item.severity == "blocker"] blockers = [item for item in preflight.diagnostics if item.severity == "blocker"]
if blockers: if blockers:
return ConfigurationApplyResult(diagnostics=tuple(blockers)) return ConfigurationApplyResult(
diagnostics=tuple(blockers),
rollback=ConfigurationRollbackState(
status="blocked_before_apply",
summary="No provider changes were attempted because package preflight is blocked.",
),
)
provider_map = _configuration_provider_map(providers) provider_map = _configuration_provider_map(providers)
diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics) diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics)
created_refs: dict[str, str] = {} created_refs: dict[str, str] = {}
updated_refs: dict[str, str] = {} updated_refs: dict[str, str] = {}
stopped_after_blocker = False
for fragment in manifest.fragments: for fragment in manifest.fragments:
provider = provider_map[fragment.module_id] provider = provider_map[fragment.module_id]
resolved_fragment = _resolve_fragment_data_references(
fragment,
apply_context.supplied_data,
)
try: try:
result = provider.apply(fragment, apply_context.supplied_data, apply_context) result = provider.apply(
resolved_fragment,
apply_context.supplied_data,
apply_context,
)
diagnostics.extend(result.diagnostics) diagnostics.extend(result.diagnostics)
created_refs.update(result.created_refs) created_refs.update(result.created_refs)
updated_refs.update(result.updated_refs) updated_refs.update(result.updated_refs)
diagnostics.extend(provider.health(result, apply_context)) health_diagnostics = provider.health(result, apply_context)
diagnostics.extend(health_diagnostics)
if any(
item.severity == "blocker"
for item in (*result.diagnostics, *health_diagnostics)
):
stopped_after_blocker = True
break
except Exception as exc: except Exception as exc:
diagnostics.append(ConfigurationDiagnostic( diagnostics.append(ConfigurationDiagnostic(
severity="blocker", severity="blocker",
@@ -627,10 +722,36 @@ def apply_configuration_package(
object_ref=fragment.fragment_id or fragment.fragment_type, object_ref=fragment.fragment_id or fragment.fragment_type,
resolution="Stop the import, keep previous configuration, and inspect provider logs.", resolution="Stop the import, keep previous configuration, and inspect provider logs.",
)) ))
stopped_after_blocker = True
break
changed = bool(created_refs or updated_refs)
if stopped_after_blocker and changed:
rollback = ConfigurationRollbackState(
status="partial_apply_requires_recovery",
summary="At least one provider committed changes before a later provider blocked the package.",
recovery_action="Restore the reviewed pre-apply database snapshot or use module-owned compensation where explicitly supported.",
)
elif stopped_after_blocker:
rollback = ConfigurationRollbackState(
status="blocked_before_apply",
summary="The first provider blocked before any configuration reference was created or updated.",
)
elif changed:
rollback = ConfigurationRollbackState(
status="database_restore_required",
summary="The package changed provider-owned configuration; generic cross-module compensation is not available.",
recovery_action="Retain the pre-apply database snapshot until verification is complete; restore it if the package must be rolled back.",
)
else:
rollback = ConfigurationRollbackState(
status="not_required",
summary="All package fragments were no-ops, so no rollback action is required.",
)
return ConfigurationApplyResult( return ConfigurationApplyResult(
diagnostics=tuple(_dedupe_diagnostics(diagnostics)), diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
created_refs=created_refs, created_refs=created_refs,
updated_refs=updated_refs, updated_refs=updated_refs,
rollback=rollback,
) )
@@ -669,10 +790,29 @@ def export_configuration_package(
fragments.extend(result.fragments) fragments.extend(result.fragments)
data_requirements.extend(result.data_requirements) data_requirements.extend(result.data_requirements)
diagnostics.extend(result.diagnostics) diagnostics.extend(result.diagnostics)
deduped_required_data = tuple(_dedupe_required_data(data_requirements))
provenance = ConfigurationExportProvenance(
exported_at=datetime.now(UTC).isoformat(),
source_core_version=_installed_core_version(),
module_versions={
module_id: context.installed_modules[module_id]
for module_id in sorted(set(module_ids))
if module_id in context.installed_modules
},
tenant_id=selection.tenant_id,
exporter_id=context.operator_user_id,
scopes=selection.scopes,
module_ids=tuple(module_ids),
object_refs=selection.object_refs,
redacted_secret_keys=tuple(
sorted(item.key for item in deduped_required_data if item.secret)
),
)
return ConfigurationExportResult( return ConfigurationExportResult(
fragments=tuple(fragments), fragments=tuple(fragments),
data_requirements=tuple(_dedupe_required_data(data_requirements)), data_requirements=deduped_required_data,
diagnostics=tuple(_dedupe_diagnostics(diagnostics)), diagnostics=tuple(_dedupe_diagnostics(diagnostics)),
provenance=provenance,
) )
@@ -1283,6 +1423,103 @@ def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[Co
return result return result
def _fragment_data_reference_diagnostics(
fragment: ConfigurationPackageFragment,
*,
declared_data: Mapping[str, ConfigurationRequiredData],
supplied_data: Mapping[str, Any],
) -> list[ConfigurationDiagnostic]:
references: set[str] = set()
invalid = _collect_fragment_data_references(fragment.payload, references)
diagnostics: list[ConfigurationDiagnostic] = []
object_ref = fragment.fragment_id or fragment.fragment_type
if invalid:
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="fragment_data_reference_invalid",
message="Configuration fragment data references must be objects containing only a non-empty $data key.",
module_id=fragment.module_id,
object_ref=object_ref,
resolution="Replace malformed references with {\"$data\": \"declared_requirement_key\"}.",
))
for key in sorted(references - set(declared_data)):
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="fragment_data_reference_undeclared",
message=f"Configuration fragment references undeclared operator data {key!r}.",
module_id=fragment.module_id,
object_ref=key,
resolution="Declare the key in package data_requirements before using it in a fragment.",
))
for key in sorted(references & set(declared_data)):
if key in supplied_data:
continue
diagnostics.append(ConfigurationDiagnostic(
severity="blocker",
code="fragment_data_reference_missing",
message=f"Configuration fragment needs operator data {declared_data[key].label!r} before provider preflight.",
module_id=fragment.module_id,
object_ref=key,
resolution="Provide the value in the generated configuration package form.",
))
return diagnostics
def _collect_fragment_data_references(value: object, references: set[str]) -> bool:
invalid = False
if isinstance(value, Mapping):
if "$data" in value:
key = value.get("$data")
if len(value) != 1 or not isinstance(key, str) or not key.strip():
return True
references.add(key.strip())
return False
for item in value.values():
invalid = _collect_fragment_data_references(item, references) or invalid
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
for item in value:
invalid = _collect_fragment_data_references(item, references) or invalid
return invalid
def _resolve_fragment_data_references(
fragment: ConfigurationPackageFragment,
supplied_data: Mapping[str, Any],
) -> ConfigurationPackageFragment:
payload = _resolve_data_reference_value(fragment.payload, supplied_data)
if not isinstance(payload, Mapping):
raise ValueError("Resolved configuration fragment payload must remain an object.")
return ConfigurationPackageFragment(
module_id=fragment.module_id,
fragment_type=fragment.fragment_type,
fragment_id=fragment.fragment_id,
payload=payload,
)
def _resolve_data_reference_value(value: object, supplied_data: Mapping[str, Any]) -> object:
if isinstance(value, Mapping):
if set(value) == {"$data"}:
key = value.get("$data")
if not isinstance(key, str) or key not in supplied_data:
raise ValueError("Configuration fragment contains an unresolved $data reference.")
return supplied_data[key]
return {
str(key): _resolve_data_reference_value(item, supplied_data)
for key, item in value.items()
}
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
return [_resolve_data_reference_value(item, supplied_data) for item in value]
return value
def _installed_core_version() -> str:
try:
return package_version("govoplan-core")
except PackageNotFoundError:
return "workspace"
def _catalog_source(path: Path | str | None) -> Path | str | None: def _catalog_source(path: Path | str | None) -> Path | str | None:
if path is not None: if path is not None:
return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser() return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser()
+9
View File
@@ -101,6 +101,8 @@ class DatasourceGovernance:
transfer_agreement_ref: str | None = None transfer_agreement_ref: str | None = None
freshness_policy: Mapping[str, object] = field(default_factory=dict) freshness_policy: Mapping[str, object] = field(default_factory=dict)
quality_policy: Mapping[str, object] = field(default_factory=dict) quality_policy: Mapping[str, object] = field(default_factory=dict)
approval_policy: Mapping[str, object] = field(default_factory=dict)
retention_policy: Mapping[str, object] = field(default_factory=dict)
known_limits: tuple[str, ...] = () known_limits: tuple[str, ...] = ()
correction_procedure_ref: str | None = None correction_procedure_ref: str | None = None
affected_refs: tuple[str, ...] = () affected_refs: tuple[str, ...] = ()
@@ -179,6 +181,8 @@ class DatasourceGovernance:
), ),
freshness_policy=_governance_mapping(source.get("freshness_policy")), freshness_policy=_governance_mapping(source.get("freshness_policy")),
quality_policy=_governance_mapping(source.get("quality_policy")), quality_policy=_governance_mapping(source.get("quality_policy")),
approval_policy=_governance_mapping(source.get("approval_policy")),
retention_policy=_governance_mapping(source.get("retention_policy")),
known_limits=_governance_texts(source.get("known_limits")), known_limits=_governance_texts(source.get("known_limits")),
correction_procedure_ref=_optional_governance_text( correction_procedure_ref=_optional_governance_text(
source.get("correction_procedure_ref") source.get("correction_procedure_ref")
@@ -210,6 +214,8 @@ class DatasourceGovernance:
"transfer_agreement_ref": self.transfer_agreement_ref, "transfer_agreement_ref": self.transfer_agreement_ref,
"freshness_policy": dict(self.freshness_policy), "freshness_policy": dict(self.freshness_policy),
"quality_policy": dict(self.quality_policy), "quality_policy": dict(self.quality_policy),
"approval_policy": dict(self.approval_policy),
"retention_policy": dict(self.retention_policy),
"known_limits": list(self.known_limits), "known_limits": list(self.known_limits),
"correction_procedure_ref": self.correction_procedure_ref, "correction_procedure_ref": self.correction_procedure_ref,
"affected_refs": list(self.affected_refs), "affected_refs": list(self.affected_refs),
@@ -289,6 +295,8 @@ class DatasourceMaterialization:
frozen_label: str | None = None frozen_label: str | None = None
source_timestamp: datetime | None = None source_timestamp: datetime | None = None
created_at: datetime | None = None created_at: datetime | None = None
disposed_at: datetime | None = None
disposition: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict) provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict) metadata: Mapping[str, object] = field(default_factory=dict)
governance: DatasourceGovernance = field(default_factory=DatasourceGovernance) governance: DatasourceGovernance = field(default_factory=DatasourceGovernance)
@@ -309,6 +317,7 @@ class DatasourceStage:
row_count: int | None = None row_count: int | None = None
byte_count: int | None = None byte_count: int | None = None
validation: Mapping[str, object] = field(default_factory=dict) validation: Mapping[str, object] = field(default_factory=dict)
approval: Mapping[str, object] = field(default_factory=dict)
created_at: datetime | None = None created_at: datetime | None = None
promoted_at: datetime | None = None promoted_at: datetime | None = None
promoted_materialization_ref: str | None = None promoted_materialization_ref: str | None = None
@@ -2,14 +2,18 @@ from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
import json import json
import os import os
from pathlib import Path from pathlib import Path
import re import re
from typing import Any from typing import Any, Protocol, runtime_checkable
DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH" DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH"
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX = (
"infrastructure.dependency_inventory."
)
MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024 MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024
CAPABILITY_STATES = frozenset( CAPABILITY_STATES = frozenset(
{ {
@@ -20,12 +24,125 @@ CAPABILITY_STATES = frozenset(
} }
) )
_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$") _ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$")
_DEPENDENCY_STATES = frozenset(
{"active", "inactive", "data_present", "pending_work", "runtime_binding"}
)
class InfrastructureCapabilityReceiptError(ValueError): class InfrastructureCapabilityReceiptError(ValueError):
pass pass
@dataclass(frozen=True, slots=True)
class InfrastructureDependency:
"""A non-secret module-owned dependency on deployment infrastructure."""
capability_id: str
module_id: str
dependency_type: str
dependency_ref: str
state: str
scope: str
summary: str
metrics: Mapping[str, int]
required_action: str
def __post_init__(self) -> None:
for field_name, value, maximum in (
("capability_id", self.capability_id, 120),
("module_id", self.module_id, 120),
("dependency_type", self.dependency_type, 120),
("dependency_ref", self.dependency_ref, 240),
("scope", self.scope, 120),
("summary", self.summary, 1000),
("required_action", self.required_action, 1000),
):
if (
not value.strip()
or len(value) > maximum
or any(ord(char) < 32 for char in value)
):
raise ValueError(
f"Infrastructure dependency {field_name} is invalid."
)
if self.state not in _DEPENDENCY_STATES:
raise ValueError(
f"Infrastructure dependency state is unsupported: {self.state!r}."
)
if len(self.metrics) > 20 or any(
not isinstance(key, str)
or not key.strip()
or len(key) > 80
or any(ord(char) < 32 for char in key)
or type(value) is not int
or value < 0
for key, value in self.metrics.items()
):
raise ValueError("Infrastructure dependency metrics are invalid.")
def to_dict(self) -> dict[str, object]:
return {
"capability_id": self.capability_id,
"module_id": self.module_id,
"dependency_type": self.dependency_type,
"dependency_ref": self.dependency_ref,
"state": self.state,
"scope": self.scope,
"summary": self.summary,
"metrics": dict(sorted(self.metrics.items())),
"required_action": self.required_action,
}
@runtime_checkable
class InfrastructureDependencyProvider(Protocol):
module_id: str
capability_ids: tuple[str, ...]
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
...
@dataclass(frozen=True, slots=True)
class InfrastructureDependencyProviderReport:
module_id: str
capability_ids: tuple[str, ...]
state: str
dependency_count: int
error: str | None = None
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"capability_ids": list(self.capability_ids),
"state": self.state,
"dependency_count": self.dependency_count,
"error": self.error,
}
@dataclass(frozen=True, slots=True)
class InfrastructureDependencyInventory:
installation_id: str
generated_at: str
complete: bool
inspected_capability_ids: tuple[str, ...]
providers: tuple[InfrastructureDependencyProviderReport, ...]
dependencies: tuple[InfrastructureDependency, ...]
schema_version: int = 1
def to_dict(self) -> dict[str, object]:
return {
"schema_version": self.schema_version,
"installation_id": self.installation_id,
"generated_at": self.generated_at,
"complete": self.complete,
"inspected_capability_ids": list(self.inspected_capability_ids),
"providers": [item.to_dict() for item in self.providers],
"dependencies": [item.to_dict() for item in self.dependencies],
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class InfrastructureCapability: class InfrastructureCapability:
id: str id: str
@@ -214,6 +331,134 @@ def deployment_capability_status(
} }
def collect_infrastructure_dependency_inventory(
registry: object,
*,
installation_id: str,
observed_at: datetime | None = None,
) -> InfrastructureDependencyInventory:
"""Collect actual module-owned dependencies without importing module internals."""
normalized_installation_id = installation_id.strip()
if not normalized_installation_id or len(normalized_installation_id) > 100:
raise ValueError("Infrastructure dependency installation id is invalid.")
capability_names = getattr(registry, "capability_names", None)
capability = getattr(registry, "capability", None)
if not callable(capability_names) or not callable(capability):
raise ValueError("Infrastructure dependency inventory requires a module registry.")
provider_names = tuple(
name
for name in capability_names()
if isinstance(name, str)
and name.startswith(INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX)
)
reports: list[InfrastructureDependencyProviderReport] = []
dependencies: list[InfrastructureDependency] = []
inspected_capability_ids: set[str] = set()
complete = True
for provider_name in sorted(provider_names):
expected_module_id = provider_name.removeprefix(
INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX
)
module_id = expected_module_id or "unknown"
declared_ids: tuple[str, ...] = ()
try:
provider = capability(provider_name)
if not isinstance(provider, InfrastructureDependencyProvider):
raise TypeError("provider does not implement the inventory contract")
module_id = provider.module_id.strip()
declared_ids = tuple(
sorted(
{
item.strip()
for item in provider.capability_ids
if isinstance(item, str) and item.strip()
}
)
)
if (
module_id != expected_module_id
or len(module_id) > 120
or any(ord(char) < 32 for char in module_id)
or not declared_ids
or len(declared_ids) > 30
or any(
len(item) > 120 or any(ord(char) < 32 for char in item)
for item in declared_ids
)
):
raise ValueError("provider identity or capability declaration is invalid")
provider_dependencies = tuple(provider.infrastructure_dependencies())
if len(provider_dependencies) > 10_000:
raise ValueError("provider dependency inventory is too large")
seen_refs: set[tuple[str, str, str]] = set()
for item in provider_dependencies:
if not isinstance(item, InfrastructureDependency):
raise TypeError("provider returned an invalid dependency")
if item.module_id != module_id or item.capability_id not in declared_ids:
raise ValueError("provider returned a dependency outside its declaration")
identity = (
item.capability_id,
item.dependency_type,
item.dependency_ref,
)
if identity in seen_refs:
raise ValueError("provider returned a duplicate dependency")
seen_refs.add(identity)
if len(dependencies) + len(provider_dependencies) > 10_000:
raise ValueError("combined dependency inventory is too large")
dependencies.extend(provider_dependencies)
inspected_capability_ids.update(declared_ids)
reports.append(
InfrastructureDependencyProviderReport(
module_id=module_id,
capability_ids=declared_ids,
state="complete",
dependency_count=len(provider_dependencies),
)
)
except Exception as exc:
complete = False
inspected_capability_ids.update(declared_ids)
reports.append(
InfrastructureDependencyProviderReport(
module_id=module_id,
capability_ids=declared_ids,
state="error",
dependency_count=0,
error=(
f"{type(exc).__name__}: provider inventory could not be completed"
),
)
)
timestamp = observed_at or datetime.now(UTC)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return InfrastructureDependencyInventory(
installation_id=normalized_installation_id,
generated_at=timestamp.astimezone(UTC).isoformat(),
complete=complete,
inspected_capability_ids=tuple(sorted(inspected_capability_ids)),
providers=tuple(
sorted(reports, key=lambda item: (item.module_id, item.capability_ids))
),
dependencies=tuple(
sorted(
dependencies,
key=lambda item: (
item.capability_id,
item.module_id,
item.dependency_type,
item.dependency_ref,
),
)
),
)
def _capability(value: object) -> InfrastructureCapability: def _capability(value: object) -> InfrastructureCapability:
if not isinstance(value, Mapping): if not isinstance(value, Mapping):
raise InfrastructureCapabilityReceiptError( raise InfrastructureCapabilityReceiptError(
@@ -352,10 +597,16 @@ def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, obj
__all__ = [ __all__ = [
"CAPABILITY_STATES", "CAPABILITY_STATES",
"DEPLOYMENT_CAPABILITIES_ENV", "DEPLOYMENT_CAPABILITIES_ENV",
"INFRASTRUCTURE_DEPENDENCY_PROVIDER_CAPABILITY_PREFIX",
"InfrastructureCapability", "InfrastructureCapability",
"InfrastructureCapabilityReceipt", "InfrastructureCapabilityReceipt",
"InfrastructureCapabilityReceiptError", "InfrastructureCapabilityReceiptError",
"InfrastructureDependency",
"InfrastructureDependencyInventory",
"InfrastructureDependencyProvider",
"InfrastructureDependencyProviderReport",
"InfrastructurePostInstallTask", "InfrastructurePostInstallTask",
"collect_infrastructure_dependency_inventory",
"deployment_capability_status", "deployment_capability_status",
"infrastructure_capability_receipt_from_mapping", "infrastructure_capability_receipt_from_mapping",
"load_infrastructure_capability_receipt", "load_infrastructure_capability_receipt",
+256 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from typing import Any, Literal, Protocol, TYPE_CHECKING from typing import Any, Literal, Protocol, TYPE_CHECKING
from govoplan_core.core.information_governance import ModuleInformationGovernance from govoplan_core.core.information_governance import ModuleInformationGovernance
@@ -30,6 +30,7 @@ if TYPE_CHECKING:
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1" SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION = "1"
SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1" SUPPORTED_PRESENTATION_CONTRACT_VERSION = "1"
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION = "1"
PermissionLevel = Literal["system", "tenant"] PermissionLevel = Literal["system", "tenant"]
SubjectType = Literal["account", "membership", "group", "service_account", "tenant"] SubjectType = Literal["account", "membership", "group", "service_account", "tenant"]
@@ -114,6 +115,55 @@ class ProductAreaContribution:
order: int = 100 order: int = 100
ProductSurfacePresentation = Literal["task", "reader", "admin", "operator"]
ProductAvailabilityReason = Literal[
"authorization",
"policy",
"configuration",
"disabled",
"capability",
"offline",
"provider_degraded",
]
@dataclass(frozen=True, slots=True)
class ProductAvailabilityExplanation:
"""Explain a product outcome without making package topology user-facing."""
reason: ProductAvailabilityReason
title: str
description: str
resolution: str
responsible_role: str | None = None
@dataclass(frozen=True, slots=True)
class ProductSurfaceContribution:
"""Bind an owner route to a stable, cross-module product identity."""
id: str
module_id: str
label: str
icon: str
entry_path: str
route_path: str
surface_ids: tuple[str, ...]
unavailable: ProductAvailabilityExplanation
description: str | None = None
degraded: ProductAvailabilityExplanation | None = None
presentations: tuple[ProductSurfacePresentation, ...] = ("task",)
capability_ids: tuple[str, ...] = ()
search_source_ids: tuple[str, ...] = ()
help_context_ids: tuple[str, ...] = ()
documentation_topic_ids: tuple[str, ...] = ()
required_all: tuple[str, ...] = ()
required_any: tuple[str, ...] = ()
aliases: tuple[str, ...] = ()
order: int = 100
contract_version: str = SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class QuickAccessTool: class QuickAccessTool:
"""Declare a versioned, bounded module-owned Quick Access tool.""" """Declare a versioned, bounded module-owned Quick Access tool."""
@@ -153,6 +203,7 @@ class FrontendModule:
settings_routes: tuple[FrontendRoute, ...] = () settings_routes: tuple[FrontendRoute, ...] = ()
view_surfaces: tuple[ViewSurface, ...] = () view_surfaces: tuple[ViewSurface, ...] = ()
product_areas: tuple[ProductAreaContribution, ...] = () product_areas: tuple[ProductAreaContribution, ...] = ()
product_surfaces: tuple[ProductSurfaceContribution, ...] = ()
quick_access_tools: tuple[QuickAccessTool, ...] = () quick_access_tools: tuple[QuickAccessTool, ...] = ()
@@ -289,6 +340,30 @@ DocumentationSourceState = Literal["configured", "disabled", "unavailable"]
CapabilityStability = Literal["experimental", "stable", "deprecated"] CapabilityStability = Literal["experimental", "stable", "deprecated"]
DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION = "1"
DOCUMENTATION_LOCALIZABLE_METADATA_KEYS = frozenset(
{
"admin_explanation",
"consequence_classes",
"consequences",
"constraints",
"current_configuration",
"fields",
"limitations",
"operational_consequences",
"outcome",
"prerequisites",
"privacy_notes",
"purpose",
"result",
"steps",
"user_explanation",
"verification",
"when_used",
}
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DocumentationLink: class DocumentationLink:
label: str label: str
@@ -324,12 +399,142 @@ class DocumentationTopic:
configuration_keys: tuple[str, ...] = () configuration_keys: tuple[str, ...] = ()
i18n_key: str | None = None i18n_key: str | None = None
translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict) translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict)
structured_translation_version: str | None = None
structured_translations: Mapping[str, Mapping[str, Any]] = field(
default_factory=dict
)
source_module_id: str | None = None source_module_id: str | None = None
version_min: str | None = None version_min: str | None = None
version_max_exclusive: str | None = None version_max_exclusive: str | None = None
metadata: Mapping[str, Any] = field(default_factory=dict) metadata: Mapping[str, Any] = field(default_factory=dict)
def localizable_documentation_metadata_keys(
topic: DocumentationTopic,
) -> tuple[str, ...]:
"""Return structured metadata keys whose values are public prose."""
return tuple(
sorted(DOCUMENTATION_LOCALIZABLE_METADATA_KEYS.intersection(topic.metadata))
)
def localized_documentation_metadata(
topic: DocumentationTopic,
locale: str,
) -> dict[str, Any]:
"""Overlay one validated structured translation onto source metadata."""
localized = dict(topic.metadata)
translation = topic.structured_translations.get(locale)
if translation:
localized.update(translation)
return localized
def documentation_structured_translation_issues(
topic: DocumentationTopic,
) -> tuple[str, ...]:
"""Validate the opt-in, versioned structured-documentation translation."""
version = topic.structured_translation_version
translations = topic.structured_translations
if version is None:
if translations:
return (
"structured_translations require structured_translation_version",
)
return ()
if version != DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION:
return (
"unsupported structured_translation_version "
f"{version!r}; expected {DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION!r}",
)
localizable_keys = set(localizable_documentation_metadata_keys(topic))
issues: list[str] = []
for locale, translation in translations.items():
if not locale.strip():
issues.append("structured translation locale must not be empty")
continue
translated_keys = set(translation)
for key in sorted(translated_keys - localizable_keys):
issues.append(
f"structured translation {locale!r} contains non-localizable or missing metadata key {key!r}"
)
for key in sorted(localizable_keys - translated_keys):
issues.append(
f"structured translation {locale!r} is missing metadata key {key!r}"
)
for key in sorted(localizable_keys & translated_keys):
issues.extend(
_structured_translation_shape_issues(
topic.metadata[key],
translation[key],
path=f"{locale}.{key}",
)
)
return tuple(issues)
def _structured_translation_shape_issues(
source: object,
translated: object,
*,
path: str,
) -> tuple[str, ...]:
if isinstance(source, str):
if not isinstance(translated, str) or not translated.strip():
return (f"structured translation {path} must be a non-empty string",)
return ()
if isinstance(source, Mapping):
if not isinstance(translated, Mapping):
return (f"structured translation {path} must preserve object shape",)
issues: list[str] = []
source_keys = {str(key) for key in source}
translated_keys = {str(key) for key in translated}
if source_keys != translated_keys:
issues.append(
f"structured translation {path} must preserve object keys"
)
return tuple(issues)
for key, value in source.items():
issues.extend(
_structured_translation_shape_issues(
value,
translated[key],
path=f"{path}.{key}",
)
)
return tuple(issues)
if isinstance(source, Sequence) and not isinstance(
source, (str, bytes, bytearray)
):
if not isinstance(translated, Sequence) or isinstance(
translated, (str, bytes, bytearray)
):
return (f"structured translation {path} must preserve list shape",)
if len(source) != len(translated):
return (f"structured translation {path} must preserve list length",)
issues: list[str] = []
for index, (source_item, translated_item) in enumerate(
zip(source, translated, strict=True)
):
issues.extend(
_structured_translation_shape_issues(
source_item,
translated_item,
path=f"{path}[{index}]",
)
)
return tuple(issues)
if translated != source:
return (
f"structured translation {path} must preserve non-text value {source!r}",
)
return ()
def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]: def user_workflow_scope_condition_issues(topic: DocumentationTopic) -> tuple[str, ...]:
"""Return fail-closed authoring issues for a user-facing workflow topic. """Return fail-closed authoring issues for a user-facing workflow topic.
@@ -533,3 +738,53 @@ class ModuleManifest:
# runtime module ID changes. # runtime module ID changes.
permission_namespace: str | None = None permission_namespace: str | None = None
workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = () workflow_definitions: tuple["WorkflowDefinitionContribution", ...] = ()
def with_documentation_structured_translations(
manifest: ModuleManifest,
*,
locale: str,
translations: Mapping[str, Mapping[str, Any]],
) -> ModuleManifest:
"""Merge module-owned structured documentation translations by topic id.
The helper keeps feature prose in its owning module while giving every
manifest the same fail-closed merge behavior. Unknown topic ids and
incomplete or shape-changing locale maps are rejected immediately.
"""
locale = locale.strip()
if not locale:
raise ValueError("structured documentation locale must not be empty")
topics_by_id = {topic.id: topic for topic in manifest.documentation}
unknown_topic_ids = sorted(set(translations) - set(topics_by_id))
if unknown_topic_ids:
raise ValueError(
"structured documentation translations reference unknown topic ids: "
+ ", ".join(unknown_topic_ids)
)
localized_topics: list[DocumentationTopic] = []
for topic in manifest.documentation:
translation = translations.get(topic.id)
if translation is None:
localized_topics.append(topic)
continue
structured_translations = dict(topic.structured_translations)
structured_translations[locale] = translation
localized_topic = replace(
topic,
structured_translation_version=DOCUMENTATION_STRUCTURED_TRANSLATION_VERSION,
structured_translations=structured_translations,
)
issues = documentation_structured_translation_issues(localized_topic)
if issues:
raise ValueError(
f"invalid {locale!r} structured documentation translation for "
f"{topic.id!r}: {'; '.join(issues)}"
)
localized_topics.append(localized_topic)
return replace(manifest, documentation=tuple(localized_topics))
@@ -22,6 +22,7 @@ PlatformInterfaceKind = Literal[
"navigation", "navigation",
"permission", "permission",
"product_area", "product_area",
"product_surface",
"provided_interface", "provided_interface",
"public_route", "public_route",
"search_provider", "search_provider",
@@ -237,6 +238,41 @@ def manifest_interface_declarations(
}, },
) )
) )
for surface in frontend.product_surfaces:
declarations.append(
PlatformInterfaceDeclaration(
id=f"{manifest.id}.{surface.id}",
module_id=manifest.id,
kind="product_surface",
label=surface.label,
path=surface.route_path,
required_all=surface.required_all,
required_any=surface.required_any,
metadata={
"contract_version": surface.contract_version,
"product_surface_id": surface.id,
"description": surface.description,
"icon": surface.icon,
"entry_path": surface.entry_path,
"surface_ids": list(surface.surface_ids),
"presentations": list(surface.presentations),
"capability_ids": list(surface.capability_ids),
"search_source_ids": list(surface.search_source_ids),
"help_context_ids": list(surface.help_context_ids),
"documentation_topic_ids": list(
surface.documentation_topic_ids
),
"aliases": list(surface.aliases),
"order": surface.order,
"unavailable_reason": surface.unavailable.reason,
"degraded_reason": (
surface.degraded.reason
if surface.degraded is not None
else None
),
},
)
)
for tool in frontend.quick_access_tools: for tool in frontend.quick_access_tools:
declarations.append( declarations.append(
PlatformInterfaceDeclaration( PlatformInterfaceDeclaration(
+32
View File
@@ -41,10 +41,12 @@ ViewGovernanceAction = Literal[
"workflow_activate", "workflow_activate",
] ]
FunctionAssignmentChangeKind = Literal["request", "grant"] FunctionAssignmentChangeKind = Literal["request", "grant"]
FunctionAssignmentReviewStep = Literal["holder", "authority", "recipient"]
FunctionAssignmentGovernanceAction = Literal[ FunctionAssignmentGovernanceAction = Literal[
"submit", "submit",
"approve_holder", "approve_holder",
"approve_authority", "approve_authority",
"approve_escalation",
"accept_recipient", "accept_recipient",
"request_changes", "request_changes",
"respond", "respond",
@@ -419,6 +421,20 @@ class FunctionAssignmentGovernanceRequest:
context: Mapping[str, Any] = field(default_factory=dict) context: Mapping[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class FunctionAssignmentEscalationRule:
step: FunctionAssignmentReviewStep
target_function_id: str
timeout_hours: int
def to_dict(self) -> dict[str, Any]:
return {
"step": self.step,
"target_function_id": self.target_function_id,
"timeout_hours": self.timeout_hours,
}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class FunctionAssignmentGovernanceDecision: class FunctionAssignmentGovernanceDecision:
allowed: bool allowed: bool
@@ -431,6 +447,10 @@ class FunctionAssignmentGovernanceDecision:
separation_of_duties: bool = True separation_of_duties: bool = True
quorum: int = 1 quorum: int = 1
maximum_validity_days: int | None = None maximum_validity_days: int | None = None
delegation_allowed: bool = False
maximum_delegation_depth: int = 0
maximum_delegated_validity_days: int | None = None
escalation_rules: tuple[FunctionAssignmentEscalationRule, ...] = ()
request_expiry_hours: int = 336 request_expiry_hours: int = 336
source_path: tuple[PolicySourceStep, ...] = () source_path: tuple[PolicySourceStep, ...] = ()
requirements: tuple[str, ...] = () requirements: tuple[str, ...] = ()
@@ -448,12 +468,24 @@ class FunctionAssignmentGovernanceDecision:
"separation_of_duties": self.separation_of_duties, "separation_of_duties": self.separation_of_duties,
"quorum": self.quorum, "quorum": self.quorum,
"maximum_validity_days": self.maximum_validity_days, "maximum_validity_days": self.maximum_validity_days,
"delegation_allowed": self.delegation_allowed,
"maximum_delegation_depth": self.maximum_delegation_depth,
"maximum_delegated_validity_days": (
self.maximum_delegated_validity_days
),
"escalation_rules": [rule.to_dict() for rule in self.escalation_rules],
"request_expiry_hours": self.request_expiry_hours, "request_expiry_hours": self.request_expiry_hours,
"source_path": [step.to_dict() for step in self.source_path], "source_path": [step.to_dict() for step in self.source_path],
"requirements": list(self.requirements), "requirements": list(self.requirements),
"details": dict(self.details), "details": dict(self.details),
} }
def escalation_rule(
self,
step: FunctionAssignmentReviewStep,
) -> FunctionAssignmentEscalationRule | None:
return next((rule for rule in self.escalation_rules if rule.step == step), None)
@runtime_checkable @runtime_checkable
class FunctionAssignmentGovernancePolicy(Protocol): class FunctionAssignmentGovernancePolicy(Protocol):
+203
View File
@@ -16,16 +16,20 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
ProductAvailabilityExplanation,
ProductAreaContribution, ProductAreaContribution,
ProductSurfaceContribution,
PublicFrontendRoute, PublicFrontendRoute,
QuickAccessTool, QuickAccessTool,
ResourceAclProvider, ResourceAclProvider,
RoleTemplate, RoleTemplate,
SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION, SUPPORTED_FRONTEND_ASSET_MANIFEST_CONTRACT_VERSION,
SUPPORTED_MANIFEST_CONTRACT_VERSION, SUPPORTED_MANIFEST_CONTRACT_VERSION,
SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION,
TenantSummaryBatchProvider, TenantSummaryBatchProvider,
TenantSummaryProvider, TenantSummaryProvider,
user_workflow_scope_condition_issues, user_workflow_scope_condition_issues,
documentation_structured_translation_issues,
) )
from govoplan_core.core.module_entitlements import ( from govoplan_core.core.module_entitlements import (
TenantModuleEntitlementResolver, TenantModuleEntitlementResolver,
@@ -89,6 +93,9 @@ _WILDCARD_RE = re.compile(
) )
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") _INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
_PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$") _PRESENTATION_ID_RE = re.compile(r"^[a-z][a-z0-9_-]{1,79}$")
_PRODUCT_SURFACE_ID_RE = re.compile(
r"^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)+$"
)
_QUICK_ACCESS_TOOL_ID_RE = re.compile( _QUICK_ACCESS_TOOL_ID_RE = re.compile(
r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$" r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_-]*)+$"
) )
@@ -962,6 +969,10 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
raise RegistryError( raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}" f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
) )
for issue in documentation_structured_translation_issues(topic):
raise RegistryError(
f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}"
)
_validate_documentation_extensions(manifest) _validate_documentation_extensions(manifest)
_validate_architecture_declarations(manifest) _validate_architecture_declarations(manifest)
_validate_workflow_definition_contributions(manifest) _validate_workflow_definition_contributions(manifest)
@@ -969,7 +980,19 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None: def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> None:
area_definitions: dict[str, tuple[str, str]] = {} area_definitions: dict[str, tuple[str, str]] = {}
surface_definitions: dict[str, tuple[str, str, str, str | None]] = {}
product_paths: dict[str, str] = {}
tool_owners: dict[str, str] = {} tool_owners: dict[str, str] = {}
concrete_paths = {
route.path: manifest.id
for manifest in manifests
if manifest.frontend is not None
for route in (
*manifest.frontend.routes,
*manifest.frontend.settings_routes,
*manifest.frontend.public_routes,
)
}
for manifest in manifests: for manifest in manifests:
frontend = manifest.frontend frontend = manifest.frontend
if frontend is None: if frontend is None:
@@ -982,6 +1005,33 @@ def _validate_presentation_catalog(manifests: tuple[ModuleManifest, ...]) -> Non
f"Product area {area.id!r} has conflicting labels or icons" f"Product area {area.id!r} has conflicting labels or icons"
) )
area_definitions[area.id] = definition area_definitions[area.id] = definition
for surface in frontend.product_surfaces:
definition = (
surface.label,
surface.icon,
surface.entry_path,
surface.description,
)
previous = surface_definitions.get(surface.id)
if previous is not None and previous != definition:
raise RegistryError(
f"Product surface {surface.id!r} has conflicting product identity metadata"
)
surface_definitions[surface.id] = definition
for path in (surface.entry_path, *surface.aliases):
concrete_owner = concrete_paths.get(path)
if concrete_owner is not None:
raise RegistryError(
f"Product path {path!r} collides with a concrete route "
f"owned by module {concrete_owner!r}"
)
previous_id = product_paths.get(path)
if previous_id is not None and previous_id != surface.id:
raise RegistryError(
f"Product path {path!r} is shared by product surfaces "
f"{previous_id!r} and {surface.id!r}"
)
product_paths[path] = surface.id
for tool in frontend.quick_access_tools: for tool in frontend.quick_access_tools:
previous_owner = tool_owners.get(tool.id) previous_owner = tool_owners.get(tool.id)
if previous_owner is not None: if previous_owner is not None:
@@ -1469,6 +1519,14 @@ def _validate_presentation_contributions(manifest: ModuleManifest) -> None:
f"in module {manifest.id!r}" f"in module {manifest.id!r}"
) )
seen_area_memberships.add(membership) seen_area_memberships.add(membership)
seen_product_surfaces: set[str] = set()
for surface in frontend.product_surfaces:
_validate_product_surface(manifest, surface, known_surface_ids)
if surface.id in seen_product_surfaces:
raise RegistryError(
f"Duplicate product surface {surface.id!r} in module {manifest.id!r}"
)
seen_product_surfaces.add(surface.id)
seen_tools: set[str] = set() seen_tools: set[str] = set()
for tool in frontend.quick_access_tools: for tool in frontend.quick_access_tools:
_validate_quick_access_tool(manifest.id, tool, known_surface_ids) _validate_quick_access_tool(manifest.id, tool, known_surface_ids)
@@ -1507,6 +1565,151 @@ def _validate_product_area(
) )
def _validate_product_surface(
manifest: ModuleManifest,
surface: ProductSurfaceContribution,
known_surface_ids: set[str],
) -> None:
module_id = manifest.id
frontend = manifest.frontend
assert frontend is not None
if surface.module_id != module_id:
raise RegistryError(
f"Product surface {surface.id!r} belongs to {surface.module_id!r}, "
f"not module {module_id!r}"
)
if not _PRODUCT_SURFACE_ID_RE.fullmatch(surface.id):
raise RegistryError(f"Invalid product surface id: {surface.id!r}")
if surface.contract_version != SUPPORTED_PRODUCT_SURFACE_CONTRACT_VERSION:
raise RegistryError(
f"Product surface {surface.id!r} uses unsupported contract version "
f"{surface.contract_version!r}"
)
if not surface.label.strip() or not surface.icon.strip():
raise RegistryError(
f"Product surface {surface.id!r} needs a label and icon"
)
for label, path in (
("entry", surface.entry_path),
("owner", surface.route_path),
*(("alias", alias) for alias in surface.aliases),
):
if not path.startswith("/") or "?" in path or "#" in path:
raise RegistryError(
f"Product surface {surface.id!r} has an invalid {label} path {path!r}"
)
if (
surface.entry_path == surface.route_path
or surface.entry_path in surface.aliases
or surface.route_path in surface.aliases
):
raise RegistryError(
f"Product surface {surface.id!r} must keep its stable entry distinct from owner and alias paths"
)
if len(set(surface.aliases)) != len(surface.aliases):
raise RegistryError(
f"Product surface {surface.id!r} contains duplicate aliases"
)
route_paths = {route.path for route in (*frontend.routes, *frontend.settings_routes)}
if surface.route_path not in route_paths:
raise RegistryError(
f"Product surface {surface.id!r} references unknown owner route "
f"{surface.route_path!r}"
)
if not surface.surface_ids:
raise RegistryError(
f"Product surface {surface.id!r} has no owner surfaces"
)
unknown_surfaces = set(surface.surface_ids) - known_surface_ids
if unknown_surfaces:
raise RegistryError(
f"Product surface {surface.id!r} references unknown surfaces: "
+ ", ".join(sorted(unknown_surfaces))
)
allowed_presentations = {"task", "reader", "admin", "operator"}
if (
not surface.presentations
or len(set(surface.presentations)) != len(surface.presentations)
or set(surface.presentations) - allowed_presentations
):
raise RegistryError(
f"Product surface {surface.id!r} has invalid presentations"
)
declared_capabilities = {
*manifest.required_capabilities,
*manifest.optional_capabilities,
*manifest.capability_factories,
*(provider.name for provider in manifest.provides_interfaces),
*(requirement.name for requirement in manifest.requires_interfaces),
}
unknown_capabilities = set(surface.capability_ids) - declared_capabilities
if unknown_capabilities:
raise RegistryError(
f"Product surface {surface.id!r} references undeclared capabilities: "
+ ", ".join(sorted(unknown_capabilities))
)
search_source_ids = {source.id for source in manifest.search_sources}
unknown_search_sources = set(surface.search_source_ids) - search_source_ids
if unknown_search_sources:
raise RegistryError(
f"Product surface {surface.id!r} references unknown search sources: "
+ ", ".join(sorted(unknown_search_sources))
)
topics = {topic.id: topic for topic in manifest.documentation}
unknown_topics = set(surface.documentation_topic_ids) - set(topics)
if unknown_topics:
raise RegistryError(
f"Product surface {surface.id!r} references unknown documentation topics: "
+ ", ".join(sorted(unknown_topics))
)
documented_help_contexts: set[str] = set()
for topic in manifest.documentation:
contexts = topic.metadata.get("help_contexts", ())
if isinstance(contexts, (list, tuple, set, frozenset)):
documented_help_contexts.update(
context for context in contexts if isinstance(context, str)
)
unknown_help = set(surface.help_context_ids) - documented_help_contexts
if unknown_help:
raise RegistryError(
f"Product surface {surface.id!r} references undocumented help contexts: "
+ ", ".join(sorted(unknown_help))
)
_validate_product_availability_explanation(surface.id, surface.unavailable)
if surface.degraded is not None:
_validate_product_availability_explanation(surface.id, surface.degraded)
def _validate_product_availability_explanation(
surface_id: str,
explanation: ProductAvailabilityExplanation,
) -> None:
allowed_reasons = {
"authorization",
"policy",
"configuration",
"disabled",
"capability",
"offline",
"provider_degraded",
}
if explanation.reason not in allowed_reasons:
raise RegistryError(
f"Product surface {surface_id!r} has an invalid availability reason"
)
if any(
not value.strip()
for value in (
explanation.title,
explanation.description,
explanation.resolution,
)
):
raise RegistryError(
f"Product surface {surface_id!r} has an incomplete availability explanation"
)
def _validate_quick_access_tool( def _validate_quick_access_tool(
module_id: str, module_id: str,
tool: QuickAccessTool, tool: QuickAccessTool,
+467
View File
@@ -0,0 +1,467 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Literal, Protocol, runtime_checkable
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX = "tenancy.erasure_provider."
TenantErasureDisposition = Literal[
"erase",
"retain",
"legal_hold",
"external_cleanup",
"key_destroy",
"backup_expiry",
"unavailable",
]
TenantErasureStepKind = Literal[
"export",
"erase",
"retain",
"external_cleanup",
"key_destroy",
"backup_expiry",
"verify",
]
TenantErasureResultState = Literal[
"completed",
"pending",
"blocked",
"outcome_unknown",
]
_DISPOSITIONS = frozenset(
{
"erase",
"retain",
"legal_hold",
"external_cleanup",
"key_destroy",
"backup_expiry",
"unavailable",
}
)
_STEP_KINDS = frozenset(
{
"export",
"erase",
"retain",
"external_cleanup",
"key_destroy",
"backup_expiry",
"verify",
}
)
_RESULT_STATES = frozenset(
{"completed", "pending", "blocked", "outcome_unknown"}
)
def _text(value: str, label: str, *, maximum: int) -> str:
normalized = value.strip()
if (
not normalized
or len(normalized) > maximum
or any(ord(character) < 32 for character in normalized)
):
raise ValueError(f"Tenant erasure {label} is invalid.")
return normalized
def _texts(
values: tuple[str, ...],
label: str,
*,
maximum_items: int = 100,
maximum_length: int = 500,
) -> tuple[str, ...]:
if len(values) > maximum_items:
raise ValueError(f"Tenant erasure {label} has too many entries.")
normalized = tuple(
_text(value, label, maximum=maximum_length) for value in values
)
if len(normalized) != len(set(normalized)):
raise ValueError(f"Tenant erasure {label} contains duplicates.")
return normalized
def _metrics(values: Mapping[str, int]) -> dict[str, int]:
if len(values) > 30:
raise ValueError("Tenant erasure metrics has too many entries.")
normalized: dict[str, int] = {}
for key, value in values.items():
normalized_key = _text(key, "metric key", maximum=80)
if type(value) is not int or value < 0:
raise ValueError("Tenant erasure metric values must be non-negative integers.")
normalized[normalized_key] = value
return normalized
@dataclass(frozen=True, slots=True)
class TenantErasureResource:
resource_type: str
count: int
disposition: TenantErasureDisposition
summary: str
governance_ref: str | None = None
external: bool = False
def __post_init__(self) -> None:
_text(self.resource_type, "resource type", maximum=120)
_text(self.summary, "resource summary", maximum=1000)
if type(self.count) is not int or self.count < 0:
raise ValueError("Tenant erasure resource count is invalid.")
if self.disposition not in _DISPOSITIONS:
raise ValueError("Tenant erasure resource disposition is invalid.")
if self.governance_ref is not None:
_text(self.governance_ref, "governance reference", maximum=300)
def to_dict(self) -> dict[str, object]:
return {
"resource_type": self.resource_type,
"count": self.count,
"disposition": self.disposition,
"summary": self.summary,
"governance_ref": self.governance_ref,
"external": self.external,
}
@dataclass(frozen=True, slots=True)
class TenantErasureStep:
step_id: str
kind: TenantErasureStepKind
summary: str
destructive: bool
irreversible: bool
requires_reconciliation: bool = False
depends_on: tuple[str, ...] = ()
def __post_init__(self) -> None:
_text(self.step_id, "step id", maximum=160)
_text(self.summary, "step summary", maximum=1000)
if self.kind not in _STEP_KINDS:
raise ValueError("Tenant erasure step kind is invalid.")
_texts(self.depends_on, "step dependencies", maximum_length=160)
if self.step_id in self.depends_on:
raise ValueError("Tenant erasure step cannot depend on itself.")
if self.irreversible and not self.destructive:
raise ValueError("An irreversible tenant erasure step must be destructive.")
def to_dict(self) -> dict[str, object]:
return {
"step_id": self.step_id,
"kind": self.kind,
"summary": self.summary,
"destructive": self.destructive,
"irreversible": self.irreversible,
"requires_reconciliation": self.requires_reconciliation,
"depends_on": list(self.depends_on),
}
@dataclass(frozen=True, slots=True)
class TenantErasurePreview:
module_id: str
complete: bool
resources: tuple[TenantErasureResource, ...] = ()
steps: tuple[TenantErasureStep, ...] = ()
blockers: tuple[str, ...] = ()
warnings: tuple[str, ...] = ()
provider_revision: str = "1"
def __post_init__(self) -> None:
_text(self.module_id, "module id", maximum=120)
_text(self.provider_revision, "provider revision", maximum=120)
_texts(self.blockers, "blockers", maximum_length=1000)
_texts(self.warnings, "warnings", maximum_length=1000)
if len(self.resources) > 500 or len(self.steps) > 500:
raise ValueError("Tenant erasure preview is too large.")
resource_types = [item.resource_type for item in self.resources]
if len(resource_types) != len(set(resource_types)):
raise ValueError("Tenant erasure preview repeats a resource type.")
resources_requiring_action = tuple(
item for item in self.resources if item.count > 0
)
if resources_requiring_action and not self.steps and not self.blockers:
raise ValueError(
"Tenant erasure resources require steps or an explicit blocker."
)
if any(
item.count > 0 and item.disposition == "unavailable"
for item in self.resources
) and not self.blockers:
raise ValueError(
"Unavailable tenant erasure resources require an explicit blocker."
)
if not self.complete and not self.blockers:
raise ValueError(
"An incomplete tenant erasure preview requires an explicit blocker."
)
step_ids = [item.step_id for item in self.steps]
if len(step_ids) != len(set(step_ids)):
raise ValueError("Tenant erasure preview repeats a step id.")
known_step_ids = set(step_ids)
if any(
dependency not in known_step_ids
for step in self.steps
for dependency in step.depends_on
):
raise ValueError("Tenant erasure step references an unknown dependency.")
remaining = {
step.step_id: set(step.depends_on)
for step in self.steps
}
resolved: set[str] = set()
while remaining:
ready = sorted(
step_id
for step_id, dependencies in remaining.items()
if dependencies.issubset(resolved)
)
if not ready:
raise ValueError("Tenant erasure step dependencies contain a cycle.")
resolved.update(ready)
for step_id in ready:
remaining.pop(step_id)
@property
def allowed(self) -> bool:
return self.complete and not self.blockers
def to_dict(self) -> dict[str, object]:
return {
"module_id": self.module_id,
"complete": self.complete,
"allowed": self.allowed,
"provider_revision": self.provider_revision,
"resources": [item.to_dict() for item in self.resources],
"steps": [item.to_dict() for item in self.steps],
"blockers": list(self.blockers),
"warnings": list(self.warnings),
}
@dataclass(frozen=True, slots=True)
class TenantErasureStepResult:
state: TenantErasureResultState
summary: str
receipt_ref: str | None = None
metrics: Mapping[str, int] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.state not in _RESULT_STATES:
raise ValueError("Tenant erasure result state is invalid.")
_text(self.summary, "result summary", maximum=1000)
if self.receipt_ref is not None:
_text(self.receipt_ref, "receipt reference", maximum=500)
_metrics(self.metrics)
def to_dict(self) -> dict[str, object]:
return {
"state": self.state,
"summary": self.summary,
"receipt_ref": self.receipt_ref,
"metrics": dict(sorted(_metrics(self.metrics).items())),
}
@runtime_checkable
class TenantErasureProvider(Protocol):
module_id: str
def preview_tenant_erasure(
self,
session: object,
tenant_id: str,
) -> TenantErasurePreview:
...
def execute_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
...
def reconcile_tenant_erasure_step(
self,
session: object,
tenant_id: str,
step_id: str,
idempotency_key: str,
) -> TenantErasureStepResult:
...
@dataclass(frozen=True, slots=True)
class TenantErasureInventory:
tenant_id: str
generated_at: datetime
complete: bool
modules: tuple[TenantErasurePreview, ...]
@property
def allowed(self) -> bool:
return self.complete and all(item.allowed for item in self.modules)
def to_dict(self) -> dict[str, object]:
generated_at = self.generated_at
if generated_at.tzinfo is None:
generated_at = generated_at.replace(tzinfo=UTC)
return {
"schema_version": 1,
"tenant_id": self.tenant_id,
"generated_at": generated_at.astimezone(UTC).isoformat(),
"complete": self.complete,
"allowed": self.allowed,
"modules": [item.to_dict() for item in self.modules],
}
def tenant_erasure_providers(registry: object) -> dict[str, TenantErasureProvider]:
capability_names = getattr(registry, "capability_names", None)
capability = getattr(registry, "capability", None)
if not callable(capability_names) or not callable(capability):
raise ValueError("Tenant erasure requires a module registry.")
providers: dict[str, TenantErasureProvider] = {}
for capability_name in sorted(capability_names()):
if not capability_name.startswith(TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX):
continue
expected_module_id = capability_name.removeprefix(
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX
)
provider = capability(capability_name)
if not isinstance(provider, TenantErasureProvider):
raise TypeError(
f"Tenant erasure provider {expected_module_id or 'unknown'} is invalid."
)
module_id = _text(provider.module_id, "provider module id", maximum=120)
if module_id != expected_module_id or module_id in providers:
raise ValueError("Tenant erasure provider identity is invalid.")
providers[module_id] = provider
return providers
def collect_tenant_erasure_inventory(
registry: object,
session: object,
tenant_id: str,
*,
observed_at: datetime | None = None,
) -> TenantErasureInventory:
normalized_tenant_id = _text(tenant_id, "tenant id", maximum=120)
manifests = getattr(registry, "manifests", None)
summary_providers = getattr(registry, "tenant_summary_providers", None)
if not callable(manifests) or not callable(summary_providers):
raise ValueError("Tenant erasure inventory requires a module registry.")
provider_by_module = tenant_erasure_providers(registry)
summary_by_module = dict(summary_providers())
manifest_ids = {
str(manifest.id)
for manifest in manifests()
if getattr(manifest, "id", None)
}
module_ids = manifest_ids | set(summary_by_module) | set(provider_by_module)
previews: list[TenantErasurePreview] = []
complete = True
for module_id in sorted(module_ids):
provider = provider_by_module.get(module_id)
if provider is not None:
try:
preview = provider.preview_tenant_erasure(session, normalized_tenant_id)
if not isinstance(preview, TenantErasurePreview):
raise TypeError("provider returned an invalid preview")
if preview.module_id != module_id:
raise ValueError("provider returned another module's preview")
except Exception as exc:
complete = False
preview = TenantErasurePreview(
module_id=module_id,
complete=False,
blockers=(
f"{type(exc).__name__}: provider preview could not be completed",
),
)
previews.append(preview)
complete = complete and preview.complete
continue
summary_provider = summary_by_module.get(module_id)
if summary_provider is None:
previews.append(
TenantErasurePreview(
module_id=module_id,
complete=True,
warnings=(
"Module declares no tenant-owned summary or erasure provider; no tenant persistence is in scope.",
),
provider_revision="manifest-no-tenant-data",
)
)
continue
try:
raw_counts = summary_provider(session, normalized_tenant_id)
counts = _metrics({str(key): int(value) for key, value in raw_counts.items()})
resources = tuple(
TenantErasureResource(
resource_type=resource_type,
count=count,
disposition="unavailable" if count else "erase",
summary=(
"Tenant-owned data requires a module erasure provider."
if count
else "The module reported no tenant-owned records."
),
)
for resource_type, count in sorted(counts.items())
)
blockers = (
("Tenant-owned data exists but the module has no erasure provider.",)
if any(counts.values())
else ()
)
preview = TenantErasurePreview(
module_id=module_id,
complete=True,
resources=resources,
blockers=blockers,
provider_revision="tenant-summary-fallback",
)
except Exception as exc:
complete = False
preview = TenantErasurePreview(
module_id=module_id,
complete=False,
blockers=(
f"{type(exc).__name__}: tenant summary could not be completed",
),
provider_revision="tenant-summary-fallback",
)
previews.append(preview)
timestamp = observed_at or datetime.now(UTC)
if timestamp.tzinfo is None:
timestamp = timestamp.replace(tzinfo=UTC)
return TenantErasureInventory(
tenant_id=normalized_tenant_id,
generated_at=timestamp,
complete=complete,
modules=tuple(previews),
)
__all__ = [
"TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX",
"TenantErasureInventory",
"TenantErasurePreview",
"TenantErasureProvider",
"TenantErasureResource",
"TenantErasureStep",
"TenantErasureStepResult",
"collect_tenant_erasure_inventory",
"tenant_erasure_providers",
]
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Mapping, Protocol, runtime_checkable
TICKET_INTEGRATION_CONTRACT_VERSION = "1"
CAPABILITY_TICKET_ROUTING = "tickets.routing"
CAPABILITY_TICKET_CASE_ESCALATION = "tickets.case_escalation"
@dataclass(frozen=True, slots=True)
class TicketRoutingRequest:
tenant_id: str
ticket_id: str
ticket_type: str
priority: str
title: str
received_at: datetime
queue_hint: str | None = None
attributes: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.tenant_id, "Ticket routing tenant", 255)
_required(self.ticket_id, "Ticket routing ticket", 255)
_required(self.ticket_type, "Ticket routing type", 80)
_required(self.priority, "Ticket routing priority", 40)
_required(self.title, "Ticket routing title", 500)
_aware(self.received_at, "Ticket routing received_at")
_optional(self.queue_hint, "Ticket routing queue hint", 255)
if len(self.attributes) > 100:
raise ValueError("Ticket routing attributes are limited to 100 entries.")
@dataclass(frozen=True, slots=True)
class TicketRoutingPlan:
provider_id: str
queue_ref: str | None = None
service_target_at: datetime | None = None
explanation: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.provider_id, "Ticket routing provider", 200)
_optional(self.queue_ref, "Ticket routing queue reference", 255)
_optional(self.explanation, "Ticket routing explanation", 4_000)
_aware(self.service_target_at, "Ticket routing service_target_at")
if len(self.metadata) > 100:
raise ValueError("Ticket routing metadata is limited to 100 entries.")
@runtime_checkable
class TicketRoutingProvider(Protocol):
def route_ticket(
self,
session: object,
principal: object,
*,
request: TicketRoutingRequest,
) -> TicketRoutingPlan: ...
@dataclass(frozen=True, slots=True)
class TicketCaseEscalationCommand:
tenant_id: str
ticket_id: str
ticket_number: str
title: str
case_type_key: str
occurred_at: datetime
idempotency_key: str
handoff_note: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.tenant_id, "Ticket escalation tenant", 255)
_required(self.ticket_id, "Ticket escalation ticket", 255)
_required(self.ticket_number, "Ticket escalation number", 255)
_required(self.title, "Ticket escalation title", 500)
_required(self.case_type_key, "Ticket escalation case type", 120)
_required(self.idempotency_key, "Ticket escalation idempotency key", 255)
_optional(self.handoff_note, "Ticket escalation handoff note", 10_000)
_aware(self.occurred_at, "Ticket escalation occurred_at")
if len(self.metadata) > 100:
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
@dataclass(frozen=True, slots=True)
class TicketCaseEscalationResult:
provider_id: str
case_id: str
case_number: str
case_url: str
replayed: bool = False
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
_required(self.provider_id, "Ticket escalation provider", 200)
_required(self.case_id, "Ticket escalation case", 255)
_required(self.case_number, "Ticket escalation case number", 255)
_relative_url(self.case_url)
if len(self.metadata) > 100:
raise ValueError("Ticket escalation metadata is limited to 100 entries.")
@runtime_checkable
class TicketCaseEscalationProvider(Protocol):
def escalate_ticket(
self,
session: object,
principal: object,
*,
command: TicketCaseEscalationCommand,
) -> TicketCaseEscalationResult: ...
def ticket_routing_provider(registry: object | None) -> TicketRoutingProvider | None:
provider = _capability(registry, CAPABILITY_TICKET_ROUTING)
return provider if isinstance(provider, TicketRoutingProvider) else None
def ticket_case_escalation_provider(
registry: object | None,
) -> TicketCaseEscalationProvider | None:
provider = _capability(registry, CAPABILITY_TICKET_CASE_ESCALATION)
return provider if isinstance(provider, TicketCaseEscalationProvider) else None
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
def _required(value: str, label: str, maximum: int) -> None:
if not value.strip() or len(value) > maximum:
raise ValueError(f"{label} must contain 1 to {maximum} characters.")
def _optional(value: str | None, label: str, maximum: int) -> None:
if value is not None and (not value.strip() or len(value) > maximum):
raise ValueError(f"{label} must contain 1 to {maximum} characters when set.")
def _aware(value: datetime | None, label: str) -> None:
if value is not None and (value.tzinfo is None or value.utcoffset() is None):
raise ValueError(f"{label} must include a timezone.")
def _relative_url(value: str) -> None:
if (
not value.startswith("/")
or value.startswith("//")
or "\\" in value
or len(value) > 1_500
or any(ord(character) < 32 or ord(character) == 127 for character in value)
):
raise ValueError("Ticket escalation URLs must be bounded application-relative paths.")
__all__ = [
"CAPABILITY_TICKET_CASE_ESCALATION",
"CAPABILITY_TICKET_ROUTING",
"TICKET_INTEGRATION_CONTRACT_VERSION",
"TicketCaseEscalationCommand",
"TicketCaseEscalationProvider",
"TicketCaseEscalationResult",
"TicketRoutingPlan",
"TicketRoutingProvider",
"TicketRoutingRequest",
"ticket_case_escalation_provider",
"ticket_routing_provider",
]
+43 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Mapping from typing import Mapping
@@ -12,6 +13,12 @@ from govoplan_core.security.outbound_http import (
) )
MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES = 1_000_000
_STANDARD_REDIRECT_SENSITIVE_HEADERS = frozenset(
{"authorization", "proxy-authorization", "cookie", "cookie2"}
)
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class HttpFetchResponse: class HttpFetchResponse:
status: int status: int
@@ -46,15 +53,27 @@ def fetch_http(
label: str = "URL", label: str = "URL",
method: str = "GET", method: str = "GET",
headers: Mapping[str, str] | None = None, headers: Mapping[str, str] | None = None,
body: bytes | None = None,
max_bytes: int | None = None, max_bytes: int | None = None,
redirect_sensitive_headers: Iterable[str] = (),
) -> HttpFetchResponse: ) -> HttpFetchResponse:
if body is not None and len(body) > MAX_OUTBOUND_HTTP_REQUEST_BODY_BYTES:
raise ValueError(
"Outbound HTTP request body exceeds the 1000000-byte safety limit."
)
validated_url = validate_outbound_http_url(url, label=label) validated_url = validate_outbound_http_url(url, label=label)
request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S). request = urllib.request.Request( # noqa: S310 - URL is restricted to validated HTTP(S).
validated_url, validated_url,
data=body,
headers=dict(headers or {}), headers=dict(headers or {}),
method=method, method=method,
) )
opener = build_outbound_http_opener(_PolicyRedirectHandler(label=label)) opener = build_outbound_http_opener(
_PolicyRedirectHandler(
label=label,
sensitive_headers=redirect_sensitive_headers,
)
)
with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected with opener.open(request, timeout=timeout) as response: # noqa: S310 - URL and every redirect are policy-validated. # nosec B310 # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
response_headers = dict(response.headers.items()) response_headers = dict(response.headers.items())
return HttpFetchResponse( return HttpFetchResponse(
@@ -76,16 +95,35 @@ def fetch_http_text(
label: str = "URL", label: str = "URL",
method: str = "GET", method: str = "GET",
headers: Mapping[str, str] | None = None, headers: Mapping[str, str] | None = None,
body: bytes | None = None,
encoding: str = "utf-8", encoding: str = "utf-8",
max_bytes: int | None = None, max_bytes: int | None = None,
redirect_sensitive_headers: Iterable[str] = (),
) -> str: ) -> str:
return fetch_http(url, timeout=timeout, label=label, method=method, headers=headers, max_bytes=max_bytes).text(encoding) return fetch_http(
url,
timeout=timeout,
label=label,
method=method,
headers=headers,
body=body,
max_bytes=max_bytes,
redirect_sensitive_headers=redirect_sensitive_headers,
).text(encoding)
class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler): class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
def __init__(self, *, label: str) -> None: def __init__(
self,
*,
label: str,
sensitive_headers: Iterable[str] = (),
) -> None:
super().__init__() super().__init__()
self._label = label self._label = label
self._sensitive_headers = _STANDARD_REDIRECT_SENSITIVE_HEADERS | {
value.strip().lower() for value in sensitive_headers if value.strip()
}
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def] def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[no-untyped-def]
candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect") candidate = validate_outbound_http_url(newurl, label=f"{self._label} redirect")
@@ -95,7 +133,8 @@ class _PolicyRedirectHandler(urllib.request.HTTPRedirectHandler):
return None return None
new_request = super().redirect_request(req, fp, code, msg, headers, candidate) new_request = super().redirect_request(req, fp, code, msg, headers, candidate)
if new_request is not None and _http_origin(previous) != _http_origin(redirected): if new_request is not None and _http_origin(previous) != _http_origin(redirected):
for header in ("Authorization", "Proxy-Authorization", "Cookie", "Cookie2"): for header in tuple(new_request.headers) + tuple(new_request.unredirected_hdrs):
if header.lower() in self._sensitive_headers:
new_request.remove_header(header) new_request.remove_header(header)
return new_request return new_request
@@ -27,6 +27,7 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"system:tenants:create": "access:tenant:create", "system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update", "system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend", "system:tenants:suspend": "access:tenant:suspend",
"system:tenants:erase": "access:tenant:erase",
"system:accounts:read": "access:account:read", "system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create", "system:accounts:create": "access:account:create",
"system:accounts:update": "access:account:update", "system:accounts:update": "access:account:update",
@@ -78,6 +78,7 @@ SYSTEM_PERMISSIONS: tuple[PermissionDefinition, ...] = (
PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"), PermissionDefinition("system:tenants:create", "Create tenants", "Create new tenant spaces.", "System administration", "system"),
PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"), PermissionDefinition("system:tenants:update", "Update tenants", "Edit tenant metadata and governance overrides.", "System administration", "system"),
PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"), PermissionDefinition("system:tenants:suspend", "Suspend tenants", "Activate or suspend tenant spaces while preserving evidence.", "System administration", "system"),
PermissionDefinition("system:tenants:erase", "Erase tenants", "Preview, approve, execute, and reconcile governed destructive tenant erasure.", "System administration", "system"),
PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"), PermissionDefinition("system:accounts:read", "View accounts", "List global login accounts and memberships.", "System administration", "system"),
PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"), PermissionDefinition("system:accounts:create", "Create accounts", "Create global login accounts.", "System administration", "system"),
PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"), PermissionDefinition("system:accounts:update", "Update accounts", "Edit global account metadata.", "System administration", "system"),
+47
View File
@@ -22,7 +22,9 @@ from govoplan_core.core.modules import (
FrontendRoute, FrontendRoute,
ModuleManifest, ModuleManifest,
NavItem, NavItem,
ProductAvailabilityExplanation,
ProductAreaContribution, ProductAreaContribution,
ProductSurfaceContribution,
PublicFrontendRoute, PublicFrontendRoute,
QuickAccessTool, QuickAccessTool,
SUPPORTED_PRESENTATION_CONTRACT_VERSION, SUPPORTED_PRESENTATION_CONTRACT_VERSION,
@@ -254,6 +256,47 @@ def _product_area_payload(area: ProductAreaContribution) -> dict[str, object]:
} }
def _product_availability_payload(
explanation: ProductAvailabilityExplanation,
) -> dict[str, object]:
return {
"reason": explanation.reason,
"title": explanation.title,
"description": explanation.description,
"resolution": explanation.resolution,
"responsible_role": explanation.responsible_role,
}
def _product_surface_payload(surface: ProductSurfaceContribution) -> dict[str, object]:
return {
"contract_version": surface.contract_version,
"id": surface.id,
"module_id": surface.module_id,
"label": surface.label,
"description": surface.description,
"icon": surface.icon,
"entry_path": surface.entry_path,
"route_path": surface.route_path,
"surface_ids": list(surface.surface_ids),
"presentations": list(surface.presentations),
"capability_ids": list(surface.capability_ids),
"search_source_ids": list(surface.search_source_ids),
"help_context_ids": list(surface.help_context_ids),
"documentation_topic_ids": list(surface.documentation_topic_ids),
"required_all": list(surface.required_all),
"required_any": list(surface.required_any),
"aliases": list(surface.aliases),
"order": surface.order,
"unavailable": _product_availability_payload(surface.unavailable),
"degraded": (
_product_availability_payload(surface.degraded)
if surface.degraded is not None
else None
),
}
def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]: def _quick_access_tool_payload(tool: QuickAccessTool) -> dict[str, object]:
return { return {
"id": tool.id, "id": tool.id,
@@ -372,6 +415,10 @@ def _frontend_payload(
"product_areas": [ "product_areas": [
_product_area_payload(area) for area in frontend.product_areas _product_area_payload(area) for area in frontend.product_areas
], ],
"product_surfaces": [
_product_surface_payload(surface)
for surface in frontend.product_surfaces
],
"quick_access_tools": [ "quick_access_tools": [
_quick_access_tool_payload(tool) for tool in frontend.quick_access_tools _quick_access_tool_payload(tool) for tool in frontend.quick_access_tools
], ],
+78
View File
@@ -71,6 +71,7 @@ from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT, CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION, CAPABILITY_CAMPAIGNS_RETENTION,
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
CampaignAccessProvider, CampaignAccessProvider,
CampaignDeliveryTaskProvider, CampaignDeliveryTaskProvider,
CampaignMailPolicyContext, CampaignMailPolicyContext,
@@ -78,6 +79,10 @@ from govoplan_core.core.campaigns import (
CampaignPolicyContext, CampaignPolicyContext,
CampaignPolicyContextProvider, CampaignPolicyContextProvider,
CampaignRetentionProvider, CampaignRetentionProvider,
CampaignWorkHandoffInspection,
CampaignWorkHandoffRef,
CampaignWorkHandoffRequest,
CampaignWorkOrchestrationProvider,
) )
from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider from govoplan_core.core.files import CAPABILITY_FILES_ACCESS, FileAccessProvider
from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
@@ -464,6 +469,40 @@ class _FakeCampaignRetentionProvider:
return {"raw_campaign_json": {"eligible": int(dry_run)}} return {"raw_campaign_json": {"eligible": int(dry_run)}}
class _FakeCampaignWorkOrchestrationProvider:
def prepare_handoff(self, session: object, principal: object, *, request):
del session, principal
return CampaignWorkHandoffRef(
tenant_id=request.tenant_id,
campaign_id=request.campaign_id or "campaign-created",
campaign_version_id="campaign-version-1",
campaign_revision=1,
assignment_id="assignment-1",
assignment_revision=1,
status="open",
action_url="/campaigns/campaign-1/work?assignment=assignment-1",
campaign_ref="campaign:campaign-1:version:campaign-version-1:r1",
assignment_ref="campaign-work-assignment:assignment-1:r1",
)
def inspect_handoff(
self,
session: object,
principal: object,
*,
tenant_id: str,
assignment_id: str,
expected_revision: int | None = None,
):
del session, principal, tenant_id, assignment_id
return CampaignWorkHandoffInspection(
allowed=expected_revision in {None, 1},
status="open",
assignment_revision=1,
assignment_ref="campaign-work-assignment:assignment-1:r1",
)
class _FakeSecretProvider: class _FakeSecretProvider:
def __init__(self) -> None: def __init__(self) -> None:
self._values: dict[str, str] = {} self._values: dict[str, str] = {}
@@ -528,6 +567,10 @@ class AccessContractTests(unittest.TestCase):
self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT) self.assertEqual("campaigns.mailPolicyContext", CAPABILITY_CAMPAIGNS_MAIL_POLICY_CONTEXT)
self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT) self.assertEqual("campaigns.policyContext", CAPABILITY_CAMPAIGNS_POLICY_CONTEXT)
self.assertEqual("campaigns.retention", CAPABILITY_CAMPAIGNS_RETENTION) self.assertEqual("campaigns.retention", CAPABILITY_CAMPAIGNS_RETENTION)
self.assertEqual(
"campaigns.workOrchestration",
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
)
self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER) self.assertEqual("tenancy.tenantResolver", CAPABILITY_TENANCY_TENANT_RESOLVER)
self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER) self.assertEqual("security.secretProvider", CAPABILITY_SECURITY_SECRET_PROVIDER)
self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK) self.assertEqual("audit.sink", CAPABILITY_AUDIT_SINK)
@@ -642,6 +685,10 @@ class AccessContractTests(unittest.TestCase):
self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider) self.assertIsInstance(_FakeCampaignMailPolicyContextProvider(), CampaignMailPolicyContextProvider)
self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider) self.assertIsInstance(_FakeCampaignPolicyContextProvider(), CampaignPolicyContextProvider)
self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider) self.assertIsInstance(_FakeCampaignRetentionProvider(), CampaignRetentionProvider)
self.assertIsInstance(
_FakeCampaignWorkOrchestrationProvider(),
CampaignWorkOrchestrationProvider,
)
self.assertIsInstance(_FakeSecretProvider(), SecretProvider) self.assertIsInstance(_FakeSecretProvider(), SecretProvider)
self.assertIsInstance(_FakeAuditSink(), AuditSink) self.assertIsInstance(_FakeAuditSink(), AuditSink)
self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder) self.assertIsInstance(_FakeAuditRecorder(), AuditRecorder)
@@ -676,6 +723,37 @@ class AccessContractTests(unittest.TestCase):
self.assertEqual({"job_id": "job-1", "status": "appended"}, delivery_provider.append_sent_for_job(object(), job_id="job-1")) self.assertEqual({"job_id": "job-1", "status": "appended"}, delivery_provider.append_sent_for_job(object(), job_id="job-1"))
self.assertEqual({"raw_campaign_json": {"eligible": 1}}, retention_provider.apply_retention(object(), dry_run=True, now=object(), policy_for_campaign_id=lambda campaign_id: object())) self.assertEqual({"raw_campaign_json": {"eligible": 1}}, retention_provider.apply_retention(object(), dry_run=True, now=object(), policy_for_campaign_id=lambda campaign_id: object()))
def test_campaign_work_handoff_contract_requires_one_campaign_source(self) -> None:
request = CampaignWorkHandoffRequest(
tenant_id="tenant-1",
campaign_id="campaign-1",
idempotency_key="workflow-step-1",
purpose="Review the campaign",
assignee_kind="account",
assignee_id="account-1",
)
provider = _FakeCampaignWorkOrchestrationProvider()
handoff = provider.prepare_handoff(object(), object(), request=request)
inspection = provider.inspect_handoff(
object(),
object(),
tenant_id="tenant-1",
assignment_id=handoff.assignment_id,
expected_revision=handoff.assignment_revision,
)
self.assertEqual("campaign-1", handoff.campaign_id)
self.assertTrue(inspection.allowed)
with self.assertRaisesRegex(ValueError, "either reference one campaign"):
CampaignWorkHandoffRequest(
tenant_id="tenant-1",
idempotency_key="workflow-step-2",
purpose="Review",
assignee_kind="account",
assignee_id="account-1",
)
def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None: def test_access_capabilities_register_and_resolve_through_platform_registry(self) -> None:
directory = _FakeAccessDirectory() directory = _FakeAccessDirectory()
semantic_directory = _FakeAccessSemanticDirectory() semantic_directory = _FakeAccessSemanticDirectory()
@@ -3,13 +3,22 @@ from __future__ import annotations
import unittest import unittest
from govoplan_core.core.configuration_packages import ( from govoplan_core.core.configuration_packages import (
ConfigurationApplyResult,
ConfigurationExportResult,
ConfigurationExportSelection,
ConfigurationModuleRequirement, ConfigurationModuleRequirement,
ConfigurationPackageFragment,
ConfigurationPackageEvidence, ConfigurationPackageEvidence,
ConfigurationPackageManifest, ConfigurationPackageManifest,
ConfigurationPackageParent, ConfigurationPackageParent,
ConfigurationPlanItem,
ConfigurationPreflightContext, ConfigurationPreflightContext,
ConfigurationPreflightResult,
ConfigurationProviderExpectation, ConfigurationProviderExpectation,
ConfigurationRequiredData,
apply_configuration_package,
dry_run_configuration_package, dry_run_configuration_package,
export_configuration_package,
validate_configuration_package_derivation, validate_configuration_package_derivation,
) )
@@ -27,6 +36,121 @@ def _evidence(*kinds: str) -> tuple[ConfigurationPackageEvidence, ...]:
class ConfigurationPackageArchitectureTests(unittest.TestCase): class ConfigurationPackageArchitectureTests(unittest.TestCase):
def test_deployment_data_references_are_declared_resolved_and_never_exported(self) -> None:
class Provider:
module_id = "forms"
def __init__(self) -> None:
self.preflight_payloads: list[dict[str, object]] = []
def describe(self):
from govoplan_core.core.configuration_packages import ConfigurationProviderDescription
return ConfigurationProviderDescription(
module_id=self.module_id,
fragment_types=("definition",),
)
def preflight(self, fragment, context):
del context
self.preflight_payloads.append(dict(fragment.payload))
return ConfigurationPreflightResult(plan=(ConfigurationPlanItem(
action="create",
module_id=self.module_id,
fragment_type=fragment.fragment_type,
fragment_id=fragment.fragment_id,
),))
def apply(self, fragment, supplied_data, context):
del supplied_data, context
return ConfigurationApplyResult(
created_refs={fragment.fragment_id or "definition": "form:resident-parking"}
)
def export(self, selection, context):
del selection, context
return ConfigurationExportResult(
fragments=(ConfigurationPackageFragment(
module_id=self.module_id,
fragment_type="definition",
payload={"name": "Resident parking permit"},
),),
data_requirements=(ConfigurationRequiredData(
key="payment_credential_ref",
label="Payment credential reference",
secret=True,
),),
)
def health(self, import_result, context):
del import_result, context
return ()
provider = Provider()
package = ConfigurationPackageManifest(
package_id="product.resident-parking",
name="Resident parking permit",
version="1.0.0",
required_modules=(ConfigurationModuleRequirement("forms"),),
data_requirements=({
"key": "service_name",
"label": "Public service name",
},),
fragments=(ConfigurationPackageFragment(
module_id="forms",
fragment_type="definition",
fragment_id="resident-parking",
payload={
"definition": {
"title": {"$data": "service_name"},
}
},
),),
)
missing_context = ConfigurationPreflightContext(
installed_modules={"forms": "0.1.0"},
)
missing = dry_run_configuration_package(package, (provider,), missing_context)
self.assertEqual([], provider.preflight_payloads)
self.assertIn(
"fragment_data_reference_missing",
{item.code for item in missing.diagnostics},
)
ready_context = ConfigurationPreflightContext(
installed_modules={"forms": "0.1.0"},
supplied_data={"service_name": "Anwohnerparkausweis"},
operator_user_id="operator-1",
)
ready = dry_run_configuration_package(package, (provider,), ready_context)
applied = apply_configuration_package(package, (provider,), ready_context)
exported = export_configuration_package(
(provider,),
ConfigurationExportSelection(
tenant_id="tenant-1",
module_ids=("forms",),
),
ready_context,
)
self.assertFalse(any(item.severity == "blocker" for item in ready.diagnostics))
self.assertEqual(
"Anwohnerparkausweis",
provider.preflight_payloads[-1]["definition"]["title"], # type: ignore[index]
)
self.assertIsNotNone(applied.rollback)
assert applied.rollback is not None
self.assertEqual("database_restore_required", applied.rollback.status)
self.assertIsNotNone(exported.provenance)
assert exported.provenance is not None
self.assertEqual("operator-1", exported.provenance.exporter_id)
self.assertEqual(
("payment_credential_ref",),
exported.provenance.redacted_secret_keys,
)
def test_legacy_package_defaults_to_product_and_round_trips(self) -> None: def test_legacy_package_defaults_to_product_and_round_trips(self) -> None:
package = ConfigurationPackageManifest.from_mapping( package = ConfigurationPackageManifest.from_mapping(
{"package_id": "example", "name": "Example", "version": "1.0.0"} {"package_id": "example", "name": "Example", "version": "1.0.0"}
+50
View File
@@ -13,6 +13,7 @@ from govoplan_core.core.datasources import (
DatasourceArtifactBackendProvider, DatasourceArtifactBackendProvider,
DatasourceDescriptor, DatasourceDescriptor,
DatasourceField, DatasourceField,
DatasourceGovernance,
DatasourceLifecycleProvider, DatasourceLifecycleProvider,
DatasourceMaterialization, DatasourceMaterialization,
DatasourceOrigin, DatasourceOrigin,
@@ -212,6 +213,55 @@ class DatasourceContractTests(unittest.TestCase):
self.assertEqual("upload", descriptor.kind) self.assertEqual("upload", descriptor.kind)
self.assertEqual("tabular", descriptor.shape) self.assertEqual("tabular", descriptor.shape)
def test_lifecycle_governance_round_trips_without_provider_specific_types(self) -> None:
governance = DatasourceGovernance.from_mapping(
{
"approval_policy": {
"version": "approval-v2",
"required": True,
"required_approvals": 2,
},
"retention_policy": {
"version": "retention-v3",
"enabled": True,
"stage_days": 30,
},
}
)
self.assertEqual("approval-v2", governance.approval_policy["version"])
self.assertEqual(30, governance.retention_policy["stage_days"])
self.assertEqual(
governance.approval_policy,
governance.to_dict()["approval_policy"],
)
self.assertEqual(
governance.retention_policy,
governance.to_dict()["retention_policy"],
)
stage = DatasourceStage(
ref="stage:governed",
name="Governed stage",
source_name="governed",
kind="upload",
mode="static",
shape="tabular",
state="awaiting_approval",
approval={"status": "pending", "policy_version": "approval-v2"},
)
materialization = DatasourceMaterialization(
ref="materialization:disposed",
datasource_ref="datasource:governed",
revision=1,
state="disposed",
fingerprint="abc123",
disposition={"reason": "retention_policy", "policy_version": "retention-v3"},
)
self.assertEqual("pending", stage.approval["status"])
self.assertEqual("retention_policy", materialization.disposition["reason"])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -10,7 +10,9 @@ from govoplan_core.core.modules import (
DocumentationSourceDefinition, DocumentationSourceDefinition,
DocumentationTopic, DocumentationTopic,
ModuleManifest, ModuleManifest,
localized_documentation_metadata,
user_workflow_scope_condition_issues, user_workflow_scope_condition_issues,
with_documentation_structured_translations,
) )
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -83,6 +85,97 @@ class DocumentationTopicContractTests(unittest.TestCase):
self.assertEqual(user_workflow_scope_condition_issues(user_reference), ()) self.assertEqual(user_workflow_scope_condition_issues(user_reference), ())
registry_for(scoped, admin_workflow, user_reference).validate() registry_for(scoped, admin_workflow, user_reference).validate()
def test_versioned_structured_translation_preserves_metadata_shape(self) -> None:
topic = DocumentationTopic(
id="example.workflow.localized",
title="Run task",
summary="Run the task.",
metadata={
"kind": "workflow",
"steps": ["Review", "Execute"],
"verification": "Confirm the result.",
},
structured_translation_version="1",
structured_translations={
"de": {
"steps": ["Prüfen", "Ausführen"],
"verification": "Das Ergebnis bestätigen.",
}
},
)
registry_for(topic).validate()
self.assertEqual(
["Prüfen", "Ausführen"],
localized_documentation_metadata(topic, "de")["steps"],
)
self.assertEqual(
"workflow", localized_documentation_metadata(topic, "de")["kind"]
)
def test_structured_translation_requires_version_and_complete_shape(self) -> None:
missing_version = DocumentationTopic(
id="example.localized.missing-version",
title="Localized",
summary="Invalid contract.",
metadata={"limitations": ["One", "Two"]},
structured_translations={"de": {"limitations": ["Eins", "Zwei"]}},
)
with self.assertRaisesRegex(
RegistryError, "require structured_translation_version"
):
registry_for(missing_version).validate()
incomplete_shape = DocumentationTopic(
id="example.localized.incomplete",
title="Localized",
summary="Invalid shape.",
metadata={"limitations": ["One", "Two"]},
structured_translation_version="1",
structured_translations={"de": {"limitations": ["Eins"]}},
)
with self.assertRaisesRegex(RegistryError, "preserve list length"):
registry_for(incomplete_shape).validate()
def test_manifest_helper_merges_and_validates_owner_translations(self) -> None:
topic = DocumentationTopic(
id="example.workflow.localized",
title="Run task",
summary="Run the task.",
metadata={"steps": ["Review", "Execute"]},
)
manifest = ModuleManifest(
id="example",
name="Example",
version="1.0.0",
documentation=(topic,),
)
localized = with_documentation_structured_translations(
manifest,
locale="de",
translations={
topic.id: {"steps": ["Prüfen", "Ausführen"]},
},
)
self.assertEqual(
["Prüfen", "Ausführen"],
localized.documentation[0].structured_translations["de"]["steps"],
)
with self.assertRaisesRegex(ValueError, "unknown topic ids"):
with_documentation_structured_translations(
manifest,
locale="de",
translations={"missing.topic": {"steps": ["Prüfen", "Ausführen"]}},
)
with self.assertRaisesRegex(ValueError, "preserve list length"):
with_documentation_structured_translations(
manifest,
locale="de",
translations={topic.id: {"steps": ["Prüfen"]}},
)
def test_documentation_configuration_and_source_extensions_are_validated(self) -> None: def test_documentation_configuration_and_source_extensions_are_validated(self) -> None:
resolver = lambda _context, keys: { # noqa: E731 resolver = lambda _context, keys: { # noqa: E731
key: DocumentationConfigurationDecision(key=key, state="enabled") key: DocumentationConfigurationDecision(key=key, state="enabled")
+91 -4
View File
@@ -2,9 +2,14 @@ from __future__ import annotations
import io import io
import unittest import unittest
from unittest.mock import patch from unittest.mock import Mock, patch
from govoplan_core.security.http_fetch import _PolicyRedirectHandler, is_http_url, validate_http_url from govoplan_core.security.http_fetch import (
_PolicyRedirectHandler,
fetch_http,
is_http_url,
validate_http_url,
)
from govoplan_core.security.outbound_http import ( from govoplan_core.security.outbound_http import (
DEFAULT_FILE_TRANSFER_BYTES, DEFAULT_FILE_TRANSFER_BYTES,
DEFAULT_STRUCTURED_RESPONSE_BYTES, DEFAULT_STRUCTURED_RESPONSE_BYTES,
@@ -21,6 +26,51 @@ from govoplan_core.security.outbound_http import (
class HttpFetchTests(unittest.TestCase): class HttpFetchTests(unittest.TestCase):
def test_fetch_http_forwards_a_bounded_request_body(self) -> None:
class Response(io.BytesIO):
status = 200
headers = {"Content-Type": "application/json"}
def __enter__(self):
return self
def __exit__(self, *_args):
return False
opener = Mock()
opener.open.return_value = Response(b"{}")
with patch(
"govoplan_core.security.http_fetch.validate_outbound_http_url",
return_value="https://wiki.example.test/api.php",
), patch(
"govoplan_core.security.http_fetch.build_outbound_http_opener",
return_value=opener,
):
response = fetch_http(
"https://wiki.example.test/api.php",
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
body=b"action=edit",
max_bytes=1024,
)
request = opener.open.call_args.args[0]
self.assertEqual("POST", request.get_method())
self.assertEqual(b"action=edit", request.data)
self.assertEqual(b"{}", response.body)
def test_fetch_http_rejects_an_oversized_request_body_before_transport(self) -> None:
with patch(
"govoplan_core.security.http_fetch.validate_outbound_http_url"
) as validate:
with self.assertRaisesRegex(ValueError, "request body exceeds"):
fetch_http(
"https://wiki.example.test/api.php",
method="POST",
body=b"x" * 1_000_001,
)
validate.assert_not_called()
def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None: def test_validate_http_url_accepts_absolute_http_urls_without_credentials(self) -> None:
self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json")) self.assertEqual("https://example.test/catalog.json", validate_http_url("https://example.test/catalog.json"))
self.assertTrue(is_http_url("http://example.test/catalog.json")) self.assertTrue(is_http_url("http://example.test/catalog.json"))
@@ -189,9 +239,17 @@ class HttpFetchTests(unittest.TestCase):
request = urllib.request.Request( request = urllib.request.Request(
"https://catalog.example.test/releases", "https://catalog.example.test/releases",
headers={"Authorization": "Bearer secret", "X-Request-ID": "request-1"}, headers={
"Authorization": "Bearer secret",
"Cookie": "session=secret",
"X-OTRS-Header-Password": "secret",
"X-Request-ID": "request-1",
},
)
handler = _PolicyRedirectHandler(
label="Catalog URL",
sensitive_headers=("X-OTRS-Header-Password",),
) )
handler = _PolicyRedirectHandler(label="Catalog URL")
with patch.dict("os.environ", {"APP_ENV": "test"}), patch( with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo", "govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))], return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
@@ -215,9 +273,38 @@ class HttpFetchTests(unittest.TestCase):
self.assertIsNotNone(redirected) self.assertIsNotNone(redirected)
self.assertIsNone(redirected.get_header("Authorization")) self.assertIsNone(redirected.get_header("Authorization"))
self.assertIsNone(redirected.get_header("Cookie"))
self.assertIsNone(redirected.get_header("X-otrs-header-password"))
self.assertEqual("request-1", redirected.get_header("X-request-id")) self.assertEqual("request-1", redirected.get_header("X-request-id"))
self.assertIsNone(downgrade) self.assertIsNone(downgrade)
def test_core_redirects_preserve_caller_sensitive_headers_on_the_same_origin(self) -> None:
import urllib.request
request = urllib.request.Request(
"https://desk.example.test/original",
headers={"X-OTRS-Header-SessionID": "secret"},
)
handler = _PolicyRedirectHandler(
label="Service-desk URL",
sensitive_headers=("X-OTRS-Header-SessionID",),
)
with patch.dict("os.environ", {"APP_ENV": "test"}), patch(
"govoplan_core.security.outbound_http.socket.getaddrinfo",
return_value=[(2, 1, 6, "", ("127.0.0.1", 443))],
):
redirected = handler.redirect_request(
request,
None,
302,
"Found",
{},
"https://desk.example.test/final",
)
self.assertIsNotNone(redirected)
self.assertEqual("secret", redirected.get_header("X-otrs-header-sessionid"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+76
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import os import os
from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
import tempfile import tempfile
import unittest import unittest
@@ -9,12 +10,53 @@ from unittest.mock import patch
from govoplan_core.core.infrastructure_capabilities import ( from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapabilityReceiptError, InfrastructureCapabilityReceiptError,
InfrastructureDependency,
collect_infrastructure_dependency_inventory,
deployment_capability_status, deployment_capability_status,
infrastructure_capability_receipt_from_mapping, infrastructure_capability_receipt_from_mapping,
load_infrastructure_capability_receipt, load_infrastructure_capability_receipt,
) )
class _InventoryProvider:
module_id = "mail"
capability_ids = ("mail.smtp",)
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
return (
InfrastructureDependency(
capability_id="mail.smtp",
module_id="mail",
dependency_type="smtp_endpoint",
dependency_ref="mail-server:server-1",
state="active",
scope="system",
summary="One active SMTP endpoint uses the deployment relay.",
metrics={"credential_binding_count": 1},
required_action="Rebind or retire the endpoint before removal.",
),
)
class _FailingInventoryProvider:
module_id = "files"
capability_ids = ("files.storage",)
def infrastructure_dependencies(self) -> tuple[InfrastructureDependency, ...]:
raise RuntimeError("database URL must not escape")
class _Registry:
def __init__(self, providers: dict[str, object]) -> None:
self.providers = providers
def capability_names(self) -> tuple[str, ...]:
return tuple(self.providers)
def capability(self, name: str) -> object | None:
return self.providers.get(name)
def _receipt_payload() -> dict[str, object]: def _receipt_payload() -> dict[str, object]:
return { return {
"schema_version": 1, "schema_version": 1,
@@ -48,6 +90,40 @@ def _receipt_payload() -> dict[str, object]:
class InfrastructureCapabilityReceiptTests(unittest.TestCase): class InfrastructureCapabilityReceiptTests(unittest.TestCase):
def test_collects_non_secret_provider_dependency_inventory(self) -> None:
inventory = collect_infrastructure_dependency_inventory(
_Registry(
{
"infrastructure.dependency_inventory.mail": _InventoryProvider(),
"unrelated.capability": object(),
}
),
installation_id="govoplan-test",
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
self.assertTrue(inventory.complete)
self.assertEqual(("mail.smtp",), inventory.inspected_capability_ids)
self.assertEqual("mail-server:server-1", inventory.dependencies[0].dependency_ref)
self.assertEqual("2026-08-24T12:00:00+00:00", inventory.generated_at)
self.assertNotIn("database URL", json.dumps(inventory.to_dict()))
def test_provider_failure_makes_inventory_incomplete_without_leaking_error(self) -> None:
inventory = collect_infrastructure_dependency_inventory(
_Registry(
{
"infrastructure.dependency_inventory.files": (
_FailingInventoryProvider()
)
}
),
installation_id="govoplan-test",
)
self.assertFalse(inventory.complete)
self.assertEqual("error", inventory.providers[0].state)
self.assertNotIn("database URL", str(inventory.providers[0].error))
def test_parses_typed_capability_and_task_lookup(self) -> None: def test_parses_typed_capability_and_task_lookup(self) -> None:
receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload()) receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload())
+51 -9
View File
@@ -344,6 +344,18 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read")) self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read")) self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read")) self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
self.assertTrue(
scopes_grant_compatible(
["access:tenant:erase"],
"system:tenants:erase",
)
)
self.assertFalse(
scopes_grant_compatible(
["system:tenants:write"],
"system:tenants:erase",
)
)
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read")) self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
self.assertTrue( self.assertTrue(
scopes_grant_compatible( scopes_grant_compatible(
@@ -1015,8 +1027,10 @@ finally:
json={"mode": "destroy", "reason": "not supported"}, json={"mode": "destroy", "reason": "not supported"},
) )
self.assertEqual(409, destructive.status_code, destructive.text) self.assertEqual(409, destructive.status_code, destructive.text)
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]} self.assertIn(
self.assertIn("tenant_data_present", issue_codes) "Direct destructive deletion is disabled",
destructive.json()["detail"]["message"],
)
with database.session() as session: with database.session() as session:
empty_tenant = Tenant( empty_tenant = Tenant(
@@ -1029,15 +1043,43 @@ finally:
session.commit() session.commit()
empty_tenant_id = empty_tenant.id empty_tenant_id = empty_tenant.id
destroyed = client.request( erasure_policy = client.patch(
"DELETE", "/api/v1/admin/tenant-erasure-policy",
f"/api/v1/admin/tenants/{empty_tenant_id}",
headers=headers, headers=headers,
json={"mode": "destroy", "reason": "empty tenant cleanup"}, json={
"production_profile": False,
"required_approvals": 1,
"preview_ttl_seconds": 900,
"recent_authentication_seconds": 900,
},
) )
self.assertEqual(200, destroyed.status_code, destroyed.text) self.assertEqual(200, erasure_policy.status_code, erasure_policy.text)
self.assertEqual("destroy", destroyed.json()["plan"]["action"]) erasure_preview = client.post(
self.assertTrue(destroyed.json()["plan"]["destructive_supported"]) f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations",
headers=headers,
json={
"idempotency_key": f"empty-destroy-{name}",
"reason": "empty tenant cleanup",
},
)
self.assertEqual(201, erasure_preview.status_code, erasure_preview.text)
self.assertTrue(erasure_preview.json()["preview"]["allowed"])
operation_id = erasure_preview.json()["id"]
approved_erasure = client.post(
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/approve",
headers=headers,
json={"confirmation": f"empty-destroy-{name}"},
)
self.assertEqual(200, approved_erasure.status_code, approved_erasure.text)
self.assertEqual("ready", approved_erasure.json()["state"])
executed_erasure = client.post(
f"/api/v1/admin/tenants/{empty_tenant_id}/erasure-operations/{operation_id}/execute",
headers=headers,
json={"confirmation": f"empty-destroy-{name}"},
)
self.assertEqual(200, executed_erasure.status_code, executed_erasure.text)
self.assertEqual("completed", executed_erasure.json()["state"])
self.assertIsNone(executed_erasure.json()["reason"])
retired = client.request( retired = client.request(
"DELETE", "DELETE",
+30
View File
@@ -10,6 +10,8 @@ from govoplan_core.core.configuration_safety import (
ui_managed_configuration_fields_requiring_approval, ui_managed_configuration_fields_requiring_approval,
) )
from govoplan_core.core.policy import ( from govoplan_core.core.policy import (
FunctionAssignmentEscalationRule,
FunctionAssignmentGovernanceDecision,
PolicyDecision, PolicyDecision,
PolicySourceStep, PolicySourceStep,
parse_policy_source_path, parse_policy_source_path,
@@ -19,6 +21,34 @@ from govoplan_core.core.policy import (
class PolicyContractTests(unittest.TestCase): class PolicyContractTests(unittest.TestCase):
def test_function_assignment_policy_serializes_delegation_and_escalation(self) -> None:
decision = FunctionAssignmentGovernanceDecision(
allowed=True,
delegation_allowed=True,
maximum_delegation_depth=2,
maximum_delegated_validity_days=30,
escalation_rules=(
FunctionAssignmentEscalationRule(
step="authority",
target_function_id="function-escalation",
timeout_hours=48,
),
),
)
payload = decision.to_dict()
self.assertEqual(2, payload["maximum_delegation_depth"])
self.assertEqual(30, payload["maximum_delegated_validity_days"])
self.assertEqual(
"function-escalation",
payload["escalation_rules"][0]["target_function_id"],
)
self.assertEqual(
"function-escalation",
decision.escalation_rule("authority").target_function_id,
)
def test_policy_source_paths_are_stable_and_round_trip(self) -> None: def test_policy_source_paths_are_stable_and_round_trip(self) -> None:
self.assertEqual(policy_source_path("system"), "system") self.assertEqual(policy_source_path("system"), "system")
self.assertEqual(policy_source_path("tenant", "tenant-1"), "tenant:tenant-1") self.assertEqual(policy_source_path("tenant", "tenant-1"), "tenant:tenant-1")
+88
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from dataclasses import replace
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -10,7 +11,9 @@ from govoplan_core.core.modules import (
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
ModuleManifest, ModuleManifest,
ProductAvailabilityExplanation,
ProductAreaContribution, ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessTool, QuickAccessTool,
) )
from govoplan_core.core.registry import PlatformRegistry, RegistryError from govoplan_core.core.registry import PlatformRegistry, RegistryError
@@ -49,6 +52,26 @@ def presentation_manifest() -> ModuleManifest:
surface_ids=("example.route.main",), surface_ids=("example.route.main",),
), ),
), ),
product_surfaces=(
ProductSurfaceContribution(
id="work.examples",
module_id="example",
label="Examples",
description="Review and update governed examples.",
icon="list-checks",
entry_path="/work/examples",
route_path="/example",
surface_ids=("example.route.main",),
presentations=("task", "reader"),
unavailable=ProductAvailabilityExplanation(
reason="authorization",
title="Examples are unavailable",
description="Your current responsibility does not include examples.",
resolution="Ask the responsible administrator to review your assignment.",
responsible_role="Access administrator",
),
),
),
quick_access_tools=( quick_access_tools=(
QuickAccessTool( QuickAccessTool(
id="example.summary", id="example.summary",
@@ -121,6 +144,16 @@ class PresentationContractTests(unittest.TestCase):
frontend = response.json()["modules"][0]["frontend"] frontend = response.json()["modules"][0]["frontend"]
self.assertEqual("1", frontend["presentation_contract_version"]) self.assertEqual("1", frontend["presentation_contract_version"])
self.assertEqual("work", frontend["product_areas"][0]["id"]) self.assertEqual("work", frontend["product_areas"][0]["id"])
product_surface = frontend["product_surfaces"][0]
self.assertEqual("1", product_surface["contract_version"])
self.assertEqual("work.examples", product_surface["id"])
self.assertEqual("/work/examples", product_surface["entry_path"])
self.assertEqual("/example", product_surface["route_path"])
self.assertEqual(["task", "reader"], product_surface["presentations"])
self.assertEqual(
"authorization",
product_surface["unavailable"]["reason"],
)
self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"]) self.assertEqual("example.summary", frontend["quick_access_tools"][0]["id"])
self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"]) self.assertEqual("1", frontend["quick_access_tools"][0]["contract_version"])
self.assertEqual( self.assertEqual(
@@ -132,6 +165,61 @@ class PresentationContractTests(unittest.TestCase):
frontend["quick_access_tools"][0]["help_context_id"], frontend["quick_access_tools"][0]["help_context_id"],
) )
def test_registry_rejects_product_surface_without_owner_route(self) -> None:
manifest = presentation_manifest()
frontend = manifest.frontend
assert frontend is not None
surface = frontend.product_surfaces[0]
invalid = ModuleManifest(
id=manifest.id,
name=manifest.name,
version=manifest.version,
frontend=FrontendModule(
module_id=manifest.id,
routes=frontend.routes,
product_surfaces=(
ProductSurfaceContribution(
id=surface.id,
module_id=surface.module_id,
label=surface.label,
description=surface.description,
icon=surface.icon,
entry_path=surface.entry_path,
route_path="/missing",
surface_ids=surface.surface_ids,
unavailable=surface.unavailable,
),
),
),
)
registry = PlatformRegistry()
registry.register(invalid)
with self.assertRaisesRegex(RegistryError, "unknown owner route"):
registry.validate()
def test_registry_rejects_product_alias_that_shadows_a_route(self) -> None:
manifest = presentation_manifest()
frontend = manifest.frontend
assert frontend is not None
surface = frontend.product_surfaces[0]
invalid = replace(
manifest,
frontend=replace(
frontend,
routes=(
*frontend.routes,
FrontendRoute(path="/shortcut", component="ShortcutPage"),
),
product_surfaces=(replace(surface, aliases=("/shortcut",)),),
),
)
registry = PlatformRegistry()
registry.register(invalid)
with self.assertRaisesRegex(RegistryError, "collides with a concrete route"):
registry.validate()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from govoplan_core.core.tenant_erasure import (
TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX,
TenantErasurePreview,
TenantErasureResource,
TenantErasureStep,
TenantErasureStepResult,
collect_tenant_erasure_inventory,
tenant_erasure_providers,
)
class _Provider:
module_id = "files"
def preview_tenant_erasure(self, session, tenant_id: str) -> TenantErasurePreview:
del session
assert tenant_id == "tenant-1"
return TenantErasurePreview(
module_id=self.module_id,
complete=True,
resources=(
TenantErasureResource(
resource_type="file_blobs",
count=2,
disposition="erase",
summary="Two tenant-owned file blobs will be erased.",
),
),
steps=(
TenantErasureStep(
step_id="erase-blobs",
kind="erase",
summary="Erase tenant-owned file blobs.",
destructive=True,
irreversible=True,
),
),
)
def execute_tenant_erasure_step(
self, session, tenant_id: str, step_id: str, idempotency_key: str
) -> TenantErasureStepResult:
del session, tenant_id, step_id, idempotency_key
return TenantErasureStepResult(
state="completed",
summary="Tenant file blobs erased.",
metrics={"deleted": 2},
)
def reconcile_tenant_erasure_step(
self, session, tenant_id: str, step_id: str, idempotency_key: str
) -> TenantErasureStepResult:
return self.execute_tenant_erasure_step(
session, tenant_id, step_id, idempotency_key
)
class _Registry:
def __init__(self, *, provider: object | None = None, counts: dict[str, int] | None = None):
self._provider = provider
self._counts = counts
def manifests(self):
return (
SimpleNamespace(id="core"),
SimpleNamespace(id="files"),
SimpleNamespace(id="wiki"),
)
def capability_names(self):
if self._provider is None:
return ()
return (f"{TENANT_ERASURE_PROVIDER_CAPABILITY_PREFIX}files",)
def capability(self, name: str):
assert name.endswith("files")
return self._provider
def tenant_summary_providers(self):
if self._counts is None:
return {}
return {"files": lambda _session, _tenant_id: self._counts}
def test_contract_rejects_unsafe_irreversible_step() -> None:
with pytest.raises(ValueError, match="must be destructive"):
TenantErasureStep(
step_id="unsafe",
kind="erase",
summary="Invalid step.",
destructive=False,
irreversible=True,
)
def test_contract_rejects_cyclic_step_dependencies() -> None:
with pytest.raises(ValueError, match="contain a cycle"):
TenantErasurePreview(
module_id="files",
complete=True,
steps=(
TenantErasureStep(
step_id="first",
kind="erase",
summary="First.",
destructive=True,
irreversible=True,
depends_on=("second",),
),
TenantErasureStep(
step_id="second",
kind="verify",
summary="Second.",
destructive=False,
irreversible=False,
depends_on=("first",),
),
),
)
def test_contract_requires_action_or_blocker_for_tenant_data() -> None:
resource = TenantErasureResource(
resource_type="files",
count=1,
disposition="erase",
summary="One file exists.",
)
with pytest.raises(ValueError, match="steps or an explicit blocker"):
TenantErasurePreview(
module_id="files",
complete=True,
resources=(resource,),
)
def test_inventory_collects_provider_and_marks_non_data_modules() -> None:
inventory = collect_tenant_erasure_inventory(
_Registry(provider=_Provider()),
object(),
"tenant-1",
observed_at=datetime(2026, 8, 24, 12, 0, tzinfo=UTC),
)
assert inventory.complete
assert inventory.allowed
assert [item.module_id for item in inventory.modules] == ["core", "files", "wiki"]
assert inventory.modules[1].steps[0].irreversible
assert inventory.to_dict()["generated_at"] == "2026-08-24T12:00:00+00:00"
def test_summary_fallback_blocks_when_data_exists() -> None:
inventory = collect_tenant_erasure_inventory(
_Registry(counts={"file_blobs": 3}),
object(),
"tenant-1",
)
files = next(item for item in inventory.modules if item.module_id == "files")
assert inventory.complete
assert not inventory.allowed
assert files.resources[0].disposition == "unavailable"
assert files.blockers == (
"Tenant-owned data exists but the module has no erasure provider.",
)
def test_provider_identity_must_match_capability_suffix() -> None:
provider = _Provider()
provider.module_id = "mail"
with pytest.raises(ValueError, match="identity"):
tenant_erasure_providers(_Registry(provider=provider))
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import UTC, datetime
import unittest
from govoplan_core.core.tickets import (
CAPABILITY_TICKET_CASE_ESCALATION,
CAPABILITY_TICKET_ROUTING,
TicketCaseEscalationCommand,
TicketCaseEscalationResult,
TicketRoutingPlan,
TicketRoutingRequest,
ticket_case_escalation_provider,
ticket_routing_provider,
)
class _Provider:
def route_ticket(self, session, principal, *, request):
del session, principal, request
return TicketRoutingPlan(provider_id="helpdesk", queue_ref="citizen-service")
def escalate_ticket(self, session, principal, *, command):
del session, principal, command
return TicketCaseEscalationResult(
provider_id="cases",
case_id="case-1",
case_number="CASE-1",
case_url="/cases/case-1",
)
class _Registry:
def __init__(self, capabilities):
self.capabilities = capabilities
def has_capability(self, name):
return name in self.capabilities
def capability(self, name):
return self.capabilities[name]
class TicketContractTests(unittest.TestCase):
def test_optional_providers_fail_open_when_absent(self) -> None:
registry = _Registry({})
self.assertIsNone(ticket_routing_provider(registry))
self.assertIsNone(ticket_case_escalation_provider(registry))
def test_optional_providers_resolve_structurally(self) -> None:
provider = _Provider()
registry = _Registry(
{
CAPABILITY_TICKET_ROUTING: provider,
CAPABILITY_TICKET_CASE_ESCALATION: provider,
}
)
self.assertIs(provider, ticket_routing_provider(registry))
self.assertIs(provider, ticket_case_escalation_provider(registry))
def test_commands_validate_tenant_time_and_relative_case_link(self) -> None:
instant = datetime(2026, 8, 22, 9, 0, tzinfo=UTC)
request = TicketRoutingRequest(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_type="request",
priority="normal",
title="Broken streetlight",
received_at=instant,
)
self.assertEqual("ticket-1", request.ticket_id)
command = TicketCaseEscalationCommand(
tenant_id="tenant-1",
ticket_id="ticket-1",
ticket_number="TKT-1",
title="Broken streetlight",
case_type_key="service-request",
occurred_at=instant,
idempotency_key="escalation-1",
)
self.assertEqual("service-request", command.case_type_key)
with self.assertRaises(ValueError):
TicketCaseEscalationResult(
provider_id="cases",
case_id="case-1",
case_number="CASE-1",
case_url="https://other.example/cases/1",
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"initialJs": { "initialJs": {
"rawBytes": 524288, "rawBytes": 524288,
"gzipBytes": 163840 "gzipBytes": 164128
}, },
"asyncChunk": { "asyncChunk": {
"rawBytes": 393216, "rawBytes": 393216,
+189 -2
View File
@@ -1,6 +1,9 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FileText, GitBranch, Inbox, Search, ShieldCheck } from "lucide-react"; import { CalendarDays, FileText, Folder, GitBranch, Inbox, ListChecks, Mail, Search, ShieldCheck } from "lucide-react";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import FormInstancePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormInstancePage";
import FormsRuntimePage from "../../../govoplan-forms-runtime/webui/src/features/forms/FormsRuntimePage";
import PublicFormPage from "../../../govoplan-forms-runtime/webui/src/features/forms/PublicFormPage";
import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail"; import QuickAccessRail from "../../../govoplan-quick-access/webui/src/components/QuickAccessRail";
import ActionToolbar from "../src/components/ActionToolbar"; import ActionToolbar from "../src/components/ActionToolbar";
import Button from "../src/components/Button"; import Button from "../src/components/Button";
@@ -27,12 +30,23 @@ import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
import BreadcrumbBar from "../src/layout/BreadcrumbBar"; import BreadcrumbBar from "../src/layout/BreadcrumbBar";
import HelpMenu from "../src/layout/HelpMenu"; import HelpMenu from "../src/layout/HelpMenu";
import IconRail from "../src/layout/IconRail";
import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../src/components/UnsavedChangesGuard";
import { import {
createQuickAccessLaunchContext, createQuickAccessLaunchContext,
quickAccessLaunchState quickAccessLaunchState
} from "../src/platform/launchContext"; } from "../src/platform/launchContext";
import type { ApiSettings, AuthInfo, EffectiveViewProjection, QuickAccessToolMetadata } from "../src/types"; import { projectProductNavigation } from "../src/platform/productSurfaces";
import type {
ApiSettings,
AuthInfo,
EffectiveViewProjection,
PlatformNavItem,
PlatformWebModule,
ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessToolMetadata
} from "../src/types";
export default function ConformanceApp() { export default function ConformanceApp() {
const location = useLocation(); const location = useLocation();
@@ -40,6 +54,20 @@ export default function ConformanceApp() {
const [editorDirty, setEditorDirty] = useState(true); const [editorDirty, setEditorDirty] = useState(true);
const [metricDrilldown, setMetricDrilldown] = useState(""); const [metricDrilldown, setMetricDrilldown] = useState("");
if (new URLSearchParams(location.search).has("product-navigation")) {
return <ProductNavigationScenario />;
}
if (location.pathname.startsWith("/forms/public/")) {
return <PublicFormPage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
}
if (location.pathname === "/forms-runtime") {
return <FormsRuntimePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
}
if (location.pathname.startsWith("/forms-runtime/")) {
return <FormInstancePage settings={CONFORMANCE_SETTINGS} auth={FORMS_RUNTIME_AUTH} />;
}
return ( return (
<main className="conformance-root" data-conformance-id="shared-ui-lab"> <main className="conformance-root" data-conformance-id="shared-ui-lab">
<PageLayout <PageLayout
@@ -172,6 +200,39 @@ export default function ConformanceApp() {
); );
} }
function ProductNavigationScenario() {
const projection = useMemo(
() => projectProductNavigation(
PRODUCT_NAV_ITEMS,
PRODUCT_NAV_MODULES,
PRODUCT_NAV_AUTH
),
[]
);
return (
<div className="app-shell" data-conformance-id="product-navigation">
<IconRail
navItems={projection.primaryItems}
allToolItems={projection.allToolItems}
productAreas={PRODUCT_NAV_AREAS}
/>
<main className="main-area">
<PageLayout
archetype="overview"
mode="embedded"
title="Anwohnerparkausweis bearbeiten"
description="Die Navigation beschreibt Arbeit und Ergebnisse; technische Eigentümer bleiben nachvollziehbar erreichbar."
>
<StatePanel
title="Vorgang ist bereit"
description="Nutzen Sie Arbeit, Kalender, Nachrichten oder Dateien für den nächsten Schritt."
/>
</PageLayout>
</main>
</div>
);
}
function HelpConformanceScenario() { function HelpConformanceScenario() {
return ( return (
<section className="conformance-section" aria-labelledby="help-heading"> <section className="conformance-section" aria-labelledby="help-heading">
@@ -290,6 +351,132 @@ const CONFORMANCE_AUTH = {
groups_loaded: true groups_loaded: true
} satisfies AuthInfo; } satisfies AuthInfo;
const PRODUCT_NAV_AUTH = {
...CONFORMANCE_AUTH,
scopes: [
"tasks:item:read",
"calendar:event:read",
"mail:mailbox:read",
"postbox:message:read",
"files:file:read"
]
} satisfies AuthInfo;
const PRODUCT_NAV_ITEMS: PlatformNavItem[] = [
{ to: "/tasks", label: "Tasks", icon: ListChecks, surfaceId: "tasks.nav.tasks", anyOf: ["tasks:item:read"], order: 30 },
{ to: "/files", label: "Files", icon: Folder, surfaceId: "files.nav.files", anyOf: ["files:file:read"], order: 40 },
{ to: "/mail", label: "Mail", icon: Mail, surfaceId: "mail.nav.mail", anyOf: ["mail:mailbox:read"], order: 50 },
{ to: "/postbox", label: "Postbox", icon: Inbox, surfaceId: "postbox.nav.postbox", anyOf: ["postbox:message:read"], order: 51 },
{ to: "/calendar", label: "Calendar", icon: CalendarDays, surfaceId: "calendar.nav.calendar", anyOf: ["calendar:event:read"], order: 55 }
];
const PRODUCT_NAV_AREAS: ProductAreaContribution[] = [
{ id: "work", moduleId: "tasks", label: "i18n:govoplan-core.product_area.work", iconName: "list-checks", surfaceIds: ["tasks.nav.tasks"], order: 10 },
{ id: "records-documents", moduleId: "files", label: "i18n:govoplan-core.product_area.records_documents", iconName: "folder", surfaceIds: ["files.nav.files"], order: 30 },
{ id: "communication", moduleId: "mail", label: "i18n:govoplan-core.product_area.communication", iconName: "mail", surfaceIds: ["mail.nav.mail", "postbox.nav.postbox"], order: 40 },
{ id: "meetings-decisions", moduleId: "calendar", label: "i18n:govoplan-core.product_area.meetings_decisions", iconName: "calendar", surfaceIds: ["calendar.nav.calendar"], order: 50 }
];
const PRODUCT_NAV_MODULES: PlatformWebModule[] = [
productModule("tasks", productSurface({
id: "work.items",
moduleId: "tasks",
label: "i18n:govoplan-core.product_surface.work",
description: "i18n:govoplan-core.product_surface.work_description",
iconName: "list-checks",
entryPath: "/work",
routePath: "/tasks",
surfaceIds: ["tasks.nav.tasks"],
anyOf: ["tasks:item:read"]
})),
productModule("files", productSurface({
id: "records.files",
moduleId: "files",
label: "i18n:govoplan-core.product_surface.files",
description: "i18n:govoplan-core.product_surface.files_description",
iconName: "folder",
entryPath: "/documents",
routePath: "/files",
surfaceIds: ["files.nav.files"],
anyOf: ["files:file:read"]
})),
productModule("mail", productSurface({
id: "communication.messages",
moduleId: "mail",
label: "i18n:govoplan-core.product_surface.messages",
description: "i18n:govoplan-core.product_surface.messages_description",
iconName: "mail",
entryPath: "/messages",
routePath: "/mail",
surfaceIds: ["mail.nav.mail"],
anyOf: ["mail:mailbox:read"],
aliases: ["/inbox"]
})),
productModule("postbox", productSurface({
id: "communication.messages",
moduleId: "postbox",
label: "i18n:govoplan-core.product_surface.messages",
description: "i18n:govoplan-core.product_surface.messages_description",
iconName: "mail",
entryPath: "/messages",
routePath: "/postbox",
surfaceIds: ["postbox.nav.postbox"],
anyOf: ["postbox:message:read"],
aliases: ["/inbox"],
order: 20
})),
productModule("calendar", productSurface({
id: "meetings.calendar",
moduleId: "calendar",
label: "i18n:govoplan-core.product_surface.calendar",
description: "i18n:govoplan-core.product_surface.calendar_description",
iconName: "calendar",
entryPath: "/agenda",
routePath: "/calendar",
surfaceIds: ["calendar.nav.calendar"],
anyOf: ["calendar:event:read"]
}))
];
function productModule(id: string, surface: ProductSurfaceContribution): PlatformWebModule {
return { id, label: id, version: "test", productSurfaces: [surface] };
}
function productSurface(
partial: Pick<ProductSurfaceContribution,
"id" | "moduleId" | "label" | "description" | "iconName" | "entryPath" |
"routePath" | "surfaceIds" | "anyOf"> & Partial<ProductSurfaceContribution>
): ProductSurfaceContribution {
return {
contractVersion: "1",
presentations: ["task", "reader"],
capabilityIds: [],
searchSourceIds: [],
helpContextIds: [],
documentationTopicIds: [],
allOf: [],
aliases: [],
order: 10,
unavailable: {
reason: "authorization",
title: "Not available",
description: "The destination is not available for this responsibility.",
resolution: "Ask the access administrator to review the assignment."
},
...partial
};
}
const FORMS_RUNTIME_AUTH = {
...CONFORMANCE_AUTH,
scopes: [
"forms_runtime:submission:assist",
"forms_runtime:submission:participate",
"forms_runtime:workspace:read",
"forms_runtime:workspace:write"
]
} satisfies AuthInfo;
const CONFORMANCE_SETTINGS: ApiSettings = { const CONFORMANCE_SETTINGS: ApiSettings = {
apiBaseUrl: "", apiBaseUrl: "",
apiKey: "", apiKey: "",
+39 -7
View File
@@ -1,20 +1,52 @@
// Narrow facade used only by the conformance build. It lets the optional // Narrow facade used only by the conformance build. It lets optional modules
// Quick Access module exercise its real rail without pulling the composed // exercise their real task surfaces without pulling the composed application's
// application's generated module catalogue into this isolated test bundle. // generated module catalogue into this isolated test bundle.
export { apiFetch } from "../src/api/client"; export { apiFetch, apiPath } from "../src/api/client";
export { default as ActionBlockerHint } from "../src/components/ActionBlockerHint";
export { default as ActionToolbar } from "../src/components/ActionToolbar";
export { default as Button } from "../src/components/Button";
export { default as ConfirmDialog } from "../src/components/ConfirmDialog";
export { default as DescriptionList, DescriptionItem } from "../src/components/DescriptionList";
export { default as Dialog } from "../src/components/Dialog";
export { DialogForm, DialogSection } from "../src/components/DialogAnatomy";
export { default as DismissibleAlert } from "../src/components/DismissibleAlert"; export { default as DismissibleAlert } from "../src/components/DismissibleAlert";
export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink"; export { default as DocumentationHelpLink } from "../src/components/help/DocumentationHelpLink";
export type { DocumentationHelpReference } from "../src/components/help/documentationHelp";
export { default as FileDropZone } from "../src/components/FileDropZone";
export { default as FormField } from "../src/components/FormField";
export { FormGrid } from "../src/components/ContentGrid";
export { default as IconButton } from "../src/components/IconButton"; export { default as IconButton } from "../src/components/IconButton";
export { default as LoadingFrame } from "../src/components/LoadingFrame"; export { default as LoadingFrame } from "../src/components/LoadingFrame";
export { useGuardedNavigate } from "../src/components/UnsavedChangesGuard"; export { default as LoadingIndicator } from "../src/components/LoadingIndicator";
export { usePlatformLanguage } from "../src/i18n/LanguageContext"; export { default as PageScrollViewport } from "../src/components/PageScrollViewport";
export {
default as SelectionList,
SelectionListItem,
SelectionListItemContent
} from "../src/components/SelectionList";
export { default as StatePanel } from "../src/components/StatePanel";
export { default as StatusBadge } from "../src/components/StatusBadge";
export { default as ToggleSwitch } from "../src/components/ToggleSwitch";
export {
useGuardedNavigate,
useUnsavedDraftGuard
} from "../src/components/UnsavedChangesGuard";
export {
i18nMessage,
usePlatformLanguage
} from "../src/i18n/LanguageContext";
export { usePlatformModuleInstalled } from "../src/platform/ModuleContext";
export { export {
dispatchQuickAccessResult, dispatchQuickAccessResult,
quickAccessLaunchState quickAccessLaunchState
} from "../src/platform/launchContext"; } from "../src/platform/launchContext";
export { i18nMessage } from "../src/i18n/LanguageContext"; export { hasScope } from "../src/utils/permissions";
export { default as WorkspaceActionBar } from "../src/components/WorkspaceActionBar";
export { default as WorkspaceFrame } from "../src/components/WorkspaceFrame";
export type { export type {
ApiSettings, ApiSettings,
PlatformRouteContext,
PlatformTranslations,
QuickAccessRailProps, QuickAccessRailProps,
QuickAccessToolsUiCapability QuickAccessToolsUiCapability
} from "../src/types"; } from "../src/types";
+12 -3
View File
@@ -1,7 +1,9 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router"; import { BrowserRouter, Route, Routes } from "react-router";
import ConformanceApp from "./ConformanceApp"; import ConformanceApp from "./ConformanceApp";
import { generatedTranslations as formsRuntimeTranslations } from "../../../govoplan-forms-runtime/webui/src/i18n/generatedTranslations";
import { productSurfaceTranslations } from "../src/index";
import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard"; import { UnsavedChangesProvider } from "../src/components/UnsavedChangesGuard";
import { PlatformLanguageProvider } from "../src/i18n/LanguageContext"; import { PlatformLanguageProvider } from "../src/i18n/LanguageContext";
import { PlatformModulesProvider } from "../src/platform/ModuleContext"; import { PlatformModulesProvider } from "../src/platform/ModuleContext";
@@ -14,6 +16,7 @@ import "../src/styles/badges.css";
import "../src/styles/components.css"; import "../src/styles/components.css";
import "../src/styles/dialogs.css"; import "../src/styles/dialogs.css";
import "@govoplan/quick-access-webui/styles/quick-access.css"; import "@govoplan/quick-access-webui/styles/quick-access.css";
import "../../../govoplan-forms-runtime/webui/src/styles/forms-runtime.css";
import "./conformance.css"; import "./conformance.css";
const theme = new URLSearchParams(window.location.search).get("theme"); const theme = new URLSearchParams(window.location.search).get("theme");
@@ -35,9 +38,15 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<PlatformModulesProvider modules={CONFORMANCE_MODULES}> <PlatformModulesProvider modules={CONFORMANCE_MODULES}>
<PlatformLanguageProvider preferredLanguageCode="de"> <PlatformLanguageProvider
preferredLanguageCode="de"
moduleTranslations={[formsRuntimeTranslations, productSurfaceTranslations]}>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<ConformanceApp /> <Routes>
<Route path="/forms/public/:publicId" element={<ConformanceApp />} />
<Route path="/forms-runtime/:instanceId" element={<ConformanceApp />} />
<Route path="*" element={<ConformanceApp />} />
</Routes>
</UnsavedChangesProvider> </UnsavedChangesProvider>
</PlatformLanguageProvider> </PlatformLanguageProvider>
</PlatformModulesProvider> </PlatformModulesProvider>
@@ -19,6 +19,125 @@ async function expectNoAccessibilityViolations(page: import("@playwright/test").
expect(violations).toEqual([]); expect(violations).toEqual([]);
} }
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 }
]) {
test(`resident permit self-service is keyboard and accessibility conformant on ${viewport.name}`, async ({ page }) => {
const journey = await mockPublicResidentPermitJourney(page);
await page.setViewportSize(viewport);
await page.goto("/forms/public/resident-parking-permit?theme=light");
await expect(page.getByRole("heading", { level: 1, name: "Anwohnerparkausweis beantragen" })).toBeVisible();
await expectNoAccessibilityViolations(page);
const name = page.getByLabel("Name der antragstellenden Person");
await name.focus();
await page.keyboard.type("Ada Lovelace");
await page.keyboard.press("Tab");
await expect(page.getByLabel("E-Mail-Adresse")).toBeFocused();
await page.keyboard.type("ada.lovelace@example.test");
await page.keyboard.press("Tab");
await expect(page.getByLabel("Hauptwohnsitz")).toBeFocused();
await page.keyboard.type("Musterstraße 17, 10115 Berlin");
await page.keyboard.press("Tab");
await expect(page.getByLabel("Kfz-Kennzeichen")).toBeFocused();
await page.keyboard.type("B-AL 1843");
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Entwurf speichern" })).toBeFocused();
await page.keyboard.press("Enter");
await expect.poll(() => journey.savedValues()).toEqual({
applicant_name: "Ada Lovelace",
applicant_email: "ada.lovelace@example.test",
residence_address: "Musterstraße 17, 10115 Berlin",
licence_plate: "B-AL 1843"
});
await page.getByRole("button", { name: "Absenden" }).click();
const confirm = page.getByRole("alertdialog", { name: "Formular absenden" });
await expect(confirm).toBeVisible();
await expectNoAccessibilityViolations(page);
await confirm.getByRole("button", { name: "Absenden" }).click();
await expect(page.getByText("Übermittlung eingegangen")).toBeVisible();
await expect(page.getByText("receipt-rpp-2026-0001")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test(`resident permit assisted intake preserves per-field provenance on ${viewport.name}`, async ({ page }) => {
const journey = await mockAssistedResidentPermitJourney(page);
await page.setViewportSize(viewport);
await page.goto("/forms-runtime?theme=light");
await page.getByRole("button", { name: "Assistierte Erfassung" }).click();
const startDialog = page.getByRole("dialog", { name: "Assistierte Erfassung starten" });
await expect(startDialog).toBeVisible();
await expectNoAccessibilityViolations(page);
await startDialog.getByLabel("Referenz der betroffenen Partei").fill("party:resident-ada-lovelace");
await startDialog.getByLabel("Referenz der zuständigen Funktion").fill("function:parking-permits");
await startDialog.getByLabel("Zweck").fill("Anwohnerparkausweis beantragen");
await startDialog.getByLabel("Referenz der Rechtsgrundlage").fill("law:resident-parking-permit");
await startDialog.getByLabel("Barrierefreiheits- oder Kommunikationsunterstützung").fill("Leichte Sprache");
const notice = startDialog.getByRole("checkbox", { name: "Datenschutz- und Verfahrenshinweis wurde erteilt" });
await notice.focus();
await page.keyboard.press("Space");
await expect(notice).toBeChecked();
await startDialog.getByLabel("Referenz der betroffenen Partei").focus();
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toHaveAttribute("aria-label", "Feldhilfe anzeigen");
await page.keyboard.press("Tab");
await expect(startDialog.getByLabel("Referenz der vertretenen Partei")).toBeFocused();
await startDialog.getByRole("button", { name: "Sitzung starten" }).click();
await expect(page).toHaveURL(/\/forms-runtime\/assisted-rpp-1$/);
await expect(page.getByRole("heading", { level: 1, name: "Anwohnerparkausweis beantragen" })).toBeVisible();
await page.getByLabel("Name der antragstellenden Person").fill("Ada Lovelace");
await page.getByLabel("E-Mail-Adresse").fill("ada.lovelace@example.test");
await page.getByLabel("Hauptwohnsitz").fill("Musterstraße 17, 10115 Berlin");
await page.getByLabel("Kfz-Kennzeichen").fill("B-AL 1843");
await page.getByLabel("Änderungsgrund").fill("Angaben gemeinsam mit der antragstellenden Person erfasst.");
await page.getByRole("button", { name: "Entwurf speichern" }).click();
await page.getByRole("button", { name: "Rücklesen und absenden" }).click();
const readback = page.getByRole("dialog", { name: "Assistiertes Rücklesen erfassen" });
await expect(readback).toBeVisible();
await expect(readback.getByRole("group", { name: "Hauptwohnsitz" })).toBeVisible();
await expectNoAccessibilityViolations(page);
const addressSource = readback.getByRole("group", { name: "Hauptwohnsitz" });
await addressSource.getByLabel("Wertquelle").selectOption("document");
await addressSource.getByLabel("Quellenvertrauen").selectOption("verified");
await addressSource.getByLabel("Erklärende Partei oder Quellenreferenz").fill("files:residence-proof-2026");
const plateSource = readback.getByRole("group", { name: "Kfz-Kennzeichen" });
await plateSource.getByLabel("Wertquelle").focus();
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("Enter");
await plateSource.getByLabel("Quellenvertrauen").selectOption("verified");
await plateSource.getByLabel("Erklärende Partei oder Quellenreferenz").fill("register:vehicle-B-AL-1843");
await readback.getByRole("button", { name: "Erfassen und fortfahren" }).click();
await expect.poll(() => journey.confirmationSources()).toMatchObject({
applicant_name: { source: "person_statement", confidence: "stated" },
residence_address: {
source: "document",
confidence: "verified",
declared_by_ref: "files:residence-proof-2026"
},
licence_plate: {
source: "system",
confidence: "verified",
declared_by_ref: "register:vehicle-B-AL-1843"
}
});
const submit = page.getByRole("alertdialog", { name: "Formular absenden" });
await submit.getByRole("button", { name: "Absenden" }).click();
await expect(page.getByText("receipt-assisted-rpp-2026-0001")).toBeVisible();
await expect(page.getByText("Rücklesen erfasst")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
}
test("shared components remain accessible and keyboard operable", async ({ page }) => { test("shared components remain accessible and keyboard operable", async ({ page }) => {
await page.goto("/?theme=light"); await page.goto("/?theme=light");
await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: "Zentrale GovOPlaN-Oberflächen" })).toBeVisible();
@@ -176,6 +295,33 @@ test("View focus has a deliberate permission-derived all-tools escape", async ({
await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0); await expect(page.getByRole("button", { name: "Messages" })).toHaveCount(0);
}); });
test("product navigation hides package topology behind stable bilingual destinations", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto("/?theme=light&product-navigation=1");
await page.getByRole("button", { name: "Expand navigation" }).click();
const primary = page.locator(".icon-nav > .icon-nav-group");
await expect(primary.getByRole("link")).toHaveText([
"Arbeit",
"Dateien",
"Nachrichten",
"Kalender"
]);
await expect(primary.getByRole("link", { name: /Tasks|Files|Mail|Postbox|Calendar/ })).toHaveCount(0);
const allTools = page.locator("[data-product-navigation='all-tools']");
await expect(allTools.getByText("Alle verfügbaren Werkzeuge", { exact: true })).toBeVisible();
await allTools.locator("summary").click();
await expect(allTools.getByRole("link")).toHaveText([
"Tasks",
"Files",
"Mail",
"Postbox",
"Calendar"
]);
await expectNoAccessibilityViolations(page);
});
test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => { test("a stale View focus falls back safely in a sparse optional-module catalogue", async ({ page }) => {
await page.route("**/api/v1/quick-access/effective*", async (route) => { await page.route("**/api/v1/quick-access/effective*", async (route) => {
await route.fulfill({ await route.fulfill({
@@ -229,6 +375,301 @@ test("narrow layout preserves task order without horizontal overflow", async ({
await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 }); await expect(page.locator("[data-conformance-id='shared-ui-lab']")).toHaveScreenshot("shared-ui-light-narrow.png", { animations: "disabled", maxDiffPixelRatio: 0.005 });
}); });
async function expectNoHorizontalOverflow(page: import("@playwright/test").Page) {
const overflowing = await page.evaluate(() => Array.from(document.querySelectorAll<HTMLElement>("body *"))
.filter((element) => {
const style = window.getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden";
})
.map((element) => {
const rect = element.getBoundingClientRect();
return {
element: `${element.tagName.toLowerCase()}.${Array.from(element.classList).join(".")}`,
left: Math.round(rect.left),
right: Math.round(rect.right)
};
})
.filter(({ left, right }) => left < -1 || right > window.innerWidth + 1)
.slice(0, 20));
expect(overflowing).toEqual([]);
}
async function mockPublicResidentPermitJourney(page: import("@playwright/test").Page) {
let current = residentPermitInstance("public-rpp-1", "started", 1, {});
let savedValues: Record<string, unknown> = {};
await page.route("**/api/v1/forms-runtime/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
if (path.endsWith("/public/profiles/resident-parking-permit/start") && method === "POST") {
return fulfillJson(route, {
session_id: "session-public-rpp-1",
mode: "anonymous",
status: "active",
expires_at: "2026-08-25T10:00:00Z",
instance: current,
token: "public-rpp-token",
replayed: false
});
}
if (path.endsWith("/public/intake") && method === "GET") {
return fulfillJson(route, { instance: current, definition: residentPermitDefinition() });
}
if (path.endsWith("/public/intake") && method === "PATCH") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
savedValues = payload.values;
current = residentPermitInstance("public-rpp-1", "draft", 2, payload.values);
return fulfillJson(route, current);
}
if (path.endsWith("/public/intake/submit") && method === "POST") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
current = {
...residentPermitInstance("public-rpp-1", "submitted", 3, payload.values),
receipt_id: "receipt-rpp-2026-0001"
};
return fulfillJson(route, current);
}
return route.abort("failed");
});
return { savedValues: () => savedValues };
}
async function mockAssistedResidentPermitJourney(page: import("@playwright/test").Page) {
let current = residentPermitInstance("assisted-rpp-1", "started", 1, {}, true);
let confirmationSources: Record<string, unknown> = {};
let confirmations: unknown[] = [];
await page.route("**/api/v1/forms-runtime/**", async (route) => {
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
if (path.endsWith("/instances") && method === "GET") {
return fulfillJson(route, { instances: [], total: 0, offset: 0, limit: 200 });
}
if (path.endsWith("/assisted-intake/profiles") && method === "GET") {
return fulfillJson(route, { profiles: [{
profile_id: "assisted-profile-rpp",
public_id: "resident-parking-permit",
definition_ref: residentPermitDefinition().reference,
mode: "assisted",
enabled: true,
revision: 1,
draft_ttl_seconds: 2_592_000,
invitation_ttl_seconds: 1_209_600,
rate_limit_per_minute: 60,
metadata: { definition_title: "Anwohnerparkausweis beantragen" }
}] });
}
if (path.endsWith("/assisted-intake/start") && method === "POST") {
return fulfillJson(route, {
session_id: "assisted-session-rpp-1",
mode: "assisted",
status: "active",
expires_at: "2026-08-25T10:00:00Z",
instance: current,
token: null,
replayed: false
});
}
if (path.endsWith("/instances/assisted-rpp-1/definition") && method === "GET") {
return fulfillJson(route, residentPermitDefinition());
}
if (path.endsWith("/instances/assisted-rpp-1/history") && method === "GET") {
return fulfillJson(route, { revisions: [current] });
}
if (path.endsWith("/instances/assisted-rpp-1/events") && method === "GET") {
return fulfillJson(route, { events: [{
event_id: `event-${current.revision}`,
event_type: current.status === "submitted" ? "submitted" : "draft_saved",
instance_revision: current.revision,
status: current.status,
occurred_at: current.recorded_at,
actor_id: "operator-1",
payload: {}
}] });
}
if (path.endsWith("/instances/assisted-rpp-1/handoffs") && method === "GET") {
return fulfillJson(route, { handoffs: [] });
}
if (path.endsWith("/instances/assisted-rpp-1/assisted-confirmations") && method === "GET") {
return fulfillJson(route, { confirmations });
}
if (path.endsWith("/instances/assisted-rpp-1/assisted-confirmations") && method === "POST") {
const payload = request.postDataJSON() as { field_sources: Record<string, unknown> };
confirmationSources = payload.field_sources;
const confirmation = {
confirmation_id: "confirmation-rpp-1",
instance_id: "assisted-rpp-1",
instance_revision: current.revision,
outcome: "confirmed",
method: "spoken_readback",
confirmed_by_ref: "party:resident-ada-lovelace",
operator_actor_id: "operator-1",
confirmed_at: "2026-08-24T10:10:00Z",
payload_sha256: "a".repeat(64),
correction_note: null,
metadata: { field_sources: confirmationSources }
};
confirmations = [confirmation];
return fulfillJson(route, confirmation);
}
if (path.endsWith("/instances/assisted-rpp-1/submit") && method === "POST") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
current = {
...residentPermitInstance("assisted-rpp-1", "submitted", current.revision + 1, payload.values, true),
receipt_id: "receipt-assisted-rpp-2026-0001"
};
return fulfillJson(route, current);
}
if (path.endsWith("/instances/assisted-rpp-1") && method === "PATCH") {
const payload = request.postDataJSON() as { values: Record<string, unknown> };
current = residentPermitInstance("assisted-rpp-1", "draft", current.revision + 1, payload.values, true);
return fulfillJson(route, current);
}
if (path.endsWith("/instances/assisted-rpp-1") && method === "GET") {
return fulfillJson(route, current);
}
return route.abort("failed");
});
return { confirmationSources: () => confirmationSources };
}
function residentPermitDefinition() {
return {
reference: {
kind: "form",
owner_module: "forms",
object_id: "resident-parking-permit-application",
tenant_id: "tenant-1",
version: "3",
label: "Anwohnerparkausweis beantragen"
},
key: "resident_parking_permit.apply",
temporal: { revision: "3", recorded_at: "2026-08-24T10:00:00Z" },
title: "Resident parking permit application",
description: "Apply digitally or together with an authorized service worker.",
fields: [
residentPermitField("applicant_name", "Applicant name", "text", { min_length: 2, max_length: 200 }),
residentPermitField("applicant_email", "Applicant email", "email", { format: "email" }),
residentPermitField("residence_address", "Primary residence", "text", { max_length: 500 }),
residentPermitField("licence_plate", "Licence plate", "text", { max_length: 20 })
],
publication_state: "published",
allow_drafts: true,
max_attachments: 0,
signature_requirement: "none",
policy_refs: ["law:resident-parking-permit"],
handoff_kinds: [],
fallback_locale: "de",
localizations: [{
locale: "de",
title: "Anwohnerparkausweis beantragen",
description: "Beantragen Sie den Anwohnerparkausweis digital oder gemeinsam mit einer berechtigten Servicestelle.",
field_labels: {
applicant_name: "Name der antragstellenden Person",
applicant_email: "E-Mail-Adresse",
residence_address: "Hauptwohnsitz",
licence_plate: "Kfz-Kennzeichen"
},
field_help_texts: {},
option_labels: {},
page_titles: {},
section_titles: {}
}]
};
}
function residentPermitField(
key: string,
label: string,
valueType: "text" | "email",
constraints: Record<string, unknown>
) {
return {
key,
label,
value_type: valueType,
required: true,
help_text: null,
options: [],
constraints,
default_value: null,
visibility_condition: null
};
}
function residentPermitInstance(
instanceId: string,
status: string,
revision: number,
values: Record<string, unknown>,
assisted = false
) {
return {
reference: {
kind: "form_instance",
owner_module: "forms_runtime",
object_id: instanceId,
tenant_id: "tenant-1",
version: String(revision),
label: "Anwohnerparkausweis beantragen"
},
tenant_id: "tenant-1",
instance_id: instanceId,
revision,
status,
definition_ref: residentPermitDefinition().reference,
values,
validation_results: [],
attachment_refs: [],
signature_refs: [],
handoff_refs: [],
service_ref: null,
receipt_id: null as string | null,
recorded_at: "2026-08-24T10:00:00Z",
change_reason: revision === 1 ? "Assisted session started." : "Draft saved.",
created_by: "operator-1",
changed_by: "operator-1",
metadata: assisted ? {
intake: {
session_id: "assisted-session-rpp-1",
profile_id: "assisted-profile-rpp",
mode: "assisted",
channel: "counter",
affected_party_ref: "party:resident-ada-lovelace",
represented_party_ref: null,
authority_basis: "self",
purpose: "Anwohnerparkausweis beantragen",
legal_basis_ref: "law:resident-parking-permit",
consent_basis: "in-person-confirmation",
notice_given: true,
responsible_function_ref: "function:parking-permits",
language: "de",
accessibility_needs: ["Leichte Sprache"],
field_sources: {},
operator: { actor_id: "operator-1", auth_method: "session" }
}
} : {},
status_access: null,
replayed: false
};
}
async function fulfillJson(
route: import("@playwright/test").Route,
body: unknown
) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body)
});
}
function quickAccessPayload(includeMessages: boolean) { function quickAccessPayload(includeMessages: boolean) {
const files = { const files = {
id: "files", id: "files",
+90 -26
View File
@@ -1,12 +1,12 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.27", "version": "0.1.44",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.27", "version": "0.1.44",
"dependencies": { "dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
@@ -27,6 +27,7 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui", "@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
"@govoplan/forms-webui": "file:../../govoplan-forms/webui", "@govoplan/forms-webui": "file:../../govoplan-forms/webui",
"@govoplan/helpdesk-webui": "file:../../govoplan-helpdesk/webui",
"@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui", "@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui",
"@govoplan/identity-webui": "file:../../govoplan-identity/webui", "@govoplan/identity-webui": "file:../../govoplan-identity/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
@@ -48,8 +49,10 @@
"@govoplan/tasks-webui": "file:../../govoplan-tasks/webui", "@govoplan/tasks-webui": "file:../../govoplan-tasks/webui",
"@govoplan/templates-webui": "file:../../govoplan-templates/webui", "@govoplan/templates-webui": "file:../../govoplan-templates/webui",
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
"@govoplan/tickets-webui": "file:../../govoplan-tickets/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/voting-webui": "file:../../govoplan-voting/webui", "@govoplan/voting-webui": "file:../../govoplan-voting/webui",
"@govoplan/wiki-webui": "file:../../govoplan-wiki/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
"@tiptap/core": "^3.29.2", "@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2", "@tiptap/extension-image": "^3.29.2",
@@ -82,7 +85,7 @@
}, },
"../../govoplan-access/webui": { "../../govoplan-access/webui": {
"name": "@govoplan/access-webui", "name": "@govoplan/access-webui",
"version": "0.1.19", "version": "0.1.20",
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
@@ -117,12 +120,12 @@
}, },
"../../govoplan-admin/webui": { "../../govoplan-admin/webui": {
"name": "@govoplan/admin-webui", "name": "@govoplan/admin-webui",
"version": "0.1.18", "version": "0.1.19",
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.35",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
@@ -151,7 +154,7 @@
}, },
"../../govoplan-audit/webui": { "../../govoplan-audit/webui": {
"name": "@govoplan/audit-webui", "name": "@govoplan/audit-webui",
"version": "0.1.18", "version": "0.1.19",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -186,7 +189,7 @@
}, },
"../../govoplan-campaign/webui": { "../../govoplan-campaign/webui": {
"name": "@govoplan/campaign-webui", "name": "@govoplan/campaign-webui",
"version": "0.1.22", "version": "0.1.24",
"dependencies": { "dependencies": {
"read-excel-file": "9.2.0" "read-excel-file": "9.2.0"
}, },
@@ -208,9 +211,9 @@
}, },
"../../govoplan-cases/webui": { "../../govoplan-cases/webui": {
"name": "@govoplan/cases-webui", "name": "@govoplan/cases-webui",
"version": "0.1.18", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20",
@@ -240,7 +243,7 @@
}, },
"../../govoplan-connectors/webui": { "../../govoplan-connectors/webui": {
"name": "@govoplan/connectors-webui", "name": "@govoplan/connectors-webui",
"version": "0.1.18", "version": "0.1.22",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
@@ -270,7 +273,7 @@
}, },
"../../govoplan-dataflow/webui": { "../../govoplan-dataflow/webui": {
"name": "@govoplan/dataflow-webui", "name": "@govoplan/dataflow-webui",
"version": "0.1.18", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
@@ -322,7 +325,7 @@
}, },
"../../govoplan-docs/webui": { "../../govoplan-docs/webui": {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.18", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
@@ -375,7 +378,7 @@
}, },
"../../govoplan-forms-runtime/webui": { "../../govoplan-forms-runtime/webui": {
"name": "@govoplan/forms-runtime-webui", "name": "@govoplan/forms-runtime-webui",
"version": "0.1.18", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -391,12 +394,29 @@
}, },
"../../govoplan-forms/webui": { "../../govoplan-forms/webui": {
"name": "@govoplan/forms-webui", "name": "@govoplan/forms-webui",
"version": "0.1.18", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20" "react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-helpdesk/webui": {
"name": "@govoplan/helpdesk-webui",
"version": "0.1.20",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@govoplan/core-webui": { "@govoplan/core-webui": {
@@ -436,7 +456,7 @@
}, },
"../../govoplan-idm/webui": { "../../govoplan-idm/webui": {
"name": "@govoplan/idm-webui", "name": "@govoplan/idm-webui",
"version": "0.1.19", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
@@ -455,7 +475,7 @@
}, },
"../../govoplan-mail/webui": { "../../govoplan-mail/webui": {
"name": "@govoplan/mail-webui", "name": "@govoplan/mail-webui",
"version": "0.1.18", "version": "0.1.22",
"devDependencies": { "devDependencies": {
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
@@ -493,7 +513,7 @@
}, },
"../../govoplan-ops/webui": { "../../govoplan-ops/webui": {
"name": "@govoplan/ops-webui", "name": "@govoplan/ops-webui",
"version": "0.1.18", "version": "0.1.19",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
@@ -547,7 +567,7 @@
}, },
"../../govoplan-policy/webui": { "../../govoplan-policy/webui": {
"name": "@govoplan/policy-webui", "name": "@govoplan/policy-webui",
"version": "0.1.19", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -563,7 +583,7 @@
}, },
"../../govoplan-portal/webui": { "../../govoplan-portal/webui": {
"name": "@govoplan/portal-webui", "name": "@govoplan/portal-webui",
"version": "0.1.18", "version": "0.1.19",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -579,7 +599,7 @@
}, },
"../../govoplan-postbox/webui": { "../../govoplan-postbox/webui": {
"name": "@govoplan/postbox-webui", "name": "@govoplan/postbox-webui",
"version": "0.1.18", "version": "0.1.19",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -611,7 +631,7 @@
}, },
"../../govoplan-quick-access/webui": { "../../govoplan-quick-access/webui": {
"name": "@govoplan/quick-access-webui", "name": "@govoplan/quick-access-webui",
"version": "0.1.18", "version": "0.1.19",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -675,7 +695,7 @@
}, },
"../../govoplan-scheduling/webui": { "../../govoplan-scheduling/webui": {
"name": "@govoplan/scheduling-webui", "name": "@govoplan/scheduling-webui",
"version": "0.1.19", "version": "0.1.18",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
@@ -710,7 +730,7 @@
}, },
"../../govoplan-tasks/webui": { "../../govoplan-tasks/webui": {
"name": "@govoplan/tasks-webui", "name": "@govoplan/tasks-webui",
"version": "0.1.19", "version": "0.1.20",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -756,9 +776,25 @@
} }
} }
}, },
"../../govoplan-tickets/webui": {
"name": "@govoplan/tickets-webui",
"version": "0.1.20",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.30",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-views/webui": { "../../govoplan-views/webui": {
"name": "@govoplan/views-webui", "name": "@govoplan/views-webui",
"version": "0.1.18", "version": "0.1.19",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
@@ -787,9 +823,25 @@
} }
} }
}, },
"../../govoplan-wiki/webui": {
"name": "@govoplan/wiki-webui",
"version": "0.1.20",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.31",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-workflow/webui": { "../../govoplan-workflow/webui": {
"name": "@govoplan/workflow-webui", "name": "@govoplan/workflow-webui",
"version": "0.1.18", "version": "0.1.21",
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.18",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
@@ -1633,6 +1685,10 @@
"resolved": "../../govoplan-forms/webui", "resolved": "../../govoplan-forms/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/helpdesk-webui": {
"resolved": "../../govoplan-helpdesk/webui",
"link": true
},
"node_modules/@govoplan/identity-trust-webui": { "node_modules/@govoplan/identity-trust-webui": {
"resolved": "../../govoplan-identity-trust/webui", "resolved": "../../govoplan-identity-trust/webui",
"link": true "link": true
@@ -1717,6 +1773,10 @@
"resolved": "../../govoplan-tenancy/webui", "resolved": "../../govoplan-tenancy/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/tickets-webui": {
"resolved": "../../govoplan-tickets/webui",
"link": true
},
"node_modules/@govoplan/views-webui": { "node_modules/@govoplan/views-webui": {
"resolved": "../../govoplan-views/webui", "resolved": "../../govoplan-views/webui",
"link": true "link": true
@@ -1725,6 +1785,10 @@
"resolved": "../../govoplan-voting/webui", "resolved": "../../govoplan-voting/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/wiki-webui": {
"resolved": "../../govoplan-wiki/webui",
"link": true
},
"node_modules/@govoplan/workflow-webui": { "node_modules/@govoplan/workflow-webui": {
"resolved": "../../govoplan-workflow/webui", "resolved": "../../govoplan-workflow/webui",
"link": true "link": true
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.27", "version": "0.1.44",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -22,6 +22,10 @@
"./wysiwyg": { "./wysiwyg": {
"types": "./src/wysiwyg.ts", "types": "./src/wysiwyg.ts",
"import": "./src/wysiwyg.ts" "import": "./src/wysiwyg.ts"
},
"./outcome-product-surface-translations": {
"types": "./src/i18n/outcomeProductSurfaceTranslations.ts",
"import": "./src/i18n/outcomeProductSurfaceTranslations.ts"
} }
}, },
"scripts": { "scripts": {
@@ -42,7 +46,7 @@
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs", "test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js", "test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js", "test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
"test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/layout-primitives.test.js", "test:layout-primitives": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && printf 'module.exports = {};\\n' > .component-test-build/src/components/ProductAvailabilityState.css && node .component-test-build/tests/layout-primitives.test.js",
"test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js && node .module-test-build/tests/help-context.test.js && node .module-test-build/tests/launch-context.test.js && node .module-test-build/tests/definition-graph.test.js",
"test:module-permutations": "node scripts/test-module-permutations.mjs", "test:module-permutations": "node scripts/test-module-permutations.mjs",
"test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js", "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js",
@@ -77,6 +81,7 @@
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui", "@govoplan/forms-runtime-webui": "file:../../govoplan-forms-runtime/webui",
"@govoplan/forms-webui": "file:../../govoplan-forms/webui", "@govoplan/forms-webui": "file:../../govoplan-forms/webui",
"@govoplan/helpdesk-webui": "file:../../govoplan-helpdesk/webui",
"@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui", "@govoplan/identity-trust-webui": "file:../../govoplan-identity-trust/webui",
"@govoplan/identity-webui": "file:../../govoplan-identity/webui", "@govoplan/identity-webui": "file:../../govoplan-identity/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
@@ -98,8 +103,10 @@
"@govoplan/tasks-webui": "file:../../govoplan-tasks/webui", "@govoplan/tasks-webui": "file:../../govoplan-tasks/webui",
"@govoplan/templates-webui": "file:../../govoplan-templates/webui", "@govoplan/templates-webui": "file:../../govoplan-templates/webui",
"@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui", "@govoplan/tenancy-webui": "file:../../govoplan-tenancy/webui",
"@govoplan/tickets-webui": "file:../../govoplan-tickets/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui", "@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/voting-webui": "file:../../govoplan-voting/webui", "@govoplan/voting-webui": "file:../../govoplan-voting/webui",
"@govoplan/wiki-webui": "file:../../govoplan-wiki/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui", "@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
"@tiptap/core": "^3.29.2", "@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2", "@tiptap/extension-image": "^3.29.2",
+18 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/core-webui", "name": "@govoplan/core-webui",
"version": "0.1.27", "version": "0.1.44",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -26,19 +26,23 @@
"preview": "vite preview --host 127.0.0.1 --port 4173" "preview": "vite preview --host 127.0.0.1 --port 4173"
}, },
"dependencies": { "dependencies": {
"@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.19", "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.24",
"@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18", "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.22",
"@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.18", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.20",
"@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.18", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.23",
"@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.18", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",
"@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.18", "@govoplan/dashboard-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-dashboard.git#v0.1.20",
"@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.20", "@govoplan/docs-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-docs.git#v0.1.22",
"@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.19", "@govoplan/files-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-files.git#v0.1.25",
"@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.18", "@govoplan/helpdesk-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-helpdesk.git#v0.1.20",
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.22", "@govoplan/idm-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-idm.git#v0.1.24",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.18", "@govoplan/mail-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-mail.git#v0.1.26",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.18", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.27",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.18", "@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.20",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.21",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.22",
"@govoplan/tickets-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-tickets.git#v0.1.22",
"@govoplan/wiki-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-wiki.git#v0.1.22",
"@tiptap/core": "^3.29.2", "@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2", "@tiptap/extension-image": "^3.29.2",
"@tiptap/pm": "^3.29.2", "@tiptap/pm": "^3.29.2",
+6 -4
View File
@@ -15,12 +15,14 @@ const sourceRoots = fs.readdirSync(workspaceRoot, { withFileTypes: true })
.map((entry) => path.join(workspaceRoot, entry.name, "webui", "src")) .map((entry) => path.join(workspaceRoot, entry.name, "webui", "src"))
.filter((sourceRoot) => fs.existsSync(sourceRoot)); .filter((sourceRoot) => fs.existsSync(sourceRoot));
const generatedCatalogs = sourceRoots const generatedCatalogs = sourceRoots.flatMap((sourceRoot) =>
.map((sourceRoot) => path.join(sourceRoot, "i18n", "generatedTranslations.ts")) fs.existsSync(path.join(sourceRoot, "i18n"))
.filter((file) => fs.existsSync(file)); ? rgFiles(path.join(sourceRoot, "i18n")).filter((file) => /Translations\.ts$/.test(file))
: []
);
function scanStructuralSourcePositions(roots) { function scanStructuralSourcePositions(roots) {
const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !file.endsWith("/i18n/generatedTranslations.ts"))); const files = roots.flatMap((root) => rgFiles(root).filter((file) => /\.(tsx?|jsx?)$/.test(file) && !/\/i18n\/[^/]*Translations\.ts$/.test(file)));
const findings = []; const findings = [];
for (const file of files) { for (const file of files) {
const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); const source = ts.createSourceFile(file, fs.readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
+8 -1
View File
@@ -22,6 +22,7 @@ const packageByModule = {
files: "@govoplan/files-webui", files: "@govoplan/files-webui",
forms: "@govoplan/forms-webui", forms: "@govoplan/forms-webui",
forms_runtime: "@govoplan/forms-runtime-webui", forms_runtime: "@govoplan/forms-runtime-webui",
helpdesk: "@govoplan/helpdesk-webui",
idm: "@govoplan/idm-webui", idm: "@govoplan/idm-webui",
identity: "@govoplan/identity-webui", identity: "@govoplan/identity-webui",
mail: "@govoplan/mail-webui", mail: "@govoplan/mail-webui",
@@ -42,8 +43,10 @@ const packageByModule = {
tasks: "@govoplan/tasks-webui", tasks: "@govoplan/tasks-webui",
tenancy: "@govoplan/tenancy-webui", tenancy: "@govoplan/tenancy-webui",
templates: "@govoplan/templates-webui", templates: "@govoplan/templates-webui",
tickets: "@govoplan/tickets-webui",
views: "@govoplan/views-webui", views: "@govoplan/views-webui",
voting: "@govoplan/voting-webui", voting: "@govoplan/voting-webui",
wiki: "@govoplan/wiki-webui",
workflow: "@govoplan/workflow-webui" workflow: "@govoplan/workflow-webui"
}; };
@@ -75,6 +78,10 @@ const cases = [
{ name: "files-only", modules: ["files"] }, { name: "files-only", modules: ["files"] },
{ name: "forms-only", modules: ["forms"] }, { name: "forms-only", modules: ["forms"] },
{ name: "forms-runtime", modules: ["forms", "forms_runtime"] }, { name: "forms-runtime", modules: ["forms", "forms_runtime"] },
{ name: "tickets-only", modules: ["tickets"] },
{ name: "tickets-with-helpdesk-and-cases", modules: ["tickets", "helpdesk", "cases"] },
{ name: "wiki-only", modules: ["wiki"] },
{ name: "wiki-with-files-search", modules: ["wiki", "files", "search"] },
{ name: "mail-only", modules: ["mail"] }, { name: "mail-only", modules: ["mail"] },
{ name: "notifications-only", modules: ["notifications"] }, { name: "notifications-only", modules: ["notifications"] },
{ name: "organizations-only", modules: ["organizations"] }, { name: "organizations-only", modules: ["organizations"] },
@@ -106,7 +113,7 @@ const cases = [
{ name: "tasks-only", modules: ["access", "tasks"] }, { name: "tasks-only", modules: ["access", "tasks"] },
{ name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] }, { name: "tasks-with-contributors", modules: ["access", "approvals", "postbox", "workflow", "dashboard", "tasks"] },
{ name: "voting-only", modules: ["access", "voting"] }, { name: "voting-only", modules: ["access", "voting"] },
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity", "identity_trust", "encryption", "cases", "committee", "connectors", "campaigns", "files", "forms", "forms_runtime", "mail", "notifications", "docs", "ops", "payments", "calendar", "scheduling", "portal", "postbox", "projects", "quick_access", "reporting", "records", "risk_compliance", "search", "tasks", "voting"] } { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "approvals", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "templates", "workflow", "views", "organizations", "idm", "identity", "identity_trust", "encryption", "cases", "committee", "connectors", "campaigns", "files", "forms", "forms_runtime", "helpdesk", "mail", "notifications", "docs", "ops", "payments", "calendar", "scheduling", "portal", "postbox", "projects", "quick_access", "reporting", "records", "risk_compliance", "search", "tasks", "tickets", "voting", "wiki"] }
]; ];
const npmExec = process.env.npm_execpath; const npmExec = process.env.npm_execpath;
+8 -3
View File
@@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell";
import PublicLandingPage from "./features/auth/PublicLandingPage"; import PublicLandingPage from "./features/auth/PublicLandingPage";
import LoginModal from "./features/auth/LoginModal"; import LoginModal from "./features/auth/LoginModal";
import { PermissionBoundary } from "./components/AccessBoundary"; import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules"; import { configurableNavigationItemsForModules, firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext"; import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext"; import { PlatformViewProvider } from "./platform/ViewContext";
import { PlatformTemporalProvider } from "./platform/TemporalContext"; import { PlatformTemporalProvider } from "./platform/TemporalContext";
@@ -30,6 +30,7 @@ import { applyAppearanceOverrides } from "./components/AppearanceOverridesEditor
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage")); const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const ProductSurfaceRoute = lazy(() => import("./components/ProductSurfaceRoute"));
const DEFAULT_UI_PREFERENCES: UserUiPreferences = { const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
compact_tables: false, compact_tables: false,
@@ -72,6 +73,10 @@ export default function App() {
() => navItemsForModules(webModules, viewProjection), () => navItemsForModules(webModules, viewProjection),
[viewProjection, webModules] [viewProjection, webModules]
); );
const allToolItems = useMemo(
() => configurableNavigationItemsForModules(webModules),
[webModules]
);
const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]); const moduleRoutes = useMemo(() => routeContributionsForModules(webModules), [webModules]);
const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]); const publicRoutes = useMemo(() => publicRouteContributionsForModules(publicWebModules), [publicWebModules]);
const contextModules = auth ? webModules : publicWebModules; const contextModules = auth ? webModules : publicWebModules;
@@ -549,7 +554,7 @@ export default function App() {
<PlatformViewProvider modules={webModules} projection={viewProjection}> <PlatformViewProvider modules={webModules} projection={viewProjection}>
<PlatformActiveObjectProvider> <PlatformActiveObjectProvider>
<UnsavedChangesProvider> <UnsavedChangesProvider>
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}> <AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} allToolItems={allToolItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
<ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}> <ModuleLoadBoundary resetKey={`${location.pathname}:${temporalRevision}`} loading={webModulesLoading}>
<Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}> <Routes key={`${(auth.active_tenant ?? auth.tenant).id}:${temporalRevision}`}>
<Route path="/" element={<Navigate to={defaultRoute} replace />} /> <Route path="/" element={<Navigate to={defaultRoute} replace />} />
@@ -579,7 +584,7 @@ export default function App() {
)} )}
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} /> <Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
<Route path="*" element={<Navigate to={defaultRoute} replace />} /> <Route path="*" element={<ProductSurfaceRoute auth={auth} />} />
</Routes> </Routes>
</ModuleLoadBoundary> </ModuleLoadBoundary>
{reloginMessage && {reloginMessage &&
+5 -2
View File
@@ -35,7 +35,7 @@ export type MailProfilePatternRules = Partial<Record<MailProfilePatternKey, stri
export type MailConnectionTestResponse = { export type MailConnectionTestResponse = {
ok: boolean; ok: boolean;
protocol: "smtp" | "imap"; protocol: "smtp" | "imap" | "jmap" | "pop3";
host?: string | null; host?: string | null;
port?: number | null; port?: number | null;
security?: MailSecurity | string | null; security?: MailSecurity | string | null;
@@ -52,7 +52,7 @@ export type MailImapFolderResponse = {
export type MailImapFolderListResponse = { export type MailImapFolderListResponse = {
ok: boolean; ok: boolean;
protocol: "imap"; protocol: "imap" | "jmap";
host?: string | null; host?: string | null;
port?: number | null; port?: number | null;
security?: MailSecurity | string | null; security?: MailSecurity | string | null;
@@ -69,6 +69,7 @@ export type MailImapFolderListResponse = {
export const mailProfilePatternKeys = [ export const mailProfilePatternKeys = [
"smtp_hosts", "smtp_hosts",
"imap_hosts", "imap_hosts",
"jmap_hosts",
"envelope_senders", "envelope_senders",
"from_headers", "from_headers",
"recipient_domains" "recipient_domains"
@@ -82,11 +83,13 @@ export const mailProfilePolicyLimitKeys = [
"imap_credentials.inherit", "imap_credentials.inherit",
"whitelist.smtp_hosts", "whitelist.smtp_hosts",
"whitelist.imap_hosts", "whitelist.imap_hosts",
"whitelist.jmap_hosts",
"whitelist.envelope_senders", "whitelist.envelope_senders",
"whitelist.from_headers", "whitelist.from_headers",
"whitelist.recipient_domains", "whitelist.recipient_domains",
"blacklist.smtp_hosts", "blacklist.smtp_hosts",
"blacklist.imap_hosts", "blacklist.imap_hosts",
"blacklist.jmap_hosts",
"blacklist.envelope_senders", "blacklist.envelope_senders",
"blacklist.from_headers", "blacklist.from_headers",
"blacklist.recipient_domains" "blacklist.recipient_domains"
+1
View File
@@ -207,6 +207,7 @@ export default function HoverTooltip({
<span <span
ref={triggerRef} ref={triggerRef}
className={className} className={className}
role={ariaLabel ? "button" : undefined}
tabIndex={triggerTabIndex} tabIndex={triggerTabIndex}
aria-label={translatedAriaLabel} aria-label={translatedAriaLabel}
aria-describedby={isOpen ? tooltipId : undefined} aria-describedby={isOpen ? tooltipId : undefined}
+26 -2
View File
@@ -3,8 +3,9 @@ import { Dice5, Eye, EyeOff } from "lucide-react";
import PasswordGeneratorDialog from "./PasswordGeneratorDialog"; import PasswordGeneratorDialog from "./PasswordGeneratorDialog";
import type { PasswordGeneratorOptions } from "./passwordGenerator"; import type { PasswordGeneratorOptions } from "./passwordGenerator";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & { export type PasswordFieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> & PlatformInterfaceIdentityProps & {
value: string; value: string;
onValueChange: (value: string) => void; onValueChange: (value: string) => void;
saved?: boolean; saved?: boolean;
@@ -32,6 +33,10 @@ export default function PasswordField({
className = "", className = "",
inputClassName = "", inputClassName = "",
id, id,
interfaceId,
helpContextId,
helpModuleId,
helpTopicId,
...inputProps ...inputProps
}: PasswordFieldProps) { }: PasswordFieldProps) {
const generatedId = useId(); const generatedId = useId();
@@ -50,10 +55,20 @@ export default function PasswordField({
return ( return (
<> <>
<div className={`password-field ${canReveal || canGenerate ? "has-actions" : ""} ${canGenerate ? "has-generator" : ""} ${canReveal ? "has-reveal" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}> <div
className={`password-field ${canReveal || canGenerate ? "has-actions" : ""} ${canGenerate ? "has-generator" : ""} ${canReveal ? "has-reveal" : ""} ${showSavedPlaceholder ? "is-saved-empty" : ""} ${className}`.trim()}
data-help-scope="field"
data-interface-id={interfaceId}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
>
<input <input
{...inputProps} {...inputProps}
id={inputId} id={inputId}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
className={inputClassName} className={inputClassName}
type={inputType} type={inputType}
value={value} value={value}
@@ -72,6 +87,9 @@ export default function PasswordField({
className="password-field-action" className="password-field-action"
aria-label={translatedGeneratorLabel} aria-label={translatedGeneratorLabel}
title={translatedGeneratorLabel} title={translatedGeneratorLabel}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
onClick={() => setGeneratorOpen(true)} onClick={() => setGeneratorOpen(true)}
> >
<Dice5 size={17} aria-hidden="true" /> <Dice5 size={17} aria-hidden="true" />
@@ -83,6 +101,9 @@ export default function PasswordField({
className="password-field-action" className="password-field-action"
aria-label={visible ? translatedHideLabel : translatedRevealLabel} aria-label={visible ? translatedHideLabel : translatedRevealLabel}
title={visible ? translatedHideLabel : translatedRevealLabel} title={visible ? translatedHideLabel : translatedRevealLabel}
data-help-context-id={helpContextId}
data-help-module-id={helpModuleId}
data-help-topic-id={helpTopicId}
onClick={() => setVisible((current) => !current)} onClick={() => setVisible((current) => !current)}
> >
{visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />} {visible ? <EyeOff size={17} aria-hidden="true" /> : <Eye size={17} aria-hidden="true" />}
@@ -95,6 +116,9 @@ export default function PasswordField({
<PasswordGeneratorDialog <PasswordGeneratorDialog
open={generatorOpen} open={generatorOpen}
initialOptions={generatorOptions} initialOptions={generatorOptions}
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
onUse={(password) => { onUse={(password) => {
onValueChange(password); onValueChange(password);
setVisible(false); setVisible(false);
@@ -14,6 +14,7 @@ import {
type PasswordGeneratorOptions type PasswordGeneratorOptions
} from "./passwordGenerator"; } from "./passwordGenerator";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformInterfaceIdentityProps } from "../types";
const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = { const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = {
"invalid-length": "i18n:govoplan-core.password_length_must_be_between_12_and_128_character.4d147c07", "invalid-length": "i18n:govoplan-core.password_length_must_be_between_12_and_128_character.4d147c07",
@@ -23,7 +24,7 @@ const GENERATION_ERROR_LABELS: Record<PasswordGeneratorErrorCode, string> = {
"secure-random-unavailable": "i18n:govoplan-core.secure_browser_password_generation_is_unavailable.55275f10" "secure-random-unavailable": "i18n:govoplan-core.secure_browser_password_generation_is_unavailable.55275f10"
}; };
export type PasswordGeneratorDialogProps = { export type PasswordGeneratorDialogProps = PlatformInterfaceIdentityProps & {
open: boolean; open: boolean;
initialOptions?: Partial<PasswordGeneratorOptions>; initialOptions?: Partial<PasswordGeneratorOptions>;
onUse: (password: string) => void; onUse: (password: string) => void;
@@ -33,6 +34,9 @@ export type PasswordGeneratorDialogProps = {
export default function PasswordGeneratorDialog({ export default function PasswordGeneratorDialog({
open, open,
initialOptions, initialOptions,
helpContextId,
helpModuleId,
helpTopicId,
onUse, onUse,
onClose onClose
}: PasswordGeneratorDialogProps) { }: PasswordGeneratorDialogProps) {
@@ -95,6 +99,9 @@ export default function PasswordGeneratorDialog({
bodyClassName="password-generator-body" bodyClassName="password-generator-body"
footerClassName="button-row compact-actions" footerClassName="button-row compact-actions"
portal portal
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
onClose={onClose} onClose={onClose}
footer={( footer={(
<> <>
@@ -103,6 +110,9 @@ export default function PasswordGeneratorDialog({
type="button" type="button"
variant="primary" variant="primary"
disabled={!candidate} disabled={!candidate}
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
onClick={() => { onClick={() => {
if (!candidate) return; if (!candidate) return;
onUse(candidate); onUse(candidate);
@@ -116,7 +126,7 @@ export default function PasswordGeneratorDialog({
> >
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<div className="password-generator-options"> <div className="password-generator-options">
<FormField label="i18n:govoplan-core.length.adc95605"> <FormField label="i18n:govoplan-core.length.adc95605" helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId}>
<input <input
type="number" type="number"
min={12} min={12}
@@ -127,13 +137,13 @@ export default function PasswordGeneratorDialog({
/> />
</FormField> </FormField>
<div className="password-generator-character-sets" aria-label={translateText("i18n:govoplan-core.character_sets.db6efda2")}> <div className="password-generator-character-sets" aria-label={translateText("i18n:govoplan-core.character_sets.db6efda2")}>
<ToggleSwitch label="i18n:govoplan-core.lowercase.3b677a18" checked={options.lowercase} onChange={(checked) => setOption("lowercase", checked)} /> <ToggleSwitch label="i18n:govoplan-core.lowercase.3b677a18" checked={options.lowercase} onChange={(checked) => setOption("lowercase", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
<ToggleSwitch label="i18n:govoplan-core.uppercase.b463d690" checked={options.uppercase} onChange={(checked) => setOption("uppercase", checked)} /> <ToggleSwitch label="i18n:govoplan-core.uppercase.b463d690" checked={options.uppercase} onChange={(checked) => setOption("uppercase", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
<ToggleSwitch label="i18n:govoplan-core.digits.9cd500d3" checked={options.digits} onChange={(checked) => setOption("digits", checked)} /> <ToggleSwitch label="i18n:govoplan-core.digits.9cd500d3" checked={options.digits} onChange={(checked) => setOption("digits", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
<ToggleSwitch label="i18n:govoplan-core.symbols.9491fc41" checked={options.symbols} onChange={(checked) => setOption("symbols", checked)} /> <ToggleSwitch label="i18n:govoplan-core.symbols.9491fc41" checked={options.symbols} onChange={(checked) => setOption("symbols", checked)} helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId} />
</div> </div>
</div> </div>
<FormField label="i18n:govoplan-core.generated_password.78461854"> <FormField label="i18n:govoplan-core.generated_password.78461854" helpContextId={helpContextId} helpModuleId={helpModuleId} helpTopicId={helpTopicId}>
<div className="password-generator-result"> <div className="password-generator-result">
<input <input
type="text" type="text"
@@ -147,11 +157,17 @@ export default function PasswordGeneratorDialog({
icon={<Copy size={16} />} icon={<Copy size={16} />}
onClick={() => void copy()} onClick={() => void copy()}
disabled={!candidate || typeof navigator === "undefined" || !navigator.clipboard?.writeText} disabled={!candidate || typeof navigator === "undefined" || !navigator.clipboard?.writeText}
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
/> />
<IconButton <IconButton
label="i18n:govoplan-core.generate_another_password.d99fc019" label="i18n:govoplan-core.generate_another_password.d99fc019"
icon={<RefreshCw size={16} />} icon={<RefreshCw size={16} />}
onClick={generate} onClick={generate}
helpContextId={helpContextId}
helpModuleId={helpModuleId}
helpTopicId={helpTopicId}
/> />
</div> </div>
</FormField> </FormField>
@@ -0,0 +1,8 @@
.product-availability-resolution,
.product-availability-owner { margin: 0; }
.product-availability-technical { width: min(100%, 560px); margin-top: 4px; color: var(--muted); text-align: start; }
.product-availability-technical summary { cursor: pointer; color: var(--text); font-weight: 700; }
.product-availability-technical dl { display: grid; gap: 6px; margin: 10px 0 0; }
.product-availability-technical dl > div { display: grid; grid-template-columns: minmax(110px, .4fr) minmax(0, 1fr); gap: 10px; }
.product-availability-technical dt { color: var(--muted); font-weight: 700; }
.product-availability-technical dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: var(--text); font-family: var(--font-mono, monospace); font-size: 12px; }
@@ -0,0 +1,114 @@
import { CircleOff, TriangleAlert } from "lucide-react";
import type { ReactNode } from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { ProductAvailabilityExplanation } from "../types";
import StatePanel, { type StatePanelProps } from "./StatePanel";
import "./ProductAvailabilityState.css";
export type ProductTechnicalProvenance = {
moduleId?: string | null;
capabilityId?: string | null;
providerId?: string | null;
correlationId?: string | null;
};
export type ProductAvailabilityStateProps = {
state: "unavailable" | "degraded";
explanation: ProductAvailabilityExplanation;
actions?: ReactNode;
technical?: ProductTechnicalProvenance | null;
size?: StatePanelProps["size"];
surface?: StatePanelProps["surface"];
className?: string;
};
export default function ProductAvailabilityState({
state,
explanation,
actions,
technical,
size = "default",
surface = "subtle",
className = ""
}: ProductAvailabilityStateProps) {
const { language, translateText } = usePlatformLanguage();
const labels = AVAILABILITY_LABELS[language.split("-", 1)[0] === "de" ? "de" : "en"];
const title = translateText(explanation.title);
const description = translateText(explanation.description);
const resolution = translateText(explanation.resolution);
const responsibleRole = explanation.responsibleRole
? translateText(explanation.responsibleRole)
: null;
const technicalEntries = technical ? Object.entries(technical).filter((entry) => Boolean(entry[1])) : [];
return (
<StatePanel
aria-live="polite"
className={["product-availability-state", `product-availability-${state}`, className].filter(Boolean).join(" ")}
icon={state === "degraded" ? <TriangleAlert size={24} /> : <CircleOff size={24} />}
title={title}
description={description}
actions={actions}
size={size}
surface={surface}
tone="warning"
>
<p className="product-availability-resolution">{resolution}</p>
{responsibleRole ? (
<p className="product-availability-owner">
<strong>{labels.responsibleRole}: </strong>
{responsibleRole}
</p>
) : null}
{technicalEntries.length ? (
<details className="product-availability-technical">
<summary>{labels.technicalDetails}</summary>
<dl>
{technicalEntries.map(([key, value]) => (
<div key={key}>
<dt>{technicalLabel(key, labels)}</dt>
<dd>{String(value)}</dd>
</div>
))}
</dl>
</details>
) : null}
</StatePanel>
);
}
type AvailabilityLabels = {
responsibleRole: string;
technicalDetails: string;
module: string;
capability: string;
provider: string;
correlationId: string;
};
const AVAILABILITY_LABELS: Record<"en" | "de", AvailabilityLabels> = {
en: {
responsibleRole: "Responsible role",
technicalDetails: "Technical details",
module: "Module",
capability: "Capability",
provider: "Provider",
correlationId: "Correlation ID"
},
de: {
responsibleRole: "Zuständige Rolle",
technicalDetails: "Technische Details",
module: "Modul",
capability: "Fähigkeit",
provider: "Anbieter",
correlationId: "Korrelations-ID"
}
};
function technicalLabel(key: string, labels: AvailabilityLabels): string {
if (key === "moduleId") return labels.module;
if (key === "capabilityId") return labels.capability;
if (key === "providerId") return labels.provider;
if (key === "correlationId") return labels.correlationId;
return key;
}
@@ -0,0 +1,63 @@
import { lazy, useEffect, useMemo } from "react";
import { Navigate, useLocation } from "react-router";
import type { AuthInfo } from "../types";
import { usePlatformModules } from "../platform/ModuleContext";
import { firstAccessibleRoute } from "../platform/modules";
import {
availableProductSurfaceContributors,
composeProductSurfaces,
dispatchProductSurfaceRouteResolved
} from "../platform/productSurfaces";
import { useEffectiveView } from "../platform/ViewContext";
const ProductAvailabilityState = lazy(() => import("./ProductAvailabilityState"));
export default function ProductSurfaceRoute({
auth
}: {
auth: AuthInfo;
}) {
const location = useLocation();
const modules = usePlatformModules();
const projection = useEffectiveView();
const surface = useMemo(
() => composeProductSurfaces(modules).find((candidate) =>
candidate.entryPath === location.pathname || candidate.aliases.includes(location.pathname)
) ?? null,
[location.pathname, modules]
);
const contributors = useMemo(
() => surface ? availableProductSurfaceContributors(surface, auth, modules, projection) : [],
[auth, modules, projection, surface]
);
const target = contributors[0] ?? null;
useEffect(() => {
if (!surface || !target) return;
dispatchProductSurfaceRouteResolved({
contractVersion: "1",
productSurfaceId: surface.id,
requestedPath: location.pathname,
targetPath: target.routePath,
contributorModuleId: target.moduleId,
usedAlias: location.pathname !== surface.entryPath
});
}, [location.pathname, surface, target]);
if (target) {
return <Navigate to={`${target.routePath}${location.search}${location.hash}`} replace />;
}
if (!surface) {
return <Navigate to={firstAccessibleRoute(auth, modules, projection)} replace />;
}
const explanation = surface.contributors[0]?.unavailable;
return explanation ? (
<ProductAvailabilityState
state="unavailable"
explanation={explanation}
size="fill"
/>
) : null;
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default function InlineHelp({ children, className = "" }: InlineHelpProps
content={children} content={children}
className={`inline-help ${className}`.trim()} className={`inline-help ${className}`.trim()}
ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f" ariaLabel="i18n:govoplan-core.show_field_help.e3dfe98f"
triggerTabIndex={-1}> triggerTabIndex={0}>
<span className="inline-help-mark" aria-hidden="true">?</span> <span className="inline-help-mark" aria-hidden="true">?</span>
</HoverTooltip> </HoverTooltip>
); );
+4 -4
View File
@@ -57,11 +57,11 @@ export default function LoginModal({
<FormLayout columns={1} collapseAt="standard" id={formId} className="" onSubmit={submit}> <FormLayout columns={1} collapseAt="standard" id={formId} className="" onSubmit={submit}>
{message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>} {message && <DismissibleAlert tone="info" dismissible={false}>{message}</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<FormField label="i18n:govoplan-core.email.84add5b2"> <FormField label="i18n:govoplan-core.email.84add5b2" helpContextId="access.authentication.email" helpModuleId="access">
<input type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} /> <input data-help-context-id="access.authentication.email" data-help-module-id="access" type="email" value={email} autoComplete="username" onChange={(e) => setEmail(e.target.value)} />
</FormField> </FormField>
<FormField label="i18n:govoplan-core.password.8be3c943"> <FormField label="i18n:govoplan-core.password.8be3c943" helpContextId="access.authentication.password" helpModuleId="access">
<PasswordField value={password} autoComplete="current-password" onValueChange={setPassword} /> <PasswordField helpContextId="access.authentication.password" helpModuleId="access" value={password} autoComplete="current-password" onValueChange={setPassword} />
</FormField> </FormField>
</FormLayout> </FormLayout>
</Dialog>); </Dialog>);
+3 -1
View File
@@ -572,8 +572,10 @@ export default function SettingsPage({
<FormField label="i18n:govoplan-core.api_base_url.1358fba4" help="i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7"> <FormField label="i18n:govoplan-core.api_base_url.1358fba4" help="i18n:govoplan-core.leave_empty_to_use_the_same_origin_in_vite_dev_a.9a1c25d7">
<input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" /> <input value={settings.apiBaseUrl} onChange={(e) => onSettingsChange({ ...settings, apiBaseUrl: e.target.value })} placeholder="https://example.org or empty" />
</FormField> </FormField>
<FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70"> <FormField label="i18n:govoplan-core.automation_api_key.5d4e2e6e" help="i18n:govoplan-core.used_only_when_there_is_no_browser_session_token.9d399e70" helpContextId="access.settings.automation-api-key" helpModuleId="access">
<PasswordField <PasswordField
helpContextId="access.settings.automation-api-key"
helpModuleId="access"
value={settings.apiKey} value={settings.apiKey}
autoComplete="off" autoComplete="off"
onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} /> onValueChange={(apiKey) => onSettingsChange({ ...settings, apiKey })} />
+1 -4
View File
@@ -43,10 +43,7 @@ export const DEFAULT_AVAILABLE_LANGUAGES: PlatformLanguage[] = [
{ code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }]; { code: "en", label: "i18n:govoplan-core.english.649df08a", nativeLabel: "i18n:govoplan-core.language_native_english" }];
export const DEFAULT_TRANSLATIONS: PlatformTranslations = { export const DEFAULT_TRANSLATIONS: PlatformTranslations = generatedTranslations;
en: generatedTranslations.en,
de: generatedTranslations.de
};
const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null); const PlatformLanguageContext = createContext<PlatformLanguageContextValue | null>(null);
+4 -2
View File
@@ -138,6 +138,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
"i18n:govoplan-core.application_notices": "Application notices", "i18n:govoplan-core.application_notices": "Application notices",
"i18n:govoplan-core.more_tools": "More tools", "i18n:govoplan-core.more_tools": "More tools",
"i18n:govoplan-core.all_available_tools": "All available tools",
"i18n:govoplan-core.product_area.work": "Work", "i18n:govoplan-core.product_area.work": "Work",
"i18n:govoplan-core.product_area.services_cases": "Services and cases", "i18n:govoplan-core.product_area.services_cases": "Services and cases",
"i18n:govoplan-core.product_area.communication": "Communication", "i18n:govoplan-core.product_area.communication": "Communication",
@@ -874,6 +875,7 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder", "i18n:govoplan-core.append_target_folder.0aaacc0c": "Append target folder",
"i18n:govoplan-core.application_notices": "Anwendungshinweise", "i18n:govoplan-core.application_notices": "Anwendungshinweise",
"i18n:govoplan-core.more_tools": "Weitere Werkzeuge", "i18n:govoplan-core.more_tools": "Weitere Werkzeuge",
"i18n:govoplan-core.all_available_tools": "Alle verfügbaren Werkzeuge",
"i18n:govoplan-core.product_area.work": "Arbeit", "i18n:govoplan-core.product_area.work": "Arbeit",
"i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge", "i18n:govoplan-core.product_area.services_cases": "Leistungen und Vorgänge",
"i18n:govoplan-core.product_area.communication": "Kommunikation", "i18n:govoplan-core.product_area.communication": "Kommunikation",
@@ -1276,8 +1278,8 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.share.09ca55ca": "Freigabe", "i18n:govoplan-core.share.09ca55ca": "Freigabe",
"i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.", "i18n:govoplan-core.set_concrete_system_retention_values_blank_day_f.98b9a627": "Set concrete system retention values. Blank day fields mean unlimited retention.",
"i18n:govoplan-core.settings.c7f73bb5": "Einstellungen", "i18n:govoplan-core.settings.c7f73bb5": "Einstellungen",
"i18n:govoplan-core.show_content.0528d8d2": "Show content", "i18n:govoplan-core.show_content.0528d8d2": "Inhalt anzeigen",
"i18n:govoplan-core.show_field_help.e3dfe98f": "Show field help", "i18n:govoplan-core.show_field_help.e3dfe98f": "Feldhilfe anzeigen",
"i18n:govoplan-core.show_guided_warnings_while_editing.bc5dba85": "Show guided warnings while editing", "i18n:govoplan-core.show_guided_warnings_while_editing.bc5dba85": "Show guided warnings while editing",
"i18n:govoplan-core.show_header_only.24afefca": "Show header only", "i18n:govoplan-core.show_header_only.24afefca": "Show header only",
"i18n:govoplan-core.show_inline_guidance_and_warnings_while_campaign.a892f5e9": "Show inline guidance and warnings while campaign data is being edited.", "i18n:govoplan-core.show_inline_guidance_and_warnings_while_campaign.a892f5e9": "Show inline guidance and warnings while campaign data is being edited.",
@@ -0,0 +1,28 @@
import type { PlatformTranslations } from "../types";
export const generatedTranslations = {
en: {
"i18n:govoplan-core.product_surface.messages": "Messages",
"i18n:govoplan-core.product_surface.messages_description": "Read and act on messages without merging channel custody, policy, or delivery state.",
"i18n:govoplan-core.product_surface.messages_unavailable": "Messages are unavailable",
"i18n:govoplan-core.product_surface.messages_unavailable_description": "No message source is available for your current responsibility and permissions.",
"i18n:govoplan-core.product_surface.messages_unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.",
"i18n:govoplan-core.product_surface.messages_degraded": "Messages are temporarily limited",
"i18n:govoplan-core.product_surface.messages_degraded_description": "Saved messages remain available, but a channel or provider may not be current.",
"i18n:govoplan-core.product_surface.messages_degraded_resolution": "Retry later or ask the integration operator to review provider health.",
"i18n:govoplan-core.access_administrator": "Access administrator",
"i18n:govoplan-core.integration_operator": "Integration operator"
},
de: {
"i18n:govoplan-core.product_surface.messages": "Nachrichten",
"i18n:govoplan-core.product_surface.messages_description": "Nachrichten lesen und bearbeiten, ohne Verwahrung, Regeln oder Zustellstatus der Kanäle zusammenzuführen.",
"i18n:govoplan-core.product_surface.messages_unavailable": "Nachrichten sind nicht verfügbar",
"i18n:govoplan-core.product_surface.messages_unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist keine Nachrichtenquelle verfügbar.",
"i18n:govoplan-core.product_surface.messages_unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.",
"i18n:govoplan-core.product_surface.messages_degraded": "Nachrichten sind vorübergehend eingeschränkt",
"i18n:govoplan-core.product_surface.messages_degraded_description": "Gespeicherte Nachrichten bleiben verfügbar, ein Kanal oder Anbieter ist jedoch möglicherweise nicht aktuell.",
"i18n:govoplan-core.product_surface.messages_degraded_resolution": "Versuchen Sie es später erneut oder bitten Sie die Integrationsadministration, den Anbieterstatus zu prüfen.",
"i18n:govoplan-core.access_administrator": "Zugriffsadministration",
"i18n:govoplan-core.integration_operator": "Integrationsadministration"
}
} satisfies PlatformTranslations;
@@ -0,0 +1,28 @@
import type { PlatformTranslations } from "../types";
export const generatedTranslations = {
en: {
"i18n:govoplan-core.product_surface.work": "Work",
"i18n:govoplan-core.product_surface.work_description": "Review and resume authorized work without navigating by package ownership.",
"i18n:govoplan-core.product_surface.calendar": "Calendar",
"i18n:govoplan-core.product_surface.calendar_description": "Plan and review authorized events through one stable calendar destination.",
"i18n:govoplan-core.product_surface.files": "Files",
"i18n:govoplan-core.product_surface.files_description": "Find, select, and manage authorized files without exposing their storage implementation.",
"i18n:govoplan-core.product_surface.unavailable": "Destination is unavailable",
"i18n:govoplan-core.product_surface.unavailable_description": "No product destination is available for your current responsibility and permissions.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Ask the responsible access administrator to review your assignment or permissions.",
"i18n:govoplan-core.access_administrator": "Access administrator"
},
de: {
"i18n:govoplan-core.product_surface.work": "Arbeit",
"i18n:govoplan-core.product_surface.work_description": "Berechtigte Arbeit prüfen und fortsetzen, ohne nach Paketzuständigkeit zu navigieren.",
"i18n:govoplan-core.product_surface.calendar": "Kalender",
"i18n:govoplan-core.product_surface.calendar_description": "Berechtigte Termine über ein stabiles Kalenderziel planen und prüfen.",
"i18n:govoplan-core.product_surface.files": "Dateien",
"i18n:govoplan-core.product_surface.files_description": "Berechtigte Dateien finden, auswählen und verwalten, ohne ihre Speicherimplementierung offenzulegen.",
"i18n:govoplan-core.product_surface.unavailable": "Das Produktziel ist nicht verfügbar",
"i18n:govoplan-core.product_surface.unavailable_description": "Für Ihre aktuelle Verantwortung und Berechtigungen ist kein Produktziel verfügbar.",
"i18n:govoplan-core.product_surface.unavailable_resolution": "Bitten Sie die zuständige Zugriffsadministration, Ihre Zuordnung oder Berechtigungen zu prüfen.",
"i18n:govoplan-core.access_administrator": "Zugriffsadministration"
}
} satisfies PlatformTranslations;
+6
View File
@@ -32,6 +32,10 @@ export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents"; export * from "./platform/moduleEvents";
export * from "./platform/ViewContext"; export * from "./platform/ViewContext";
export * from "./platform/views"; export * from "./platform/views";
export * from "./platform/productSurfaces";
export { productSurfaceTranslations } from "./productSurfaceTranslations";
export { generatedTranslations as messagesProductSurfaceTranslations } from "./i18n/messagesProductSurfaceTranslations";
export { generatedTranslations as outcomeProductSurfaceTranslations } from "./i18n/outcomeProductSurfaceTranslations";
export * from "./platform/temporal"; export * from "./platform/temporal";
export * from "./platform/TemporalContext"; export * from "./platform/TemporalContext";
export * from "./platform/ActiveObjectContext"; export * from "./platform/ActiveObjectContext";
@@ -215,6 +219,8 @@ export { default as SelectionList, SelectionListItem, SelectionListItemContent }
export type { SelectionListItemContentProps, SelectionListItemProps, SelectionListProps } from "./components/SelectionList"; export type { SelectionListItemContentProps, SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
export { default as StatePanel } from "./components/StatePanel"; export { default as StatePanel } from "./components/StatePanel";
export type { StatePanelProps, StatePanelSize, StatePanelSurface, StatePanelTone } from "./components/StatePanel"; export type { StatePanelProps, StatePanelSize, StatePanelSurface, StatePanelTone } from "./components/StatePanel";
export { default as ProductAvailabilityState } from "./components/ProductAvailabilityState";
export type { ProductAvailabilityStateProps, ProductTechnicalProvenance } from "./components/ProductAvailabilityState";
export { default as StatusBadge } from "./components/StatusBadge"; export { default as StatusBadge } from "./components/StatusBadge";
export { default as StageRail } from "./components/StageRail"; export { default as StageRail } from "./components/StageRail";
export type { export type {
+60
View File
@@ -0,0 +1,60 @@
import { Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router";
import type { MouseEvent } from "react";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import type { PlatformNavItem } from "../types";
export default function AllToolsNavigation({
items,
rememberedTargets
}: {
items: PlatformNavItem[];
rememberedTargets: Record<string, string>;
}) {
const location = useLocation();
const navigate = useGuardedNavigate();
const { translateText } = usePlatformLanguage();
function handleClick(event: MouseEvent<HTMLAnchorElement>, target: string) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
navigate(target);
}
return (
<details className="icon-nav-all-tools" data-product-navigation="all-tools">
<summary
className="icon-nav-all-tools-summary"
title={translateText("i18n:govoplan-core.all_available_tools")}
>
<Settings size={20} aria-hidden="true" />
<span className="icon-nav-label">
{translateText("i18n:govoplan-core.all_available_tools")}
</span>
</summary>
<div className="icon-nav-all-tools-items">
{items.map(({ to, label, icon: Icon }) => {
const target = rememberedTargets[to] ?? to;
const renderedLabel = translateText(label);
return (
<NavLink
key={to}
to={target}
className={`icon-nav-item ${pathActive(location.pathname, to) ? "active" : ""}`}
title={renderedLabel}
onClick={(event) => handleClick(event, target)}
>
{Icon ? <Icon size={20} /> : <span className="icon-nav-fallback">{renderedLabel.slice(0, 1)}</span>}
<span className="icon-nav-label">{renderedLabel}</span>
</NavLink>
);
})}
</div>
</details>
);
}
function pathActive(pathname: string, root: string): boolean {
return pathname === root || pathname.startsWith(`${root}/`);
}
+10 -3
View File
@@ -11,6 +11,7 @@ import { useActiveObject } from "../platform/ActiveObjectContext";
import { createQuickAccessLaunchContext } from "../platform/launchContext"; import { createQuickAccessLaunchContext } from "../platform/launchContext";
import { isViewSurfaceVisible } from "../platform/views"; import { isViewSurfaceVisible } from "../platform/views";
import { hasAnyScope, hasScope } from "../utils/permissions"; import { hasAnyScope, hasScope } from "../utils/permissions";
import { projectProductNavigation } from "../platform/productSurfaces";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
@@ -20,6 +21,7 @@ type Props = {
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void; onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
publicMode?: boolean; publicMode?: boolean;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
maintenanceMode?: { enabled: boolean; message?: string | null }; maintenanceMode?: { enabled: boolean; message?: string | null };
backendReachable?: boolean; backendReachable?: boolean;
}; };
@@ -32,6 +34,7 @@ export default function AppShell({
onAuthChange, onAuthChange,
publicMode = false, publicMode = false,
navItems = [], navItems = [],
allToolItems = navItems,
maintenanceMode, maintenanceMode,
backendReachable = true backendReachable = true
}: Props) { }: Props) {
@@ -68,11 +71,15 @@ export default function AppShell({
() => modules.flatMap((module) => module.productAreas ?? []), () => modules.flatMap((module) => module.productAreas ?? []),
[modules] [modules]
); );
const productNavigation = useMemo(
() => projectProductNavigation(navItems, modules, auth, projection, allToolItems),
[allToolItems, auth, modules, navItems, projection]
);
if (publicMode) { if (publicMode) {
return ( return (
<div className="app-shell public-shell"> <div className="app-shell public-shell">
<IconRail compact auth={auth} navItems={navItems} /> <IconRail compact navItems={navItems} />
<div className="app-main public-main"> <div className="app-main public-main">
<Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} /> <Titlebar settings={settings} auth={auth} onSettingsChange={onSettingsChange} onAuthChange={onAuthChange} maintenanceMode={maintenanceMode} backendReachable={backendReachable} />
<main className="public-content">{children}</main> <main className="public-content">{children}</main>
@@ -84,8 +91,8 @@ export default function AppShell({
return ( return (
<div className="app-shell"> <div className="app-shell">
<IconRail <IconRail
auth={auth} navItems={productNavigation.primaryItems}
navItems={navItems} allToolItems={productNavigation.allToolItems}
productAreas={productAreas} productAreas={productAreas}
presentation={projection?.presentation} presentation={projection?.presentation}
/> />
+22 -21
View File
@@ -1,45 +1,35 @@
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router"; import { NavLink, useLocation } from "react-router";
import { useEffect, useMemo, useState, type MouseEvent } from "react"; import { lazy, Suspense, useEffect, useMemo, useState, type MouseEvent } from "react";
import type { import type {
AuthInfo,
PlatformNavItem, PlatformNavItem,
ProductAreaContribution, ProductAreaContribution,
ViewPresentation ViewPresentation
} from "../types"; } from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { usePlatformLanguage } from "../i18n/LanguageContext"; import { usePlatformLanguage } from "../i18n/LanguageContext";
import { useGuardedNavigate } from "../components/UnsavedChangesGuard"; import { useGuardedNavigate } from "../components/UnsavedChangesGuard";
import { groupNavigationItems } from "../platform/productAreas"; import { groupNavigationItems } from "../platform/productAreas";
const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav"; const MODULE_NAV_STORAGE_KEY = "govoplan.lastModuleNav";
const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded"; const RAIL_EXPANDED_STORAGE_KEY = "govoplan.iconRailExpanded";
const AllToolsNavigation = lazy(() => import("./AllToolsNavigation"));
function visibleNavItems(auth: AuthInfo | null | undefined, navItems: PlatformNavItem[]): PlatformNavItem[] {
return [...navItems].
sort((left, right) => (left.order ?? 100) - (right.order ?? 100)).
filter((item) => {
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
return true;
});
}
export default function IconRail({ export default function IconRail({
compact = false, compact = false,
auth = null,
navItems = [], navItems = [],
allToolItems = [],
productAreas = [], productAreas = [],
presentation presentation
}: { }: {
compact?: boolean; compact?: boolean;
auth?: AuthInfo | null;
navItems?: PlatformNavItem[]; navItems?: PlatformNavItem[];
allToolItems?: PlatformNavItem[];
productAreas?: ProductAreaContribution[]; productAreas?: ProductAreaContribution[];
presentation?: ViewPresentation; presentation?: ViewPresentation;
}) { }) {
const location = useLocation(); const location = useLocation();
const items = visibleNavItems(auth, navItems); const items = navItems;
const technicalItems = allToolItems;
const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets()); const [rememberedTargets, setRememberedTargets] = useState<Record<string, string>>(() => loadRememberedTargets());
const [expanded, setExpanded] = useState(() => loadRailExpanded()); const [expanded, setExpanded] = useState(() => loadRailExpanded());
const topLevelItems = useMemo(() => items.map((item) => item.to), [items]); const topLevelItems = useMemo(() => items.map((item) => item.to), [items]);
@@ -95,9 +85,9 @@ export default function IconRail({
{translateText(group.label)} {translateText(group.label)}
</div> </div>
)} )}
{group.items.map(({ to, label, icon: Icon }) => { {group.items.map(({ to, label, icon: Icon, activePaths }) => {
const target = rememberedTargets[to] ?? to; const target = rememberedTargets[to] ?? to;
const active = modulePathActive(location.pathname, to); const active = modulePathActive(location.pathname, to, activePaths);
const renderedLabel = translateText(label); const renderedLabel = translateText(label);
const areaLabel = group.areaLabel const areaLabel = group.areaLabel
? translateText(group.areaLabel) ? translateText(group.areaLabel)
@@ -120,6 +110,11 @@ export default function IconRail({
})} })}
</div> </div>
))} ))}
{technicalItems.length > 0 && (
<Suspense fallback={null}>
<AllToolsNavigation items={technicalItems} rememberedTargets={rememberedTargets} />
</Suspense>
)}
</nav> </nav>
</div> </div>
<div className="icon-rail-bottom"> <div className="icon-rail-bottom">
@@ -144,9 +139,15 @@ export default function IconRail({
} }
function modulePathActive(pathname: string, root: string): boolean { function modulePathActive(
if (root === "/") return pathname === "/"; pathname: string,
return pathname === root || pathname.startsWith(`${root}/`); root: string,
activePaths: string[] = []
): boolean {
return [root, ...activePaths].some((candidate) => {
if (candidate === "/") return pathname === "/";
return pathname === candidate || pathname.startsWith(`${candidate}/`);
});
} }
function loadRememberedTargets(): Record<string, string> { function loadRememberedTargets(): Record<string, string> {
+1
View File
@@ -237,6 +237,7 @@ function applyServerMetadata(module: PlatformWebModule, info: PlatformModuleInfo
publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes), publicRoutes: filterPublicRoutes(module, info.frontend?.public_routes),
viewSurfaces: mergeViewSurfaces(module, info), viewSurfaces: mergeViewSurfaces(module, info),
productAreas: productAreasFromMetadata(info), productAreas: productAreasFromMetadata(info),
productSurfaceMetadata: info.frontend?.product_surfaces,
quickAccessTools: quickAccessToolsFromMetadata(info), quickAccessTools: quickAccessToolsFromMetadata(info),
helpContexts: info.help_contexts ?? module.helpContexts, helpContexts: info.help_contexts ?? module.helpContexts,
uiCapabilities: { uiCapabilities: {
+1 -9
View File
@@ -87,13 +87,5 @@ export function groupNavigationItems(
items: remaining items: remaining
}); });
} }
const overview = groups.filter((group) => group.id === "overview"); return groups;
const configurable = groups
.filter((group) => group.id !== "overview")
.sort((left, right) => minimumOrder(left.items) - minimumOrder(right.items));
return [...overview, ...configurable];
}
function minimumOrder(items: PlatformNavItem[]): number {
return Math.min(...items.map((item) => item.order ?? 100), 10_000);
} }
+216
View File
@@ -0,0 +1,216 @@
import type {
AuthInfo,
ComposedProductSurface,
EffectiveViewProjection,
PlatformNavItem,
ProductSurfaceMetadata,
PlatformWebModule,
ProductSurfaceContribution
} from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
import { isViewSurfaceVisible, viewSurfaceCatalogueForModules } from "./views";
export const PRODUCT_SURFACE_ROUTE_RESOLVED_EVENT = "govoplan:product-surface-route-resolved";
export type ProductSurfaceRouteResolvedEventDetail = {
contractVersion: "1";
productSurfaceId: string;
requestedPath: string;
targetPath: string;
contributorModuleId: string;
usedAlias: boolean;
};
export type ProductNavigationProjection = {
primaryItems: PlatformNavItem[];
allToolItems: PlatformNavItem[];
};
export function composeProductSurfaces(
modules: readonly PlatformWebModule[]
): ComposedProductSurface[] {
const composed = new Map<string, ComposedProductSurface>();
const contributions = modules.flatMap((module) => [
...(module.productSurfaces ?? []),
...(module.productSurfaceMetadata ?? []).map(productSurfaceFromMetadata)
]);
for (const contribution of contributions) {
const existing = composed.get(contribution.id);
if (!existing) {
composed.set(contribution.id, {
contractVersion: contribution.contractVersion,
id: contribution.id,
label: contribution.label,
description: contribution.description,
iconName: contribution.iconName,
entryPath: contribution.entryPath,
presentations: [...contribution.presentations],
contributors: [contribution],
aliases: [...contribution.aliases],
order: contribution.order
});
continue;
}
assertSharedIdentity(existing, contribution);
existing.contributors.push(contribution);
existing.aliases = [...new Set([...existing.aliases, ...contribution.aliases])];
existing.order = Math.min(existing.order, contribution.order);
}
return [...composed.values()]
.map((surface) => ({
...surface,
contributors: [...surface.contributors].sort(compareContributions),
aliases: [...surface.aliases].sort()
}))
.sort((left, right) => left.order - right.order || left.label.localeCompare(right.label));
}
function productSurfaceFromMetadata(surface: ProductSurfaceMetadata): ProductSurfaceContribution {
return {
contractVersion: surface.contract_version,
id: surface.id,
moduleId: surface.module_id,
label: surface.label,
description: surface.description,
iconName: surface.icon,
entryPath: surface.entry_path,
routePath: surface.route_path,
surfaceIds: surface.surface_ids,
presentations: surface.presentations,
capabilityIds: surface.capability_ids,
searchSourceIds: surface.search_source_ids,
helpContextIds: surface.help_context_ids,
documentationTopicIds: surface.documentation_topic_ids,
allOf: surface.required_all,
anyOf: surface.required_any,
aliases: surface.aliases,
order: surface.order,
unavailable: {
...surface.unavailable,
responsibleRole: surface.unavailable.responsible_role
},
degraded: surface.degraded ? {
...surface.degraded,
responsibleRole: surface.degraded.responsible_role
} : null
};
}
export function availableProductSurfaceContributors(
surface: ComposedProductSurface,
auth: AuthInfo | null | undefined,
modules: readonly PlatformWebModule[],
projection?: EffectiveViewProjection | null
): ProductSurfaceContribution[] {
const catalogue = viewSurfaceCatalogueForModules([...modules]);
return surface.contributors.filter((contribution) => {
if (contribution.allOf.length && !contribution.allOf.every((scope) => hasScope(auth, scope))) {
return false;
}
if (contribution.anyOf.length && !hasAnyScope(auth, contribution.anyOf)) {
return false;
}
return contribution.surfaceIds.some((surfaceId) =>
isViewSurfaceVisible(projection, surfaceId, catalogue)
);
});
}
/**
* Replace authorized owner routes with stable product entries while retaining
* the complete permission-derived catalogue as an explicit escape.
*/
export function projectProductNavigation(
items: readonly PlatformNavItem[],
modules: readonly PlatformWebModule[],
auth: AuthInfo | null | undefined,
projection?: EffectiveViewProjection | null,
catalogueItems: readonly PlatformNavItem[] = items
): ProductNavigationProjection {
const authorizedItems = items.filter((item) => navigationItemAuthorized(item, auth));
const allToolItems = catalogueItems.filter((item) => navigationItemAuthorized(item, auth));
const ownerItemByPath = new Map(authorizedItems.map((item) => [item.to, item]));
const consumedOwnerPaths = new Set<string>();
const replacementByPath = new Map<string, PlatformNavItem>();
for (const surface of composeProductSurfaces(modules)) {
const contributors = availableProductSurfaceContributors(
surface,
auth,
modules,
projection
);
const navigable = contributors
.map((contribution) => ({
contribution,
item: ownerItemByPath.get(contribution.routePath)
}))
.filter((candidate): candidate is {
contribution: ProductSurfaceContribution;
item: PlatformNavItem;
} => candidate.item !== undefined);
const target = navigable[0];
if (!target) continue;
navigable.forEach(({ contribution }) => {
consumedOwnerPaths.add(contribution.routePath);
});
replacementByPath.set(target.contribution.routePath, {
...target.item,
to: surface.entryPath,
label: surface.label,
navigationId: surface.id,
activePaths: [
...surface.aliases,
...navigable.map(({ contribution }) => contribution.routePath)
]
});
}
const primaryItems = authorizedItems.flatMap((item) => {
const replacement = replacementByPath.get(item.to);
if (replacement) return [replacement];
return consumedOwnerPaths.has(item.to) ? [] : [item];
});
return { primaryItems, allToolItems };
}
export function dispatchProductSurfaceRouteResolved(
detail: ProductSurfaceRouteResolvedEventDetail
): void {
if (typeof window === "undefined") return;
window.dispatchEvent(new CustomEvent<ProductSurfaceRouteResolvedEventDetail>(
PRODUCT_SURFACE_ROUTE_RESOLVED_EVENT,
{ detail }
));
}
function assertSharedIdentity(
existing: ComposedProductSurface,
contribution: ProductSurfaceContribution
): void {
if (
existing.contractVersion !== contribution.contractVersion
|| existing.label !== contribution.label
|| existing.iconName !== contribution.iconName
|| existing.entryPath !== contribution.entryPath
|| existing.description !== contribution.description
) {
throw new Error(`Conflicting product surface identity: ${contribution.id}`);
}
}
function compareContributions(
left: ProductSurfaceContribution,
right: ProductSurfaceContribution
): number {
return left.order - right.order || left.moduleId.localeCompare(right.moduleId);
}
function navigationItemAuthorized(
item: PlatformNavItem,
auth: AuthInfo | null | undefined
): boolean {
return !item.allOf?.some((scope) => !hasScope(auth, scope))
&& (!item.anyOf?.length || hasAnyScope(auth, item.anyOf));
}
+8
View File
@@ -0,0 +1,8 @@
import type { PlatformTranslations } from "./types";
import { generatedTranslations as messages } from "./i18n/messagesProductSurfaceTranslations";
import { generatedTranslations as outcomes } from "./i18n/outcomeProductSurfaceTranslations";
export const productSurfaceTranslations = {
en: { ...messages.en, ...outcomes.en },
de: { ...messages.de, ...outcomes.de }
} satisfies PlatformTranslations;
+1 -1
View File
@@ -1,5 +1,5 @@
.status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: var(--radius-pill); padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; } .status-badge { display: inline-flex; align-items: center; height: 24px; border-radius: var(--radius-pill); padding: 0 9px; font-size: 12px; font-weight: 800; background: var(--status-neutral-bg); color: var(--text-soft); text-transform: uppercase; }
.status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text-strong); } .status-ready, .status-sent, .status-appended, .status-success, .status-active { background: var(--success-soft); color: var(--success-text); }
.status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); } .status-warning, .status-needs-review, .status-pending { background: var(--warning-soft); color: var(--warning-text-strong); }
.status-blocked, .status-error, .status-danger, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); } .status-blocked, .status-error, .status-danger, .status-failed, .status-failed-permanent { background: var(--danger-bg); color: var(--danger-text-strong); }
.status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); } .status-queued, .status-sending { background: var(--info-soft); color: var(--info-text-strong); }
+10
View File
@@ -29,6 +29,16 @@
.icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } .icon-nav-label { display: none; min-width: 0; overflow: hidden; padding-right: 14px; font-size: 13px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.icon-rail.expanded .icon-nav-label { display: block; } .icon-rail.expanded .icon-nav-label { display: block; }
.icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); } .icon-nav-item:hover, .icon-nav-item.active { background: var(--rail-bg-active); color: var(--on-accent); border-left-color: var(--accent); }
.icon-nav-all-tools { width: 100%; min-width: 0; border-top: 1px solid var(--rail-bg-active); }
.icon-nav-all-tools-summary { width: 100%; height: 52px; display: grid; grid-template-columns: 55px minmax(0, 1fr); align-items: center; box-sizing: border-box; border-left: 3px solid transparent; color: var(--rail-text-muted); cursor: pointer; list-style: none; }
.icon-nav-all-tools-summary::-webkit-details-marker { display: none; }
.icon-nav-all-tools-summary > svg { justify-self: center; }
.icon-nav-all-tools-summary:hover,
.icon-nav-all-tools-summary:focus-visible,
.icon-nav-all-tools[open] > .icon-nav-all-tools-summary { background: var(--rail-bg-active); color: var(--on-accent); outline: none; }
.icon-nav-all-tools-summary:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.icon-nav-all-tools-items { width: 100%; min-width: 0; background: color-mix(in srgb, var(--rail-bg-active) 45%, var(--rail-bg)); }
.icon-nav-all-tools-items .icon-nav-item { min-height: 46px; height: 46px; }
.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); }
+106 -3
View File
@@ -304,6 +304,8 @@ export type PlatformNavItem = {
navigationVisibilitySource?: string; navigationVisibilitySource?: string;
navigationLockSource?: string | null; navigationLockSource?: string | null;
navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>; navigationLayers?: Partial<Record<"module" | "system" | "tenant", NavigationLayerState>>;
/** Additional stable or owner paths that should mark a composed product entry active. */
activePaths?: string[];
}; };
export type NavigationLayerState = { export type NavigationLayerState = {
@@ -330,6 +332,59 @@ export type ProductAreaContribution = {
order?: number; order?: number;
}; };
export type ProductSurfacePresentation = "task" | "reader" | "admin" | "operator";
export type ProductAvailabilityReason =
| "authorization"
| "policy"
| "configuration"
| "disabled"
| "capability"
| "offline"
| "provider_degraded";
export type ProductAvailabilityExplanation = {
reason: ProductAvailabilityReason;
title: string;
description: string;
resolution: string;
responsibleRole?: string | null;
};
export type ProductSurfaceContribution = {
contractVersion: "1";
id: string;
moduleId: string;
label: string;
description?: string | null;
iconName: PlatformIconName;
entryPath: string;
routePath: string;
surfaceIds: string[];
presentations: ProductSurfacePresentation[];
capabilityIds: string[];
searchSourceIds: string[];
helpContextIds: string[];
documentationTopicIds: string[];
allOf: string[];
anyOf: string[];
aliases: string[];
order: number;
unavailable: ProductAvailabilityExplanation;
degraded?: ProductAvailabilityExplanation | null;
};
export type ComposedProductSurface = Omit<
ProductSurfaceContribution,
"moduleId" | "routePath" | "surfaceIds" | "capabilityIds" |
"searchSourceIds" | "helpContextIds" | "documentationTopicIds" |
"allOf" | "anyOf" | "aliases" | "order" | "unavailable" | "degraded"
> & {
contributors: ProductSurfaceContribution[];
aliases: string[];
order: number;
};
export type QuickAccessToolMetadata = { export type QuickAccessToolMetadata = {
contractVersion: "1"; contractVersion: "1";
id: string; id: string;
@@ -507,6 +562,8 @@ export type PlatformWebModule = {
runtimeUiCapabilities?: PlatformUiCapabilities; runtimeUiCapabilities?: PlatformUiCapabilities;
viewSurfaces?: PlatformViewSurface[]; viewSurfaces?: PlatformViewSurface[];
productAreas?: ProductAreaContribution[]; productAreas?: ProductAreaContribution[];
productSurfaces?: ProductSurfaceContribution[];
productSurfaceMetadata?: ProductSurfaceMetadata[];
quickAccessTools?: QuickAccessToolMetadata[]; quickAccessTools?: QuickAccessToolMetadata[];
helpContexts?: PlatformDocumentationHelpContext[]; helpContexts?: PlatformDocumentationHelpContext[];
}; };
@@ -847,6 +904,16 @@ export type MailImapTransportSettings = MailTransportSettings & {
folder_mappings?: MailImapFolderMappings | null; folder_mappings?: MailImapFolderMappings | null;
}; };
export type MailJmapTransportSettings = {
session_url: string;
account_id?: string | null;
auth_scheme?: "bearer" | "basic";
timeout_seconds?: number | null;
max_response_bytes?: number | null;
max_body_value_bytes?: number | null;
allowed_api_origins?: string[];
};
export type MailServerProfileCredentials = { export type MailServerProfileCredentials = {
smtp?: MailTransportCredentials | null; smtp?: MailTransportCredentials | null;
imap?: MailTransportCredentials | null; imap?: MailTransportCredentials | null;
@@ -883,9 +950,9 @@ export type MailServerEndpoint = {
id: string; id: string;
profile_id: string; profile_id: string;
tenant_id?: string | null; tenant_id?: string | null;
protocol: "smtp" | "imap"; protocol: "smtp" | "imap" | "jmap" | "pop3";
name: string; name: string;
config: MailTransportSettings | MailImapTransportSettings; config: MailTransportSettings | MailImapTransportSettings | MailJmapTransportSettings;
scope_type: MailProfileScope; scope_type: MailProfileScope;
scope_id?: string | null; scope_id?: string | null;
inherit_to_lower_scopes: boolean; inherit_to_lower_scopes: boolean;
@@ -922,7 +989,7 @@ export type MailCredentialPolicy = {
allow_override?: boolean | null; allow_override?: boolean | null;
}; };
export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains"; export type MailProfilePatternKey = "smtp_hosts" | "imap_hosts" | "jmap_hosts" | "envelope_senders" | "from_headers" | "recipient_domains";
export type MailProfilePolicy = { export type MailProfilePolicy = {
allowed_profile_ids?: string[] | null; allowed_profile_ids?: string[] | null;
@@ -1245,6 +1312,40 @@ export type PlatformFrontendModuleInfo = {
surface_ids: string[]; surface_ids: string[];
order: number; order: number;
}>; }>;
product_surfaces?: Array<{
contract_version: "1";
id: string;
module_id: string;
label: string;
description?: string | null;
icon: string;
entry_path: string;
route_path: string;
surface_ids: string[];
presentations: ProductSurfacePresentation[];
capability_ids: string[];
search_source_ids: string[];
help_context_ids: string[];
documentation_topic_ids: string[];
required_all: string[];
required_any: string[];
aliases: string[];
order: number;
unavailable: {
reason: ProductAvailabilityReason;
title: string;
description: string;
resolution: string;
responsible_role?: string | null;
};
degraded?: {
reason: ProductAvailabilityReason;
title: string;
description: string;
resolution: string;
responsible_role?: string | null;
} | null;
}>;
quick_access_tools?: Array<{ quick_access_tools?: Array<{
id: string; id: string;
module_id: string; module_id: string;
@@ -1267,6 +1368,8 @@ export type PlatformFrontendModuleInfo = {
}>; }>;
}; };
export type ProductSurfaceMetadata = NonNullable<PlatformFrontendModuleInfo["product_surfaces"]>[number];
export type PlatformDocumentationHelpContext = { export type PlatformDocumentationHelpContext = {
id: string; id: string;
topic_id: string; topic_id: string;
+22
View File
@@ -19,6 +19,7 @@ import MetricGrid from "../src/components/MetricGrid";
import PageActionBar from "../src/components/PageActionBar"; import PageActionBar from "../src/components/PageActionBar";
import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList"; import SelectionList, { SelectionListItem, SelectionListItemContent } from "../src/components/SelectionList";
import StatePanel from "../src/components/StatePanel"; import StatePanel from "../src/components/StatePanel";
import ProductAvailabilityState from "../src/components/ProductAvailabilityState";
import WorkspaceLayout from "../src/components/WorkspaceLayout"; import WorkspaceLayout from "../src/components/WorkspaceLayout";
import WorkspaceFrame from "../src/components/WorkspaceFrame"; import WorkspaceFrame from "../src/components/WorkspaceFrame";
import WorkspaceActionBar from "../src/components/WorkspaceActionBar"; import WorkspaceActionBar from "../src/components/WorkspaceActionBar";
@@ -183,6 +184,27 @@ assert(workspaceMarkup.includes("selection-list-navigation"), "resource navigati
assert(workspaceMarkup.includes("selection-list-item-content"), "selection-list copy owns title and description typography"); assert(workspaceMarkup.includes("selection-list-item-content"), "selection-list copy owns title and description typography");
assert(workspaceMarkup.includes("state-panel-size-fill"), "whole-surface states share sizing and action anatomy"); assert(workspaceMarkup.includes("state-panel-size-fill"), "whole-surface states share sizing and action anatomy");
const availabilityMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<ProductAvailabilityState
state="degraded"
explanation={{
reason: "provider_degraded",
title: "Messages are delayed",
description: "Saved messages remain available, but new provider results may be delayed.",
resolution: "Retry later or contact the integration operator.",
responsibleRole: "Integration operator"
}}
technical={{ moduleId: "mail", providerId: "smtp-primary", correlationId: "event-1" }}
actions={<button type="button">Retry</button>}
/>
</PlatformLanguageProvider>
);
assert(availabilityMarkup.includes("product-availability-degraded"), "product availability uses one semantic state primitive");
assert(availabilityMarkup.includes("Retry later or contact the integration operator."), "availability states include an actionable recovery path");
assert(availabilityMarkup.includes("<details"), "technical provenance remains available on demand");
assert(availabilityMarkup.includes("smtp-primary"), "technical details preserve exact provider provenance");
const frameMarkup = renderToStaticMarkup( const frameMarkup = renderToStaticMarkup(
<PlatformLanguageProvider><WorkspaceFrame as="main" height="viewport" label="Planning workspace" surface="panel"><span>Body</span></WorkspaceFrame></PlatformLanguageProvider> <PlatformLanguageProvider><WorkspaceFrame as="main" height="viewport" label="Planning workspace" surface="panel"><span>Body</span></WorkspaceFrame></PlatformLanguageProvider>
); );
+128
View File
@@ -18,6 +18,10 @@ import {
visibleRoutesForProjection visibleRoutesForProjection
} from "../src/platform/views"; } from "../src/platform/views";
import { groupNavigationItems } from "../src/platform/productAreas"; import { groupNavigationItems } from "../src/platform/productAreas";
import {
composeProductSurfaces,
projectProductNavigation
} from "../src/platform/productSurfaces";
import { hasAnyScope, scopeGrants } from "../src/utils/permissions"; import { hasAnyScope, scopeGrants } from "../src/utils/permissions";
function assert(condition: unknown, message: string): void { function assert(condition: unknown, message: string): void {
@@ -193,6 +197,130 @@ assert(
"flat navigation should retain every authorized destination" "flat navigation should retain every authorized destination"
); );
const productSurfaceExplanation = {
reason: "authorization" as const,
title: "Messages are unavailable",
description: "No message source is available for the current responsibility.",
resolution: "Ask the responsible administrator to review the assignment."
};
const messageSurfaceModules: PlatformWebModule[] = [
{
id: "mail",
label: "Mail",
version: "test",
productSurfaces: [{
contractVersion: "1",
id: "communication.messages",
moduleId: "mail",
label: "Messages",
description: "Read messages without merging channel custody.",
iconName: "mail",
entryPath: "/messages",
routePath: "/mail",
surfaceIds: ["mail.route.mail"],
presentations: ["task", "reader"],
capabilityIds: [],
searchSourceIds: ["mail.mailbox_messages"],
helpContextIds: ["mail.quick_access.messages"],
documentationTopicIds: ["mail.quick-access-and-product-area"],
allOf: [],
anyOf: ["mail:mailbox:read"],
aliases: ["/inbox"],
order: 10,
unavailable: productSurfaceExplanation
}]
},
{
id: "postbox",
label: "Postbox",
version: "test",
productSurfaceMetadata: [{
contract_version: "1",
id: "communication.messages",
module_id: "postbox",
label: "Messages",
description: "Read messages without merging channel custody.",
icon: "mail",
entry_path: "/messages",
route_path: "/postbox",
surface_ids: ["postbox.route.postbox"],
presentations: ["task", "reader"],
capability_ids: [],
search_source_ids: ["postbox.messages"],
help_context_ids: ["postbox.quick_access.messages"],
documentation_topic_ids: ["postbox.quick-access-and-product-area"],
required_all: [],
required_any: ["postbox:message:read"],
aliases: ["/inbox"],
order: 20,
unavailable: productSurfaceExplanation
}]
}
];
const composedMessages = composeProductSurfaces(messageSurfaceModules);
assert(composedMessages.length === 1, "related owner routes should compose into one product identity");
assert(composedMessages[0]?.entryPath === "/messages", "the composed identity should keep its stable entry path");
assert(composedMessages[0]?.contributors.map((item) => item.moduleId).join(",") === "mail,postbox", "composition should retain ordered technical provenance");
assert(composedMessages[0]?.aliases.join(",") === "/inbox", "migration aliases should be de-duplicated across owners");
const messageNavigation = projectProductNavigation(
[
{ to: "/dashboard", label: "Dashboard", order: 1 },
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] },
{ to: "/admin", label: "Administration", order: 90, anyOf: ["core:admin:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo
);
assert(
messageNavigation.primaryItems.map((item) => item.to).join(",") === "/dashboard,/messages",
"ordinary navigation should replace authorized owner routes with one stable product entry"
);
assert(
messageNavigation.primaryItems.find((item) => item.to === "/messages")?.activePaths?.includes("/postbox"),
"the product entry should remain active on an owner route"
);
assert(
messageNavigation.allToolItems.map((item) => item.to).join(",") === "/dashboard,/mail,/postbox",
"the explicit tool catalogue should retain authorized technical routes"
);
const mailOnlyNavigation = projectProductNavigation(
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read"] } as AuthInfo
);
assert(
mailOnlyNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"composition should remain stable when only one optional contributor is authorized"
);
assert(
mailOnlyNavigation.allToolItems.map((item) => item.to).join(",") === "/mail",
"the tool catalogue must not disclose unauthorized owner routes"
);
const focusedMessageNavigation = projectProductNavigation(
[{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] }],
messageSurfaceModules,
{ scopes: ["mail:mailbox:read", "postbox:message:read"] } as AuthInfo,
null,
[
{ to: "/mail", label: "Mail", order: 50, anyOf: ["mail:mailbox:read"] },
{ to: "/postbox", label: "Postbox", order: 51, anyOf: ["postbox:message:read"] }
]
);
assert(
focusedMessageNavigation.primaryItems.map((item) => item.to).join(",") === "/messages",
"a focused View should keep the selected stable product destination"
);
assert(
focusedMessageNavigation.allToolItems.map((item) => item.to).join(",") === "/mail,/postbox",
"All available tools should provide an explicit permission-derived escape from View focus"
);
const viewAwareFiles: PlatformWebModule = { const viewAwareFiles: PlatformWebModule = {
...files, ...files,
navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }], navItems: [{ to: "/files", label: "Files", order: 20, anyOf: ["files:file:read"] }],
+18 -1
View File
@@ -4,6 +4,7 @@ function assert(condition: unknown, message = "assertion failed"): asserts condi
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import PasswordField from "../src/components/PasswordField"; import PasswordField from "../src/components/PasswordField";
import PasswordGeneratorDialog from "../src/components/PasswordGeneratorDialog";
import { import {
DEFAULT_PASSWORD_GENERATOR_OPTIONS, DEFAULT_PASSWORD_GENERATOR_OPTIONS,
generateSecurePassword, generateSecurePassword,
@@ -56,11 +57,27 @@ for (const [options, expected] of [
const markup = renderToStaticMarkup( const markup = renderToStaticMarkup(
<PlatformLanguageProvider> <PlatformLanguageProvider>
<PasswordField value="" onValueChange={() => undefined} generator /> <PasswordField value="" onValueChange={() => undefined} generator helpContextId="access.authentication.password" helpModuleId="access" />
</PlatformLanguageProvider> </PlatformLanguageProvider>
); );
assert(markup.includes('aria-label="Generate password"'), "the opt-in generator action is accessible"); assert(markup.includes('aria-label="Generate password"'), "the opt-in generator action is accessible");
assert(markup.includes("lucide-dice-5"), "the familiar generator icon is used"); assert(markup.includes("lucide-dice-5"), "the familiar generator icon is used");
assert(!markup.includes("password-generator-dialog"), "the generator dialog stays closed until explicitly requested"); assert(!markup.includes("password-generator-dialog"), "the generator dialog stays closed until explicitly requested");
assert(markup.includes('data-help-context-id="access.authentication.password"'), "the owner context reaches the password field and its actions");
assert(markup.includes('data-help-module-id="access"'), "the password field retains its documentation owner");
const dialogMarkup = renderToStaticMarkup(
<PlatformLanguageProvider>
<PasswordGeneratorDialog
open
helpContextId="access.authentication.password"
helpModuleId="access"
onUse={() => undefined}
onClose={() => undefined}
/>
</PlatformLanguageProvider>
);
assert(dialogMarkup.includes('data-help-context-id="access.authentication.password"'), "the generator dialog inherits the calling credential context");
assert(dialogMarkup.includes('data-help-module-id="access"'), "generated-password controls retain the credential owner's module");
console.log("Password generator contract passed."); console.log("Password generator contract passed.");
+3
View File
@@ -31,6 +31,9 @@
], ],
"@govoplan/core-webui/wysiwyg": [ "@govoplan/core-webui/wysiwyg": [
"./src/wysiwyg.ts" "./src/wysiwyg.ts"
],
"@govoplan/core-webui/outcome-product-surface-translations": [
"./src/i18n/outcomeProductSurfaceTranslations.ts"
] ]
} }
}, },
+1
View File
@@ -22,6 +22,7 @@
"tests/definition-graph.test.ts", "tests/definition-graph.test.ts",
"src/platform/moduleLogic.ts", "src/platform/moduleLogic.ts",
"src/platform/productAreas.ts", "src/platform/productAreas.ts",
"src/platform/productSurfaces.ts",
"src/platform/launchContext.ts", "src/platform/launchContext.ts",
"src/utils/helpContext.ts", "src/utils/helpContext.ts",
"src/features/privacy/policyLogic.ts", "src/features/privacy/policyLogic.ts",
+11
View File
@@ -32,6 +32,7 @@ const defaultWebModulePackages = [
"@govoplan/files-webui", "@govoplan/files-webui",
"@govoplan/forms-webui", "@govoplan/forms-webui",
"@govoplan/forms-runtime-webui", "@govoplan/forms-runtime-webui",
"@govoplan/helpdesk-webui",
"@govoplan/idm-webui", "@govoplan/idm-webui",
"@govoplan/identity-webui", "@govoplan/identity-webui",
"@govoplan/identity-trust-webui", "@govoplan/identity-trust-webui",
@@ -52,8 +53,10 @@ const defaultWebModulePackages = [
"@govoplan/search-webui", "@govoplan/search-webui",
"@govoplan/tenancy-webui", "@govoplan/tenancy-webui",
"@govoplan/templates-webui", "@govoplan/templates-webui",
"@govoplan/tickets-webui",
"@govoplan/views-webui", "@govoplan/views-webui",
"@govoplan/voting-webui", "@govoplan/voting-webui",
"@govoplan/wiki-webui",
"@govoplan/workflow-webui" "@govoplan/workflow-webui"
]; ];
@@ -211,6 +214,10 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: deferredVendorChunk, manualChunks: deferredVendorChunk,
// Dynamic chunk names are implementation details. Keeping only the
// content hash avoids shipping every source/module name in Vite's
// preload table, which is part of the initial application payload.
chunkFileNames: "assets/c-[hash].js",
// Keep dependencies of deferred BPMN packages in their lazy graph. The // Keep dependencies of deferred BPMN packages in their lazy graph. The
// legacy Rollup behavior merged those dependencies into manual chunks // legacy Rollup behavior merged those dependencies into manual chunks
// and hoisted the properties-panel runtime into the application entry. // and hoisted the properties-panel runtime into the application entry.
@@ -243,6 +250,7 @@ export default defineConfig({
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) }, { find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) }, { find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) }, { find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/outcome-product-surface-translations", replacement: fileURLToPath(new URL("./src/i18n/outcomeProductSurfaceTranslations.ts", import.meta.url)) },
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) } { find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
] ]
}, },
@@ -266,6 +274,7 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-files/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-forms/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-forms/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-forms-runtime/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-forms-runtime/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-helpdesk/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-idm/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-identity/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-identity/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-mail/webui', import.meta.url)),
@@ -283,8 +292,10 @@ export default defineConfig({
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-tenancy/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-tenancy/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-templates/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-templates/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-tickets/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-voting/webui', import.meta.url)), fileURLToPath(new URL('../../govoplan-voting/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-wiki/webui', import.meta.url)),
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url)) fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
] ]
}, },