Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac40774785 | ||
|
|
9a3008002d | ||
|
|
9cb2080938 | ||
|
|
08c3e47b6d | ||
|
|
6e518fa6a2 | ||
|
|
f98cf9ced8 | ||
|
|
d2e491348d | ||
|
|
562d278f60 | ||
|
|
c6f6faf64f | ||
|
|
1c3ee9e8c7 | ||
|
|
aa91063211 | ||
|
|
fa2d5d40dd | ||
|
|
6ccef162f6 | ||
|
|
48dac139a5 | ||
|
|
a090e5af20 | ||
|
|
0c1358b862 | ||
|
|
a9035c4c3b |
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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. |
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"initialJs": {
|
"initialJs": {
|
||||||
"rawBytes": 524288,
|
"rawBytes": 524288,
|
||||||
"gzipBytes": 163840
|
"gzipBytes": 164128
|
||||||
},
|
},
|
||||||
"asyncChunk": {
|
"asyncChunk": {
|
||||||
"rawBytes": 393216,
|
"rawBytes": 393216,
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+90
-26
@@ -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
|
||||||
|
|||||||
+516
-448
File diff suppressed because it is too large
Load Diff
+9
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 &&
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>);
|
||||||
|
|||||||
@@ -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 })} />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}/`);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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,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); }
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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"] }],
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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))
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user