Release v0.1.5

This commit is contained in:
2026-07-07 15:49:06 +02:00
parent ee5b06b1e7
commit a5c24fe73e
18 changed files with 335 additions and 122 deletions

View File

@@ -0,0 +1,35 @@
---
name: "Bug"
about: "Report a reproducible defect, regression, or incorrect behavior"
title: "[Bug] "
labels:
- type/bug
- status/triage
- module/files
---
## Scope
- Repository:
- Area/module:
- Affected version or commit:
## Behavior
Expected:
Actual:
## Reproduction
1.
2.
3.
## Evidence
Logs, screenshots, traces, or failing test output:
## Verification Target
Command or workflow that should pass when fixed:

View File

@@ -0,0 +1 @@
blank_issues_enabled: false

View File

@@ -0,0 +1,27 @@
---
name: "Docs / workflow"
about: "Request documentation, process, or developer workflow changes"
title: "[Docs] "
labels:
- type/docs
- status/triage
- module/files
- area/docs
---
## Scope
- Repository:
- Document or workflow:
## Current State
What is missing, unclear, duplicated, or stale?
## Desired State
What should the docs or workflow make clear?
## Verification Target
How should this be checked?

View File

@@ -0,0 +1,32 @@
---
name: "Feature"
about: "Propose new user-visible behavior or platform capability"
title: "[Feature] "
labels:
- type/feature
- status/triage
- module/files
---
## Problem
What user, operator, or developer problem should this solve?
## Proposed Capability
What should exist when this is done?
## Ownership
- Owning repository:
- Related module repositories:
- Extension point or integration boundary:
## Acceptance Criteria
- [ ]
- [ ]
## Verification Target
Command, scenario, or UI flow that should prove completion:

View File

@@ -0,0 +1,28 @@
---
name: "Task"
about: "Track implementation, maintenance, or migration work"
title: "[Task] "
labels:
- type/task
- status/triage
- module/files
---
## Objective
What needs to be completed?
## Scope
- Owning repository:
- In-scope:
- Out-of-scope:
## Checklist
- [ ]
- [ ]
## Verification Target
Command or manual check:

View File

@@ -0,0 +1,25 @@
---
name: "Tech debt"
about: "Track cleanup, refactoring, risk reduction, or deferred engineering work"
title: "[Debt] "
labels:
- type/debt
- status/triage
- module/files
---
## Current Cost
What does this make harder, riskier, slower, or more fragile?
## Desired Shape
What should the code, tests, or architecture look like afterwards?
## Constraints
What behavior, compatibility, or module boundary must be preserved?
## Verification Target
Focused checks that should pass:

View File

@@ -0,0 +1,15 @@
## Issue
Closes #
## Summary
-
## Verification
-
## Notes
Follow-up issues:

View File

@@ -46,7 +46,10 @@ Frontend package:
@govoplan/files-webui
```
The campaign module can integrate with files when both modules are installed, for example for managed attachment selection and campaign file sharing.
The campaign module can integrate with files when both modules are installed,
for example for managed attachment selection and campaign file sharing. Files
does not import campaign internals; campaign share/existence checks use the core
`campaigns.access` capability registered by the campaign module.
Platform RBAC and governance rules are documented in `govoplan-core/docs/`.

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/files-webui",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
@@ -19,7 +19,7 @@
"LICENSE"
],
"peerDependencies": {
"@govoplan/core-webui": "^0.1.4",
"@govoplan/core-webui": "^0.1.5",
"lucide-react": "^0.555.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-files"
version = "0.1.4"
version = "0.1.5"
description = "GovOPlaN files module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.4",
"govoplan-core>=0.1.5",
]
[tool.setuptools.packages.find]

View File

@@ -30,5 +30,5 @@ class FilesCampaignCapability:
def campaign_capability(context: ModuleContext) -> FilesCampaignCapability:
configure_runtime(settings=context.settings)
configure_runtime(registry=context.registry, settings=context.settings)
return FilesCampaignCapability()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from pathlib import Path
from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import FrontendModule, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
from govoplan_core.db.base import Base
from govoplan_files.backend.db import models as file_models # noqa: F401 - populate Files ORM metadata
@@ -54,10 +55,30 @@ ROLE_TEMPLATES = (
)
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
from govoplan_files.backend.db.models import FileAsset
return {"files": session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id).count()}
def _veto_group_delete(session, tenant_id: str, group_id: str) -> None:
from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare
owned_asset_count = session.query(FileAsset).filter(FileAsset.tenant_id == tenant_id, FileAsset.owner_group_id == group_id).count()
owned_folder_count = session.query(FileFolder).filter(FileFolder.tenant_id == tenant_id, FileFolder.owner_group_id == group_id).count()
shared_asset_count = session.query(FileShare).filter(
FileShare.tenant_id == tenant_id,
FileShare.target_type == "group",
FileShare.target_id == group_id,
).count()
if owned_asset_count or owned_folder_count or shared_asset_count:
raise ValueError("Cannot remove the group while it owns files, folders or file shares.")
def _files_router(context: ModuleContext):
from govoplan_files.backend.runtime import configure_runtime
configure_runtime(settings=context.settings)
configure_runtime(registry=context.registry, settings=context.settings)
from govoplan_files.backend.router import router
return router
@@ -66,12 +87,14 @@ def _files_router(context: ModuleContext):
manifest = ModuleManifest(
id="files",
name="Files",
version="0.1.4",
version="0.1.5",
dependencies=("access",),
optional_dependencies=(),
permissions=PERMISSIONS,
route_factory=_files_router,
role_templates=ROLE_TEMPLATES,
tenant_summary_providers=(_tenant_summary,),
delete_veto_providers={"group": (_veto_group_delete,)},
nav_items=(NavItem(path="/files", label="Files", icon="folder", required_any=("files:file:read",), order=40),),
frontend=FrontendModule(
module_id="files",
@@ -82,6 +105,28 @@ manifest = ModuleManifest(
module_id="files",
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.CampaignAttachmentUse,
label="Files",
),
retirement_notes="Destructive retirement drops files-owned database tables after the installer captures a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
file_models.FileBlob,
file_models.FileFolder,
file_models.FileAsset,
file_models.FileVersion,
file_models.FileShare,
file_models.CampaignAttachmentUse,
label="Files",
),
),
capability_factories={
"files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),

View File

@@ -9,11 +9,10 @@ from urllib.parse import quote
from fastapi import APIRouter, Depends, File as FastAPIFile, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, StreamingResponse
from starlette.background import BackgroundTask
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope, require_scope
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
from govoplan_files.backend.schemas import (
ArchiveRequest,
BulkFileShareRequest,
@@ -43,12 +42,11 @@ from govoplan_files.backend.schemas import (
TransferResponse,
_conflict_resolutions,
)
from govoplan_core.db.models import Group, UserGroupMembership
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileFolder, FileShare, FileVersion
from govoplan_core.db.session import get_session
from govoplan_files.backend.runtime import settings
from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.paths import UnsafeFilePathError, filename_from_path, normalize_logical_path
from govoplan_files.backend.storage.access import ensure_group_access, user_group_ids
from govoplan_files.backend.storage.access import ensure_group_access, group_refs_for_ids, user_group_ids
from govoplan_files.backend.storage.archives import create_zip_file, extract_zip_upload
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import (
@@ -69,13 +67,14 @@ from govoplan_files.backend.storage.transfers import rename_selection, transfer_
router = APIRouter(prefix="/files", tags=["files"])
def _campaign_models():
try:
from govoplan_campaign.backend.db.models import Campaign, CampaignShare
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_campaign")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed") from exc
return Campaign, CampaignShare
def _campaign_access_provider() -> CampaignAccessProvider:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Campaign module is not installed")
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
if not isinstance(capability, CampaignAccessProvider):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Campaign access capability is invalid")
return capability
def _is_admin(principal: ApiPrincipal) -> bool:
@@ -116,37 +115,20 @@ def _ensure_campaign_file_access(session: Session, principal: ApiPrincipal, camp
return
if not has_scope(principal, "campaigns:campaign:read"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Missing scope: campaign:read")
Campaign, CampaignShare = _campaign_models()
campaign = session.get(Campaign, campaign_id)
if not campaign or campaign.tenant_id != principal.tenant_id:
provider = _campaign_access_provider()
if not provider.campaign_exists(session, tenant_id=principal.tenant_id, campaign_id=campaign_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found")
if has_scope(principal, "tenant:*"):
group_ids = set(user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id))
if provider.can_read_campaign(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
user_id=principal.user.id,
group_ids=group_ids,
tenant_admin=has_scope(principal, "tenant:*"),
):
return
if campaign.owner_user_id == principal.user.id:
return
group_ids = {
row[0]
for row in session.query(UserGroupMembership.group_id)
.filter(UserGroupMembership.tenant_id == principal.tenant_id, UserGroupMembership.user_id == principal.user.id)
.all()
}
if campaign.owner_group_id and campaign.owner_group_id in group_ids:
return
share = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
or_(
(CampaignShare.target_type == "user") & (CampaignShare.target_id == principal.user.id),
(CampaignShare.target_type == "group") & (CampaignShare.target_id.in_(group_ids)),
),
)
.first()
)
if share is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this campaign")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No access to this campaign")
def _http_error(exc: Exception, *, not_found: bool = False) -> HTTPException:
@@ -332,7 +314,7 @@ def list_file_spaces(
]
group_ids = user_group_ids(session, tenant_id=principal.tenant_id, user_id=principal.user.id, include_admin_groups=_is_admin(principal))
if group_ids:
groups = session.query(Group).filter(Group.tenant_id == principal.tenant_id, Group.id.in_(group_ids)).order_by(Group.name.asc()).all()
groups = group_refs_for_ids(tenant_id=principal.tenant_id, group_ids=group_ids)
spaces.extend(
FileSpaceResponse(
id=f"group:{group.id}",

View File

@@ -2,15 +2,22 @@ from __future__ import annotations
from typing import Any
_runtime_registry: object | None = None
_runtime_settings: object | None = None
def configure_runtime(*, settings: object | None = None) -> None:
global _runtime_settings
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
global _runtime_registry, _runtime_settings
if registry is not None:
_runtime_registry = registry
if settings is not None:
_runtime_settings = settings
def get_registry() -> object | None:
return _runtime_registry
def get_settings() -> object:
if _runtime_settings is not None:
return _runtime_settings

View File

@@ -2,33 +2,50 @@ from __future__ import annotations
from sqlalchemy.orm import Session
from govoplan_core.db.models import Group, UserGroupMembership
from govoplan_core.core.access import (
CAPABILITY_ACCESS_DIRECTORY,
AccessDirectory,
GroupRef,
)
from govoplan_core.core.runtime import get_registry
from govoplan_files.backend.storage.common import FileStorageError
def _access_directory() -> AccessDirectory:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
raise FileStorageError("Access directory capability is not configured")
capability = registry.require_capability(CAPABILITY_ACCESS_DIRECTORY)
if not isinstance(capability, AccessDirectory):
raise FileStorageError("Access directory capability is invalid")
return capability
def group_refs_for_ids(*, tenant_id: str, group_ids: list[str]) -> list[GroupRef]:
if not group_ids:
return []
groups = _access_directory().get_groups(group_ids)
return sorted(
[group for group in groups.values() if group.tenant_id == tenant_id],
key=lambda group: group.name.casefold(),
)
def user_group_ids(session: Session, *, tenant_id: str, user_id: str, include_admin_groups: bool = False) -> list[str]:
del session
directory = _access_directory()
if include_admin_groups:
return [row.id for row in session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.name.asc()).all()]
return [
row.group_id
for row in session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id)
.all()
]
return [group.id for group in directory.groups_for_tenant(tenant_id)]
return [group.id for group in directory.groups_for_user(user_id, tenant_id=tenant_id)]
def ensure_group_access(session: Session, *, tenant_id: str, group_id: str, user_id: str, is_admin: bool = False) -> None:
group = session.get(Group, group_id)
group = _access_directory().get_group(group_id)
if not group or group.tenant_id != tenant_id:
raise FileStorageError("Group not found")
if is_admin:
return
membership = (
session.query(UserGroupMembership)
.filter(UserGroupMembership.tenant_id == tenant_id, UserGroupMembership.user_id == user_id, UserGroupMembership.group_id == group_id)
.one_or_none()
)
if membership is None:
if group_id not in user_group_ids(session, tenant_id=tenant_id, user_id=user_id):
raise FileStorageError("No access to this group file space")
@@ -42,3 +59,21 @@ def ensure_owner_access(session: Session, *, tenant_id: str, owner_type: str, ow
ensure_group_access(session, tenant_id=tenant_id, group_id=owner_id, user_id=user_id, is_admin=is_admin)
return
raise FileStorageError("Files must be owned by a user or group")
def ensure_share_target_exists(*, tenant_id: str, target_type: str, target_id: str) -> None:
target_type = target_type.lower().strip()
if target_type == "user":
user = _access_directory().get_user(target_id)
if not user or user.tenant_id != tenant_id or user.status != "active":
raise FileStorageError("User not found")
return
if target_type == "group":
group = _access_directory().get_group(target_id)
if not group or group.tenant_id != tenant_id or group.status != "active":
raise FileStorageError("Group not found")
return
if target_type == "tenant":
if target_id != tenant_id:
raise FileStorageError("Tenant not found")
return

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from collections import defaultdict
from pathlib import PurePosixPath
from typing import TYPE_CHECKING, Iterable
from typing import Any, Iterable
from sqlalchemy.orm import Session
@@ -10,8 +10,7 @@ from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, F
from govoplan_files.backend.storage.common import utcnow
from govoplan_files.backend.storage.files import current_versions_and_blobs, list_assets_for_user
if TYPE_CHECKING:
from govoplan_campaign.backend.db.models import CampaignJob
CampaignJobLike = Any
def _candidate_match_keys(raw_match: str) -> set[str]:
@@ -29,7 +28,7 @@ def _attachment_use_key(*, job_id: str, file_version_id: str, filename_used: str
return job_id, file_version_id, filename_used, stage
def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, stage: str) -> set[AttachmentUseKey]:
def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJobLike], *, stage: str) -> set[AttachmentUseKey]:
job_ids = {job.id for job in jobs if job.id}
if not job_ids:
return set()
@@ -72,13 +71,13 @@ def _known_use_keys_for_jobs(session: Session, jobs: Iterable[CampaignJob], *, s
return keys
def _known_use_keys(session: Session, job: CampaignJob, *, stage: str) -> set[AttachmentUseKey]:
def _known_use_keys(session: Session, job: CampaignJobLike, *, stage: str) -> set[AttachmentUseKey]:
return _known_use_keys_for_jobs(session, [job], stage=stage)
def _add_use(
session: Session,
job: CampaignJob,
job: CampaignJobLike,
*,
asset: FileAsset,
version: FileVersion,
@@ -118,7 +117,7 @@ def _add_use(
def record_campaign_attachment_uses_for_jobs(
session: Session,
jobs: Iterable[CampaignJob],
jobs: Iterable[CampaignJobLike],
*,
stage: str = "built",
) -> None:
@@ -249,13 +248,13 @@ def record_campaign_attachment_uses_for_jobs(
)
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJob, *, stage: str = "built") -> None:
def record_campaign_attachment_uses_for_job(session: Session, job: CampaignJobLike, *, stage: str = "built") -> None:
"""Record immutable managed file versions used by one built/sent job."""
record_campaign_attachment_uses_for_jobs(session, [job], stage=stage)
def mark_job_attachment_uses_sent(session: Session, job: CampaignJob) -> None:
def mark_job_attachment_uses_sent(session: Session, job: CampaignJobLike) -> None:
record_campaign_attachment_uses_for_job(session, job, stage="built")
# Sessions use autoflush=False. Flush any compatibility-built evidence so
# the following query can copy it to the sent stage in the same call.

View File

@@ -9,23 +9,28 @@ from uuid import uuid4
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.db.models import Group, Tenant, User
from govoplan_core.core.optional import reraise_unless_missing_package
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_ACCESS, CampaignAccessProvider
from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion
from govoplan_files.backend.runtime import settings
from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids
from govoplan_files.backend.runtime import get_registry, settings
from govoplan_files.backend.storage.access import ensure_owner_access, ensure_share_target_exists, user_group_ids
from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend
from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow
from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component
def _campaign_model():
try:
from govoplan_campaign.backend.db.models import Campaign
except ModuleNotFoundError as exc:
reraise_unless_missing_package(exc, "govoplan_campaign")
raise FileStorageError("Campaign module is not installed") from exc
return Campaign
def _campaign_access_provider() -> CampaignAccessProvider:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_CAMPAIGNS_ACCESS):
raise FileStorageError("Campaign module is not installed")
capability = registry.require_capability(CAPABILITY_CAMPAIGNS_ACCESS)
if not isinstance(capability, CampaignAccessProvider):
raise FileStorageError("Campaign access capability is invalid")
return capability
def _ensure_campaign_exists(session: Session, *, tenant_id: str, campaign_id: str) -> None:
if not _campaign_access_provider().campaign_exists(session, tenant_id=tenant_id, campaign_id=campaign_id):
raise FileStorageError("Campaign not found")
def _asset_query_for_owner(session: Session, *, tenant_id: str, owner_type: str, owner_id: str):
@@ -318,23 +323,10 @@ def share_file(
raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission")
if target_type == "user":
target_user = session.get(User, target_id)
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
raise FileStorageError("User not found")
if target_type == "group":
group = session.get(Group, target_id)
if not group or group.tenant_id != tenant_id or not group.is_active:
raise FileStorageError("Group not found")
if target_type == "tenant":
tenant = session.get(Tenant, target_id)
if target_id != tenant_id or not tenant or not tenant.is_active:
raise FileStorageError("Tenant not found")
if target_type in {"user", "group", "tenant"}:
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if target_type == "campaign":
Campaign = _campaign_model()
campaign = session.get(Campaign, target_id)
if not campaign or campaign.tenant_id != tenant_id:
raise FileStorageError("Campaign not found")
_ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
existing = (
session.query(FileShare)
.filter(
@@ -378,23 +370,10 @@ def share_files(
raise FileStorageError("Unsupported share target")
if permission not in {"read", "write", "manage"}:
raise FileStorageError("Unsupported file permission")
if target_type == "user":
target_user = session.get(User, target_id)
if not target_user or target_user.tenant_id != tenant_id or not target_user.is_active:
raise FileStorageError("User not found")
if target_type == "group":
group = session.get(Group, target_id)
if not group or group.tenant_id != tenant_id or not group.is_active:
raise FileStorageError("Group not found")
if target_type == "tenant":
tenant = session.get(Tenant, target_id)
if target_id != tenant_id or not tenant or not tenant.is_active:
raise FileStorageError("Tenant not found")
if target_type in {"user", "group", "tenant"}:
ensure_share_target_exists(tenant_id=tenant_id, target_type=target_type, target_id=target_id)
if target_type == "campaign":
Campaign = _campaign_model()
campaign = session.get(Campaign, target_id)
if not campaign or campaign.tenant_id != tenant_id:
raise FileStorageError("Campaign not found")
_ensure_campaign_exists(session, tenant_id=tenant_id, campaign_id=target_id)
asset_list = list(assets)
if not asset_list:

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/files-webui",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -21,7 +21,7 @@
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"lucide-react": "^0.555.0",
"@govoplan/core-webui": "^0.1.4"
"@govoplan/core-webui": "^0.1.5"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {