* Add /marketplace browse page + Model B opt-in stack composition
New /marketplace browse surface unifies the curated marketplaces
(admin-managed git mirrors) and the community Flea Market behind
three tabs — Curated / Flea / My Stack — with per-tab category
filter, search across both sources with scope checkboxes, and
numeric pagination, all driven by URL query state. Plugin detail
at /marketplace/curated/<slug>/<plugin> and /marketplace/flea/<id>;
nested skill / agent detail at /marketplace/curated/<slug>/<plugin>/
{skill,agent}/<name> and the flea-side single-page detail.
Model B opt-in: an RBAC grant on a curated plugin is now only
*eligibility*. The user must click "Add to my stack" for it to
enter their served Claude Code marketplace. Composition flips
from (rbac ∖ opt_outs) ∪ store_installs to
(rbac ∩ subscriptions) ∪ store_installs. The legacy
user_plugin_optouts table is renamed user_curated_subscriptions
(schema v27) — same table shape, inverted semantic, repository
methods become subscribe / unsubscribe / is_subscribed.
UX vocabulary: Install → Add to my stack, Installed → In your
stack, card "Installed" badge → "In stack" (amber pill), tab
"My Subscriptions" → "My Stack". Bridges the two-step model
(server-side bookmark vs. on-laptop install) the previous label
hid. Click triggers an inline post-add hint panel under the
description with the agnes refresh-marketplace recipe + Copy
chip, dismissible per-browser via localStorage.
Per-tab info blocks above the filter row:
- Curated: trust signal — "Each plugin here has a named curator
accountable for it." (blue accent + See-all-curators link)
- Flea: open-shelf signal — "Anyone in the company can upload
here." (purple accent + Tips-for-sharing link)
- My Stack: personal-shelf orientation — "Your AI stack —
everything you've added." (slate accent, no link)
Tabs carry per-tab Heroicons (shield-check / building-storefront
/ rectangle-stack) tinted to match each tab's accent; flips white
when the tab is active for contrast.
Hero illustration anchored to the right of the blue hero panel
(absolute, 47% wide, behind the search row content). Hidden
under 900px viewport.
Action-row CTAs realigned to publication intent: curated
"How to add new content" → "Submit a plugin" (links to the
guide page); flea button removed since +Upload sits next to it.
Empty-state CTAs match. /marketplace/guide/{curated,flea}
routes now host publication-flow guide pages with placeholder
ledes — full copy to be authored separately.
Categories: Heroicons-based icons mapped per category in
src/category_icons.py (zero new dependencies; SVG path strings
inlined). Marketplace cards, filter pills, and detail pages
read from the same source.
API endpoints under /api/marketplace:
- GET /items per-tab listing (curated / flea / my)
- GET /categories per-tab non-zero counts
- GET /curated/{slug}/{plugin} plugin detail
- POST/DELETE /curated/{slug}/{plugin}/install subscribe toggle
- GET /curated/{slug}/{plugin}/{skill,agent}/{name} inner item
The tab=my branch reads directly from
user_curated_subscriptions ∪ user_store_installs (not
resolve_user_marketplace, which bundles flea skills/agents into
a single store-bundle synthetic entry useful for serving the
Claude Code marketplace ZIP/git but wrong for browsing where
each item should appear as its own card).
Detail pages: plugin detail surfaces inner skills/agents as
clickable nested cards; commands/hooks/MCPs render as plain
name lists. Skill/agent detail mirrors the plugin layout with
kind-tinted accents (skill = green, agent = purple), Description
+ Details sidebar, Files + Docs sections, and the "How to call
it" copy-able invocation chip showing /<plugin>:<inner-name>
exactly as Claude Code namespaces it post-install. Curated
nested has no install button — links back to the parent plugin.
Navbar: standalone "My AI Stack" relabelled "My Stack" and
points at /marketplace?tab=my; "Store" link removed (Store
flow is reachable via the Flea Market tab's +Upload button).
The standalone /my-ai-stack and /store routes still work for
old bookmarks.
Tests cover the new browse / categories / install / RBAC paths
under tests/test_marketplace_api.py; existing marketplace and
store tests updated for Model B (explicit subscribe in fixtures).
Schema bumped v26 → v27 with idempotent migration that wipes
existing user_plugin_optouts rows on flip and adds
marketplace_plugins.created_at with registered_at backfill.
* Fix v28 migration + post-rebase test fallout
v28 ALTER TABLE marketplace_plugins ADD COLUMN created_at conflicted with
_SYSTEM_SCHEMA's earlier CREATE that already includes the column on fresh
installs (test fixtures starting at any pre-v28 version trip on it).
Switch to ADD COLUMN IF NOT EXISTS — same idiom as the upstream v27
Keboola sync-strategy migration on the same ladder.
Two test patches needed after the rebase bumped SCHEMA_VERSION 27 → 28:
- test_keboola_v27_migration.py: test_schema_version_constant_is_27 was
pinning ==27. Loosened to >=27 (the test's purpose is to verify the
v27 Keboola migration, not to pin the current SCHEMA_VERSION).
- test_setup_page_unified.py: was monkeypatching resolve_allowed_plugins
but compute_default_agent_prompt now reads from resolve_user_marketplace
(Model B-aware). Stub the right function so the test exercises the
v28 served-set path.
* Harden curated skill/agent inner endpoints against path traversal
`_read_inner`, the `skill_dir` walk in `curated_skill_detail`, and the
`agent_path.stat` in `curated_agent_detail` joined URL path-params onto
`plugin_root` without verifying the resolved candidate stayed inside it.
Starlette's `[^/]+` on `{skill_name}` / `{agent_name}` blocks the direct
URL exploit (encoded `/` 404s before the handler), but a curator-planted
symlink inside a curated marketplace's git mirror could still dereference
outside the plugin tree on read.
Adds `_safe_join(plugin_root, *parts)` doing
`Path.resolve(strict=True)` + `relative_to(plugin_root.resolve())`, used
by all three call sites so the boundary is enforced once and consistently.
Tests cover the helper directly (normal path resolves, escaping `..`
returns None, escaping symlink returns None, missing file returns None)
plus an end-to-end check that the symlink case actually 404s on the
HTTP endpoint. Symlink tests skip on Windows where symlink creation
needs elevated permissions; they run on Linux CI.
---------
Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
183 lines
7.8 KiB
Python
183 lines
7.8 KiB
Python
"""Repository tests for store_entities, user_store_installs, user_plugin_optouts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def db_conn(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
from src.db import get_system_db
|
|
conn = get_system_db()
|
|
yield conn
|
|
conn.close()
|
|
|
|
|
|
def _make_user(conn, *, user_id: str, email: str) -> None:
|
|
from src.repositories.users import UserRepository
|
|
UserRepository(conn).create(id=user_id, email=email, name=email.split("@")[0])
|
|
|
|
|
|
def _create_entity(conn, *, owner_id: str, owner_username: str, name: str,
|
|
type_: str = "skill") -> str:
|
|
from src.repositories.store_entities import StoreEntitiesRepository
|
|
repo = StoreEntitiesRepository(conn)
|
|
eid = uuid.uuid4().hex
|
|
repo.create(
|
|
id=eid, owner_user_id=owner_id, owner_username=owner_username,
|
|
type=type_, name=name, description="desc", category=None,
|
|
version="abcd1234abcd1234", file_size=100,
|
|
)
|
|
return eid
|
|
|
|
|
|
class TestStoreEntities:
|
|
def test_create_and_get(self, db_conn):
|
|
from src.repositories.store_entities import StoreEntitiesRepository
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="my-skill")
|
|
e = StoreEntitiesRepository(db_conn).get(eid)
|
|
assert e is not None
|
|
assert e["name"] == "my-skill"
|
|
assert e["owner_username"] == "u1"
|
|
assert e["install_count"] == 0
|
|
assert e["doc_paths"] == []
|
|
|
|
def test_unique_owner_name(self, db_conn):
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="dup")
|
|
with pytest.raises(Exception):
|
|
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="dup")
|
|
|
|
def test_different_owners_same_name_ok(self, db_conn):
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
_make_user(db_conn, user_id="u2", email="u2@x")
|
|
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="shared")
|
|
_create_entity(db_conn, owner_id="u2", owner_username="u2", name="shared")
|
|
|
|
def test_list_with_filters(self, db_conn):
|
|
from src.repositories.store_entities import StoreEntitiesRepository
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="alpha", type_="skill")
|
|
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="beta", type_="agent")
|
|
_create_entity(db_conn, owner_id="u1", owner_username="u1", name="gamma", type_="plugin")
|
|
|
|
repo = StoreEntitiesRepository(db_conn)
|
|
items, total = repo.list(skip=0, limit=10)
|
|
assert total == 3
|
|
assert len(items) == 3
|
|
|
|
items, total = repo.list(skip=0, limit=10, type="skill")
|
|
assert total == 1 and items[0]["name"] == "alpha"
|
|
|
|
items, total = repo.list(skip=0, limit=10, search="bet")
|
|
assert total == 1 and items[0]["name"] == "beta"
|
|
|
|
def test_bump_install_count(self, db_conn):
|
|
from src.repositories.store_entities import StoreEntitiesRepository
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="x")
|
|
repo = StoreEntitiesRepository(db_conn)
|
|
repo.bump_install_count(eid, 1)
|
|
repo.bump_install_count(eid, 1)
|
|
assert repo.get(eid)["install_count"] == 2
|
|
repo.bump_install_count(eid, -1)
|
|
assert repo.get(eid)["install_count"] == 1
|
|
# Floor at zero
|
|
repo.bump_install_count(eid, -10)
|
|
assert repo.get(eid)["install_count"] == 0
|
|
|
|
|
|
class TestUserStoreInstalls:
|
|
def test_install_idempotent(self, db_conn):
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
_make_user(db_conn, user_id="u2", email="u2@x")
|
|
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="x")
|
|
repo = UserStoreInstallsRepository(db_conn)
|
|
assert repo.install("u2", eid) is True
|
|
assert repo.install("u2", eid) is False
|
|
assert repo.is_installed("u2", eid) is True
|
|
assert repo.installer_count(eid) == 1
|
|
|
|
def test_uninstall(self, db_conn):
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
_make_user(db_conn, user_id="u2", email="u2@x")
|
|
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="x")
|
|
repo = UserStoreInstallsRepository(db_conn)
|
|
repo.install("u2", eid)
|
|
assert repo.uninstall("u2", eid) is True
|
|
assert repo.uninstall("u2", eid) is False # already gone
|
|
|
|
def test_list_for_user_joins_entity(self, db_conn):
|
|
from src.repositories.user_store_installs import UserStoreInstallsRepository
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
_make_user(db_conn, user_id="u2", email="u2@x")
|
|
eid = _create_entity(db_conn, owner_id="u1", owner_username="u1", name="zzz")
|
|
repo = UserStoreInstallsRepository(db_conn)
|
|
repo.install("u2", eid)
|
|
rows = repo.list_for_user("u2")
|
|
assert len(rows) == 1
|
|
assert rows[0]["name"] == "zzz"
|
|
assert rows[0]["owner_username"] == "u1"
|
|
|
|
|
|
class TestUserCuratedSubscriptions:
|
|
"""Same physical table (user_plugin_optouts) as the legacy opt-out repo,
|
|
but with v27+ Model B semantics: presence = subscribed.
|
|
"""
|
|
|
|
def test_subscribe_unsubscribe(self, db_conn):
|
|
from src.repositories.user_curated_subscriptions import (
|
|
UserCuratedSubscriptionsRepository,
|
|
)
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
repo = UserCuratedSubscriptionsRepository(db_conn)
|
|
assert repo.subscribe("u1", "mkt", "p1") is True
|
|
assert repo.is_subscribed("u1", "mkt", "p1") is True
|
|
assert ("mkt", "p1") in repo.subscribed_set("u1")
|
|
assert repo.unsubscribe("u1", "mkt", "p1") is True
|
|
assert repo.is_subscribed("u1", "mkt", "p1") is False
|
|
assert repo.subscribed_set("u1") == set()
|
|
|
|
def test_subscribe_idempotent(self, db_conn):
|
|
from src.repositories.user_curated_subscriptions import (
|
|
UserCuratedSubscriptionsRepository,
|
|
)
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
repo = UserCuratedSubscriptionsRepository(db_conn)
|
|
assert repo.subscribe("u1", "mkt", "p1") is True
|
|
assert repo.subscribe("u1", "mkt", "p1") is False # second call: no-op
|
|
assert len(repo.list_for_user("u1")) == 1
|
|
|
|
def test_delete_for_plugin_drops_all_users(self, db_conn):
|
|
from src.repositories.user_curated_subscriptions import (
|
|
UserCuratedSubscriptionsRepository,
|
|
)
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
_make_user(db_conn, user_id="u2", email="u2@x")
|
|
repo = UserCuratedSubscriptionsRepository(db_conn)
|
|
repo.subscribe("u1", "mkt", "p1")
|
|
repo.subscribe("u2", "mkt", "p1")
|
|
repo.subscribe("u1", "mkt", "p2") # different plugin — survives
|
|
dropped = repo.delete_for_plugin("mkt", "p1")
|
|
assert dropped == 2
|
|
assert repo.subscribed_set("u1") == {("mkt", "p2")}
|
|
assert repo.subscribed_set("u2") == set()
|
|
|
|
def test_delete_for_marketplace(self, db_conn):
|
|
from src.repositories.user_curated_subscriptions import (
|
|
UserCuratedSubscriptionsRepository,
|
|
)
|
|
_make_user(db_conn, user_id="u1", email="u1@x")
|
|
repo = UserCuratedSubscriptionsRepository(db_conn)
|
|
repo.subscribe("u1", "mkt-a", "p1")
|
|
repo.subscribe("u1", "mkt-a", "p2")
|
|
repo.subscribe("u1", "mkt-b", "p1")
|
|
dropped = repo.delete_for_marketplace("mkt-a")
|
|
assert dropped == 2
|
|
assert repo.subscribed_set("u1") == {("mkt-b", "p1")}
|