"""Plugin review workflow backed by PostgreSQL. Manages the approval queue for newly submitted plugins and enforces a security checklist before any plugin is made visible in the marketplace. Module-level singleton:: from app.marketplace.plugin_review import review_queue """ from __future__ import annotations import re from typing import Any, Literal from sqlalchemy.ext.asyncio import AsyncSession from app.marketplace.plugin_registry import registry from app.models import PluginReview as PluginReviewModel from app.schemas import PluginManifest # ── Security policy ─────────────────────────────────────────────────── ALLOWED_PERMISSIONS: frozenset[str] = frozenset( { "read:tasks", "write:tasks", "read:projects", "write:projects", "read:notes", "write:notes", "read:checkpoints", "write:checkpoints", "read:calendar", "write:calendar", } ) _PLUGIN_ID_RE = re.compile(r"^[a-z0-9-]+$") def validate_manifest(manifest: PluginManifest) -> None: """Enforce the plugin security checklist. Raises: ``ValueError`` on the first violation found. Callers should catch this and return HTTP 422 / reject the submission. Checks: 1. Plugin id matches ``^[a-z0-9-]+$`` 2. All declared permissions are in ``ALLOWED_PERMISSIONS`` 3. No manifest field contains raw binary data """ if not _PLUGIN_ID_RE.match(manifest.id): raise ValueError( f"Invalid plugin id format: '{manifest.id}'. " "Only lowercase letters, digits, and hyphens are allowed." ) for perm in manifest.permissions: if perm not in ALLOWED_PERMISSIONS: raise ValueError( f"Unknown permission: '{perm}'. " f"Allowed permissions: {sorted(ALLOWED_PERMISSIONS)}" ) for field_name, value in manifest.model_dump().items(): if isinstance(value, (bytes, bytearray)): raise ValueError( f"Binary content is not allowed in manifest field '{field_name}'." ) class ReviewQueue: """Approval queue for pending plugin submissions. Delegates status changes to the shared ``PluginRegistry`` singleton. Review records are persisted in the ``plugin_reviews`` table. """ async def get_pending(self, db: AsyncSession) -> list[dict[str, Any]]: """Return all plugins currently awaiting review. Each item is ``{plugin_id, manifest, submitted_at}``. """ entries = await registry.get_pending_entries(db) return [ { "plugin_id": e["manifest"].id, "manifest": e["manifest"], "submitted_at": e["submitted_at"], } for e in entries ] async def submit_review( self, db: AsyncSession, plugin_id: str, reviewer_id: str, decision: Literal["approved", "rejected"], notes: str = "", ) -> None: """Record a review decision and update the plugin's status. Raises: ``KeyError`` if *plugin_id* is not found in the registry. """ if decision == "approved": await registry.approve_plugin(db, plugin_id) else: await registry.reject_plugin(db, plugin_id, reason=notes) review = PluginReviewModel( plugin_id=plugin_id, reviewer_id=reviewer_id, decision=decision, notes=notes, ) db.add(review) await db.commit() # Module-level singleton review_queue = ReviewQueue()