agnes-the-ai-analyst/app/api/marketplaces.py
minasarustamyan 9de679c714
System plugins (schema v39) + marketplace UX polish + drop legacy pages (#241)
* System plugin tier with mark/unmark fanout (schema v39)

Adds a mandatory plugin tier so admins can pin a small set of curated
plugins into every user's stack from day one. Marking a plugin via the
new toggle on /admin/marketplaces materializes resource_grants for every
group and user_plugin_optouts subscriptions for every user, so the
existing resolver pulls the plugin into every served set without a new
filter layer. Hooks on user-create (Google OAuth, magic-link, admin
POST, scheduler) and group-create propagate the same materialization to
new principals. UI locks: /admin/access disables the checkbox with a
SYSTEM pill; /marketplace cards swap the "In stack" green pill for an
amber "Required" badge with shield icon; the plugin detail install
button reads "Required by your org"; /my-ai-stack toggle is disabled.
Bypass paths return 409 (DELETE /api/admin/grants for system grants,
PUT /api/my-stack/curated/.../{enabled:false}, DELETE
/api/marketplace/curated/.../install). Unmark only flips the flag —
materialized rows persist so admins curate cleanup at their leisure
through the now-unlocked /admin/access checkboxes.

* Marketplace UX polish + drop legacy /store and /my-ai-stack pages

Two-part cleanup post-v39:

(1) Page deletion. /store and /my-ai-stack were already replaced by
/marketplace?tab=flea and /marketplace?tab=my respectively, but the
standalone routes lingered. Hard delete in dev mode — no redirects,
stale bookmarks 404. The /store/new upload wizard, the flea
detail/edit pages, the admin queue, and all /api/store/* +
/api/my-stack endpoints (CLI consumers) stay. Internal hardcoded
hrefs in the upload wizard's Cancel button and the advanced-setup
page repointed to the marketplace tabs.

(2) Detail-page install button rework. The single button that morphed
between "+ Add to my stack" and "✓ In your stack" did not
communicate uninstall affordance. The installed state now renders an
inline white status label *before* a separate red-bordered
"✕ Remove from stack" button on the same row, both at identical
height to avoid layout shift. System plugins keep their locked amber
"✓ Required by your org" pill (no Remove button — API refuses 409).
The post-action hint panel now fires on remove too with the title
flipped to "✓ Removed from your stack" — Claude Code needs the same
/update-agnes-plugins refresh either way.

Also: /admin/marketplaces Details modal "Mark as system" toggle
redesigned. The button was near-invisible (matched neutral row
metadata). It's now a balanced amber-toned chip with shield icon
and a structured confirm modal replacing the native confirm() dialog
that summarizes fanout consequences before commit.

* Move stack-hint inside hero with glass-on-gradient styling

The post-action hint card ("✓ Added to your stack" with the
/update-agnes-plugins recipe) used to live below the hero in
panel-what (gray card on white page body). Clicking add/remove
inserted/removed it between the hero and content, shifting the
panels below — a noticeable scroll jump.

The hint is now anchored inside the hero's top-right corner alongside
the install/remove buttons, both as flex children of an absolutely
positioned .actions container. The card uses a translucent
white-on-glass treatment that adopts the hero's kind color (blue for
plugin, green for skill, purple for agent) without per-kind branching.
Hero is always tall enough (160px photo) to contain the action+hint
stack without overflow, so toggling the hint visibility doesn't grow
the hero or shift body content.

The hero-head grid reserves a third 300px column for the absolute
actions overlay so meta gets the proper 1fr free space instead of
being squeezed by a padding-right hack. Responsive breakpoint at
1100px reflows the actions stack below hero-head when the viewport
isn't wide enough to keep meta + actions side-by-side comfortably.

* Add optional -DataPath bind mount to run-local-dev.ps1

When the operator wants to inspect DuckDB files (system.duckdb, extracts,
marketplaces, store/, …) directly from Windows Explorer, the named volume
inside the Docker Desktop WSL VM isn't reachable. The new -DataPath param
generates a transient compose override that rebinds /data on app, scheduler,
extract (and Caddy's /srv:ro mirror) to a Windows host folder.

Fully additive — when -DataPath is omitted everything behaves exactly as
before: no override file is generated, $composeFiles array is unchanged,
finally cleanup is a no-op. Existing positional invocations
(.\run-local-dev.ps1 up | down | logs) keep binding to $Action because
$DataPath is a named-only parameter with no Position attribute.

The override is written via [System.IO.File]::WriteAllText so the YAML is
BOM-less across PS 5.1 / 7+ — Compose rejects BOM-prefixed YAML on Windows.
The override file is unique per PID and removed in the script's finally
block so concurrent invocations and crashes don't leak files.

* factor mark_system fanout into UserCuratedSubscriptionsRepository

The endpoint imported UserCuratedSubscriptionsRepository, ignored it
(noqa: F841), then duplicated the user-side fanout SQL inline. Adds
fanout_system_for_plugin() symmetric to the existing
fanout_system_for_user() and routes mark_plugin_system through it —
removes the dead import + 14 lines of inline SQL, returns the same
`affected_users` delta count, no behavior change.

* drop customer-specific path from .ps1 example

Per CLAUDE.md vendor-agnostic OSS rule: replaced
C:\\Business\\Groupon\\Agnes\\agnes-data with the generic
C:\\Users\\<you>\\agnes-data placeholder so the docstring
example reads cleanly on any reviewer's box.

* release: 0.48.0 + parallelize Release-workflow pytest

Cuts the release shipped via #228 #230 #231 #232 #233 #234 #236 #237 #238
#239 #240 plus this PR (#241). Major changes:

- System plugin tier (schema v39) — admins mark a plugin mandatory; fans
  out RBAC grants + subscriptions to every existing user/group plus
  hooks for new principals
- BREAKING: removed standalone /store + /my-ai-stack page routes
  (replaced by /marketplace?tab=flea + /marketplace?tab=my)
- Setup-prompt + bootstrap recovery fixes (#240)
- DuckDB CHECKPOINT-on-shutdown + 60s compose grace (#235)
- Marketplace + flea-market UX polish, agnes-metadata.json enrichment

Bonus: switch release.yml test step to `-n auto` (matches ci.yml).
Single-threaded was 15-20 min and frequently the bottleneck on PR
mergeability — now ~6 min.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
Co-authored-by: ZdenekSrotyr <zdenek.srotyr@keboola.com>
2026-05-10 19:15:41 +00:00

715 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""Admin endpoints for marketplace git repositories.
CRUD + on-demand "Sync now" mirroring the /api/users shape. Tokens supplied
through the admin UI are persisted to data/state/.env_overlay (same pattern
as /api/admin/configure for Keboola/BigQuery) — never stored in the DB.
"""
import logging
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Any, List, Optional
import duckdb
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from app.auth.access import require_admin
from app.auth.dependencies import _get_db
from app.resource_types import ResourceType
from src.marketplace import (
MarketplaceNotFound,
delete_marketplace_dir,
is_valid_slug,
sync_marketplaces,
sync_one,
)
from src.repositories.audit import AuditRepository
from src.repositories.marketplace_plugins import MarketplacePluginsRepository
from src.repositories.marketplace_registry import MarketplaceRegistryRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/marketplaces", tags=["marketplaces"])
# ---------------------------------------------------------------------------
# Audit helper — same shape as app/api/users.py::_audit
# ---------------------------------------------------------------------------
def _audit(
conn: duckdb.DuckDBPyConnection,
actor_id: str,
action: str,
target_id: str,
params: Optional[dict] = None,
) -> None:
try:
safe_params = None
if params:
safe_params = {}
for k, v in params.items():
if isinstance(v, datetime):
safe_params[k] = v.isoformat()
else:
safe_params[k] = v
AuditRepository(conn).log(
user_id=actor_id,
action=action,
resource=f"marketplace:{target_id}",
params=safe_params,
)
except Exception:
pass
# ---------------------------------------------------------------------------
# Pydantic models
# ---------------------------------------------------------------------------
class CreateMarketplaceRequest(BaseModel):
name: str
slug: str
url: str
branch: Optional[str] = None
description: Optional[str] = None
token: Optional[str] = None
# v32: required at create time. Surfaced on /marketplace cards + plugin
# detail in place of the historical owner_todo placeholder. The plan
# decision was to validate at the application layer (no DB NOT NULL)
# so existing rows survive the schema migration without forcing all
# admins to refill before the next request lands.
curator_name: Optional[str] = None
curator_email: Optional[str] = None
class UpdateMarketplaceRequest(BaseModel):
name: Optional[str] = None
url: Optional[str] = None
branch: Optional[str] = None
description: Optional[str] = None
# None = leave untouched; empty string = clear token; non-empty = rotate
token: Optional[str] = None
# Either field None = leave untouched; non-empty string = update.
# Empty string is treated the same as None on update so admins can't
# accidentally null out a curator by submitting an empty form input.
curator_name: Optional[str] = None
curator_email: Optional[str] = None
class MarketplaceResponse(BaseModel):
id: str
name: str
url: str
branch: Optional[str] = None
description: Optional[str] = None
registered_by: Optional[str] = None
registered_at: Optional[str] = None
last_synced_at: Optional[str] = None
last_commit_sha: Optional[str] = None
last_error: Optional[str] = None
has_token: bool = False
plugin_count: int = 0
curator_name: Optional[str] = None
curator_email: Optional[str] = None
# Liberal email regex — RFC 5322 is too permissive to be useful at the
# admin-form layer. Anchored, requires `local@domain.tld`, no whitespace,
# 1-254 chars total. Matches what /admin UI expects an admin to type.
_EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
def _to_response(row: dict, plugin_count: int = 0) -> MarketplaceResponse:
token_env = row.get("token_env") or ""
has_token = bool(token_env) and bool(os.environ.get(token_env, ""))
return MarketplaceResponse(
id=row["id"],
name=row["name"],
url=row["url"],
branch=row.get("branch"),
description=row.get("description"),
registered_by=row.get("registered_by"),
registered_at=str(row["registered_at"]) if row.get("registered_at") else None,
last_synced_at=str(row["last_synced_at"]) if row.get("last_synced_at") else None,
last_commit_sha=row.get("last_commit_sha"),
last_error=row.get("last_error"),
has_token=has_token,
plugin_count=plugin_count,
curator_name=row.get("curator_name"),
curator_email=row.get("curator_email"),
)
class PluginResponse(BaseModel):
name: str
description: Optional[str] = None
version: Optional[str] = None
author_name: Optional[str] = None
homepage: Optional[str] = None
category: Optional[str] = None
source_type: Optional[str] = None
source_spec: Optional[Any] = None
# v39: surfaced so the admin Details modal renders the SYSTEM pill
# + flips the "Mark as system" / "Unmark system" toggle button.
is_system: bool = False
class SystemFlagResponse(BaseModel):
"""Return shape of the mark/unmark_system endpoints."""
marketplace_id: str
plugin_name: str
is_system: bool
affected_groups: int = 0
affected_users: int = 0
# ---------------------------------------------------------------------------
# Token persistence — mirrors app/api/admin.py::configure_instance
# ---------------------------------------------------------------------------
def _token_env_name(slug: str) -> str:
"""Derive a conventional env-var name from a slug.
"foundry-ai" -> "AGNES_MARKETPLACE_FOUNDRY_AI_TOKEN"
"""
normalized = slug.upper().replace("-", "_")
return f"AGNES_MARKETPLACE_{normalized}_TOKEN"
def _persist_token(env_name: str, value: str) -> None:
"""Write (or update) a single key in ``${STATE_DIR}/.env_overlay`` and ``os.environ``.
Path resolution matches ``app/main.py``'s startup-time read; without
this alignment, marketplace PATs persisted under the flat-mount
layout (``STATE_DIR=/data-state``) would land at
``/data/state/.env_overlay`` while the app reads from
``/data-state/.env_overlay``, silently dropping the token on the
next restart.
"""
from app.secrets import _state_dir
overlay_path = _state_dir() / ".env_overlay"
overlay_path.parent.mkdir(parents=True, exist_ok=True)
existing: dict[str, str] = {}
if overlay_path.exists():
for line in overlay_path.read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
existing[k.strip()] = v.strip()
if value:
existing[env_name] = value
os.environ[env_name] = value
else:
existing.pop(env_name, None)
os.environ.pop(env_name, None)
overlay_path.write_text(
"\n".join(f"{k}={v}" for k, v in existing.items()) + ("\n" if existing else "")
)
try:
overlay_path.chmod(0o600)
except OSError:
pass
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=List[MarketplaceResponse])
async def list_marketplaces(
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
counts = MarketplacePluginsRepository(conn).count_by_marketplace()
return [
_to_response(row, counts.get(row["id"], 0))
for row in MarketplaceRegistryRepository(conn).list_all()
]
@router.get("/{marketplace_id}/plugins", response_model=List[PluginResponse])
async def list_plugins(
marketplace_id: str,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Return the cached plugin list for a marketplace.
Rows come from `marketplace_plugins`, which is refreshed from
`.claude-plugin/marketplace.json` on every successful sync. An
unsynced marketplace will return an empty list.
"""
if not MarketplaceRegistryRepository(conn).get(marketplace_id):
raise HTTPException(status_code=404, detail="marketplace not found")
rows = MarketplacePluginsRepository(conn).list_for_marketplace(marketplace_id)
return [
PluginResponse(
name=r["name"],
description=r.get("description"),
version=r.get("version"),
author_name=r.get("author_name"),
homepage=r.get("homepage"),
category=r.get("category"),
source_type=r.get("source_type"),
source_spec=r.get("source_spec"),
is_system=bool(r.get("is_system")),
)
for r in rows
]
@router.post("", response_model=MarketplaceResponse, status_code=201)
async def create_marketplace(
payload: CreateMarketplaceRequest,
request: Request,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
slug = (payload.slug or "").strip().lower()
if not is_valid_slug(slug):
raise HTTPException(
status_code=400,
detail="slug must match [a-z0-9][a-z0-9_-]{0,63} (1-64 chars, start with alnum)",
)
if not (payload.url or "").strip().lower().startswith("https://"):
raise HTTPException(status_code=400, detail="url must start with https://")
if not (payload.name or "").strip():
raise HTTPException(status_code=400, detail="name is required")
# v32: curator is mandatory at create time. Validation lives here (not in
# DB schema) so legacy rows that pre-date the column survive — admins
# patch them via the edit modal at their leisure.
curator_name = (payload.curator_name or "").strip()
curator_email = (payload.curator_email or "").strip()
if not curator_name:
raise HTTPException(
status_code=400, detail="curator_name is required",
)
if not curator_email:
raise HTTPException(
status_code=400, detail="curator_email is required",
)
if not _EMAIL_RE.match(curator_email):
raise HTTPException(
status_code=400, detail="curator_email is not a valid email address",
)
repo = MarketplaceRegistryRepository(conn)
if repo.get(slug):
raise HTTPException(status_code=409, detail=f"marketplace '{slug}' already exists")
token_env: Optional[str] = None
if payload.token:
token_env = _token_env_name(slug)
_persist_token(token_env, payload.token)
repo.register(
id=slug,
name=payload.name.strip(),
url=payload.url.strip(),
branch=(payload.branch or "").strip() or None,
token_env=token_env,
description=payload.description,
registered_by=user.get("email"),
curator_name=curator_name,
curator_email=curator_email,
)
_audit(
conn,
user["id"],
"marketplace.create",
slug,
{"url": payload.url, "has_token": bool(payload.token)},
)
return _to_response(repo.get(slug))
@router.patch("/{marketplace_id}", response_model=MarketplaceResponse)
async def update_marketplace(
marketplace_id: str,
payload: UpdateMarketplaceRequest,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = MarketplaceRegistryRepository(conn)
existing = repo.get(marketplace_id)
if not existing:
raise HTTPException(status_code=404, detail="marketplace not found")
# Start with the existing row; override fields the caller provided.
updated = {
"id": existing["id"],
"name": existing["name"],
"url": existing["url"],
"branch": existing.get("branch"),
"token_env": existing.get("token_env"),
"description": existing.get("description"),
"registered_by": existing.get("registered_by"),
"curator_name": existing.get("curator_name"),
"curator_email": existing.get("curator_email"),
}
changed: dict = {}
if payload.name is not None:
if not payload.name.strip():
raise HTTPException(status_code=400, detail="name cannot be empty")
updated["name"] = payload.name.strip()
changed["name"] = updated["name"]
if payload.url is not None:
url = payload.url.strip()
if not url.lower().startswith("https://"):
raise HTTPException(status_code=400, detail="url must start with https://")
updated["url"] = url
changed["url"] = url
if payload.branch is not None:
updated["branch"] = payload.branch.strip() or None
changed["branch"] = updated["branch"]
if payload.description is not None:
updated["description"] = payload.description
changed["description"] = payload.description
# Curator fields: empty-string treated as "no change" so an admin
# editing only the URL doesn't accidentally null out curator metadata.
if payload.curator_name is not None and payload.curator_name.strip():
updated["curator_name"] = payload.curator_name.strip()
changed["curator_name"] = updated["curator_name"]
if payload.curator_email is not None and payload.curator_email.strip():
if not _EMAIL_RE.match(payload.curator_email.strip()):
raise HTTPException(
status_code=400,
detail="curator_email is not a valid email address",
)
updated["curator_email"] = payload.curator_email.strip()
changed["curator_email"] = updated["curator_email"]
if payload.token is not None:
# None = untouched; "" = clear token_env binding; non-empty = rotate.
if payload.token == "":
if updated["token_env"]:
_persist_token(updated["token_env"], "")
updated["token_env"] = None
changed["token"] = "cleared"
else:
env_name = _token_env_name(marketplace_id)
_persist_token(env_name, payload.token)
updated["token_env"] = env_name
changed["token"] = "rotated"
# Mandatory curator on UPDATE too — legacy rows that pre-date v32 have
# NULL curator and survive the migration, but the moment an admin opens
# the edit modal they must fill the gap. The previous PATCH flow let
# URL/description tweaks persist indefinitely with OWNER_TODO_PLACEHOLDER
# showing on every /marketplace card. The DB column itself stays nullable
# so untouched legacy rows are not broken.
if not (updated.get("curator_name") or "").strip():
raise HTTPException(
status_code=400, detail="curator_name is required",
)
if not (updated.get("curator_email") or "").strip():
raise HTTPException(
status_code=400, detail="curator_email is required",
)
repo.register(
id=updated["id"],
name=updated["name"],
url=updated["url"],
branch=updated["branch"],
token_env=updated["token_env"],
description=updated["description"],
registered_by=updated["registered_by"],
curator_name=updated["curator_name"],
curator_email=updated["curator_email"],
)
_audit(conn, user["id"], "marketplace.update", marketplace_id, changed)
counts = MarketplacePluginsRepository(conn).count_by_marketplace()
return _to_response(repo.get(marketplace_id), counts.get(marketplace_id, 0))
@router.delete("/{marketplace_id}", status_code=204)
async def delete_marketplace(
marketplace_id: str,
purge: bool = False,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
repo = MarketplaceRegistryRepository(conn)
existing = repo.get(marketplace_id)
if not existing:
raise HTTPException(status_code=404, detail="marketplace not found")
# Also clear any overlay token binding so a re-created marketplace of the
# same slug doesn't accidentally inherit the old PAT.
if existing.get("token_env"):
_persist_token(existing["token_env"], "")
repo.unregister(marketplace_id)
# Drop cached plugin rows and any resource grants that reference plugins
# from this marketplace. resource_grants stores resource_id as
# "<marketplace_slug>/<plugin_name>" — match the slash-prefix via
# starts_with(), not LIKE: marketplace slugs may contain '_' (validated
# by [a-z0-9][a-z0-9_-]{0,63}) and LIKE would interpret it as a
# single-char wildcard, silently dropping grants from sibling
# marketplaces whose slug differs by exactly one character.
try:
conn.execute(
"DELETE FROM marketplace_plugins WHERE marketplace_id = ?",
[marketplace_id],
)
conn.execute(
"DELETE FROM resource_grants "
"WHERE resource_type = ? AND starts_with(resource_id, ? || '/')",
[ResourceType.MARKETPLACE_PLUGIN.value, marketplace_id],
)
# Drop user subscriptions to plugins from this marketplace so a
# re-registered slug doesn't inherit stale subscribe state.
from src.repositories.user_curated_subscriptions import (
UserCuratedSubscriptionsRepository,
)
UserCuratedSubscriptionsRepository(conn).delete_for_marketplace(
marketplace_id
)
except Exception as e:
logger.warning("cleanup for marketplace %s failed: %s", marketplace_id, e)
purged = False
if purge:
try:
purged = delete_marketplace_dir(marketplace_id)
except Exception as e:
logger.warning("delete_marketplace_dir(%s) failed: %s", marketplace_id, e)
_audit(
conn,
user["id"],
"marketplace.delete",
marketplace_id,
{"purged_disk": purged},
)
@router.post("/{marketplace_id}/sync")
async def trigger_sync(
marketplace_id: str,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
try:
result = sync_one(marketplace_id)
except MarketplaceNotFound:
raise HTTPException(status_code=404, detail="marketplace not found")
except (RuntimeError, ValueError) as e:
_audit(conn, user["id"], "marketplace.sync_failed", marketplace_id, {"error": str(e)})
raise HTTPException(status_code=500, detail=str(e))
_audit(
conn,
user["id"],
"marketplace.sync",
marketplace_id,
{"commit": result["commit"], "action": result["action"]},
)
return result
@router.post("/sync-all")
def trigger_sync_all(
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Sync every registered marketplace.
Wired up so the scheduler service can drive the nightly refresh over
HTTP. The previous implementation called ``src.marketplace.sync_marketplaces``
in-process from the scheduler container, which conflicted with the app's
long-lived ``system.duckdb`` handle (DuckDB allows only one writer per
file across processes). Routing through the app inherits the existing
connection without contention.
Declared ``def`` (not ``async def``) so FastAPI runs it in a thread
pool — :func:`sync_marketplaces` does blocking I/O (subprocess git
clones with ``GIT_TIMEOUT_SEC=300`` per repo, DuckDB writes, a
process-wide threading.Lock) and would freeze the event loop for the
duration of a bulk sync if it ran on the asyncio thread. Health
checks, login redirects, and every other concurrent request keep
serving while the bulk sync churns through the registry.
One audit row per call summarises the outcome — per-marketplace details
live in ``marketplace_registry`` and the per-call result payload below.
"""
result = sync_marketplaces()
# _audit appends "marketplace:" to the target id when writing the
# resource column. "_all" produces "marketplace:_all" — a stable,
# greppable sentinel for bulk-sync rows; the real per-marketplace
# commit/error breakdown is in the params payload.
_audit(
conn,
user["id"],
"marketplace.sync_all",
"_all",
{
"synced": [r.get("id") for r in result.get("synced", [])],
"errors": [{"id": e.get("id"), "error": e.get("error")} for e in result.get("errors", [])],
},
)
return result
# ---------------------------------------------------------------------------
# v39: system plugin mark / unmark
#
# A plugin marked as "system" is materialized into:
# * resource_grants — one row per existing user_groups row
# * user_plugin_optouts — one row per existing users row
# so the resolver's existing (rbac ∩ subscriptions) computation naturally
# includes it for every user. The UI then locks the corresponding controls
# (admin can't revoke per-group, user can't unsubscribe). Unmark flips the
# flag only — materialized rows survive so unmark cannot accidentally rip
# the plugin out of every user's stack mid-day; admin curates the cleanup
# afterwards via the standard resource_grants UI.
# ---------------------------------------------------------------------------
def _invalidate_marketplace_etag() -> None:
"""Drop the served-marketplace ETag cache so the next ZIP / git fetch
rehashes against the post-mark/post-unmark RBAC view. Best-effort —
the cache layer survives an import error during early startup."""
try:
from app.marketplace_server import packager
packager.invalidate_etag_cache()
except Exception:
logger.exception("failed to invalidate marketplace etag cache")
@router.post(
"/{marketplace_id}/plugins/{plugin_name}/system",
response_model=SystemFlagResponse,
)
def mark_plugin_system(
marketplace_id: str,
plugin_name: str,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Mark a plugin as system (mandatory for every user).
Idempotent — re-running a mark on an already-system plugin still
runs the fanout (cheap; ON CONFLICT DO NOTHING) so any user/group
that slipped past the creation hooks gets caught up.
"""
from src.repositories.resource_grants import ResourceGrantsRepository
from src.repositories.user_curated_subscriptions import (
UserCuratedSubscriptionsRepository,
)
plugin_row = conn.execute(
"SELECT 1 FROM marketplace_plugins WHERE marketplace_id = ? AND name = ?",
[marketplace_id, plugin_name],
).fetchone()
if not plugin_row:
raise HTTPException(status_code=404, detail="plugin not found")
resource_id = f"{marketplace_id}/{plugin_name}"
affected_groups = 0
affected_users = 0
conn.execute(
"UPDATE marketplace_plugins SET is_system = TRUE "
"WHERE marketplace_id = ? AND name = ?",
[marketplace_id, plugin_name],
)
# Pivot fanout: this plugin × every group / every user. We
# intentionally do NOT wrap the loop in a single BEGIN/COMMIT —
# DuckDB aborts the whole transaction on a ConstraintException, so
# an idempotent re-run (where most rows are duplicates) would die
# on the very first existing grant. Each row stands alone here:
# duplicate grants raise ConstraintException which we swallow
# (mirrors ``ON CONFLICT DO NOTHING`` semantics without DuckDB
# multi-target conflict resolution); duplicate subscriptions go
# through DuckDB's ON CONFLICT on the PK, which is well-supported.
# Partial-application is safe: a re-run completes the remainder.
groups = conn.execute("SELECT id FROM user_groups").fetchall()
grants_repo = ResourceGrantsRepository(conn)
actor_email = user.get("email") or user.get("id")
for (group_id,) in groups:
try:
grants_repo.create(
group_id=group_id,
resource_type=ResourceType.MARKETPLACE_PLUGIN.value,
resource_id=resource_id,
assigned_by=actor_email,
)
affected_groups += 1
except duckdb.ConstraintException:
continue
affected_users = UserCuratedSubscriptionsRepository(
conn,
).fanout_system_for_plugin(marketplace_id, plugin_name)
_audit(
conn,
user["id"],
"marketplace.plugin.mark_system",
f"{marketplace_id}/{plugin_name}",
{"affected_groups": affected_groups, "affected_users": affected_users},
)
_invalidate_marketplace_etag()
return SystemFlagResponse(
marketplace_id=marketplace_id,
plugin_name=plugin_name,
is_system=True,
affected_groups=affected_groups,
affected_users=affected_users,
)
@router.delete(
"/{marketplace_id}/plugins/{plugin_name}/system",
response_model=SystemFlagResponse,
)
def unmark_plugin_system(
marketplace_id: str,
plugin_name: str,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Flip ``is_system`` to FALSE. Materialized grants/subscriptions
survive — admin curates cleanup via the standard /admin/access UI
(which immediately unlocks the checkboxes for this plugin) and
users can unsubscribe normally on /marketplace?tab=my.
"""
plugin_row = conn.execute(
"SELECT 1 FROM marketplace_plugins WHERE marketplace_id = ? AND name = ?",
[marketplace_id, plugin_name],
).fetchone()
if not plugin_row:
raise HTTPException(status_code=404, detail="plugin not found")
conn.execute(
"UPDATE marketplace_plugins SET is_system = FALSE "
"WHERE marketplace_id = ? AND name = ?",
[marketplace_id, plugin_name],
)
_audit(
conn,
user["id"],
"marketplace.plugin.unmark_system",
f"{marketplace_id}/{plugin_name}",
None,
)
_invalidate_marketplace_etag()
return SystemFlagResponse(
marketplace_id=marketplace_id,
plugin_name=plugin_name,
is_system=False,
)