* 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).
1260 lines
46 KiB
Python
1260 lines
46 KiB
Python
"""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,
|
||
)
|