From b65b48832b15280f734fb18aea7f147abb166e7b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 22:48:07 +0200 Subject: [PATCH] Add cross-module operational and interaction contracts --- docs/DEPLOYMENT_OPERATOR_GUIDE.md | 10 +- docs/THEMING.md | 28 +++++ src/govoplan_core/api/v1/schemas.py | 7 ++ src/govoplan_core/auth/__init__.py | 4 + src/govoplan_core/celery_app.py | 47 +++++++- src/govoplan_core/core/access.py | 3 + src/govoplan_core/core/calendar.py | 96 ++++++++++++++++ src/govoplan_core/core/feeds.py | 122 +++++++++++++++++++++ src/govoplan_core/core/install_config.py | 4 +- src/govoplan_core/core/mail.py | 75 ++++++++++++- src/govoplan_core/core/module_installer.py | 11 ++ src/govoplan_core/core/modules.py | 7 ++ src/govoplan_core/core/operations.py | 44 ++++++++ src/govoplan_core/core/postbox.py | 32 ++++++ src/govoplan_core/core/registry.py | 8 ++ src/govoplan_core/settings.py | 4 +- tests/test_celery_queue_contract.py | 34 ++++++ tests/test_documentation_topic_contract.py | 12 ++ webui/package.json | 3 +- webui/scripts/test-theme-contract.mjs | 30 +++++ webui/src/layout/Titlebar.tsx | 9 +- webui/src/styles/layout.css | 5 + webui/src/types.ts | 11 ++ 23 files changed, 593 insertions(+), 13 deletions(-) create mode 100644 docs/THEMING.md create mode 100644 src/govoplan_core/core/feeds.py create mode 100644 src/govoplan_core/core/operations.py create mode 100644 tests/test_celery_queue_contract.py create mode 100644 webui/scripts/test-theme-contract.mjs diff --git a/docs/DEPLOYMENT_OPERATOR_GUIDE.md b/docs/DEPLOYMENT_OPERATOR_GUIDE.md index cd6b5ac..6310843 100644 --- a/docs/DEPLOYMENT_OPERATOR_GUIDE.md +++ b/docs/DEPLOYMENT_OPERATOR_GUIDE.md @@ -157,7 +157,7 @@ release evidence. | --- | --- | --- | | `REDIS_URL` | `redis://redis:6379/0` | Celery broker/result backend when async workers are enabled. | | `CELERY_ENABLED` | `false` | Local/dev can send synchronously. Production campaign delivery should run workers and set this to `true`. | -| `CELERY_QUEUES` | `send_email,append_sent,notifications,calendar,dataflow,workflow,events,default` | Queue list expected by worker/process manager definitions. The `events` queue drains transactional platform events; `dataflow` drains trigger deliveries and schedules; `workflow` reconciles Workflow Engine instances and module standards. | +| `CELERY_QUEUES` | `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` | Queue list expected by worker/process manager definitions. Keep this aligned with every enabled module task route; Ops reports missing worker queue consumers. | | `PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS` | `8` | Failed durable consumer deliveries are quarantined after this many attempts. | | `PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS` | `90` | Successful event envelopes older than this are removed by the daily retention task. Quarantined evidence is retained. | @@ -165,7 +165,7 @@ Worker command: ```bash python -m celery -A govoplan_core.celery_app:celery worker \ - --queues send_email,append_sent,notifications,calendar,dataflow,events,default \ + --queues send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default \ --loglevel INFO ``` @@ -343,7 +343,7 @@ the checked in `.env.example`. It runs: - explicit `ENABLED_MODULES` - explicit migrations and `--with-dev-data` bootstrap - API via the module-aware devserver -- a Celery worker for `send_email,append_sent,notifications,calendar,default` +- a Celery worker for `send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default` - WebUI through the Vite dev server - durable local files under `runtime/production-like/files` @@ -462,7 +462,9 @@ Run the rollback drill before relying on installer automation in a new environment: ```bash -/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py --format json +/mnt/DATA/git/govoplan/tools/checks/module-installer-rollback-drill.py \ + --format json \ + --evidence-path runtime/module-installer/restore-drill-evidence.json ``` The drill uses temporary SQLite databases and simulated package commands. It diff --git a/docs/THEMING.md b/docs/THEMING.md new file mode 100644 index 0000000..b21ee77 --- /dev/null +++ b/docs/THEMING.md @@ -0,0 +1,28 @@ +# WebUI Theme Contract + +GovOPlaN supports `system`, `light`, and `dark` as persisted user preferences. +`system` follows `prefers-color-scheme` live; it is not resolved permanently at +save time. Core applies the resolved mode through `data-theme` on the document +root and exposes the selected preference through `data-theme-preference`. + +## Ownership + +- Core owns semantic CSS tokens, native `color-scheme`, preference persistence, + the Settings selector, and the shared shell. +- Modules consume semantic tokens such as `--surface`, `--text`, `--line`, and + the status token families. They may define domain aliases whose values resolve + to shared tokens. +- User preference selects the mode. Tenant and system policy may provide a + future default, but must not silently replace an explicit user choice. +- Tenant branding is a separate policy surface and must preserve contrast and + status semantics in both modes. + +Do not introduce fixed foreground/background colors in a module merely to make +one mode look correct. Add or reuse a semantic Core token, then define both +light and dark values. Bitmap content and externally authored HTML are exempt, +but their surrounding controls must still use the shared tokens. + +`npm run test:theme-contract` verifies the root behavior and representative +Campaign, Calendar, Files, and Mail token consumption. The check runs before a +production WebUI build. + diff --git a/src/govoplan_core/api/v1/schemas.py b/src/govoplan_core/api/v1/schemas.py index 1c9ff74..e9fef32 100644 --- a/src/govoplan_core/api/v1/schemas.py +++ b/src/govoplan_core/api/v1/schemas.py @@ -73,6 +73,12 @@ class SwitchTenantRequest(BaseModel): tenant_id: str +class SwitchActingContextRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + assignment_id: str | None = Field(default=None, max_length=36) + + class TenantInfo(BaseModel): id: str slug: str @@ -178,6 +184,7 @@ class PrincipalContextInfo(BaseModel): api_key_id: str | None = None session_id: str | None = None service_account_id: str | None = None + acting_assignment_id: str | None = None acting_for_account_id: str | None = None email: str | None = None display_name: str | None = None diff --git a/src/govoplan_core/auth/__init__.py b/src/govoplan_core/auth/__init__.py index c844c60..eedb6c5 100644 --- a/src/govoplan_core/auth/__init__.py +++ b/src/govoplan_core/auth/__init__.py @@ -81,6 +81,10 @@ class ApiPrincipal: def acting_for_account_id(self) -> str | None: return self.principal.acting_for_account_id + @property + def acting_assignment_id(self) -> str | None: + return self.principal.acting_assignment_id + @property def auth_method(self) -> str: return self.principal.auth_method diff --git a/src/govoplan_core/celery_app.py b/src/govoplan_core/celery_app.py index 767dd15..a456154 100644 --- a/src/govoplan_core/celery_app.py +++ b/src/govoplan_core/celery_app.py @@ -24,7 +24,12 @@ from govoplan_core.core.idm import ( IdmAssignmentLifecycle, ) from govoplan_core.core.module_management import load_startup_enabled_modules, startup_candidate_module_ids -from govoplan_core.core.mail import CAPABILITY_MAIL_DELIVERY_OUTBOX, MailDeliveryOutboxProvider +from govoplan_core.core.mail import ( + CAPABILITY_MAIL_BOUNCE_PROCESSING, + CAPABILITY_MAIL_DELIVERY_OUTBOX, + MailBounceProcessingProvider, + MailDeliveryOutboxProvider, +) from govoplan_core.core.modules import ModuleContext from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH, NotificationDispatchProvider from govoplan_core.core.postbox import ( @@ -58,6 +63,7 @@ celery.conf.update( "govoplan.notifications.deliver_pending": {"queue": "notifications"}, "govoplan.mail.dispatch_outbox": {"queue": "mail"}, "govoplan.mail.purge_outbox": {"queue": "mail"}, + "govoplan.mail.scan_bounces": {"queue": "mail"}, "govoplan.calendar.dispatch_outbox": {"queue": "calendar"}, "govoplan.dataflow.dispatch_runs": {"queue": "dataflow"}, "govoplan.dataflow.purge_runs": {"queue": "dataflow"}, @@ -87,6 +93,11 @@ celery.conf.update( "schedule": 24 * 60 * 60.0, "args": (250,), }, + "mail-bounces-every-five-minutes": { + "task": "govoplan.mail.scan_bounces", + "schedule": 5 * 60.0, + "args": (None, 250), + }, "dataflow-triggers-every-minute": { "task": "govoplan.dataflow.dispatch_triggers", "schedule": 60.0, @@ -184,6 +195,16 @@ def _mail_delivery_outbox() -> MailDeliveryOutboxProvider | None: return capability +def _mail_bounce_processing() -> MailBounceProcessingProvider | None: + registry = _platform_registry() + if not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING): + return None + capability = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING) + if not isinstance(capability, MailBounceProcessingProvider): + raise RuntimeError("Mail bounce-processing capability is invalid") + return capability + + def _dataflow_trigger_dispatcher( registry: PlatformRegistry | None = None, ) -> DataflowTriggerDispatcher | None: @@ -357,6 +378,30 @@ def purge_mail_outbox(self, limit: int = 250): return dict(provider.purge_expired(session, limit=limit)) +@celery.task(name="govoplan.mail.scan_bounces", bind=True, max_retries=0) +def scan_mail_bounces( + self, + tenant_id: str | None = None, + limit: int = 250, +): + """Read configured DSN folders without mutating provider mailbox flags.""" + + from govoplan_core.db.session import get_database + + with get_database().SessionLocal() as session: + provider = _mail_bounce_processing() + if provider is None: + return { + "sources": 0, + "processed_messages": 0, + "observations": 0, + "failures": [], + } + result = dict(provider.scan_due(session, tenant_id=tenant_id, limit=limit)) + session.commit() + return result + + @celery.task(name="govoplan.calendar.dispatch_outbox", bind=True, max_retries=0) def dispatch_calendar_outbox(self, tenant_id: str | None = None, limit: int = 50): """Drain durable Calendar operations; retry timing lives in the database.""" diff --git a/src/govoplan_core/core/access.py b/src/govoplan_core/core/access.py index 1b66361..180110e 100644 --- a/src/govoplan_core/core/access.py +++ b/src/govoplan_core/core/access.py @@ -125,6 +125,7 @@ class PrincipalRef: api_key_id: str | None = None session_id: str | None = None service_account_id: str | None = None + acting_assignment_id: str | None = None acting_for_account_id: str | None = None email: str | None = None display_name: str | None = None @@ -144,6 +145,7 @@ class PrincipalRef: "api_key_id": self.api_key_id, "session_id": self.session_id, "service_account_id": self.service_account_id, + "acting_assignment_id": self.acting_assignment_id, "acting_for_account_id": self.acting_for_account_id, "email": self.email, "display_name": self.display_name, @@ -165,6 +167,7 @@ class PrincipalRef: api_key_id=_optional_str(value.get("api_key_id")), session_id=_optional_str(value.get("session_id")), service_account_id=_optional_str(value.get("service_account_id")), + acting_assignment_id=_optional_str(value.get("acting_assignment_id")), acting_for_account_id=_optional_str(value.get("acting_for_account_id")), email=_optional_str(value.get("email")), display_name=_optional_str(value.get("display_name")), diff --git a/src/govoplan_core/core/calendar.py b/src/govoplan_core/core/calendar.py index 4adf44d..5d88544 100644 --- a/src/govoplan_core/core/calendar.py +++ b/src/govoplan_core/core/calendar.py @@ -8,6 +8,7 @@ from typing import Protocol, runtime_checkable CAPABILITY_CALENDAR_SCHEDULING = "calendar.scheduling" CAPABILITY_CALENDAR_OUTBOX = "calendar.outbox" +CAPABILITY_CALENDAR_INVITATIONS = "calendar.invitations" CALENDAR_AVAILABILITY_READ_SCOPE = "calendar:availability:read" CALENDAR_EVENT_WRITE_SCOPE = "calendar:event:write" @@ -43,6 +44,52 @@ class CalendarEventRef: outbox_operation_id: str | None = None +@dataclass(frozen=True, slots=True) +class CalendarInvitationAttendeeRequest: + address: str + name: str | None = None + role: str = "REQ-PARTICIPANT" + participation_status: str = "NEEDS-ACTION" + rsvp: bool = True + + +@dataclass(frozen=True, slots=True) +class CalendarInvitationRequest: + correlation_id: str + source_module: str + source_resource_type: str + source_resource_id: str | None + summary: str + start_at: datetime + attendees: tuple[CalendarInvitationAttendeeRequest, ...] + calendar_id: str | None = None + description: str | None = None + location: str | None = None + end_at: datetime | None = None + timezone: str | None = None + organizer: Mapping[str, object] | None = None + classification: str = "PUBLIC" + categories: tuple[str, ...] = () + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class CalendarInvitationRef: + event_id: str + calendar_id: str + uid: str + correlation_id: str + source_module: str + source_resource_type: str + source_resource_id: str | None + attendees: tuple[Mapping[str, object], ...] = () + external_state: str = "local" + outbox_operation_id: str | None = None + reply_ingress: str = "capability" + recurrence_supported: bool = False + degraded_reasons: tuple[str, ...] = () + + @runtime_checkable class CalendarSchedulingProvider(Protocol): def list_freebusy( @@ -80,6 +127,44 @@ class CalendarOutboxProvider(Protocol): ) -> Mapping[str, object]: ... + +@runtime_checkable +class CalendarInvitationProvider(Protocol): + """Correlation-aware invitation boundary for Campaign and Mail adapters.""" + + def upsert_invitation( + self, + session: object, + *, + tenant_id: str, + user_id: str | None, + request: CalendarInvitationRequest, + ) -> CalendarInvitationRef: + ... + + def get_invitation( + self, + session: object, + *, + tenant_id: str, + correlation_id: str, + ) -> CalendarInvitationRef | None: + ... + + def record_response( + self, + session: object, + *, + tenant_id: str, + attendee_address: str, + participation_status: str, + correlation_id: str | None = None, + uid: str | None = None, + responded_at: datetime | None = None, + evidence: Mapping[str, object] | None = None, + ) -> CalendarInvitationRef: + ... + def calendar_scheduling_provider(registry: object | None) -> CalendarSchedulingProvider | None: if registry is None or not hasattr(registry, "has_capability"): return None @@ -96,3 +181,14 @@ def calendar_outbox_provider(registry: object | None) -> CalendarOutboxProvider return None capability = registry.capability(CAPABILITY_CALENDAR_OUTBOX) return capability if isinstance(capability, CalendarOutboxProvider) else None + + +def calendar_invitation_provider( + registry: object | None, +) -> CalendarInvitationProvider | None: + if registry is None or not hasattr(registry, "has_capability"): + return None + if not registry.has_capability(CAPABILITY_CALENDAR_INVITATIONS): + return None + capability = registry.capability(CAPABILITY_CALENDAR_INVITATIONS) + return capability if isinstance(capability, CalendarInvitationProvider) else None diff --git a/src/govoplan_core/core/feeds.py b/src/govoplan_core/core/feeds.py new file mode 100644 index 0000000..4b793b4 --- /dev/null +++ b/src/govoplan_core/core/feeds.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from typing import Literal, Protocol, runtime_checkable + + +CAPABILITY_CONNECTORS_FEEDS = "connectors.feeds" +FeedFormat = Literal["rss", "atom"] +FeedVisibility = Literal["public", "tenant", "private"] + + +class FeedCapabilityError(ValueError): + """Stable error raised by feed transport implementations.""" + + +@dataclass(frozen=True, slots=True) +class FeedEntry: + id: str + title: str + url: str | None = None + summary: str | None = None + content: str | None = None + author: str | None = None + published_at: datetime | None = None + updated_at: datetime | None = None + categories: tuple[str, ...] = () + enclosures: tuple[Mapping[str, object], ...] = () + visibility: FeedVisibility = "public" + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class FeedDocument: + format: FeedFormat + title: str + source_url: str + entries: tuple[FeedEntry, ...] + description: str | None = None + home_url: str | None = None + language: str | None = None + updated_at: datetime | None = None + acquired_at: datetime | None = None + fresh_until: datetime | None = None + etag: str | None = None + last_modified: str | None = None + content_type: str | None = None + sha256: str = "" + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class FeedRenderRequest: + format: FeedFormat + title: str + feed_url: str + home_url: str + entries: tuple[FeedEntry, ...] + description: str | None = None + language: str | None = None + allowed_visibilities: frozenset[FeedVisibility] = frozenset({"public"}) + + +@dataclass(frozen=True, slots=True) +class FeedRenderResult: + format: FeedFormat + content_type: str + body: bytes + included_entries: int + excluded_entries: int + + +@runtime_checkable +class FeedProvider(Protocol): + def fetch( + self, + url: str, + *, + timeout: float = 15, + max_entries: int = 2_000, + ) -> FeedDocument: + ... + + def parse( + self, + content: bytes, + *, + source_url: str, + content_type: str | None = None, + max_entries: int = 2_000, + ) -> FeedDocument: + ... + + def render(self, request: FeedRenderRequest) -> FeedRenderResult: + ... + + +def feed_provider(registry: object | None) -> FeedProvider | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not hasattr(registry, "capability") + or not registry.has_capability(CAPABILITY_CONNECTORS_FEEDS) + ): + return None + provider = registry.capability(CAPABILITY_CONNECTORS_FEEDS) + return provider if isinstance(provider, FeedProvider) else None + + +__all__ = [ + "CAPABILITY_CONNECTORS_FEEDS", + "FeedCapabilityError", + "FeedDocument", + "FeedEntry", + "FeedFormat", + "FeedProvider", + "FeedRenderRequest", + "FeedRenderResult", + "FeedVisibility", + "feed_provider", +] diff --git a/src/govoplan_core/core/install_config.py b/src/govoplan_core/core/install_config.py index 3ad0700..90ab051 100644 --- a/src/govoplan_core/core/install_config.py +++ b/src/govoplan_core/core/install_config.py @@ -373,7 +373,7 @@ ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,aud CELERY_ENABLED=true REDIS_URL=redis://127.0.0.1:6379/0 -CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default +CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90 PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8 PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90 @@ -446,7 +446,7 @@ DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan REDIS_URL=redis://127.0.0.1:56379/0 CELERY_ENABLED=true -CELERY_QUEUES=send_email,append_sent,notifications,calendar,dataflow,workflow,events,default +CELERY_QUEUES=send_email,append_sent,notifications,mail,calendar,dataflow,workflow,postbox,events,idm,default CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90 PLATFORM_EVENT_OUTBOX_MAX_ATTEMPTS=8 PLATFORM_EVENT_OUTBOX_TERMINAL_RETENTION_DAYS=90 diff --git a/src/govoplan_core/core/mail.py b/src/govoplan_core/core/mail.py index 989218d..75215e9 100644 --- a/src/govoplan_core/core/mail.py +++ b/src/govoplan_core/core/mail.py @@ -2,11 +2,13 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass, field +from datetime import datetime from typing import Protocol, runtime_checkable CAPABILITY_MAIL_DELIVERY_OUTBOX = "mail.delivery_outbox" CAPABILITY_MAIL_NOTIFICATION_DELIVERY = "mail.notificationDelivery" +CAPABILITY_MAIL_BOUNCE_PROCESSING = "mail.bounce_processing" @dataclass(frozen=True, slots=True) @@ -36,7 +38,6 @@ class NotificationMailDeliveryProvider(Protocol): ) -> Mapping[str, object]: ... - @runtime_checkable class MailDeliveryOutboxProvider(Protocol): """Stable worker boundary for Mail-owned external delivery effects.""" @@ -60,6 +61,60 @@ class MailDeliveryOutboxProvider(Protocol): ... +@dataclass(frozen=True, slots=True) +class MailBounceObservationRef: + id: str + tenant_id: str + profile_id: str + folder: str + uid: str + original_message_id: str | None + command_id: str | None + recipient: str | None + action: str + status_code: str | None + diagnostic: str | None + permanent: bool + observed_at: datetime + matched: bool + evidence: Mapping[str, object] = field(default_factory=dict) + + +@runtime_checkable +class MailBounceProcessingProvider(Protocol): + """Mail-owned DSN ingestion and durable correlation boundary.""" + + def process_raw_message( + self, + session: object, + *, + tenant_id: str, + profile_id: str, + folder: str, + uid: str, + raw_message: bytes, + ) -> tuple[MailBounceObservationRef, ...]: + ... + + def scan_due( + self, + session: object, + *, + tenant_id: str | None = None, + limit: int = 100, + ) -> Mapping[str, object]: + ... + + def observations_for_commands( + self, + session: object, + *, + tenant_id: str, + command_ids: tuple[str, ...], + ) -> Mapping[str, tuple[MailBounceObservationRef, ...]]: + ... + + def notification_mail_delivery_provider( registry: object | None, ) -> NotificationMailDeliveryProvider | None: @@ -76,3 +131,21 @@ def notification_mail_delivery_provider( "NotificationMailDeliveryProvider" ) return provider + + +def mail_bounce_processing_provider( + registry: object | None, +) -> MailBounceProcessingProvider | None: + if ( + registry is None + or not hasattr(registry, "has_capability") + or not registry.has_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING) + ): + return None + provider = registry.require_capability(CAPABILITY_MAIL_BOUNCE_PROCESSING) + if not isinstance(provider, MailBounceProcessingProvider): + raise TypeError( + "mail.bounce_processing provider does not implement " + "MailBounceProcessingProvider" + ) + return provider diff --git a/src/govoplan_core/core/module_installer.py b/src/govoplan_core/core/module_installer.py index 21b93fb..8b60c21 100644 --- a/src/govoplan_core/core/module_installer.py +++ b/src/govoplan_core/core/module_installer.py @@ -3679,10 +3679,21 @@ def _snapshot_sqlite_database(run_dir: Path, database_url: str | None) -> dict[s source.backup(target) else: backup_path.touch() + with closing(sqlite3.connect(str(backup_path))) as candidate: + row = candidate.execute("PRAGMA integrity_check").fetchone() + integrity = str(row[0] if row else "missing result") + if integrity.lower() != "ok": + raise ModuleInstallerError( + f"SQLite backup failed its restore-readiness integrity check: {integrity}" + ) return { "type": "sqlite", "source": str(db_path), "path": backup_path.name, + "restore_check": { + "type": "sqlite_integrity_check", + "result": integrity, + }, } diff --git a/src/govoplan_core/core/modules.py b/src/govoplan_core/core/modules.py index d550980..f6c04ef 100644 --- a/src/govoplan_core/core/modules.py +++ b/src/govoplan_core/core/modules.py @@ -9,6 +9,7 @@ from govoplan_core.core.views import ViewSurface if TYPE_CHECKING: from fastapi import APIRouter + from govoplan_core.core.operations import OperationalCheckProviderRegistration from govoplan_core.core.search import ( SearchProviderRegistration, SearchSourceProviderRegistration, @@ -268,6 +269,8 @@ class DocumentationTopic: i18n_key: str | None = None translations: Mapping[str, Mapping[str, str]] = field(default_factory=dict) source_module_id: str | None = None + version_min: str | None = None + version_max_exclusive: str | None = None metadata: Mapping[str, Any] = field(default_factory=dict) @@ -431,6 +434,10 @@ class ModuleManifest: capability_documentation: Mapping[str, CapabilityDocumentation] = field(default_factory=dict) search_providers: tuple["SearchProviderRegistration", ...] = () search_sources: tuple["SearchSourceProviderRegistration", ...] = () + operational_check_providers: tuple[ + "OperationalCheckProviderRegistration", + ..., + ] = () compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility) on_activate: LifecycleHook | None = None on_deactivate: LifecycleHook | None = None diff --git a/src/govoplan_core/core/operations.py b/src/govoplan_core/core/operations.py new file mode 100644 index 0000000..866127c --- /dev/null +++ b/src/govoplan_core/core/operations.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Literal + + +OperationalCheckState = Literal["ok", "warning", "error", "inactive"] + + +@dataclass(frozen=True, slots=True) +class OperationalCheck: + """A bounded module-owned runtime check exposed through the Ops module.""" + + id: str + label: str + state: OperationalCheckState + detail: str + readiness_critical: bool = False + metrics: Mapping[str, object] = field(default_factory=dict) + + def as_dict(self) -> dict[str, object]: + return { + "id": self.id, + "label": self.label, + "state": self.state, + "detail": self.detail, + "readiness_critical": self.readiness_critical, + "metrics": dict(self.metrics), + } + + +OperationalCheckProvider = Callable[[], OperationalCheck] + + +@dataclass(frozen=True, slots=True) +class OperationalCheckProviderRegistration: + """Register one independently executable operational check.""" + + module_id: str + check_id: str + provider: OperationalCheckProvider + cache_seconds: int = 60 + diff --git a/src/govoplan_core/core/postbox.py b/src/govoplan_core/core/postbox.py index 06a3ffc..39e46b3 100644 --- a/src/govoplan_core/core/postbox.py +++ b/src/govoplan_core/core/postbox.py @@ -196,6 +196,32 @@ class PostboxAttachmentRef: metadata: Mapping[str, object] = field(default_factory=dict) +@dataclass(frozen=True, slots=True) +class PostboxWrappedKeyRef: + """Opaque envelope-key record; key material remains crypto-provider owned.""" + + recipient_type: str + recipient_id: str + key_epoch: int + wrapped_key_ref: str + algorithm: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class PostboxExternalRecipientTokenRef: + """External grant state without the bearer secret itself.""" + + token_id: str + state: str + expires_at: datetime | None = None + one_time: bool = False + key_fetched_at: datetime | None = None + revoked_at: datetime | None = None + assurance_profile: str | None = None + metadata: Mapping[str, object] = field(default_factory=dict) + + @dataclass(frozen=True, slots=True) class PostboxMessageRef: id: str @@ -221,6 +247,8 @@ class PostboxMessageRef: key_epoch: int = 1 ciphertext_ref: str | None = None signed_manifest_ref: str | None = None + wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = () + external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = () participants: tuple[PostboxParticipantRef, ...] = () attachments: tuple[PostboxAttachmentRef, ...] = () metadata: Mapping[str, object] = field(default_factory=dict) @@ -275,6 +303,10 @@ class PostboxDeliveryRequest: participants: tuple[PostboxParticipantRef, ...] = () attachments: tuple[PostboxAttachmentRef, ...] = () expires_at: datetime | None = None + ciphertext_ref: str | None = None + signed_manifest_ref: str | None = None + wrapped_keys: tuple[PostboxWrappedKeyRef, ...] = () + external_recipient_tokens: tuple[PostboxExternalRecipientTokenRef, ...] = () metadata: Mapping[str, object] = field(default_factory=dict) diff --git a/src/govoplan_core/core/registry.py b/src/govoplan_core/core/registry.py index aceadfe..461644c 100644 --- a/src/govoplan_core/core/registry.py +++ b/src/govoplan_core/core/registry.py @@ -616,6 +616,14 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None: for item in manifest.nav_items: _validate_nav_item(manifest.id, item) for topic in manifest.documentation: + if not version_range_is_valid( + version_min=topic.version_min, + version_max_exclusive=topic.version_max_exclusive, + ): + raise RegistryError( + f"Module {manifest.id!r} documentation topic {topic.id!r} " + "declares an empty version range" + ) for issue in user_workflow_scope_condition_issues(topic): raise RegistryError( f"Module {manifest.id!r} documentation topic {topic.id!r}: {issue}" diff --git a/src/govoplan_core/settings.py b/src/govoplan_core/settings.py index b05fac0..d502b1f 100644 --- a/src/govoplan_core/settings.py +++ b/src/govoplan_core/settings.py @@ -155,8 +155,8 @@ class Settings(BaseSettings): master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") celery_queues: str = Field( default=( - "send_email,append_sent,notifications,calendar," - "dataflow,workflow,events,default" + "send_email,append_sent,notifications,mail,calendar," + "dataflow,workflow,postbox,events,idm,default" ), alias="CELERY_QUEUES", ) diff --git a/tests/test_celery_queue_contract.py b/tests/test_celery_queue_contract.py new file mode 100644 index 0000000..d9d7390 --- /dev/null +++ b/tests/test_celery_queue_contract.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.celery_app import celery +from govoplan_core.settings import settings + + +class CeleryQueueContractTests(unittest.TestCase): + def test_default_worker_queues_cover_every_routed_task(self) -> None: + configured = { + value.strip() + for value in settings.celery_queues.split(",") + if value.strip() + } + routes = celery.conf.task_routes + self.assertIsInstance(routes, dict) + routed = { + str(route["queue"]) + for route in routes.values() + if isinstance(route, dict) and route.get("queue") + } + + self.assertEqual(set(), routed - configured) + + def test_delivery_tasks_keep_worker_loss_protection(self) -> None: + self.assertTrue(celery.conf.task_acks_late) + self.assertTrue(celery.conf.task_reject_on_worker_lost) + self.assertEqual(1, celery.conf.worker_prefetch_multiplier) + + +if __name__ == "__main__": + unittest.main() + diff --git a/tests/test_documentation_topic_contract.py b/tests/test_documentation_topic_contract.py index 612a440..5e1bf8c 100644 --- a/tests/test_documentation_topic_contract.py +++ b/tests/test_documentation_topic_contract.py @@ -32,6 +32,18 @@ def workflow_topic( class DocumentationTopicContractTests(unittest.TestCase): + def test_registry_rejects_empty_documentation_version_range(self) -> None: + topic = DocumentationTopic( + id="example.versioned", + title="Versioned", + summary="Invalid range", + version_min="2.0.0", + version_max_exclusive="2.0.0", + ) + + with self.assertRaisesRegex(RegistryError, "empty version range"): + registry_for(topic).validate() + def test_user_workflow_requires_scope_conditions(self) -> None: topic = workflow_topic(conditions=()) diff --git a/webui/package.json b/webui/package.json index 3ff8c5b..7d8970d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -26,12 +26,13 @@ }, "scripts": { "dev": "vite --host 127.0.0.1 --port 5173", - "prebuild": "npm run audit:i18n-structural", + "prebuild": "npm run audit:i18n-structural && npm run test:theme-contract", "build": "tsc && vite build && node scripts/check-bundle-budget.mjs", "check:bundle-budget": "node scripts/check-bundle-budget.mjs", "preview": "vite preview --host 127.0.0.1 --port 4173", "audit:i18n-structural": "node scripts/audit-i18n-structural.mjs", "test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs", + "test:theme-contract": "node scripts/test-theme-contract.mjs", "test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs", "test:data-grid-actions": "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/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js", "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", diff --git a/webui/scripts/test-theme-contract.mjs b/webui/scripts/test-theme-contract.mjs new file mode 100644 index 0000000..6e7e0c9 --- /dev/null +++ b/webui/scripts/test-theme-contract.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const webuiRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(webuiRoot, "..", ".."); +const tokens = readFileSync(resolve(webuiRoot, "src/styles/tokens.css"), "utf8"); +const app = readFileSync(resolve(webuiRoot, "src/App.tsx"), "utf8"); +const settings = readFileSync(resolve(webuiRoot, "src/features/settings/SettingsPage.tsx"), "utf8"); + +assert.match(tokens, /:root\[data-theme="dark"\]/, "dark token overrides are required"); +assert.match(tokens, /color-scheme:\s*dark/, "native controls must receive the dark color scheme"); +assert.match(app, /matchMedia\?\.\("\(prefers-color-scheme: dark\)"\)/, "system theme must follow OS changes"); +assert.match(app, /dataset\.themePreference/, "the selected preference must remain inspectable"); +for (const theme of ["system", "light", "dark"]) { + assert.match(settings, new RegExp(`value:\\s*"${theme}"`), `Settings must expose ${theme}`); +} + +const representativeModules = [ + "govoplan-campaign/webui/src/styles/campaign-workspace.css", + "govoplan-calendar/webui/src/styles/calendar.css", + "govoplan-files/webui/src/styles/file-manager.css", + "govoplan-mail/webui/src/styles/mail-profiles.css", +]; +for (const relativePath of representativeModules) { + const css = readFileSync(resolve(repositoryRoot, relativePath), "utf8"); + assert.match(css, /var\(--/, `${relativePath} must consume shared theme tokens`); +} + +console.log("Theme contract passed for core and representative module surfaces."); diff --git a/webui/src/layout/Titlebar.tsx b/webui/src/layout/Titlebar.tsx index db0d21f..ee0df6e 100644 --- a/webui/src/layout/Titlebar.tsx +++ b/webui/src/layout/Titlebar.tsx @@ -1,6 +1,6 @@ import { useRef, useState, useEffect } from "react"; import { Bell, Check, LogOut, Settings, UserCircle } from "lucide-react"; -import type { ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types"; +import type { ActingContextRuntimeUiCapability, ApiSettings, AuthInfo, AuthTenantMembership, AuthUpdate, LoginResponse, SearchRuntimeUiCapability, ViewsRuntimeUiCapability } from "../types"; import HelpMenu from "./HelpMenu"; import LanguageMenu from "./LanguageMenu"; import LoginModal from "../features/auth/LoginModal"; @@ -37,6 +37,8 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode const projection = useEffectiveView(); const viewsRuntime = usePlatformUiCapability("views.runtime"); const ViewSelector = viewsRuntime?.Selector ?? null; + const actingRuntime = usePlatformUiCapability("access.actingContext"); + const ActingContextSelector = actingRuntime?.Selector ?? null; const searchRuntime = usePlatformUiCapability("search.runtime"); const GlobalSearch = searchRuntime?.GlobalSearch ?? null; const canUseGlobalSearch = Boolean( @@ -60,7 +62,7 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode ); const showTenantControl = Boolean(activeTenant && (canSwitchTenant || canAdministerTenants)); const showContextSelectors = Boolean( - auth && ((activeTenant && showTenantControl) || ViewSelector) + auth && ((activeTenant && showTenantControl) || ViewSelector || ActingContextSelector) ); const notificationsAvailable = modules.some((module) => module.id === "notifications" && module.routes?.some((route) => route.path === "/notifications")); const notificationSummary = useSharedNotificationSummary(settings, { @@ -206,6 +208,9 @@ export default function Titlebar({ settings, auth, onAuthChange, maintenanceMode {ViewSelector && } + {ActingContextSelector && + + } } diff --git a/webui/src/styles/layout.css b/webui/src/styles/layout.css index a8474a1..61c5eb6 100644 --- a/webui/src/styles/layout.css +++ b/webui/src/styles/layout.css @@ -33,6 +33,9 @@ .titlebar-actions { grid-column: 2; display: flex; align-items: center; justify-self: end; min-width: 0; gap: 10px; } .titlebar.has-global-search .titlebar-actions { grid-column: 3; } .titlebar-context-selectors { display: flex; align-items: center; min-width: 0; gap: 12px; } +.acting-context-selector { display: inline-flex; align-items: center; min-width: 0; gap: 7px; color: var(--muted); } +.acting-context-selector select { width: auto; max-width: 260px; min-height: 32px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--text-strong); font: inherit; font-weight: 700; padding: 5px 24px 5px 8px; } +.acting-context-selector select:hover, .acting-context-selector select:focus-visible { background: var(--titlebar-hover-bg); } .tenant-selector { height: 40px; min-width: 210px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--surface); display: flex; align-items: center; padding: 0 12px; gap: 5px; box-shadow: var(--shadow-xs); } .tenant-label, .tenant-caret, .muted { color: var(--muted); } .block { display: block; } @@ -265,6 +268,8 @@ .docs-outline { padding-top: 22px; } .docs-sidebar-header { padding: 0 16px 16px; } .docs-sidebar-header .section-title { padding: 0 6px 12px; } +.docs-version-selector { display: grid; gap: 5px; margin-top: 12px; color: var(--muted); font-size: 12px; font-weight: 700; } +.docs-version-selector select { width: 100%; min-height: 34px; border: var(--border-line); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); font: inherit; padding: 5px 8px; } .docs-audience-toggle { width: 100%; } .docs-tree { display: grid; gap: 2px; padding: 0 10px 24px; } .docs-tree-node { min-width: 0; } diff --git a/webui/src/types.ts b/webui/src/types.ts index b203ec6..7062e45 100644 --- a/webui/src/types.ts +++ b/webui/src/types.ts @@ -92,11 +92,22 @@ export type PrincipalContext = { api_key_id?: string | null; session_id?: string | null; service_account_id?: string | null; + acting_assignment_id?: string | null; acting_for_account_id?: string | null; email?: string | null; display_name?: string | null; }; +export type ActingContextSelectorProps = { + settings: ApiSettings; + auth: AuthInfo; + onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void; +}; + +export type ActingContextRuntimeUiCapability = { + Selector: ComponentType; +}; + export type AuthInfo = { user: AuthUser; // Backwards-compatible active tenant alias returned by older/newer APIs.