* 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>
257 lines
9.1 KiB
Python
257 lines
9.1 KiB
Python
"""Per-user composition view of the served Claude Code marketplace.
|
|
|
|
Provides:
|
|
|
|
* ``GET /api/my-stack`` — combined view
|
|
* ``PUT /api/my-stack/curated/{marketplace_id}/{plugin}`` — toggle subscription
|
|
|
|
Used by the ``agnes my-stack`` CLI subcommand. The web page that historically
|
|
backed these endpoints (``/my-ai-stack``) was removed in favor of
|
|
``/marketplace?tab=my``, but the API stays as the public CLI surface. Both
|
|
endpoints touch the same caches as the Store endpoints (ETag invalidation) so
|
|
any change here propagates to ``/marketplace.zip`` + ``/marketplace.git/`` on
|
|
the next request.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, List, Optional
|
|
|
|
import duckdb
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from app.auth.dependencies import _get_db, get_current_user
|
|
from src.marketplace_filter import resolve_allowed_plugins
|
|
from src.repositories.audit import AuditRepository
|
|
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_naming import suffixed_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/my-stack", tags=["my-stack"])
|
|
|
|
|
|
class CuratedPlugin(BaseModel):
|
|
marketplace_id: str
|
|
marketplace_slug: str
|
|
plugin_name: str
|
|
manifest_name: str
|
|
description: Optional[str] = None
|
|
version: Optional[str] = None
|
|
enabled: bool
|
|
# v39: when TRUE, the user cannot unsubscribe (UI disables the
|
|
# toggle, API guard returns 409). Pre-subscribed by mark_system +
|
|
# creation hooks so ``enabled`` is always TRUE here.
|
|
is_system: bool = False
|
|
|
|
|
|
class StoreInstallEntry(BaseModel):
|
|
entity_id: str
|
|
type: str
|
|
name: str
|
|
description: Optional[str] = None
|
|
category: Optional[str] = None
|
|
version: str
|
|
owner_user_id: str
|
|
owner_username: str
|
|
invocation_name: str
|
|
install_count: int
|
|
photo_url: Optional[str] = None
|
|
installed_at: Optional[str] = None
|
|
# v35: surface visibility so my_ai_stack.html can render an
|
|
# "Archived by owner" badge on cards whose owner soft-deleted the
|
|
# entity. Bundle still serves to existing installs (per
|
|
# UserStoreInstallsRepository.list_for_user filter).
|
|
visibility_status: Optional[str] = None
|
|
|
|
|
|
class MyStackResponse(BaseModel):
|
|
curated: List[CuratedPlugin]
|
|
store: List[StoreInstallEntry]
|
|
|
|
|
|
class ToggleRequest(BaseModel):
|
|
enabled: bool
|
|
|
|
|
|
class OkResponse(BaseModel):
|
|
ok: bool = True
|
|
|
|
|
|
def _to_iso(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if isinstance(value, datetime):
|
|
return value.isoformat()
|
|
return str(value)
|
|
|
|
|
|
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
|
|
|
|
|
|
@router.get("", response_model=MyStackResponse)
|
|
async def get_my_stack(
|
|
user: dict = Depends(get_current_user),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Combined view of curated plugins the caller can subscribe to
|
|
and Store entities they have installed.
|
|
"""
|
|
granted = resolve_allowed_plugins(conn, user)
|
|
# Model B (v28+): explicit subscriptions decide what's enabled.
|
|
# `enabled` mirrors the legacy "not opted_out" UX so the existing toggle
|
|
# remains semantically intuitive in the my-stack view.
|
|
subs = UserCuratedSubscriptionsRepository(conn).subscribed_set(user["id"])
|
|
|
|
# v39: surface is_system flag so the template can lock the toggle.
|
|
# One round trip — set membership intersection in Python is cheaper
|
|
# than joining marketplace_plugins per-row inside resolve_allowed_plugins
|
|
# (which is also called from the marketplace_filter / packager hot path).
|
|
sys_rows = conn.execute(
|
|
"SELECT marketplace_id, name FROM marketplace_plugins "
|
|
"WHERE is_system = TRUE",
|
|
).fetchall()
|
|
system_plugins: set[tuple[str, str]] = {(r[0], r[1]) for r in sys_rows}
|
|
|
|
curated: List[CuratedPlugin] = []
|
|
for p in granted:
|
|
key = (p["marketplace_id"], p["original_name"])
|
|
is_subscribed = key in subs
|
|
is_system = key in system_plugins
|
|
curated.append(
|
|
CuratedPlugin(
|
|
marketplace_id=p["marketplace_id"],
|
|
marketplace_slug=p["marketplace_slug"],
|
|
plugin_name=p["original_name"],
|
|
manifest_name=p["manifest_name"],
|
|
description=p["raw"].get("description"),
|
|
version=p.get("version"),
|
|
enabled=is_subscribed,
|
|
is_system=is_system,
|
|
)
|
|
)
|
|
|
|
installs = UserStoreInstallsRepository(conn).list_for_user(user["id"])
|
|
store_items: List[StoreInstallEntry] = []
|
|
from src.store_naming import strip_archive_suffix
|
|
for row in installs:
|
|
photo_url = (
|
|
# ``?v=`` cache-busting fingerprint via ``version_no`` — see
|
|
# ``app/api/store.py:get_entity_photo`` for the cache-header
|
|
# contract. Bumps on every re-upload, so the URL refresh
|
|
# forces a browser refetch exactly when the bytes change.
|
|
f"/api/store/entities/{row['id']}/photo?v={row.get('version_no', 1)}"
|
|
if row.get("photo_path") else None
|
|
)
|
|
# Display name strips the archive-rename suffix so the user
|
|
# sees their installed plugin's original label even after the
|
|
# owner archived (and renamed) it. The served ``invocation_name``
|
|
# carries the renamed slug since that's what Claude Code's
|
|
# `/plugin` lookup will resolve to after the next sync — this
|
|
# is the consumer-side rename described in the rename-on-
|
|
# archive plan; the My AI Stack card surfaces it via the
|
|
# "Archived by owner" badge already.
|
|
raw_name = row["name"]
|
|
display_name = strip_archive_suffix(raw_name)
|
|
store_items.append(
|
|
StoreInstallEntry(
|
|
entity_id=row["id"],
|
|
type=row["type"],
|
|
name=display_name,
|
|
description=row.get("description"),
|
|
category=row.get("category"),
|
|
version=row["version"],
|
|
owner_user_id=row["owner_user_id"],
|
|
owner_username=row["owner_username"],
|
|
invocation_name=suffixed_name(raw_name, row["owner_username"]),
|
|
install_count=int(row.get("install_count") or 0),
|
|
photo_url=photo_url,
|
|
installed_at=_to_iso(row.get("installed_at")),
|
|
visibility_status=row.get("visibility_status") or "approved",
|
|
)
|
|
)
|
|
|
|
return MyStackResponse(curated=curated, store=store_items)
|
|
|
|
|
|
@router.put(
|
|
"/curated/{marketplace_id}/{plugin_name}",
|
|
response_model=OkResponse,
|
|
)
|
|
async def toggle_curated(
|
|
marketplace_id: str,
|
|
plugin_name: str,
|
|
body: ToggleRequest,
|
|
user: dict = Depends(get_current_user),
|
|
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
|
|
):
|
|
"""Toggle subscribe/unsubscribe for a single curated plugin.
|
|
|
|
UI thinks in terms of *enabled* (default off in Model B). v28+ the
|
|
repository stores *subscribed* rows (presence = enabled in served set);
|
|
``enabled=true`` writes a row, ``enabled=false`` removes it.
|
|
"""
|
|
# Sanity: caller must actually have the plugin granted (otherwise the
|
|
# toggle is meaningless and would just leak rows for ungranted plugins).
|
|
granted = resolve_allowed_plugins(conn, user)
|
|
has_grant = any(
|
|
p["marketplace_id"] == marketplace_id and p["original_name"] == plugin_name
|
|
for p in granted
|
|
)
|
|
if not has_grant:
|
|
raise HTTPException(status_code=404, detail="grant_not_found")
|
|
|
|
# v39: system plugins are pinned in every user's stack — refuse the
|
|
# unsubscribe path. Subscribe is still allowed (no-op on the
|
|
# already-materialized row).
|
|
if not body.enabled:
|
|
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_unsubscribe_system_plugin",
|
|
)
|
|
|
|
repo = UserCuratedSubscriptionsRepository(conn)
|
|
if body.enabled:
|
|
repo.subscribe(user["id"], marketplace_id, plugin_name)
|
|
else:
|
|
repo.unsubscribe(user["id"], marketplace_id, plugin_name)
|
|
_audit(
|
|
conn,
|
|
user["id"],
|
|
"my_stack.curated.toggle",
|
|
f"plugin:{marketplace_id}/{plugin_name}",
|
|
{"enabled": body.enabled},
|
|
)
|
|
|
|
try:
|
|
from app.marketplace_server import packager
|
|
packager.invalidate_etag_cache()
|
|
except Exception:
|
|
logger.exception("failed to invalidate marketplace etag cache")
|
|
|
|
return OkResponse()
|