diff --git a/docs/CONFIGURATION_PACKAGES.md b/docs/CONFIGURATION_PACKAGES.md index c2a76dd..464392b 100644 --- a/docs/CONFIGURATION_PACKAGES.md +++ b/docs/CONFIGURATION_PACKAGES.md @@ -157,6 +157,16 @@ The initial implementation includes provider-neutral orchestration helpers: - `apply_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`. It supports access-owned `roles`, `groups`, and `group_role_assignments` fragments and applies them idempotently. Mail and Files also register providers @@ -212,6 +222,14 @@ The admin wizard backend starts with these routes: 10. Store import provenance, package version, supplied non-secret metadata, and 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 sections should cover package trust, dependency plan, required data, conflicts, review, and result. Module-specific fields should appear only when the selected @@ -262,6 +280,11 @@ Exported packages should record provenance: source GovOPlaN version, module versions, exporter identity, timestamp, selected scope, redactions, and 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 Configuration catalogs should follow the existing module package catalog model: diff --git a/pyproject.toml b/pyproject.toml index 16c56cb..458c3ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-core" -version = "0.1.34" +version = "0.1.35" description = "Reusable GovOPlaN platform core, access, tenancy, and RBAC components." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_core/core/configuration_packages.py b/src/govoplan_core/core/configuration_packages.py index 1ba8aec..f3f89f8 100644 --- a/src/govoplan_core/core/configuration_packages.py +++ b/src/govoplan_core/core/configuration_packages.py @@ -3,6 +3,8 @@ from __future__ import annotations import base64 from collections.abc import Mapping, Sequence from dataclasses import dataclass, field +from datetime import UTC, datetime +from importlib.metadata import PackageNotFoundError, version as package_version from pathlib import Path import json import os @@ -38,6 +40,12 @@ CONFIGURATION_PROVIDER_CAPABILITY = "configuration.provider" DiagnosticSeverity = Literal["blocker", "warning", "info"] PlanAction = Literal["create", "update", "bind", "skip", "blocked", "noop"] +ConfigurationRollbackStatus = Literal[ + "blocked_before_apply", + "not_required", + "database_restore_required", + "partial_apply_requires_recovery", +] ConfigurationPackageClass = Literal[ "reference", "product", @@ -461,6 +469,21 @@ class ConfigurationApplyResult: diagnostics: tuple[ConfigurationDiagnostic, ...] = () created_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) @@ -471,11 +494,40 @@ class ConfigurationExportSelection: 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) class ConfigurationExportResult: fragments: tuple[ConfigurationPackageFragment, ...] = () data_requirements: tuple[ConfigurationRequiredData, ...] = () diagnostics: tuple[ConfigurationDiagnostic, ...] = () + provenance: ConfigurationExportProvenance | None = None @runtime_checkable @@ -508,6 +560,7 @@ def dry_run_configuration_package( diagnostics: list[ConfigurationDiagnostic] = [] required_data: list[ConfigurationRequiredData] = [] plan: list[ConfigurationPlanItem] = [] + declared_data: dict[str, ConfigurationRequiredData] = {} diagnostics.extend(_module_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: requirement = ConfigurationRequiredData.from_mapping(item) required_data.append(requirement) + declared_data[requirement.key] = requirement if requirement.required and requirement.key not in context.supplied_data: diagnostics.append(ConfigurationDiagnostic( severity="blocker", @@ -525,6 +579,25 @@ def dry_run_configuration_package( )) 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) if provider is None: 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.")) continue try: - result = provider.preflight(fragment, context) + result = provider.preflight(resolved_fragment, context) except Exception as exc: diagnostics.append(ConfigurationDiagnostic( severity="blocker", @@ -605,19 +678,41 @@ def apply_configuration_package( preflight = dry_run_configuration_package(manifest, providers, apply_context) blockers = [item for item in preflight.diagnostics if item.severity == "blocker"] 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) diagnostics: list[ConfigurationDiagnostic] = list(preflight.diagnostics) created_refs: dict[str, str] = {} updated_refs: dict[str, str] = {} + stopped_after_blocker = False for fragment in manifest.fragments: provider = provider_map[fragment.module_id] + resolved_fragment = _resolve_fragment_data_references( + fragment, + apply_context.supplied_data, + ) 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) created_refs.update(result.created_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: diagnostics.append(ConfigurationDiagnostic( severity="blocker", @@ -627,10 +722,36 @@ def apply_configuration_package( object_ref=fragment.fragment_id or fragment.fragment_type, 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( diagnostics=tuple(_dedupe_diagnostics(diagnostics)), created_refs=created_refs, updated_refs=updated_refs, + rollback=rollback, ) @@ -669,10 +790,29 @@ def export_configuration_package( fragments.extend(result.fragments) data_requirements.extend(result.data_requirements) 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( fragments=tuple(fragments), - data_requirements=tuple(_dedupe_required_data(data_requirements)), + data_requirements=deduped_required_data, diagnostics=tuple(_dedupe_diagnostics(diagnostics)), + provenance=provenance, ) @@ -1283,6 +1423,103 @@ def _dedupe_required_data(items: Sequence[ConfigurationRequiredData]) -> list[Co 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: if path is not None: return path if isinstance(path, str) and _is_http_url(path) else Path(path).expanduser() diff --git a/tests/test_configuration_package_architecture.py b/tests/test_configuration_package_architecture.py index 9e9259b..a4bbaf4 100644 --- a/tests/test_configuration_package_architecture.py +++ b/tests/test_configuration_package_architecture.py @@ -3,13 +3,22 @@ from __future__ import annotations import unittest from govoplan_core.core.configuration_packages import ( + ConfigurationApplyResult, + ConfigurationExportResult, + ConfigurationExportSelection, ConfigurationModuleRequirement, + ConfigurationPackageFragment, ConfigurationPackageEvidence, ConfigurationPackageManifest, ConfigurationPackageParent, + ConfigurationPlanItem, ConfigurationPreflightContext, + ConfigurationPreflightResult, ConfigurationProviderExpectation, + ConfigurationRequiredData, + apply_configuration_package, dry_run_configuration_package, + export_configuration_package, validate_configuration_package_derivation, ) @@ -27,6 +36,121 @@ def _evidence(*kinds: str) -> tuple[ConfigurationPackageEvidence, ...]: 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: package = ConfigurationPackageManifest.from_mapping( {"package_id": "example", "name": "Example", "version": "1.0.0"} diff --git a/webui/package-lock.json b/webui/package-lock.json index 843e583..a0f848d 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1,12 +1,12 @@ { "name": "@govoplan/core-webui", - "version": "0.1.34", + "version": "0.1.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.34", + "version": "0.1.35", "dependencies": { "@govoplan/access-webui": "file:../../govoplan-access/webui", "@govoplan/addresses-webui": "file:../../govoplan-addresses/webui", @@ -85,7 +85,7 @@ }, "../../govoplan-access/webui": { "name": "@govoplan/access-webui", - "version": "0.1.19", + "version": "0.1.20", "devDependencies": { "typescript": "^5.7.2" }, @@ -120,12 +120,12 @@ }, "../../govoplan-admin/webui": { "name": "@govoplan/admin-webui", - "version": "0.1.18", + "version": "0.1.19", "devDependencies": { "typescript": "^5.7.2" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.35", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", @@ -394,7 +394,7 @@ }, "../../govoplan-forms/webui": { "name": "@govoplan/forms-webui", - "version": "0.1.19", + "version": "0.1.20", "peerDependencies": { "@govoplan/core-webui": "^0.1.18", "lucide-react": "^1.23.0", diff --git a/webui/package-lock.release.json b/webui/package-lock.release.json index 3f6f19f..daffdfb 100644 --- a/webui/package-lock.release.json +++ b/webui/package-lock.release.json @@ -1,15 +1,15 @@ { "name": "@govoplan/core-webui", - "version": "0.1.34", + "version": "0.1.35", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@govoplan/core-webui", - "version": "0.1.34", + "version": "0.1.35", "dependencies": { - "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.19", - "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18", + "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.20", + "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.19", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.18", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.18", "@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.22", @@ -757,8 +757,8 @@ "optional": true }, "node_modules/@govoplan/access-webui": { - "version": "0.1.19", - "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#2d1b1e356ecb8726219d4a502db78e61f435a88f", + "version": "0.1.20", + "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#206873b62a9c77ac5715f9ce7d6bc16d156efa74", "peerDependencies": { "@govoplan/core-webui": "^0.1.18", "lucide-react": "^1.23.0", @@ -773,10 +773,10 @@ } }, "node_modules/@govoplan/admin-webui": { - "version": "0.1.18", - "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#218f94fa23a2b1386ac89c001d9d69d155934ef0", + "version": "0.1.19", + "resolved": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#ed424c729cd0c1c7a1cbec387a793db0819c91fe", "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.35", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/package.json b/webui/package.json index 9ad68d3..126ca97 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.34", + "version": "0.1.35", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/package.release.json b/webui/package.release.json index 3b2fe25..36b6e4a 100644 --- a/webui/package.release.json +++ b/webui/package.release.json @@ -1,6 +1,6 @@ { "name": "@govoplan/core-webui", - "version": "0.1.34", + "version": "0.1.35", "private": true, "type": "module", "main": "src/index.ts", @@ -26,8 +26,8 @@ "preview": "vite preview --host 127.0.0.1 --port 4173" }, "dependencies": { - "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.19", - "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.18", + "@govoplan/access-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-access.git#v0.1.20", + "@govoplan/admin-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-admin.git#v0.1.19", "@govoplan/audit-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-audit.git#v0.1.18", "@govoplan/calendar-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-calendar.git#v0.1.18", "@govoplan/cases-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-cases.git#v0.1.20",