* perf(marketplace): browser-cache cover photos + restore Curated tab filter spacing Cover photos on /marketplace grid now serve with `Cache-Control: public, max-age=2592000, immutable` plus URL fingerprinting (`?v=<commit-sha8>` for curated, `?v=<version_no>` for flea) so browser refresh stops hitting the server entirely for unchanged assets. Per-plugin RBAC dropped from the three image endpoints (curated_asset, curated_mirrored, get_entity_photo) in favor of login-only auth — eliminates _system_db_lock contention on parallel image requests. Per-request magic-bytes revalidation also dropped from curated_asset (it was re-reading the file just to discard the bytes, then FileResponse read it again). Spacing bug: sort-dropdown commit (6be1cee) wrapped .mp-filter-row in a new flex container with inline margin-bottom:4px, masking the original 12px CSS rule. Curated tab (where .mp-type-row is hidden) ended up with 4px between filters and the card grid. Wrapper margin restored to 12px. See CHANGELOG entry under [Unreleased] — the RBAC relaxation is called out under ### Security with explicit threat-model rationale for AI/human reviewers. * test(marketplace): update renamed-html-as-png test for dropped magic-bytes check Magic-bytes body validation was dropped from `curated_asset` in the previous commit — the request path now relies on extension allowlist + pinned Content-Type + nosniff + strict CSP to neuter mismatched payloads at the browser layer. Update the test to assert the new defense-in-depth posture (200 served, but Content-Type=image/png + nosniff + CSP=default-src 'none') rather than the gone 415. --------- Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
2280 lines
90 KiB
Python
2280 lines
90 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 collections import OrderedDict
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Literal, Optional, Tuple
|
||
|
||
import duckdb
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from fastapi.responses import FileResponse
|
||
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_marketplace_cache_dir, get_marketplaces_dir
|
||
from src.marketplace_filter import (
|
||
resolve_allowed_plugins,
|
||
resolve_manifest_name,
|
||
)
|
||
from src.marketplace_listing import _FRONTMATTER_RE, _parse_frontmatter
|
||
from src.marketplace_urls import internal_asset_url, mirrored_url
|
||
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."""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
# v39: drives the "Required" pill on the curated browse cards. Only
|
||
# set on curated items (flea/store entities are never system).
|
||
is_system: bool = False
|
||
# Rich-content fields from marketplace-metadata.json (plugin-level only
|
||
# for now; skill/agent rich content lands in a later phase). Frontend
|
||
# falls back to `name` / `description` when these are missing, so the
|
||
# card renders identically to pre-enrichment behaviour for any plugin
|
||
# whose curator hasn't filled the new fields yet.
|
||
display_name: Optional[str] = None
|
||
tagline: Optional[str] = None
|
||
# telemetry (Phase B.1): populated from usage_plugin_daily rollup
|
||
invocations_30d: int = 0
|
||
unique_users_30d: int = 0
|
||
trend_pct: Optional[float] = None
|
||
|
||
|
||
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
|
||
# v32: marketplace-metadata-driven cover photo for the skill/agent card on the
|
||
# parent plugin detail page. Already in served-URL form (internal /asset/
|
||
# endpoint, mirrored /mirrored/ endpoint, or pass-through external URL).
|
||
# None → frontend renders initials placeholder ("SK" / "AG").
|
||
cover_photo_url: Optional[str] = None
|
||
|
||
|
||
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 UseCase(BaseModel):
|
||
"""One "When to use it" example from marketplace-metadata.json.
|
||
Curator-authored: friendly title + 1-2-sentence description + the
|
||
literal prompt the user would paste into Claude Code."""
|
||
title: str
|
||
description: str
|
||
prompt: str
|
||
|
||
|
||
class SampleInteraction(BaseModel):
|
||
"""Example dialog shown in the "Sample interaction" section.
|
||
|
||
``assistant`` is markdown — the API pre-renders to safe HTML in
|
||
``assistant_html`` so the template can inject without a client-side
|
||
markdown lib. ``assistant`` stays as the source for copy-paste / future
|
||
re-rendering needs.
|
||
"""
|
||
user: str
|
||
assistant: str
|
||
assistant_html: 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
|
||
curator_email: Optional[str] = None # curated only — surfaced for "contact curator"
|
||
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, marketplace-metadata for curated
|
||
video_url: Optional[str] = None # v32: external (YouTube/Vimeo/Loom) embed URL
|
||
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
|
||
# v39: drives the disabled install button on the curated plugin
|
||
# detail page. The same flag travels via /api/marketplace/items so
|
||
# the browse cards can show a "Required" pill.
|
||
is_system: bool = False
|
||
# Rich-content fields from marketplace-metadata.json (plugin-level, curated
|
||
# only — flea entities don't have a metadata layer). All optional; UI
|
||
# sections render only when populated.
|
||
#
|
||
# `display_name` overrides the technical `manifest_name` on the hero h1
|
||
# and window titlebar. `tagline` is the friendly subtitle (replacing
|
||
# `description` as the hero summary on listing cards + hero).
|
||
#
|
||
# `description_long_html` is server-side rendered + sanitized markdown
|
||
# (see app/markdown_render.render_safe). It powers the "What it does"
|
||
# panel; the raw `description` from marketplace.json is the fallback.
|
||
display_name: Optional[str] = None
|
||
tagline: Optional[str] = None
|
||
description_long_html: Optional[str] = None
|
||
use_cases: List[UseCase] = []
|
||
sample_interaction: Optional[SampleInteraction] = None
|
||
# telemetry (Phase B.1)
|
||
telemetry: Optional[Dict[str, Any]] = 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] = []
|
||
# v32: per-skill / per-agent enrichment from marketplace-metadata.json sub-tree.
|
||
# Read at request time from the cloned working tree (not cached in DB) so
|
||
# curators can update one inner asset without paying the cost of a full
|
||
# plugin-cache rewrite. Three-typed cover URL layout matches plugin
|
||
# detail: internal → /asset/, mirror status not yet checked here so
|
||
# external URLs pass through unmirrored (we don't run sync mid-request).
|
||
cover_photo_url: Optional[str] = None
|
||
video_url: Optional[str] = None
|
||
docs: List[DocEntry] = []
|
||
# Rich user-facing fields (parity with plugin-level rich content from
|
||
# the 2026-05-12 redesign). All optional — UI hides each section when
|
||
# the corresponding field is absent.
|
||
#
|
||
# `category` on the response is curator-override-OR-parent-fallback:
|
||
# the API hands back the parent plugin's category when the skill/agent
|
||
# didn't opt into its own. The override happens in the inner-detail
|
||
# handler before the response leaves the API.
|
||
#
|
||
# `invocation`: curator-provided literal command string ("…<arg>"
|
||
# forms welcome). When absent, the template falls back to its computed
|
||
# "<manifest_name>:<inner_name>" string.
|
||
display_name: Optional[str] = None
|
||
tagline: Optional[str] = None
|
||
description_long_html: Optional[str] = None
|
||
use_cases: List[UseCase] = []
|
||
sample_interaction: Optional[SampleInteraction] = None
|
||
when_to_use_html: Optional[str] = None
|
||
invocation: Optional[str] = None
|
||
|
||
|
||
class InstallActionResponse(BaseModel):
|
||
installed: bool
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _load_invocation_stats(
|
||
conn: duckdb.DuckDBPyConnection,
|
||
source: str,
|
||
) -> Dict[str, Dict]:
|
||
"""Return {ref_id: {invocations_30d, unique_users_30d, trend_pct}}.
|
||
|
||
One query per source per page render — avoids N+1.
|
||
"""
|
||
rows = conn.execute("""
|
||
WITH last_30 AS (
|
||
SELECT ref_id,
|
||
SUM(invocations) AS inv30,
|
||
SUM(distinct_users) AS u30
|
||
FROM usage_plugin_daily
|
||
WHERE source = ? AND day >= CURRENT_DATE - INTERVAL 30 DAY
|
||
GROUP BY ref_id
|
||
),
|
||
prior_week AS (
|
||
SELECT ref_id, SUM(invocations) AS inv_prior
|
||
FROM usage_plugin_daily
|
||
WHERE source = ?
|
||
AND day >= CURRENT_DATE - INTERVAL 14 DAY
|
||
AND day < CURRENT_DATE - INTERVAL 7 DAY
|
||
GROUP BY ref_id
|
||
),
|
||
recent_week AS (
|
||
SELECT ref_id, SUM(invocations) AS inv_recent
|
||
FROM usage_plugin_daily
|
||
WHERE source = ?
|
||
AND day >= CURRENT_DATE - INTERVAL 7 DAY
|
||
GROUP BY ref_id
|
||
)
|
||
SELECT l.ref_id, l.inv30, l.u30, p.inv_prior, r.inv_recent
|
||
FROM last_30 l
|
||
LEFT JOIN prior_week p USING (ref_id)
|
||
LEFT JOIN recent_week r USING (ref_id)
|
||
""", [source, source, source]).fetchall()
|
||
out: Dict[str, Dict] = {}
|
||
for ref_id, inv30, u30, prior, recent in rows:
|
||
trend = None
|
||
if prior is not None and prior >= 3:
|
||
_recent = recent or 0
|
||
trend = (_recent - prior) / prior * 100.0
|
||
out[ref_id] = {
|
||
"invocations_30d": int(inv30 or 0),
|
||
"unique_users_30d": int(u30 or 0),
|
||
"trend_pct": trend,
|
||
}
|
||
return out
|
||
|
||
|
||
def _load_plugin_daily_series(
|
||
conn: duckdb.DuckDBPyConnection,
|
||
source: str,
|
||
ref_id: str,
|
||
) -> List[Dict]:
|
||
"""Return a 30-entry list [{day, invocations}] with missing days filled to 0."""
|
||
rows = conn.execute("""
|
||
SELECT day, SUM(invocations) AS inv
|
||
FROM usage_plugin_daily
|
||
WHERE source = ? AND ref_id = ?
|
||
AND day >= CURRENT_DATE - INTERVAL 30 DAY
|
||
GROUP BY day
|
||
ORDER BY day
|
||
""", [source, ref_id]).fetchall()
|
||
by_day = {str(r[0]): int(r[1] or 0) for r in rows}
|
||
|
||
import datetime as _dt
|
||
today = _dt.date.today()
|
||
series = []
|
||
for offset in range(29, -1, -1):
|
||
day = today - _dt.timedelta(days=offset)
|
||
day_str = day.isoformat()
|
||
series.append({"day": day_str, "invocations": by_day.get(day_str, 0)})
|
||
return series
|
||
|
||
|
||
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 _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_meta(
|
||
conn: duckdb.DuckDBPyConnection, marketplace_id: str
|
||
) -> Dict[str, Optional[str]]:
|
||
"""Look up display name + curator metadata in one DB hit.
|
||
|
||
Returns a dict with keys ``name``, ``curator_name``, ``curator_email``.
|
||
Missing rows fall back to the marketplace_id as ``name`` and ``None``
|
||
for the curator fields. Caller decides what to do with the absence
|
||
(typically: surface the ``OWNER_TODO_PLACEHOLDER``).
|
||
"""
|
||
row = MarketplaceRegistryRepository(conn).get(marketplace_id)
|
||
if not row:
|
||
return {
|
||
"name": marketplace_id,
|
||
"curator_name": None,
|
||
"curator_email": None,
|
||
}
|
||
name = (row.get("name") or "").strip() or marketplace_id
|
||
return {
|
||
"name": name,
|
||
"curator_name": (row.get("curator_name") or "").strip() or None,
|
||
"curator_email": (row.get("curator_email") or "").strip() or None,
|
||
}
|
||
|
||
|
||
def _curated_to_item(
|
||
conn: duckdb.DuckDBPyConnection,
|
||
plugin_row: dict,
|
||
*,
|
||
subs: set,
|
||
marketplace_meta: Dict[str, Dict[str, Optional[str]]],
|
||
stats: Optional[Dict[str, Dict]] = None,
|
||
) -> MarketplaceItem:
|
||
marketplace_id = plugin_row["marketplace_id"]
|
||
plugin_name = plugin_row["name"]
|
||
meta = marketplace_meta.get(marketplace_id) or {
|
||
"name": marketplace_id,
|
||
"curator_name": None,
|
||
"curator_email": None,
|
||
}
|
||
# v32: card "owner" is the curator from the marketplace registry — not the
|
||
# upstream `marketplace.json::author.name` we historically used. Curator
|
||
# is the human accountable for the plugin's presence in this Agnes
|
||
# instance; upstream author belongs in the plugin source repo.
|
||
owner = meta["curator_name"] or OWNER_TODO_PLACEHOLDER
|
||
# Lazy enrichment read for the listing card — cached per (marketplace_id,
|
||
# mtime) so a page of 24 plugins from the same marketplace triggers ONE
|
||
# disk read, not 24. Empty dict when curator hasn't filled the fields →
|
||
# listing card falls back to raw name + marketplace.json description.
|
||
enrichment = _curated_plugin_enrichment(marketplace_id, plugin_name)
|
||
ref_id = f"{marketplace_id}/{plugin_name}"
|
||
stat = (stats or {}).get(ref_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=owner,
|
||
version=plugin_row.get("version"),
|
||
# Cover photo URL is already in served form (internal `/asset/`,
|
||
# mirrored `/mirrored/`, or pass-through external URL) — see
|
||
# src.marketplace._refresh_plugin_cache for the resolution path.
|
||
photo_url=plugin_row.get("cover_photo_url"),
|
||
added=_to_iso(plugin_row.get("created_at")),
|
||
installed=(marketplace_id, plugin_name) in subs,
|
||
marketplace_slug=marketplace_id,
|
||
marketplace_name=meta["name"],
|
||
detail_url=_curated_detail_url(marketplace_id, plugin_name),
|
||
is_system=bool(plugin_row.get("is_system")),
|
||
display_name=enrichment.get("display_name"),
|
||
tagline=enrichment.get("tagline"),
|
||
invocations_30d=stat.get("invocations_30d", 0),
|
||
unique_users_30d=stat.get("unique_users_30d", 0),
|
||
trend_pct=stat.get("trend_pct"),
|
||
)
|
||
|
||
|
||
def _flea_to_item(
|
||
entity: dict,
|
||
*,
|
||
installed_set: set,
|
||
viewer_id: Optional[str] = None,
|
||
stats: Optional[Dict[str, Dict]] = None,
|
||
) -> MarketplaceItem:
|
||
photo_url = (
|
||
# ``?v=`` cache-busting fingerprint: flea entities have a monotonic
|
||
# ``version_no`` (schema v37) bumped on every re-upload, so the URL
|
||
# changes exactly when the underlying bytes change.
|
||
f"/api/store/entities/{entity['id']}/photo?v={entity.get('version_no', 1)}"
|
||
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)
|
||
stat = (stats or {}).get(entity["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,
|
||
invocations_30d=stat.get("invocations_30d", 0),
|
||
unique_users_30d=stat.get("unique_users_30d", 0),
|
||
trend_pct=stat.get("trend_pct"),
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# GET /api/marketplace/items
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _build_telemetry(
|
||
conn: duckdb.DuckDBPyConnection,
|
||
source: str,
|
||
ref_id: str,
|
||
) -> Optional[Dict[str, Any]]:
|
||
"""Build the telemetry dict for detail endpoints.
|
||
|
||
Returns None when invocations_30d == 0 (no data yet).
|
||
Otherwise returns {invocations_30d, unique_users_30d, daily_series}.
|
||
"""
|
||
stats = _load_invocation_stats(conn, source)
|
||
stat = stats.get(ref_id)
|
||
inv30 = stat["invocations_30d"] if stat else 0
|
||
if inv30 == 0:
|
||
return None
|
||
return {
|
||
"invocations_30d": inv30,
|
||
"unique_users_30d": stat["unique_users_30d"] if stat else 0,
|
||
"daily_series": _load_plugin_daily_series(conn, source, ref_id),
|
||
}
|
||
|
||
|
||
def _apply_sort(
|
||
items: List[MarketplaceItem],
|
||
sort: str,
|
||
) -> List[MarketplaceItem]:
|
||
"""Sort a list of MarketplaceItem objects in-place and return it.
|
||
|
||
- ``recent`` — preserve existing order (no-op).
|
||
- ``most_used`` — DESC by invocations_30d, then DESC install_count, then name ASC.
|
||
- ``trending`` — DESC by trend_pct; items with trend_pct=None are excluded.
|
||
"""
|
||
if sort == "most_used":
|
||
items.sort(
|
||
key=lambda it: (-it.invocations_30d, it.name.lower())
|
||
)
|
||
elif sort == "trending":
|
||
items = [it for it in items if it.trend_pct is not None]
|
||
items.sort(key=lambda it: -(it.trend_pct or 0.0))
|
||
return 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),
|
||
sort: Literal["recent", "most_used", "trending"] = Query("recent"),
|
||
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.
|
||
|
||
``sort`` controls ordering after stats are joined:
|
||
* ``recent`` — existing DB order (default, backward-compatible).
|
||
* ``most_used`` — DESC by invocations_30d, ties by install_count then name.
|
||
* ``trending`` — DESC by trend_pct; items with no trend data are excluded.
|
||
"""
|
||
skip = (page - 1) * page_size
|
||
needs_sort = sort != "recent"
|
||
|
||
if tab == "curated":
|
||
group_ids = _user_group_ids(user["id"], conn) or set()
|
||
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
|
||
# When sorting, we need all rows to sort before paginating.
|
||
if needs_sort:
|
||
all_rows, total = MarketplacePluginsRepository(conn).list_with_filters(
|
||
group_ids=group_ids,
|
||
search=q or None,
|
||
category=category or None,
|
||
skip=0,
|
||
limit=10000,
|
||
)
|
||
else:
|
||
all_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_meta: Dict[str, Dict[str, Optional[str]]] = {}
|
||
if all_rows:
|
||
distinct_ids = {r["marketplace_id"] for r in all_rows}
|
||
for mp_id in distinct_ids:
|
||
marketplace_meta[mp_id] = _resolve_marketplace_meta(conn, mp_id)
|
||
curated_stats = _load_invocation_stats(conn, "curated")
|
||
items = [
|
||
_curated_to_item(
|
||
conn, r, subs=subs,
|
||
marketplace_meta=marketplace_meta,
|
||
stats=curated_stats,
|
||
)
|
||
for r in all_rows
|
||
]
|
||
if needs_sort:
|
||
items = _apply_sort(items, sort)
|
||
total = len(items)
|
||
items = items[skip: skip + page_size]
|
||
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"]
|
||
if needs_sort:
|
||
all_flea_rows, total = StoreEntitiesRepository(conn).list(
|
||
skip=0, limit=10000,
|
||
type=type, category=category, search=q or None,
|
||
visibility_status=visibility_filter,
|
||
include_owner_id=include_owner,
|
||
)
|
||
else:
|
||
all_flea_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,
|
||
)
|
||
flea_stats = _load_invocation_stats(conn, "flea")
|
||
items = [
|
||
_flea_to_item(
|
||
r, installed_set=installed_set,
|
||
viewer_id=user["id"],
|
||
stats=flea_stats,
|
||
)
|
||
for r in all_flea_rows
|
||
]
|
||
if needs_sort:
|
||
items = _apply_sort(items, sort)
|
||
total = len(items)
|
||
items = items[skip: skip + page_size]
|
||
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_meta: Dict[str, Dict[str, Optional[str]]] = {}
|
||
|
||
# Pull the enriched rows the Curated tab uses (cover_photo_url, video_url,
|
||
# category override, doc_links from marketplace-metadata.json) so the My Stack
|
||
# cards look identical to the curated cards the user just clicked
|
||
# "+ Add to my stack" on. ``resolve_allowed_plugins`` reads only the
|
||
# upstream marketplace.json, which doesn't carry those columns; without
|
||
# this lookup the same plugin renders with its cover photo on
|
||
# ``?tab=curated`` and with a gradient placeholder on ``?tab=my``.
|
||
plugin_repo = MarketplacePluginsRepository(conn)
|
||
subscribed_mp_ids = {mp_id for (mp_id, _) in subs}
|
||
enriched_lookup: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||
for mp_id in subscribed_mp_ids:
|
||
for row in plugin_repo.list_for_marketplace(mp_id):
|
||
enriched_lookup[(mp_id, row["name"])] = row
|
||
|
||
curated_stats = _load_invocation_stats(conn, "curated")
|
||
flea_stats = _load_invocation_stats(conn, "flea")
|
||
|
||
for p in granted:
|
||
key = (p["marketplace_id"], p["original_name"])
|
||
if key not in subs:
|
||
continue
|
||
mp_id = p["marketplace_id"]
|
||
if mp_id not in marketplace_meta:
|
||
marketplace_meta[mp_id] = _resolve_marketplace_meta(conn, mp_id)
|
||
|
||
plugin_row = enriched_lookup.get(key)
|
||
if plugin_row is None:
|
||
# Fallback: plugin in RBAC + subscribed but not yet ingested into
|
||
# marketplace_plugins (rare race — granted before the first sync
|
||
# cycle runs). Build the bare shape from the on-disk manifest so
|
||
# the card still renders, just without marketplace-metadata enrichment;
|
||
# cover falls through to the gradient placeholder until the next
|
||
# sync.
|
||
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,
|
||
"cover_photo_url": None,
|
||
"created_at": None,
|
||
}
|
||
items.append(_curated_to_item(
|
||
conn, plugin_row, subs=subs, marketplace_meta=marketplace_meta,
|
||
stats=curated_stats,
|
||
))
|
||
|
||
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, stats=flea_stats,
|
||
))
|
||
|
||
# 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]
|
||
items = _apply_sort(items, sort)
|
||
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"])
|
||
# Curated plugins are always type='plugin'. When the type filter
|
||
# is set to skill/agent, those rows won't show in the items grid
|
||
# (filtered out by the items endpoint at line 579), so they must
|
||
# not contribute to the category counts either — otherwise the
|
||
# pill counts overstate what the user will actually see.
|
||
if type is None or type == "plugin":
|
||
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]:
|
||
"""Build ``InnerItemSummary`` objects from the shared listing helper.
|
||
|
||
The shared helper (``src.marketplace_listing.list_inner_skills``) returns
|
||
plain names; the API layer re-reads each SKILL.md to populate the
|
||
``description`` field used on the detail page.
|
||
"""
|
||
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]:
|
||
"""Build ``InnerItemSummary`` objects from the shared listing helper."""
|
||
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 ``CommandEntry`` objects from frontmatter.
|
||
|
||
Names from the shared ``src.marketplace_listing.list_commands`` helper
|
||
already carry the leading ``/``; re-read here to pick up ``description``.
|
||
"""
|
||
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)
|
||
raw = (fm.get("name") or p.stem or "").strip()
|
||
if not raw:
|
||
continue
|
||
name = raw if raw.startswith("/") else f"/{raw}"
|
||
out.append(CommandEntry(
|
||
name=name,
|
||
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)
|
||
agents = _list_inner_agents(plugin_root)
|
||
|
||
# v32: enrich each skill/agent card with its marketplace-metadata cover photo
|
||
# so the inner cards on the plugin detail page render the real image
|
||
# instead of just initials. Read marketplace-metadata + mirror manifest once
|
||
# (mtime cache makes the metadata read free on cache hit) and reuse for
|
||
# all inner items in the plugin.
|
||
inner_metadata = _read_metadata_cached(marketplace_id)
|
||
inner_manifest = _load_mirror_manifest(marketplace_id)
|
||
asset_version = _marketplace_asset_version(marketplace_id, conn)
|
||
|
||
for s in skills:
|
||
s.detail_url = (
|
||
f"/marketplace/curated/{marketplace_id}/{plugin_name}/skill/{s.name}"
|
||
)
|
||
s.cover_photo_url = _curated_inner_cover(
|
||
marketplace_id, plugin_name, "skill", s.name,
|
||
manifest=inner_manifest, metadata=inner_metadata,
|
||
version=asset_version,
|
||
)
|
||
for a in agents:
|
||
a.detail_url = (
|
||
f"/marketplace/curated/{marketplace_id}/{plugin_name}/agent/{a.name}"
|
||
)
|
||
a.cover_photo_url = _curated_inner_cover(
|
||
marketplace_id, plugin_name, "agent", a.name,
|
||
manifest=inner_manifest, metadata=inner_metadata,
|
||
version=asset_version,
|
||
)
|
||
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 = {}
|
||
|
||
# v32: cover, video, and doc_links come from `marketplace-metadata.json` via the
|
||
# sync pipeline (`src/marketplace.py::_refresh_plugin_cache`) — already in
|
||
# served-URL form when written to the DB. We pass them through the
|
||
# response model unchanged. Curator name + email come from the marketplace
|
||
# registry and surface as the plugin's accountable owner; the upstream
|
||
# `marketplace.json::author.name` is intentionally not used here so we
|
||
# have a single source of truth for "who runs this".
|
||
meta = _resolve_marketplace_meta(conn, marketplace_id)
|
||
doc_link_entries: List[DocEntry] = []
|
||
raw_links = plugin_row.get("doc_links")
|
||
if isinstance(raw_links, list):
|
||
for link in raw_links:
|
||
if isinstance(link, dict) and link.get("name") and link.get("url"):
|
||
doc_link_entries.append(
|
||
DocEntry(name=str(link["name"]), url=str(link["url"]))
|
||
)
|
||
|
||
# Plugin-level rich content (display_name, tagline, description_long_html,
|
||
# use_cases, sample_interaction) — on-demand read from the working tree's
|
||
# marketplace-metadata.json so curator edits land at next page refresh
|
||
# without a sync cycle. Empty dict when curator hasn't filled any of the
|
||
# new fields → PluginDetailResponse renders the historical fallback shape.
|
||
enrichment = _curated_plugin_enrichment(marketplace_id, plugin_name)
|
||
|
||
return PluginDetailResponse(
|
||
source="curated",
|
||
marketplace_id=marketplace_id,
|
||
marketplace_name=meta["name"],
|
||
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=meta["curator_name"] or OWNER_TODO_PLACEHOLDER,
|
||
curator_email=meta["curator_email"],
|
||
homepage=plugin_row.get("homepage"),
|
||
cover_photo_url=plugin_row.get("cover_photo_url"),
|
||
video_url=plugin_row.get("video_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),
|
||
docs=doc_link_entries,
|
||
is_system=bool(plugin_row.get("is_system")),
|
||
**enrichment,
|
||
telemetry=_build_telemetry(
|
||
conn, "curated", f"{marketplace_id}/{plugin_name}",
|
||
),
|
||
)
|
||
|
||
|
||
@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"):
|
||
# ``?v=`` cache-busting fingerprint via ``version_no`` — see
|
||
# ``app/api/store.py:get_entity_photo`` for the matching
|
||
# ``Cache-Control: immutable`` header.
|
||
cover_url = (
|
||
f"/api/store/entities/{entity_id}/photo"
|
||
f"?v={entity.get('version_no', 1)}"
|
||
)
|
||
|
||
# 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,
|
||
telemetry=_build_telemetry(conn, "flea", entity_id),
|
||
)
|
||
|
||
|
||
@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),
|
||
):
|
||
# v39: system plugins are mandatory for every user — refuse uninstall.
|
||
sys_row = conn.execute(
|
||
"SELECT is_system FROM marketplace_plugins "
|
||
"WHERE marketplace_id = ? AND name = ?",
|
||
[marketplace_id, plugin_name],
|
||
).fetchone()
|
||
if sys_row and bool(sys_row[0]):
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="cannot_uninstall_system_plugin",
|
||
)
|
||
|
||
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
|
||
)
|
||
meta = _resolve_marketplace_meta(conn, marketplace_id)
|
||
return {
|
||
"marketplace_name": meta["name"],
|
||
"category": plugin_row.get("category"),
|
||
"parent_author_name": meta["curator_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),
|
||
}
|
||
|
||
|
||
def _load_mirror_manifest(marketplace_id: str) -> Dict[Tuple[str, str], Any]:
|
||
"""Read the asset-mirror manifest for one marketplace, keyed by
|
||
``(plugin_name, url)``.
|
||
|
||
Returns an empty dict when the cache directory or manifest doesn't exist
|
||
(fresh install, marketplace never synced) — callers treat that as "no
|
||
URL is mirrored" which collapses to the same path as a fetch failure.
|
||
"""
|
||
from src.marketplace_asset_mirror import _load_manifest
|
||
|
||
cache_dir = get_marketplace_cache_dir() / marketplace_id
|
||
return _load_manifest(cache_dir)
|
||
|
||
|
||
def _resolve_external_via_mirror(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
url: str,
|
||
manifest: Dict[Tuple[str, str], Any],
|
||
version: Optional[str] = None,
|
||
) -> Optional[str]:
|
||
"""Translate one external URL into the Agnes-served `/mirrored/` URL when
|
||
the asset mirror successfully cached it for THIS plugin; ``None`` otherwise.
|
||
|
||
Used by the inner-detail (skill/agent) enrichment to apply the same
|
||
"drop entries Agnes can't deliver" rule that the plugin-level sync
|
||
flow already enforces. When this returns None the caller drops the
|
||
enrichment entry entirely (no broken external link surfaces in the UI).
|
||
|
||
``version`` is the cache-busting fingerprint appended as ``?v=`` —
|
||
typically the 8-char prefix of ``marketplace_registry.last_commit_sha``.
|
||
"""
|
||
entry = manifest.get((plugin_name, url))
|
||
if entry is None or entry.status != "ok" or not entry.local:
|
||
return None
|
||
# Manifest stores `local` as `<plugin>/<rest>`; the /mirrored/ endpoint
|
||
# expects just `<rest>` (the plugin segment is in the URL path). Same
|
||
# transform as src/marketplace.py uses on the plugin-level path.
|
||
rest = entry.local.split("/", 1)[1] if "/" in entry.local else entry.local
|
||
return mirrored_url(marketplace_id, plugin_name, rest, version=version)
|
||
|
||
|
||
def _marketplace_asset_version(
|
||
marketplace_id: str, conn: duckdb.DuckDBPyConnection,
|
||
) -> Optional[str]:
|
||
"""8-char prefix of the cloned marketplace's git HEAD, or ``None``.
|
||
|
||
Used as the ``?v=`` cache-busting fingerprint on every cover-photo URL
|
||
composed at request time. Same upstream state → same SHA → browser keeps
|
||
cached bytes; git fetch lands a new commit → fingerprint changes →
|
||
browser refetches. Sync-time enrichment in ``src/marketplace.py``
|
||
threads the same fingerprint into the DB-persisted ``cover_photo_url``
|
||
for the grid, so request-time + sync-time URLs match for unchanged
|
||
repos.
|
||
"""
|
||
row = MarketplaceRegistryRepository(conn).get(marketplace_id)
|
||
if not row:
|
||
return None
|
||
sha = (row.get("last_commit_sha") or "")[:8]
|
||
return sha or None
|
||
|
||
|
||
def _safe_use_case(raw: Any) -> Optional[UseCase]:
|
||
"""Build a ``UseCase`` from curator JSON, skipping malformed entries.
|
||
|
||
Curator-authored input — a missing or empty ``title`` / ``description`` /
|
||
``prompt`` returns ``None`` so the caller drops the card instead of
|
||
500-ing the whole detail page on Pydantic's required-field validation.
|
||
"""
|
||
if not isinstance(raw, dict):
|
||
return None
|
||
title = raw.get("title")
|
||
description = raw.get("description")
|
||
prompt = raw.get("prompt")
|
||
if not (title and description and prompt):
|
||
return None
|
||
return UseCase(title=title, description=description, prompt=prompt)
|
||
|
||
|
||
def _safe_sample_interaction(raw: Any) -> Optional[SampleInteraction]:
|
||
"""Build a ``SampleInteraction`` from curator JSON, skipping malformed input.
|
||
|
||
Same rationale as :func:`_safe_use_case` — partial curator JSON should
|
||
silently drop the section, not crash the endpoint.
|
||
"""
|
||
from app.markdown_render import render_safe
|
||
|
||
if not isinstance(raw, dict):
|
||
return None
|
||
user = raw.get("user")
|
||
assistant = raw.get("assistant")
|
||
if not (user and assistant):
|
||
return None
|
||
return SampleInteraction(
|
||
user=user,
|
||
assistant=assistant,
|
||
assistant_html=render_safe(assistant),
|
||
)
|
||
|
||
|
||
def _curated_inner_enrichment(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
kind: str,
|
||
inner_name: str,
|
||
version: Optional[str] = None,
|
||
) -> Dict[str, Any]:
|
||
"""Load marketplace-metadata.json sub-tree for a single skill/agent.
|
||
|
||
Lives here (not in the sync pipeline) so curators can update marketplace-metadata
|
||
on a working tree and see the change at the next page refresh, without
|
||
waiting for a full plugin-cache rewrite. Read on every inner-detail
|
||
request — marketplace-metadata.json is small enough that disk hit cost is
|
||
negligible compared to the SKILL.md / agent.md frontmatter parse the
|
||
endpoint already does.
|
||
|
||
External URL handling matches the plugin-level sync flow:
|
||
* cover_photo external → kept only when the asset mirror has a
|
||
successful cached copy (URL resolves to /mirrored/...). Else dropped
|
||
→ the card / hero falls through to the gradient placeholder.
|
||
* doc_links external → same; entries that aren't mirrored OK get
|
||
dropped from the served list. Internal docs survive only when the
|
||
file actually exists in the working tree.
|
||
|
||
Returns a dict shaped for direct merge into the InnerDetailResponse:
|
||
``cover_photo_url`` (resolved served URL or None),
|
||
``video_url`` (str or None), ``docs`` (list of DocEntry).
|
||
"""
|
||
from src.marketplace_metadata import resolve_inner_metadata
|
||
|
||
repo_root = Path(get_marketplaces_dir()) / marketplace_id
|
||
# Route through the mtime cache so skill / agent detail hits don't
|
||
# re-parse the JSON on every request. Plugin listing already shares
|
||
# this cache, so opening 5 inner-detail pages on a marketplace whose
|
||
# metadata file hasn't changed = 0 disk reads beyond the first.
|
||
metadata = _read_metadata_cached(marketplace_id)
|
||
section_kind = "skills" if kind == "skill" else "agents"
|
||
resolved = resolve_inner_metadata(metadata, plugin_name, section_kind, inner_name)
|
||
if not resolved:
|
||
return {"cover_photo_url": None, "video_url": None, "docs": []}
|
||
|
||
manifest = _load_mirror_manifest(marketplace_id)
|
||
|
||
cover_url: Optional[str] = None
|
||
cover_ref = resolved.get("cover_photo_ref")
|
||
if isinstance(cover_ref, tuple):
|
||
ref_kind, target = cover_ref
|
||
if ref_kind == "internal":
|
||
local_path = repo_root / target
|
||
if local_path.is_file():
|
||
cover_url = internal_asset_url(
|
||
marketplace_id, plugin_name, target, version=version,
|
||
)
|
||
elif ref_kind == "external":
|
||
cover_url = _resolve_external_via_mirror(
|
||
marketplace_id, plugin_name, target, manifest,
|
||
version=version,
|
||
)
|
||
|
||
docs: List[DocEntry] = []
|
||
for link in resolved.get("doc_links") or []:
|
||
if not hasattr(link, "kind"):
|
||
continue
|
||
if link.kind == "internal":
|
||
local_path = repo_root / link.path
|
||
if not local_path.is_file():
|
||
continue
|
||
docs.append(DocEntry(
|
||
name=link.name,
|
||
url=(
|
||
f"/api/marketplace/curated/{marketplace_id}/{plugin_name}"
|
||
f"/doc/{link.path}"
|
||
),
|
||
))
|
||
else:
|
||
served = _resolve_external_via_mirror(
|
||
marketplace_id, plugin_name, link.url, manifest,
|
||
)
|
||
if served is None:
|
||
continue
|
||
docs.append(DocEntry(name=link.name, url=served))
|
||
|
||
from app.markdown_render import render_safe
|
||
|
||
out: Dict[str, Any] = {
|
||
"cover_photo_url": cover_url,
|
||
"video_url": resolved.get("video_url"),
|
||
"docs": docs,
|
||
}
|
||
# Rich user-facing fields — same shape as the plugin-level enrichment.
|
||
# Presence check (``in``) rather than truthy check so the resolver's
|
||
# contract is respected: if the resolver decided to include a key,
|
||
# the API propagates it. Future falsy-but-valid fields (e.g. a bool
|
||
# ``featured: false`` or numeric ``priority: 0``) inherit correctly
|
||
# instead of silently falling back to the parent — the trap @cvrysanek
|
||
# flagged in the multi-persona review. String fields stay truthy-
|
||
# guarded by the resolver itself, so today's behaviour is unchanged.
|
||
if "display_name" in resolved:
|
||
out["display_name"] = resolved["display_name"]
|
||
if "tagline" in resolved:
|
||
out["tagline"] = resolved["tagline"]
|
||
# Per-item category override — caller merges with the parent plugin's
|
||
# category, preferring the override when set.
|
||
if "category" in resolved:
|
||
out["category"] = resolved["category"]
|
||
if "description" in resolved:
|
||
out["description_long_html"] = render_safe(resolved["description"])
|
||
if "when_to_use" in resolved:
|
||
out["when_to_use_html"] = render_safe(resolved["when_to_use"])
|
||
if "invocation" in resolved:
|
||
out["invocation"] = resolved["invocation"]
|
||
|
||
use_cases_raw = resolved.get("use_cases") or []
|
||
if use_cases_raw:
|
||
cards = [_safe_use_case(uc) for uc in use_cases_raw]
|
||
cards = [c for c in cards if c is not None]
|
||
if cards:
|
||
out["use_cases"] = cards
|
||
sample = _safe_sample_interaction(resolved.get("sample_interaction"))
|
||
if sample is not None:
|
||
out["sample_interaction"] = sample
|
||
return out
|
||
|
||
|
||
# Module-level cache for marketplace-metadata.json reads. Keyed by
|
||
# ``(marketplace_id, mtime_ns)`` so a curator's `git push` (which updates
|
||
# the file's mtime after the next sync's `git pull`) implicitly invalidates
|
||
# the cached parse. The 24-plugin listing endpoint reads from the same
|
||
# marketplace's metadata 24 times per page; without this cache that's 24
|
||
# disk reads + JSON parses per request. With cache: 1.
|
||
#
|
||
# OrderedDict + popitem(last=False) on overflow gives us a bounded LRU
|
||
# without a third-party dependency. Earlier versions used a per-marketplace
|
||
# eviction predicate that only swept stale entries for the CURRENT marketplace
|
||
# — at N>100 distinct marketplaces the inner predicate matched zero entries,
|
||
# so the cap silently failed and memory grew linearly. The bounded LRU here
|
||
# guarantees the dict size never exceeds _PLUGIN_METADATA_CACHE_MAX.
|
||
_PLUGIN_METADATA_CACHE_MAX = 256
|
||
_PLUGIN_METADATA_CACHE: "OrderedDict[Tuple[str, int], Dict[str, Any]]" = OrderedDict()
|
||
|
||
|
||
def _read_metadata_cached(marketplace_id: str) -> Dict[str, Any]:
|
||
"""Return the parsed marketplace-metadata.json for a given marketplace.
|
||
|
||
Cached by mtime so curator edits land at next request without explicit
|
||
invalidation. Missing / malformed files degrade to ``{}`` (delegated to
|
||
:func:`src.marketplace_metadata.read_marketplace_metadata` which already
|
||
swallows OSError / JSONDecodeError). Cache is bounded LRU; the oldest
|
||
entry is dropped when the cap is reached.
|
||
"""
|
||
from src.marketplace_metadata import (
|
||
MARKETPLACE_METADATA_REL,
|
||
read_marketplace_metadata,
|
||
)
|
||
repo_root = Path(get_marketplaces_dir()) / marketplace_id
|
||
metadata_path = repo_root / MARKETPLACE_METADATA_REL
|
||
try:
|
||
mtime_ns = metadata_path.stat().st_mtime_ns
|
||
except OSError:
|
||
# File doesn't exist or unreadable — read_marketplace_metadata handles
|
||
# the same case below; we just skip the cache lookup.
|
||
return read_marketplace_metadata(repo_root)
|
||
key = (marketplace_id, mtime_ns)
|
||
cached = _PLUGIN_METADATA_CACHE.get(key)
|
||
if cached is not None:
|
||
# Touch the entry so the LRU bookkeeping treats it as recently used.
|
||
_PLUGIN_METADATA_CACHE.move_to_end(key)
|
||
return cached
|
||
parsed = read_marketplace_metadata(repo_root)
|
||
_PLUGIN_METADATA_CACHE[key] = parsed
|
||
# Bounded LRU: drop oldest entries until we're back under the cap.
|
||
while len(_PLUGIN_METADATA_CACHE) > _PLUGIN_METADATA_CACHE_MAX:
|
||
_PLUGIN_METADATA_CACHE.popitem(last=False)
|
||
return parsed
|
||
|
||
|
||
def _curated_plugin_enrichment(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
) -> Dict[str, Any]:
|
||
"""Load plugin-level rich content from marketplace-metadata.json.
|
||
|
||
Returns a dict shaped for direct merge into ``PluginDetailResponse``:
|
||
``display_name``, ``tagline``, ``description_long_html``, ``use_cases``,
|
||
``sample_interaction`` — any subset of those keys may be missing when
|
||
the curator hasn't filled the corresponding field.
|
||
|
||
Markdown is rendered to safe HTML here (via
|
||
:func:`app.markdown_render.render_safe`) so the template can inject the
|
||
body with ``{{ x | safe }}`` without a client-side markdown library.
|
||
|
||
This is the plugin-level analogue of ``_curated_inner_enrichment`` — both
|
||
read on demand from the working tree so curator edits don't need a full
|
||
sync cycle to land in the UI. The other persisted plugin-level fields
|
||
(cover_photo_url, video_url, category, doc_links) continue to flow via
|
||
``marketplace_plugins`` rows written at sync time — only the NEW rich
|
||
fields are read on-demand here.
|
||
"""
|
||
from app.markdown_render import render_safe
|
||
from src.marketplace_metadata import resolve_plugin_metadata
|
||
|
||
metadata = _read_metadata_cached(marketplace_id)
|
||
resolved = resolve_plugin_metadata(metadata, plugin_name)
|
||
if not resolved:
|
||
return {}
|
||
|
||
out: Dict[str, Any] = {}
|
||
# Presence check (`in`) so future falsy-but-valid resolver fields
|
||
# (bool / int / "" with explicit-empty semantics) survive — see the
|
||
# parallel comment in `_curated_inner_enrichment` for the trap rationale.
|
||
if "display_name" in resolved:
|
||
out["display_name"] = resolved["display_name"]
|
||
if "tagline" in resolved:
|
||
out["tagline"] = resolved["tagline"]
|
||
if "description" in resolved:
|
||
out["description_long_html"] = render_safe(resolved["description"])
|
||
|
||
use_cases_raw = resolved.get("use_cases") or []
|
||
if use_cases_raw:
|
||
cards = [_safe_use_case(uc) for uc in use_cases_raw]
|
||
cards = [c for c in cards if c is not None]
|
||
if cards:
|
||
out["use_cases"] = cards
|
||
|
||
sample = _safe_sample_interaction(resolved.get("sample_interaction"))
|
||
if sample is not None:
|
||
out["sample_interaction"] = sample
|
||
|
||
return out
|
||
|
||
|
||
def _curated_inner_cover(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
kind: str,
|
||
inner_name: str,
|
||
manifest: Optional[Dict[Tuple[str, str], Any]] = None,
|
||
metadata: Optional[Dict[str, Any]] = None,
|
||
version: Optional[str] = None,
|
||
) -> Optional[str]:
|
||
"""Cheap helper: just the cover URL for one inner item.
|
||
|
||
Used by ``curated_detail`` to populate ``InnerItemSummary.cover_photo_url``
|
||
on the parent-plugin's skills/agents card list. Called once per inner
|
||
item; metadata + manifest are loaded once per plugin and passed in to
|
||
avoid N disk reads.
|
||
"""
|
||
from src.marketplace_metadata import resolve_inner_metadata
|
||
|
||
if metadata is None:
|
||
# Route through the mtime cache; this helper gets called once per
|
||
# inner item on the parent-plugin's skills/agents card list, so
|
||
# cache miss only happens on the first card.
|
||
metadata = _read_metadata_cached(marketplace_id)
|
||
if manifest is None:
|
||
manifest = _load_mirror_manifest(marketplace_id)
|
||
|
||
section_kind = "skills" if kind == "skill" else "agents"
|
||
resolved = resolve_inner_metadata(metadata, plugin_name, section_kind, inner_name)
|
||
cover_ref = resolved.get("cover_photo_ref") if resolved else None
|
||
if not isinstance(cover_ref, tuple):
|
||
return None
|
||
ref_kind, target = cover_ref
|
||
if ref_kind == "internal":
|
||
local_path = (Path(get_marketplaces_dir()) / marketplace_id / target)
|
||
if local_path.is_file():
|
||
return internal_asset_url(
|
||
marketplace_id, plugin_name, target, version=version,
|
||
)
|
||
return None
|
||
return _resolve_external_via_mirror(
|
||
marketplace_id, plugin_name, target, manifest, version=version,
|
||
)
|
||
|
||
|
||
@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)
|
||
enrichment = _curated_inner_enrichment(
|
||
marketplace_id, plugin_name, "skill", skill_name,
|
||
version=_marketplace_asset_version(marketplace_id, conn),
|
||
)
|
||
# Merge parent fallback fields with curator overrides BEFORE unpacking
|
||
# — Python function-call `**a, **b` with overlapping keys raises
|
||
# TypeError, it doesn't merge like a literal dict does. Today only
|
||
# `category` overlaps (both parent + enrichment may set it), but the
|
||
# explicit merge keeps the unpack future-proof against any new field
|
||
# added to both layers.
|
||
merged = {**parent, **enrichment}
|
||
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),
|
||
**merged,
|
||
)
|
||
|
||
|
||
@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)
|
||
enrichment = _curated_inner_enrichment(
|
||
marketplace_id, plugin_name, "agent", agent_name,
|
||
version=_marketplace_asset_version(marketplace_id, conn),
|
||
)
|
||
# See curated_skill_detail above — explicit merge avoids the
|
||
# TypeError that `**parent, **enrichment` raises when both supply
|
||
# an overlapping key (e.g. `category`).
|
||
merged = {**parent, **enrichment}
|
||
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)],
|
||
**merged,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Asset / doc / mirrored serving endpoints (v32)
|
||
# ---------------------------------------------------------------------------
|
||
#
|
||
# Three sibling endpoints that serve the binary content referenced from
|
||
# `marketplace-metadata.json`. All three:
|
||
#
|
||
# * are gated by `require_resource_access(MARKETPLACE_PLUGIN, "{mp}/{plugin}")`
|
||
# so a user without RBAC can't side-load assets even with a direct URL,
|
||
# * resolve a candidate path with `Path.resolve(strict=True)` and verify the
|
||
# result lives under the expected root via `is_relative_to()` — defense
|
||
# against `..` / absolute paths / symlinks pointing out of the tree,
|
||
# * use FastAPI's `FileResponse` so Content-Type detection comes from the
|
||
# stdlib mimetypes module (good enough for the allowlisted set; binary
|
||
# fallback for anything we don't recognize).
|
||
|
||
|
||
def _doc_disposition(filename: str) -> dict:
|
||
"""Force-download headers for the /doc and /mirrored doc paths.
|
||
|
||
Browsers honor the frontend's `download` attribute only for same-origin
|
||
URLs; the explicit Content-Disposition header makes the download
|
||
behavior reliable regardless of how the link was opened (right-click →
|
||
open, programmatic fetch, curl, …). Filename is the basename so the
|
||
browser save dialog shows something readable.
|
||
"""
|
||
safe = Path(filename).name or "download"
|
||
return {"Content-Disposition": f'attachment; filename="{safe}"'}
|
||
|
||
|
||
# Mapping from validated image extension to the Content-Type we serve.
|
||
# Pinned (not stdlib mimetypes) so an unexpected file with a known extension
|
||
# can't push us into ``text/html`` territory — this endpoint NEVER serves
|
||
# anything but image bytes labelled as image bytes.
|
||
_ASSET_CONTENT_TYPE = {
|
||
".png": "image/png",
|
||
".jpg": "image/jpeg",
|
||
".jpeg": "image/jpeg",
|
||
".webp": "image/webp",
|
||
}
|
||
|
||
# Defense-in-depth headers applied to every /asset/ response. ``nosniff``
|
||
# stops browsers from second-guessing our Content-Type; the strict CSP is
|
||
# a belt-and-suspenders block — even if a future regression let HTML
|
||
# through, the browser still won't execute scripts/iframes/etc.
|
||
#
|
||
# Cache-Control: cover photos are render-blocking on the /marketplace grid
|
||
# (12-20+ per page). Bytes are paired with a ``?v=<commit-sha8>`` URL
|
||
# fingerprint (built in src/marketplace_urls.py, populated at sync time
|
||
# from marketplace_registry.last_commit_sha) so aggressive caching is safe:
|
||
# upstream repo unchanged → same fingerprint → browser keeps the cached
|
||
# bytes; sync that pulls new commits → new fingerprint → fresh fetch.
|
||
# 30-day max-age + immutable means browsers also skip the conditional GET
|
||
# on F5 refresh (Ctrl+F5 still bypasses cache).
|
||
_ASSET_SECURITY_HEADERS = {
|
||
"X-Content-Type-Options": "nosniff",
|
||
"Content-Security-Policy": "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'",
|
||
"Cache-Control": "public, max-age=2592000, immutable",
|
||
}
|
||
|
||
|
||
@router.get("/curated/{marketplace_id}/{plugin_name}/asset/{path:path}")
|
||
async def curated_asset(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
path: str,
|
||
_user: dict = Depends(get_current_user),
|
||
):
|
||
"""Serve an internal image asset from the cloned marketplace working tree.
|
||
|
||
Paths are repo-root-relative — ``{path}`` may be e.g.
|
||
``.agnes/cover.png`` or ``plugins/foo/icon.png``.
|
||
|
||
**Auth model: login-only, no per-plugin RBAC.** Cover photos are
|
||
curator-designed marketing visuals — they exist specifically to be seen
|
||
and carry no PII / source / secrets. The previous
|
||
``require_resource_access`` check serialized every image request through
|
||
a DuckDB join under ``_system_db_lock``, making the /marketplace grid
|
||
(12-20 cover photos per render) pay N round-trips of auth+RBAC cost in
|
||
sequence. Login (``get_current_user``) still required → no
|
||
unauthenticated public access. See CHANGELOG entry under "Security" for
|
||
the threat-model rationale.
|
||
|
||
**Image-only by contract.** The endpoint is the source of cover photos
|
||
referenced from ``marketplace-metadata.json`` and from inner skill / agent
|
||
cards. A curator who could land an arbitrary file in the cloned repo
|
||
(HTML, JS, SVG with inline ``<script>``) would otherwise have a
|
||
same-origin XSS via this endpoint, since the response shares the
|
||
cookie scope with ``/admin`` and ``/api/me/*``. Two layered checks:
|
||
|
||
1. Extension must be in :data:`src.marketplace_asset_validation.IMAGE_EXTENSIONS`
|
||
(``.png``/``.jpg``/``.jpeg``/``.webp``); anything else → 415.
|
||
2. ``Content-Type`` is pinned from the extension table (not stdlib
|
||
mimetypes), so the response is never served as ``text/html`` even
|
||
if mimetypes were misconfigured.
|
||
|
||
Magic-bytes body validation that previously ran per request was dropped
|
||
after the perf audit found it re-read the file just to discard the
|
||
bytes (``FileResponse`` reads it again to stream). The repo is fetched
|
||
via ``git pull`` from a trusted upstream the operator registered in
|
||
``marketplace_registry`` — accepting curator content at sync time is
|
||
the layer where adversarial-payload detection belongs, not at every
|
||
GET. Extension + Content-Type pinning + path-traversal guard remain.
|
||
|
||
SVG is intentionally not in the allowlist — ``<script>`` inside SVG
|
||
executes in the browser. ``X-Content-Type-Options: nosniff`` plus a
|
||
strict CSP harden the response further.
|
||
|
||
Inline rendering (no ``Content-Disposition``) — covers display in
|
||
``<img>``, not as a download.
|
||
"""
|
||
from src.marketplace_asset_validation import IMAGE_EXTENSIONS
|
||
|
||
repo_root = Path(get_marketplaces_dir()) / marketplace_id
|
||
if not repo_root.exists():
|
||
raise HTTPException(status_code=404, detail="marketplace_not_synced")
|
||
safe = _safe_join(repo_root, path)
|
||
if safe is None or not safe.is_file():
|
||
raise HTTPException(status_code=404, detail="asset_not_found")
|
||
|
||
ext = safe.suffix.lower()
|
||
if ext not in IMAGE_EXTENSIONS:
|
||
raise HTTPException(
|
||
status_code=415,
|
||
detail=f"unsupported_asset_extension: {ext or '(none)'}",
|
||
)
|
||
return FileResponse(
|
||
safe,
|
||
media_type=_ASSET_CONTENT_TYPE[ext],
|
||
headers=_ASSET_SECURITY_HEADERS,
|
||
)
|
||
|
||
|
||
@router.get("/curated/{marketplace_id}/{plugin_name}/doc/{path:path}")
|
||
async def curated_doc(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
path: str,
|
||
_user: dict = Depends(require_resource_access(
|
||
ResourceType.MARKETPLACE_PLUGIN, "{marketplace_id}/{plugin_name}",
|
||
)),
|
||
):
|
||
"""Serve an internal doc path from the cloned marketplace working tree.
|
||
|
||
Same path-traversal guard as ``/asset/``. Adds an allowlist check — the
|
||
doc endpoint refuses to serve a file whose extension isn't in the
|
||
documented PDF / Markdown / plain text set (HTTP 415). Defense-in-depth
|
||
even though the marketplace-metadata parser already rejects out-of-allowlist
|
||
extensions during the doc_link parse — a curator who edits the working
|
||
tree directly (or whose JSON survived parsing because of a generic
|
||
extension match elsewhere) shouldn't be able to land a .docx through
|
||
a re-served doc URL.
|
||
|
||
Force-download via Content-Disposition: attachment — clicking a doc
|
||
link in the UI saves the file to disk rather than opening it in a tab.
|
||
"""
|
||
from src.marketplace_asset_validation import DOC_EXTENSIONS
|
||
|
||
repo_root = Path(get_marketplaces_dir()) / marketplace_id
|
||
if not repo_root.exists():
|
||
raise HTTPException(status_code=404, detail="marketplace_not_synced")
|
||
safe = _safe_join(repo_root, path)
|
||
if safe is None or not safe.is_file():
|
||
raise HTTPException(status_code=404, detail="doc_not_found")
|
||
if safe.suffix.lower() not in DOC_EXTENSIONS:
|
||
raise HTTPException(
|
||
status_code=415,
|
||
detail=f"unsupported_doc_extension: {safe.suffix.lower() or '(none)'}",
|
||
)
|
||
return FileResponse(safe, headers=_doc_disposition(safe.name))
|
||
|
||
|
||
@router.get("/curated/{marketplace_id}/{plugin_name}/mirrored/{key:path}")
|
||
async def curated_mirrored(
|
||
marketplace_id: str,
|
||
plugin_name: str,
|
||
key: str,
|
||
_user: dict = Depends(get_current_user),
|
||
):
|
||
"""Serve a mirrored external asset from the marketplace cache.
|
||
|
||
``key`` is the ``local`` relpath stored in the cache manifest minus the
|
||
leading ``<plugin>/`` prefix — the mirror writes files at
|
||
``${DATA_DIR}/marketplace-cache/<slug>/<plugin>/<rest>``, and this
|
||
endpoint expects ``{plugin}/{rest}`` so the same path-resolution check
|
||
catches escapes whether the caller provided ``cover.png`` or
|
||
``docs/abc-setup.pdf``.
|
||
|
||
Doc-shaped paths (key starting with ``docs/``) get the same force-download
|
||
treatment as the internal /doc/ endpoint. Cover photos under the cache
|
||
root render inline so the <img> tag works.
|
||
|
||
**Auth model: login-only for cover photos**, same rationale as
|
||
``curated_asset`` above. The doc branch below is still gated by login
|
||
only at this point — RBAC was dropped here too because the mirrored
|
||
cache is just the cover-photo serving complement, not user-facing
|
||
document distribution. Tighter doc gating lives on the separate
|
||
``curated_doc`` endpoint which retains ``require_resource_access``.
|
||
"""
|
||
cache_root = get_marketplace_cache_dir() / marketplace_id / plugin_name
|
||
if not cache_root.exists():
|
||
raise HTTPException(status_code=404, detail="mirror_cache_missing")
|
||
safe = _safe_join(cache_root, key)
|
||
if safe is None or not safe.is_file():
|
||
raise HTTPException(status_code=404, detail="mirrored_asset_not_found")
|
||
if key.startswith("docs/"):
|
||
return FileResponse(safe, headers=_doc_disposition(safe.name))
|
||
# Cover photos: aggressive cache. URL fingerprint via ``?v=`` from
|
||
# src/marketplace.py sync enrich keeps cache coherent across upstream
|
||
# commits.
|
||
return FileResponse(
|
||
safe, headers={"Cache-Control": "public, max-age=2592000, immutable"},
|
||
)
|