agnes-the-ai-analyst/app/api/marketplace.py
Vojtech d6ad08f107
Flea-market upload guardrails + soft delete + JOIN-based admin queue (#233)
* feat(store): flea-market upload guardrails + soft delete + JOIN-based admin queue

Adds an end-to-end guardrails pipeline for store uploads (manifest +
static-security + LLM review), persists blocked bundles for forensics,
introduces soft-delete (Archive) semantics, consolidates the legacy
/store/{id} surface into /marketplace/flea/{id}, and reworks the admin
queue so lifecycle filters read live entity visibility via LEFT JOIN
rather than a denormalized submission column.

Schema v29 → v35:
  * v29 store_submissions table + store_entities.visibility_status
  * v30 file_size, bundle_sha256, bundle_purged_at on submissions
  * v31 reshape store_submissions (drop legacy unique on entity_id)
  * v32 store_entities.archived_at/by + 'archived' visibility value
  * v33 drop store_submissions.retry_count (unused)
  * v34 ensure idx_store_submissions_entity exists post column-drop
  * v35 broaden visibility_status enum + JOIN architecture cutover

Pipeline (src/store_guardrails/):
  * Inline checks: manifest_check, static_scan, quality_check
  * LLM review configurable haiku|sonnet|opus (default haiku)
  * BackgroundTasks-driven async path with structured-output JSON
  * Per-submitter daily quota (default 50)
  * 30-day TTL purge job (POST /api/admin/run-blocked-purge)
  * Bundle SHA256 + size persisted; sha256 survives purge for forensics

Visibility model:
  * pending | approved | hidden | archived
  * _enforce_visibility returns 404 (no leak) for non-owner non-admin
  * Owner sees own non-approved entries via include_owner_id widening
  * Install refused with 409 entity_not_approved when not approved

Soft-delete (DELETE /api/store/entities/{id}):
  * Default = soft (visibility_status='archived'); existing installs
    keep getting served the bundle so users don't lose the plugin
  * ?hard=true admin-only: drops bundle + cascades user_store_installs
  * Hard-delete preserves entity_id on submission as tombstone so
    audit_log linkage survives for the activity timeline

Admin queue lifecycle (the JOIN refactor):
  * Verdict (store_submissions.status) is immutable forensic record
  * Lifecycle (store_entities.visibility_status) is live state
  * /admin/store/submissions Archived chip translates to
    `e.visibility_status='archived'` via LEFT JOIN — any path that
    flips visibility surfaces in the queue immediately
  * Detail page renders Status (verdict) and Entity lifecycle side by
    side so admins see "approved at review, now archived" at a glance

URL consolidation:
  * /store/{id} deleted (no redirect, stale bookmarks 404)
  * /marketplace/flea/{id} is the canonical detail surface
  * Three in-tree callers (upload-success, my-stack card, store
    listing card) updated to point at the new URL
  * Quarantine banner extracted to _quarantine_banner.html partial,
    self-guarded, included from both flea detail templates
  * Banner JS auto-refreshes when the verdict lands by polling
    /api/marketplace/flea/{id}/detail (visibility_status +
    submission_status — the latter is needed because blocked_llm
    keeps the entity at visibility_status='pending')

Audit log resource format:
  * runner.py emits prefixed `store_submission:{id}` (post-fix)
  * Detail-page timeline query handles three patterns: prefixed
    submission, helper-emitted `store_entity:{sub_id}`, and bare-id
    legacy rows — all surface in the activity timeline

UX fixes:
  * Owner sees Under review / Quarantined / Hidden banner with status
  * Install button gray-disabled (not blue) when non-approved
  * Owner cannot delete quarantined entries (403); admin can
  * Admin queue: filter chips, sortable columns, paging, page-size
  * Auto-refresh queue every 5s while pending rows are visible
  * Store upload page file picker no longer opens twice (label →
    input default action collided with explicit JS handler)

Tests: 168 passed across the guardrails suites (admin submissions,
store API, inline / LLM / purge guardrails, store repositories,
marketplace filter, schema version). New regression coverage
includes: archive surfaces via JOIN even when API path is bypassed;
deleted submission renders activity timeline (tombstone); flea
detail surfaces submission_status only for owner/admin; detail page
renders Entity lifecycle row; audit log resource format covers both
helper and runner paths.

* fix(store-guardrails): PR #233 follow-up — prompt injection, atomic PUT, BG race, schema, reaper, sort whitelist

Addresses 9 of the 23 findings from the PR #233 review (spec at
docs/superpowers/specs/2026-05-09-pr233-guardrails-fixes-spec.md).
Merge-gate items #1-#6 plus high-value mediums #7, #9-#12, #23.
Architectural items (#8 enum split, #14 factory) and pure
maintainability (#15-#22) deferred to follow-ups.

Security:
* #1 prompt injection — SYSTEM_PROMPT now passed via the SDK's
  dedicated system= parameter; bundle wrapped in <bundle>...</bundle>
  sentinels declared data-only by the system prompt; literal
  sentinel strings in user content are escaped so an adversarial
  README can't forge a close tag.
* #6 static scan honesty — module docstring + admin copy + docs
  declare static scan as signal not gate; .md/.txt/.rst/.html/.json/
  .yaml/.yml/.toml skipped to avoid false positives on prose.
  AST mode for Python deferred (separate flag, FP comparison work).

Correctness:
* #2 PUT atomicity — bundles bake into plugin.staging-<rand>/
  alongside live, atomic-rename on success; failed checks leave
  live tree byte-for-byte intact.
* #3 BG-task race — set_visibility_if_pending guards verdict flips
  to the (pending, hidden) review window; admin archives during
  review survive; skipped flips audit-logged.
* #4 v35 NOT NULL/DEFAULT — schema v35→v36 re-applies them on
  store_entities.visibility_status. CHECK constraint enforced
  application-side (DuckDB ADD CHECK on existing column unsupported).
* #7 stuck-review reaper — reap_stuck_llm_reviews flips pending_llm
  rows older than guardrails.stuck_review_grace_seconds (default
  1800) to review_error. Scheduler runs every 15 min via new
  /api/admin/run-reap-stuck-reviews. Set knob to 0 to disable.
* #9 quota counter — count_blocked_for_submitter_since now counts
  blocked_inline + blocked_llm + review_error so a submitter
  triggering only LLM-blocked verdicts is bounded.
* #10 missing risk_level — surfaces as review_error with
  error='missing_risk_level' instead of silently defaulting to
  'medium' (which looked like a model-decided block).
* #11 archived_at clear — set_visibility nulls archived_at +
  archived_by when transitioning out of 'archived' so a future
  read doesn't show stale archive forensics on an approved row.

Maintainability:
* #12 FSM doc comment — accurate insert/transition/lifecycle
  description in src/db.py near store_submissions schema.
* #23 sort-key whitelist — admin queue rejects unknown sort keys
  with 400 invalid_sort_key; substring-replace footgun removed.

Deferred (separate PRs):
* #5 quota race — proper fix requires asyncio.Lock spanning the
  full pipeline; threading.Lock blocks event loop, DuckDB MVCC
  doesn't help. API-level slowapi bounds worst case for now.
* #6 part 3 (AST static scan), #8 (enum split), #13 (import
  bundle docs), #14 (factory consolidation), #15-#22 (maint).

Tests:
* New: tests/test_store_guardrails_prompt_injection.py (corpus +
  trust-boundary invariants), tests/test_store_put_atomic.py,
  tests/test_store_guardrails_reaper.py.
* Extended: test_store_guardrails_llm.py (system param, missing
  risk_level, BG race), test_admin_store_submissions.py (quota
  counter widening, sort whitelist 400), test_store_repositories.py
  (un-archive metadata clear), test_db_schema_version.py (v36).
* Full suite: 3738 passed; 17 pre-existing baseline failures
  unchanged (db migration tests, cli binary rename, catalog export,
  user mgmt v5 backfill — confirmed by stash + rerun on clean tree).
2026-05-09 17:32:53 +04:00

1260 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Unified Marketplace API — backs the ``/marketplace`` browse page.
Combines two upstream sources into a single browse + detail surface:
* **curated** — admin-registered git marketplaces, RBAC-scoped via
``resource_grants`` (resource_type='marketplace_plugin').
* **flea** — community Store uploads (``store_entities``).
Per-tab listing endpoints (``/items``, ``/categories``) plus curated-side
detail and explicit-install (Model B) endpoints. Flea-side install/uninstall
keeps using the existing ``/api/store/entities/{id}/install`` POST/DELETE
endpoints — no need to duplicate.
Curated detail / install endpoints are gated by
``require_resource_access(MARKETPLACE_PLUGIN, "{marketplace_id}/{plugin_name}")``
so a user without the RBAC grant gets a 403 even on direct URL hit.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
import duckdb
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from app.auth.access import (
_user_group_ids,
is_user_admin,
require_resource_access,
)
from app.auth.dependencies import _get_db, get_current_user
from app.resource_types import ResourceType
from app.utils import get_marketplaces_dir
from src.marketplace_filter import (
resolve_allowed_plugins,
resolve_manifest_name,
)
from src.repositories.audit import AuditRepository
from src.repositories.marketplace_plugins import MarketplacePluginsRepository
from src.repositories.marketplace_registry import MarketplaceRegistryRepository
from src.repositories.store_entities import StoreEntitiesRepository
from src.repositories.user_curated_subscriptions import (
UserCuratedSubscriptionsRepository,
)
from src.repositories.user_store_installs import UserStoreInstallsRepository
from src.store_categories import STORE_CATEGORIES
from src.store_naming import suffixed_name
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/marketplace", tags=["marketplace"])
OWNER_TODO_PLACEHOLDER = "owner_todo"
"""Placeholder displayed in the UI when a curated plugin has no owner / curator
metadata. To be replaced once ``marketplace_plugins.curator_owner`` lands."""
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL)
# ---------------------------------------------------------------------------
# Pydantic response models
# ---------------------------------------------------------------------------
class MarketplaceItem(BaseModel):
id: str
source: Literal["curated", "flea"]
name: str
type: Literal["skill", "agent", "plugin"]
category: Optional[str] = None
description: Optional[str] = None
owner: Optional[str] = None
version: Optional[str] = None
photo_url: Optional[str] = None
added: Optional[str] = None
installed: bool = False
marketplace_slug: Optional[str] = None
marketplace_name: Optional[str] = None
detail_url: str
# v32+ quarantine UX: surface the card's visibility + an
# is_viewer_owner flag so the listing template can render an
# "Under review" / "Quarantined" corner badge on the submitter's
# own non-approved cards. Approved cards omit both fields.
visibility_status: Optional[str] = None
is_viewer_owner: bool = False
class ItemListResponse(BaseModel):
items: List[MarketplaceItem]
total: int
page: int
page_size: int
class CategoryEntry(BaseModel):
name: str
count: int
icon_key: str
class CategoriesResponse(BaseModel):
items: List[CategoryEntry]
class InnerItemSummary(BaseModel):
name: str
description: Optional[str] = None
detail_url: Optional[str] = None # nested detail for skill/agent only
class CommandEntry(BaseModel):
name: str
description: Optional[str] = None
class HookEntry(BaseModel):
name: str
event: Optional[str] = None # "—" rendered client-side when None
description: Optional[str] = None
class McpEntry(BaseModel):
name: str
type: Optional[str] = None # "stdio" / "sse" / etc.
description: Optional[str] = None
class FileEntry(BaseModel):
"""One file in a plugin / skill / agent bundle. Path is relative to the
bundle root; size is bytes (rendered with ``humanbytes`` on the client)."""
path: str
size: int
class DocEntry(BaseModel):
"""One doc file shipped alongside a Store entity. URL points at the
serving endpoint (``/api/store/entities/<id>/docs/<filename>``)."""
name: str
url: str
class PluginDetailResponse(BaseModel):
"""Unified detail response for a plugin (curated *or* flea).
Frontend-side switching is purely cosmetic — `source` controls the
Curated/Flea pill, photo URL fallback path, and owner label.
"""
source: Literal["curated", "flea"]
# IDs / breadcrumbs
marketplace_id: Optional[str] = None # curated only
marketplace_name: Optional[str] = None # curated only
entity_id: Optional[str] = None # flea only
plugin_name: str
manifest_name: str
# Display
description: Optional[str] = None
version: Optional[str] = None
category: Optional[str] = None
author_name: Optional[str] = None # curated curator / flea owner
owner_display: Optional[str] = None # flea: users.name → email → owner_username
homepage: Optional[str] = None
cover_photo_url: Optional[str] = None # /api/store/.../photo for flea, curator metadata for curated
bundle_size: Optional[int] = None # bytes; None when unknown
install_count: int = 0 # flea only; curated leaves at 0
released_at: Optional[str] = None # ISO timestamp
updated_at: Optional[str] = None # ISO timestamp
installed: bool = False
# Internal structure
skills: List[InnerItemSummary] = []
agents: List[InnerItemSummary] = []
commands: List[CommandEntry] = []
hooks: List[HookEntry] = []
mcps: List[McpEntry] = []
# Bundle contents — used by the redesigned skill/agent/plugin detail pages
files: List[FileEntry] = []
docs: List[DocEntry] = []
# v32+ quarantine: surface the entity's visibility so the JS install
# path can refuse to render the install button when non-approved.
# Curated entries omit (always live).
visibility_status: Optional[str] = None
# Latest submission verdict for the linked entity — populated only
# for the owner / admin (the same audiences that see the quarantine
# banner). The banner's auto-refresh JS polls this field so it can
# reload the page when an LLM review lands; visibility alone is
# insufficient because `blocked_llm` keeps the entity at
# `visibility_status='pending'`.
submission_status: Optional[str] = None
# Legacy alias kept so any unmigrated import continues to resolve. The new
# unified model supersedes the curated-only response shape.
CuratedDetailResponse = PluginDetailResponse
class InnerDetailResponse(BaseModel):
marketplace_id: str
plugin_name: str
kind: Literal["skill", "agent"]
name: str
description: Optional[str] = None
body: str
relpath: str # path inside plugin_dir for diagnostics
# Parent plugin metadata + bundle contents — populated so the redesigned
# skill/agent detail page can render hero badges, sidebar rows, and the
# Files section without a second roundtrip to the parent plugin endpoint.
marketplace_name: str = ""
category: Optional[str] = None
parent_author_name: Optional[str] = None
parent_updated_at: Optional[str] = None
# Parent plugin's `name` from `.claude-plugin/plugin.json` — same value
# the synth marketplace.json uses, so /<manifest_name>:<inner_name> is
# exactly what Claude Code accepts after install.
manifest_name: str = ""
bundle_size: Optional[int] = None
files: List[FileEntry] = []
class InstallActionResponse(BaseModel):
installed: bool
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _audit(
conn: duckdb.DuckDBPyConnection,
actor_id: str,
action: str,
target: str,
params: Optional[dict] = None,
) -> None:
try:
AuditRepository(conn).log(
user_id=actor_id, action=action, resource=target, params=params
)
except Exception:
pass
def _invalidate_etag() -> None:
try:
from app.marketplace_server import packager
packager.invalidate_etag_cache()
except Exception:
logger.exception("failed to invalidate marketplace etag cache")
def _parse_frontmatter(text: str) -> Dict[str, str]:
m = _FRONTMATTER_RE.match(text)
if not m:
return {}
out: Dict[str, str] = {}
for line in m.group(1).splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if ":" in line:
k, v = line.split(":", 1)
out[k.strip()] = v.strip().strip('"').strip("'")
return out
def _frontmatter_body(text: str) -> str:
m = _FRONTMATTER_RE.match(text)
return text[m.end():].lstrip("\n") if m else text
def _to_iso(value: Any) -> Optional[str]:
if value is None:
return None
try:
return value.isoformat()
except AttributeError:
return str(value)
def _icon_key_for(category: Optional[str]) -> str:
"""Stable key used by the frontend to look up the inline SVG icon
(registered in ``src/category_icons.py``). Matches our taxonomy 1:1."""
return category or "Other"
def _curated_detail_url(marketplace_id: str, plugin_name: str) -> str:
return f"/marketplace/curated/{marketplace_id}/{plugin_name}"
def _flea_detail_url(entity_id: str) -> str:
return f"/marketplace/flea/{entity_id}"
def _resolve_marketplace_name(
conn: duckdb.DuckDBPyConnection, marketplace_id: str
) -> str:
row = MarketplaceRegistryRepository(conn).get(marketplace_id)
if not row:
return marketplace_id
return (row.get("name") or "").strip() or marketplace_id
def _curated_to_item(
conn: duckdb.DuckDBPyConnection,
plugin_row: dict,
*,
subs: set,
marketplace_names: Dict[str, str],
) -> MarketplaceItem:
marketplace_id = plugin_row["marketplace_id"]
plugin_name = plugin_row["name"]
mp_name = marketplace_names.get(marketplace_id) or marketplace_id
return MarketplaceItem(
id=f"curated-{marketplace_id}/{plugin_name}",
source="curated",
name=plugin_name,
type="plugin",
category=plugin_row.get("category") or None,
description=plugin_row.get("description"),
owner=plugin_row.get("author_name") or OWNER_TODO_PLACEHOLDER,
version=plugin_row.get("version"),
photo_url=None,
added=_to_iso(plugin_row.get("created_at")),
installed=(marketplace_id, plugin_name) in subs,
marketplace_slug=marketplace_id,
marketplace_name=mp_name,
detail_url=_curated_detail_url(marketplace_id, plugin_name),
)
def _flea_to_item(
entity: dict, *, installed_set: set, viewer_id: Optional[str] = None,
) -> MarketplaceItem:
photo_url = (
f"/api/store/entities/{entity['id']}/photo"
if entity.get("photo_path") else None
)
# The archive flow renames the row's `name` to free the slot; strip
# the suffix when rendering listings so owners don't see the ugly
# `__archived__<epoch>` in their own cards. The served catalog
# (Claude Code's `/plugin` resolution) uses the renamed slug — we
# don't strip there.
from src.store_naming import strip_archive_suffix
display_name = strip_archive_suffix(entity["name"])
invocation = suffixed_name(display_name, entity.get("owner_username") or "")
is_viewer_owner = bool(viewer_id and entity.get("owner_user_id") == viewer_id)
return MarketplaceItem(
id=f"flea-{entity['id']}",
source="flea",
name=invocation,
type=entity["type"],
category=entity.get("category") or None,
description=entity.get("description"),
owner=entity.get("owner_username"),
version=entity.get("version"),
photo_url=photo_url,
added=_to_iso(entity.get("created_at")),
installed=entity["id"] in installed_set,
marketplace_slug=None,
marketplace_name=None,
detail_url=_flea_detail_url(entity["id"]),
visibility_status=entity.get("visibility_status") or "approved",
is_viewer_owner=is_viewer_owner,
)
# ---------------------------------------------------------------------------
# GET /api/marketplace/items
# ---------------------------------------------------------------------------
@router.get("/items", response_model=ItemListResponse)
async def list_items(
tab: Literal["curated", "flea", "my"] = Query("curated"),
q: Optional[str] = Query(None),
category: Optional[str] = Query(None),
type: Optional[Literal["skill", "agent", "plugin"]] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(24, ge=1, le=100),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Paginated, RBAC-scoped item list for the ``/marketplace`` browse page.
Per-tab dispatch:
* ``curated`` → ``MarketplacePluginsRepository.list_with_filters`` scoped to
the caller's ``user_group_members``. Results are tagged with the
caller's subscription state.
* ``flea`` → ``StoreEntitiesRepository.list`` (community Store).
* ``my`` → direct read from ``user_curated_subscriptions`` (filtered
against the caller's RBAC-allowed plugins) ``user_store_installs``
(all flea types — skill / agent / plugin — surface as individual
cards). We deliberately don't reuse ``resolve_user_marketplace``
here: that resolver bundles flea skills/agents into a single
synthetic ``store-bundle`` entry useful for the served Claude Code
marketplace ZIP/git endpoints but wrong for /marketplace browsing,
where each item should appear as its own card. Mirrors the
``/api/my-stack`` reading pattern.
"""
skip = (page - 1) * page_size
if tab == "curated":
group_ids = _user_group_ids(user["id"], conn) or set()
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
rows, total = MarketplacePluginsRepository(conn).list_with_filters(
group_ids=group_ids,
search=q or None,
category=category or None,
skip=skip,
limit=page_size,
)
marketplace_names: Dict[str, str] = {}
if rows:
distinct_ids = {r["marketplace_id"] for r in rows}
for mp_id in distinct_ids:
marketplace_names[mp_id] = _resolve_marketplace_name(conn, mp_id)
items = [
_curated_to_item(conn, r, subs=subs, marketplace_names=marketplace_names)
for r in rows
]
return ItemListResponse(
items=items, total=total, page=page, page_size=page_size,
)
if tab == "flea":
installed_set = {
row["id"]
for row in UserStoreInstallsRepository(conn).list_for_user(user["id"])
}
# Visibility filter: non-admin sees approved + their own
# non-approved (so submitters spot what's still under review
# in their own grid). Admin sees everything.
from app.auth.access import is_user_admin
if is_user_admin(user["id"], conn):
visibility_filter = None
include_owner = None
else:
visibility_filter = ["approved"]
include_owner = user["id"]
rows, total = StoreEntitiesRepository(conn).list(
skip=skip, limit=page_size,
type=type, category=category, search=q or None,
visibility_status=visibility_filter,
include_owner_id=include_owner,
)
items = [
_flea_to_item(r, installed_set=installed_set, viewer_id=user["id"])
for r in rows
]
return ItemListResponse(
items=items, total=total, page=page, page_size=page_size,
)
# tab == "my" — see docstring; read directly from source-of-truth tables.
items: List[MarketplaceItem] = []
granted = resolve_allowed_plugins(conn, user)
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
marketplace_names: Dict[str, str] = {}
for p in granted:
if (p["marketplace_id"], p["original_name"]) not in subs:
continue
mp_id = p["marketplace_id"]
if mp_id not in marketplace_names:
marketplace_names[mp_id] = _resolve_marketplace_name(conn, mp_id)
author = p["raw"].get("author")
plugin_row = {
"marketplace_id": mp_id,
"name": p["original_name"],
"description": p["raw"].get("description"),
"version": p.get("version"),
"category": p["raw"].get("category"),
"author_name": author.get("name") if isinstance(author, dict) else None,
"created_at": None,
}
items.append(_curated_to_item(
conn, plugin_row, subs=subs, marketplace_names=marketplace_names,
))
flea_installs = UserStoreInstallsRepository(conn).list_for_user(user["id"])
flea_installed_set = {row["id"] for row in flea_installs}
for entity in flea_installs:
items.append(_flea_to_item(entity, installed_set=flea_installed_set))
# Apply optional filters client-server-style for `my` tab (small N):
if q:
needle = q.lower()
items = [
it for it in items
if needle in it.name.lower()
or needle in (it.description or "").lower()
or needle in (it.owner or "").lower()
or needle in (it.category or "").lower()
]
if category:
items = [it for it in items if (it.category or "Other") == category]
if type:
items = [it for it in items if it.type == type]
total = len(items)
items = items[skip : skip + page_size]
return ItemListResponse(
items=items, total=total, page=page, page_size=page_size,
)
# ---------------------------------------------------------------------------
# GET /api/marketplace/categories
# ---------------------------------------------------------------------------
@router.get("/categories", response_model=CategoriesResponse)
async def list_categories(
tab: Literal["curated", "flea", "my"] = Query("curated"),
type: Optional[Literal["skill", "agent", "plugin"]] = Query(None),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Per-tab category list with non-zero counts.
Source of categories: union of ``STORE_CATEGORIES`` and any non-empty
``marketplace_plugins.category`` values in the caller's RBAC scope.
Categories with zero matching items are omitted (the frontend hides
them this way).
"""
counts: Dict[str, int] = {}
if tab in ("curated", "my"):
group_ids = _user_group_ids(user["id"], conn) or set()
if tab == "curated":
counts.update(
MarketplacePluginsRepository(conn).category_counts(
group_ids=group_ids
)
)
else: # my — direct read mirroring the items endpoint's `my` branch.
granted = resolve_allowed_plugins(conn, user)
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
for p in granted:
if (p["marketplace_id"], p["original_name"]) not in subs:
continue
cat = (p["raw"].get("category") or "").strip() or "Other"
counts[cat] = counts.get(cat, 0) + 1
for row in UserStoreInstallsRepository(conn).list_for_user(user["id"]):
if type and row.get("type") != type:
continue
cat = (row.get("category") or "").strip() or "Other"
counts[cat] = counts.get(cat, 0) + 1
if tab == "flea":
# Visibility filter (v32+/v35): non-admin counts approved + own
# non-archived non-approved (mirrors the listing endpoint so the
# category counts match what the user actually sees in the
# grid). Admin counts everything.
from app.auth.access import is_user_admin
clauses: List[str] = []
sql_params: List[Any] = []
if type:
clauses.append("type = ?"); sql_params.append(type)
if not is_user_admin(user["id"], conn):
clauses.append(
"(visibility_status = 'approved' "
"OR (owner_user_id = ? AND visibility_status != 'archived'))"
)
sql_params.append(user["id"])
where_sql = ("WHERE " + " AND ".join(clauses)) if clauses else ""
rows = conn.execute(
f"SELECT COALESCE(NULLIF(TRIM(category),''), 'Other') AS cat, COUNT(*) "
f"FROM store_entities {where_sql} GROUP BY cat",
sql_params,
).fetchall()
for r in rows:
counts[str(r[0])] = counts.get(str(r[0]), 0) + int(r[1])
items = [
CategoryEntry(name=name, count=count, icon_key=_icon_key_for(name))
for name, count in sorted(counts.items())
if count > 0
]
return CategoriesResponse(items=items)
# ---------------------------------------------------------------------------
# Curated detail + install endpoints
# ---------------------------------------------------------------------------
def _list_inner_skills(plugin_root: Path) -> List[InnerItemSummary]:
out: List[InnerItemSummary] = []
skills_dir = plugin_root / "skills"
if not skills_dir.is_dir():
return out
for skill_dir in sorted(skills_dir.iterdir()):
if not skill_dir.is_dir():
continue
skill_md = skill_dir / "SKILL.md"
if not skill_md.is_file():
continue
try:
text = skill_md.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
fm = _parse_frontmatter(text)
out.append(InnerItemSummary(
name=fm.get("name") or skill_dir.name,
description=fm.get("description"),
detail_url=None, # populated by caller (needs marketplace_id + plugin_name)
))
return out
def _list_inner_agents(plugin_root: Path) -> List[InnerItemSummary]:
out: List[InnerItemSummary] = []
agents_dir = plugin_root / "agents"
if not agents_dir.is_dir():
return out
for agent_path in sorted(agents_dir.iterdir()):
if not agent_path.is_file() or agent_path.suffix != ".md":
continue
try:
text = agent_path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
fm = _parse_frontmatter(text)
out.append(InnerItemSummary(
name=fm.get("name") or agent_path.stem,
description=fm.get("description"),
detail_url=None,
))
return out
def _list_commands(plugin_root: Path) -> List[CommandEntry]:
"""Return ``commands/*.md`` as ``[(name, description)]`` from frontmatter."""
d = plugin_root / "commands"
if not d.is_dir():
return []
out: List[CommandEntry] = []
for p in sorted(d.iterdir()):
if not p.is_file() or p.suffix != ".md":
continue
try:
text = p.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
fm = _parse_frontmatter(text)
out.append(CommandEntry(
name="/" + (fm.get("name") or p.stem),
description=fm.get("description"),
))
return out
def _read_plugin_json(plugin_root: Path) -> dict:
"""Best-effort load of the plugin's own .claude-plugin/plugin.json."""
pj = plugin_root / ".claude-plugin" / "plugin.json"
if not pj.is_file():
return {}
try:
import json
data = json.loads(pj.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _list_hooks(plugin_root: Path) -> List[HookEntry]:
"""Combine hook declarations from plugin.json with files in ``hooks/``.
Two declaration paths exist:
1. ``plugin.json`` ``hooks`` field — ``{<EventName>: [{matcher, hooks: [...]}]}``
2. Bare scripts in ``<plugin_root>/hooks/`` — event unknown, rendered
as ``""`` so the table still surfaces them.
"""
out: List[HookEntry] = []
seen_names: set[str] = set()
# Path 1: structured declaration in plugin.json.
data = _read_plugin_json(plugin_root)
hooks_field = data.get("hooks") if isinstance(data, dict) else None
if isinstance(hooks_field, dict):
for event, entries in hooks_field.items():
if not isinstance(entries, list):
continue
for entry in entries:
if not isinstance(entry, dict):
continue
inner = entry.get("hooks") or []
if not isinstance(inner, list):
continue
for h in inner:
if not isinstance(h, dict):
continue
name = h.get("command") or h.get("name") or h.get("type") or "(hook)"
name = str(name).strip() or "(hook)"
description = entry.get("matcher") or h.get("description")
out.append(HookEntry(
name=name,
event=str(event),
description=str(description) if description else None,
))
seen_names.add(name)
# Path 2: bare scripts in hooks/ directory not already covered.
hooks_dir = plugin_root / "hooks"
if hooks_dir.is_dir():
for p in sorted(hooks_dir.iterdir()):
if not p.is_file():
continue
if p.name in seen_names or str(p) in seen_names:
continue
out.append(HookEntry(name=p.name, event=None, description=None))
return out
def _list_mcps(plugin_root: Path) -> List[McpEntry]:
"""Parse MCP server declarations from ``.mcp.json`` (or legacy
``mcp_servers.json``). Returns ``[(name, type, description)]``.
Standard shape: ``{"mcpServers": {<name>: {<config>}}}`` where ``<config>``
contains either ``command`` (stdio) or ``url`` (sse). Type is inferred
from those fields. Description falls through to ``command`` so the
operator at least sees what's launched.
"""
candidates = [
plugin_root / ".mcp.json",
plugin_root / "mcp_servers.json",
]
out: List[McpEntry] = []
for path in candidates:
if not path.is_file():
continue
try:
import json
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
continue
servers = data.get("mcpServers") if isinstance(data, dict) else None
if not isinstance(servers, dict):
continue
for name, cfg in servers.items():
if not isinstance(cfg, dict):
out.append(McpEntry(name=str(name)))
continue
kind: Optional[str] = None
description: Optional[str] = None
if "command" in cfg:
kind = "stdio"
description = str(cfg.get("command"))
args = cfg.get("args")
if isinstance(args, list) and args:
description = description + " " + " ".join(str(a) for a in args)
elif "url" in cfg:
kind = "sse"
description = str(cfg.get("url"))
out.append(McpEntry(
name=str(name),
type=kind,
description=description,
))
break # first found candidate wins
return out
def _bundle_size(plugin_root: Path) -> Optional[int]:
"""Sum of file sizes under ``plugin_root``. None if path missing."""
if not plugin_root.is_dir():
return None
total = 0
for p in plugin_root.rglob("*"):
if p.is_file():
try:
total += p.stat().st_size
except OSError:
pass
return total
def _walk_files(root: Path) -> List[FileEntry]:
"""Recursive file listing under ``root``.
Returns sorted ``[FileEntry(path, size)]`` with paths relative to ``root``
(forward-slash form for stable display). Directories are skipped — only
leaf files appear, matching the way ``store_detail.html`` renders the
Files section. Empty list if ``root`` is missing or not a directory.
"""
if not root.is_dir():
return []
out: List[FileEntry] = []
for p in sorted(root.rglob("*")):
if not p.is_file():
continue
try:
rel = p.relative_to(root).as_posix()
out.append(FileEntry(path=rel, size=p.stat().st_size))
except OSError:
continue
return out
def _resolve_owner_display(
conn: duckdb.DuckDBPyConnection,
owner_user_id: str,
fallback: str,
) -> str:
"""Friendly owner display name — ``users.name → users.email → fallback``.
Mirrors the inline lookup ``app/web/router.py::store_detail`` already does
so the marketplace API surfaces the same string the Store page shows.
"""
row = conn.execute(
"SELECT name, email FROM users WHERE id = ?", [owner_user_id]
).fetchone()
if not row:
return fallback
return row[0] or row[1] or fallback
def _get_plugin_row(
conn: duckdb.DuckDBPyConnection,
marketplace_id: str,
plugin_name: str,
) -> Optional[dict]:
"""Fetch a single ``marketplace_plugins`` row as a dict, or ``None``."""
rows = conn.execute(
"SELECT * FROM marketplace_plugins "
"WHERE marketplace_id = ? AND name = ?",
[marketplace_id, plugin_name],
).fetchall()
if not rows:
return None
columns = [d[0] for d in conn.description]
return MarketplacePluginsRepository._row_to_dict(columns, rows[0])
@router.get(
"/curated/{marketplace_id}/{plugin_name}",
response_model=PluginDetailResponse,
)
async def curated_detail(
marketplace_id: str,
plugin_name: str,
_user: dict = Depends(require_resource_access(
ResourceType.MARKETPLACE_PLUGIN, "{marketplace_id}/{plugin_name}",
)),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Return the curated plugin detail + inner skill/agent/command/hook/mcp list.
The 403 guard fires before this body runs (via ``require_resource_access``).
A second ``get_current_user`` dependency is included so we still have the
caller's user dict for the ``installed`` flag.
"""
plugin_row = _get_plugin_row(conn, marketplace_id, plugin_name)
if plugin_row is None:
raise HTTPException(status_code=404, detail="plugin_not_found")
plugin_root = (
Path(get_marketplaces_dir()) / marketplace_id / "plugins" / plugin_name
)
skills = _list_inner_skills(plugin_root)
for s in skills:
s.detail_url = (
f"/marketplace/curated/{marketplace_id}/{plugin_name}/skill/{s.name}"
)
agents = _list_inner_agents(plugin_root)
for a in agents:
a.detail_url = (
f"/marketplace/curated/{marketplace_id}/{plugin_name}/agent/{a.name}"
)
commands = _list_commands(plugin_root)
hooks = _list_hooks(plugin_root)
mcps = _list_mcps(plugin_root)
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
raw = plugin_row.get("raw") or {}
if isinstance(raw, str):
try:
import json
raw = json.loads(raw)
except (ValueError, TypeError):
raw = {}
# Curated cover photo lives in dodatečná curator metadata (not in upstream
# plugin.json today). The raw row may carry it once curators populate the
# field; until then the value falls through to None and the frontend
# paints a gradient placeholder.
cover_url: Optional[str] = None
if isinstance(raw, dict):
candidate = raw.get("cover_photo_url") or raw.get("photo_url")
if isinstance(candidate, str) and candidate.strip():
cover_url = candidate.strip()
return PluginDetailResponse(
source="curated",
marketplace_id=marketplace_id,
marketplace_name=_resolve_marketplace_name(conn, marketplace_id),
plugin_name=plugin_name,
manifest_name=(raw.get("name") if isinstance(raw, dict) else None) or plugin_name,
description=plugin_row.get("description"),
version=plugin_row.get("version"),
category=plugin_row.get("category"),
author_name=plugin_row.get("author_name") or OWNER_TODO_PLACEHOLDER,
homepage=plugin_row.get("homepage"),
cover_photo_url=cover_url,
bundle_size=_bundle_size(plugin_root),
released_at=_to_iso(plugin_row.get("created_at")),
updated_at=_to_iso(plugin_row.get("updated_at")),
installed=(marketplace_id, plugin_name) in subs,
skills=skills,
agents=agents,
commands=commands,
hooks=hooks,
mcps=mcps,
files=_walk_files(plugin_root),
)
@router.get(
"/flea/{entity_id}/detail",
response_model=PluginDetailResponse,
)
async def flea_detail(
entity_id: str,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Same shape as curated detail, sourced from a Store entity.
Flea entities live at ``${DATA_DIR}/store/<entity_id>/plugin/`` —
canonical Claude Code plugin tree, so the same parsers apply.
"""
from app.api.store import _enforce_visibility
from app.utils import get_store_dir
entity = StoreEntitiesRepository(conn).get(entity_id)
if not entity:
raise HTTPException(status_code=404, detail="entity_not_found")
# Same gate as /api/store/entities/{id}: non-owner non-admin gets
# 404 (not 403) for any non-approved entity. Without this, a user
# who guesses an entity_id can still pull the bundle metadata
# through the marketplace JSON feed even though it's quarantined
# and excluded from the public listing.
_enforce_visibility(entity, user, conn)
plugin_root = Path(get_store_dir()) / entity_id / "plugin"
if entity["type"] == "plugin":
skills = _list_inner_skills(plugin_root)
for s in skills:
s.detail_url = f"/marketplace/flea/{entity_id}/skill/{s.name}"
agents = _list_inner_agents(plugin_root)
for a in agents:
a.detail_url = f"/marketplace/flea/{entity_id}/agent/{a.name}"
commands = _list_commands(plugin_root)
hooks = _list_hooks(plugin_root)
mcps = _list_mcps(plugin_root)
else:
# skill / agent — inner structure isn't applicable; the dedicated
# item-detail page will render those entities. We still return the
# response so the frontend can decide which template to use.
skills = []
agents = []
commands = []
hooks = []
mcps = []
is_installed = UserStoreInstallsRepository(conn).is_installed(
user["id"], entity_id,
)
cover_url: Optional[str] = None
if entity.get("photo_path"):
cover_url = f"/api/store/entities/{entity_id}/photo"
# Strip archive-rename suffix for human display; manifest_name keeps
# the renamed-on-archive slug since that's what Claude Code resolves.
from src.store_naming import strip_archive_suffix
_flea_display_name = strip_archive_suffix(entity["name"])
invocation = suffixed_name(_flea_display_name, entity.get("owner_username") or "")
# doc_paths is a JSON array of relative paths the uploader picked at upload
# time; `app/api/store.py` serves them by basename via /api/store/.../docs/{filename}.
docs: List[DocEntry] = []
for raw_path in (entity.get("doc_paths") or []):
if not isinstance(raw_path, str) or not raw_path.strip():
continue
fname = Path(raw_path).name
if not fname:
continue
docs.append(DocEntry(
name=fname,
url=f"/api/store/entities/{entity_id}/docs/{fname}",
))
owner_display = _resolve_owner_display(
conn,
entity["owner_user_id"],
entity.get("owner_username") or "",
)
# Surface the latest submission verdict to the owner / admin so the
# quarantine banner's auto-refresh JS has a signal to poll on.
# Visibility alone is not enough because `blocked_llm` keeps the
# entity at `visibility_status='pending'`.
submission_status: Optional[str] = None
is_owner = entity.get("owner_user_id") == user.get("id")
is_admin_user = is_user_admin(user["id"], conn)
if is_owner or is_admin_user:
from src.repositories.store_submissions import StoreSubmissionsRepository
latest_sub = StoreSubmissionsRepository(conn).latest_for_entity(entity_id)
if latest_sub:
submission_status = latest_sub.get("status")
return PluginDetailResponse(
source="flea",
entity_id=entity_id,
plugin_name=_flea_display_name,
manifest_name=invocation,
description=entity.get("description"),
version=entity.get("version"),
category=entity.get("category"),
author_name=entity.get("owner_username"),
owner_display=owner_display,
homepage=None,
cover_photo_url=cover_url,
bundle_size=int(entity.get("file_size") or 0) or _bundle_size(plugin_root),
install_count=int(entity.get("install_count") or 0),
released_at=_to_iso(entity.get("created_at")),
updated_at=_to_iso(entity.get("updated_at")),
installed=is_installed,
skills=skills,
agents=agents,
commands=commands,
hooks=hooks,
mcps=mcps,
files=_walk_files(plugin_root),
docs=docs,
visibility_status=entity.get("visibility_status") or "approved",
submission_status=submission_status,
)
@router.post(
"/curated/{marketplace_id}/{plugin_name}/install",
response_model=InstallActionResponse,
)
async def curated_install(
marketplace_id: str,
plugin_name: str,
_user: dict = Depends(require_resource_access(
ResourceType.MARKETPLACE_PLUGIN, "{marketplace_id}/{plugin_name}",
)),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Subscribe the caller to a curated plugin (Model B opt-in).
Idempotent — repeated calls are no-ops. The plugin must exist in
``marketplace_plugins``; otherwise 404. The RBAC guard already ensured
the caller is allowed to install.
"""
exists = conn.execute(
"SELECT 1 FROM marketplace_plugins WHERE marketplace_id = ? AND name = ?",
[marketplace_id, plugin_name],
).fetchone()
if not exists:
raise HTTPException(status_code=404, detail="plugin_not_found")
inserted = UserCuratedSubscriptionsRepository(conn).subscribe(
user["id"], marketplace_id, plugin_name,
)
if inserted:
_audit(
conn, user["id"], "marketplace.curated.install",
f"plugin:{marketplace_id}/{plugin_name}",
)
_invalidate_etag()
return InstallActionResponse(installed=True)
@router.delete(
"/curated/{marketplace_id}/{plugin_name}/install",
response_model=InstallActionResponse,
)
async def curated_uninstall(
marketplace_id: str,
plugin_name: str,
_user: dict = Depends(require_resource_access(
ResourceType.MARKETPLACE_PLUGIN, "{marketplace_id}/{plugin_name}",
)),
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
deleted = UserCuratedSubscriptionsRepository(conn).unsubscribe(
user["id"], marketplace_id, plugin_name,
)
if deleted:
_audit(
conn, user["id"], "marketplace.curated.uninstall",
f"plugin:{marketplace_id}/{plugin_name}",
)
_invalidate_etag()
return InstallActionResponse(installed=False)
# ---------------------------------------------------------------------------
# Curated inner skill/agent detail
# ---------------------------------------------------------------------------
def _safe_join(plugin_root: Path, *parts: str) -> Optional[Path]:
"""Join ``parts`` onto ``plugin_root`` and return the resolved path only if
it actually lives under ``plugin_root``. Defends against ``..`` segments,
Windows ``\\`` separators that slip past Starlette's ``[^/]+`` path-param
regex, and symlinks planted inside a curated marketplace's git mirror that
point outside the plugin's own tree.
Returns ``None`` when the candidate doesn't exist, can't be resolved, or
escapes ``plugin_root``. Callers translate that into a 404.
"""
candidate = plugin_root.joinpath(*parts)
try:
resolved = candidate.resolve(strict=True)
root = plugin_root.resolve(strict=True)
resolved.relative_to(root)
except (OSError, ValueError):
return None
return resolved
def _read_inner(
plugin_root: Path,
sub: str,
name: str,
*,
is_dir_layout: bool,
) -> Optional[tuple[str, str]]:
"""Return (text, relpath) for a skill (sub='skills', is_dir=True) or
agent (sub='agents', is_dir=False) inside the plugin tree, or None if
the file is missing or escapes ``plugin_root`` (see ``_safe_join``).
"""
if is_dir_layout:
candidate = _safe_join(plugin_root, sub, name, "SKILL.md")
else:
candidate = _safe_join(plugin_root, sub, f"{name}.md")
if candidate is None:
return None
try:
text = candidate.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
try:
rel = candidate.relative_to(plugin_root.resolve()).as_posix()
except ValueError:
rel = candidate.name
return text, rel
def _curated_inner_parent_fields(
conn: duckdb.DuckDBPyConnection, marketplace_id: str, plugin_name: str,
) -> Dict[str, Any]:
"""Pull the parent-plugin metadata that the redesigned skill/agent detail
page renders in the hero badges, meta-row, and sidebar.
Returns a dict with ``marketplace_name``, ``category``, ``parent_author_name``,
``parent_updated_at``. Missing fields fall through to safe defaults so the
inner endpoint still succeeds when the parent row is absent (which would be
a sync-skew bug, but shouldn't 500 the page).
"""
plugin_row = _get_plugin_row(conn, marketplace_id, plugin_name) or {}
plugin_root = (
Path(get_marketplaces_dir()) / marketplace_id / "plugins" / plugin_name
)
return {
"marketplace_name": _resolve_marketplace_name(conn, marketplace_id),
"category": plugin_row.get("category"),
"parent_author_name": plugin_row.get("author_name") or OWNER_TODO_PLACEHOLDER,
"parent_updated_at": _to_iso(plugin_row.get("updated_at")),
"manifest_name": resolve_manifest_name(plugin_root, fallback=plugin_name),
}
@router.get(
"/curated/{marketplace_id}/{plugin_name}/skill/{skill_name}",
response_model=InnerDetailResponse,
)
async def curated_skill_detail(
marketplace_id: str,
plugin_name: str,
skill_name: str,
_user: dict = Depends(require_resource_access(
ResourceType.MARKETPLACE_PLUGIN, "{marketplace_id}/{plugin_name}",
)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
plugin_root = (
Path(get_marketplaces_dir()) / marketplace_id / "plugins" / plugin_name
)
res = _read_inner(plugin_root, "skills", skill_name, is_dir_layout=True)
skill_dir = _safe_join(plugin_root, "skills", skill_name)
if res is None or skill_dir is None:
raise HTTPException(status_code=404, detail="skill_not_found")
text, relpath = res
fm = _parse_frontmatter(text)
parent = _curated_inner_parent_fields(conn, marketplace_id, plugin_name)
return InnerDetailResponse(
marketplace_id=marketplace_id,
plugin_name=plugin_name,
kind="skill",
name=fm.get("name") or skill_name,
description=fm.get("description"),
body=_frontmatter_body(text),
relpath=relpath,
bundle_size=_bundle_size(skill_dir),
files=_walk_files(skill_dir),
**parent,
)
@router.get(
"/curated/{marketplace_id}/{plugin_name}/agent/{agent_name}",
response_model=InnerDetailResponse,
)
async def curated_agent_detail(
marketplace_id: str,
plugin_name: str,
agent_name: str,
_user: dict = Depends(require_resource_access(
ResourceType.MARKETPLACE_PLUGIN, "{marketplace_id}/{plugin_name}",
)),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
plugin_root = (
Path(get_marketplaces_dir()) / marketplace_id / "plugins" / plugin_name
)
res = _read_inner(plugin_root, "agents", agent_name, is_dir_layout=False)
agent_path = _safe_join(plugin_root, "agents", f"{agent_name}.md")
if res is None or agent_path is None:
raise HTTPException(status_code=404, detail="agent_not_found")
text, relpath = res
fm = _parse_frontmatter(text)
# Agents are flat single-file .md — bundle = file size, files = one entry.
try:
agent_size = agent_path.stat().st_size
except OSError:
agent_size = 0
parent = _curated_inner_parent_fields(conn, marketplace_id, plugin_name)
return InnerDetailResponse(
marketplace_id=marketplace_id,
plugin_name=plugin_name,
kind="agent",
name=fm.get("name") or agent_name,
description=fm.get("description"),
body=_frontmatter_body(text),
relpath=relpath,
bundle_size=agent_size,
files=[FileEntry(path=f"{agent_name}.md", size=agent_size)],
**parent,
)