import { html, nothing } from 'lit'; import { LightElement } from '../lib/base.js'; // Connector marketplace — blueprint §14/§15. // // Admin-only: browses the remote feed of vetted connectors and *installs* one into // the local catalog. Installing is deliberately not activating — a global entry // still needs the admin to enable it with a key, a per-user one still needs each // user to activate it from the Connectors page. The feed only ever proposes; the // trust anchor stays on this box. // // Page shell from the shared `um-*` styling; the card grid, chips and filter bar // live in `css/connectors.css`. Colours come from the theme's own variables — no // literal colour belongs in here. const ADMIN_ID = 'admin'; async function jf(url, opts) { const res = await fetch(url, opts); if (!res.ok) throw new Error(await res.text() || `HTTP ${res.status}`); const ct = res.headers.get('content-type') || ''; return ct.includes('application/json') ? res.json() : null; } export class MarketplacePage extends LightElement { static get properties() { return { _open: { state: true }, _me: { state: true }, _cards: { state: true }, _feedErr: { state: true }, // feed unreachable — scoped, not page-level _error: { state: true }, _q: { state: true }, _scope: { state: true }, // 'all' | 'per_user' | 'global' _source: { state: true }, // 'all' | 'remote' | 'local_script' _installing: { state: true }, }; } constructor() { super(); this._open = false; this._q = ''; this._scope = 'all'; this._source = 'all'; this._reset(); } _reset() { this._me = null; this._cards = null; this._feedErr = null; this._error = null; this._installing = null; } connectedCallback() { super.connectedCallback(); window.addEventListener('llm-page-change', (e) => { this._open = e.detail.page === 'marketplace'; this.style.display = this._open ? 'flex' : 'none'; if (this._open) this._load(); }); } get _isAdmin() { return this._me?.role_id === ADMIN_ID; } async _load() { this._error = null; try { this._me = await jf('/api/auth/me'); if (!this._isAdmin) return; await this._loadFeed(false); } catch (e) { this._error = e.message; } } async _loadFeed(refresh) { this._feedErr = null; if (refresh) this._cards = null; try { const res = await jf(`/api/mcp/marketplace${refresh ? '?refresh=true' : ''}`); this._cards = res.connectors ?? []; } catch (e) { this._cards = []; this._feedErr = e.message; } } async _install(card) { const warn = card.source === 'local_script' ? `\n\nThis puts code on this box:\n • ${card.file_count} file(s), each verified against its SHA-256\n • installed into ./connectors/${card.id}/` : ''; if (!confirm(`Install "${card.name}" into the catalog?${warn}\n\nInstalling does not activate it.`)) return; this._installing = card.id; this._error = null; try { await jf('/api/mcp/marketplace/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: card.id }), }); await this._loadFeed(false); } catch (e) { this._error = e.message; } finally { this._installing = null; } } // Client-side: the feed is small, and one payload keeps typing instant. get _filtered() { const q = this._q.trim().toLowerCase(); return (this._cards ?? []).filter((c) => { if (this._scope !== 'all' && c.scope !== this._scope) return false; if (this._source !== 'all' && c.source !== this._source) return false; if (!q) return true; const hay = [c.name, c.id, c.user_description, ...(c.tags ?? []), ...(c.requires ?? [])] .filter(Boolean).join(' ').toLowerCase(); return hay.includes(q); }); } // The marketplace is a destination of the catalog's "Add connector" action, not a // place of its own — so it goes back where it came from. _goCatalog() { history.pushState({ page: 'catalog' }, '', '#catalog'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'catalog' } })); } render() { if (!this._open) return nothing; const loading = this._cards === null && !this._feedErr && !this._error; return html`
The marketplace is managed by the admin.
Connectors the admin has installed appear on the { e.preventDefault(); history.pushState({ page: 'connectors' }, '', '#connectors'); window.dispatchEvent(new CustomEvent('llm-page-change', { detail: { page: 'connectors' } })); }}>Connectors page.
Loading feed…
${total === 0 ? 'The feed is empty.' : 'No connector matches these filters.'}
${s}`)}