From 310e87fd50539fb4522a926eaac8c315205a20b5 Mon Sep 17 00:00:00 2001 From: Faxbot Agent Date: Fri, 26 Sep 2025 14:41:04 -0600 Subject: [PATCH 1/3] p4(ui): wire Marketplace to backend with disabled-state banner (ADMIN_MARKETPLACE_ENABLED=false) --- api/admin_ui/src/App.tsx | 2 +- .../src/components/PluginMarketplace.tsx | 72 +++++++++++++++++-- 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/api/admin_ui/src/App.tsx b/api/admin_ui/src/App.tsx index c835827b..7cd8fc92 100644 --- a/api/admin_ui/src/App.tsx +++ b/api/admin_ui/src/App.tsx @@ -832,7 +832,7 @@ function AppContent() { )} {toolsTab === 2 && } {toolsTab === 3 && } - {toolsTab === 4 && } + {toolsTab === 4 && } {toolsTab === 5 && } {toolsTab === 6 && ( >([]); + const [disabled, setDisabled] = useState(false); + + useEffect(() => { + let cancelled = false; + const run = async () => { + setLoading(true); + try { + const res = await fetch(`${window.location.origin}/admin/marketplace/plugins`, { + headers: { 'X-API-Key': (client as any).apiKey || '' }, + }); + if (res.status === 404) { + if (!cancelled) setDisabled(true); + return; + } + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + if (!cancelled) setPlugins(Array.isArray(data?.plugins) ? data.plugins : []); + } catch { + if (!cancelled) setDisabled(true); + } finally { + if (!cancelled) setLoading(false); + } + }; + run(); + return () => { cancelled = true; }; + }, [client]); return ( @@ -32,14 +63,42 @@ export default function PluginMarketplace(): JSX.Element { sx={{ mb: 3 }} /> + {disabled && ( + + Plugin Marketplace is disabled. Enable by setting ADMIN_MARKETPLACE_ENABLED=true. + {docsBase && ( + <> + {' '}Learn more at docs. + + )} + + )} + + {loading && } + {/* Empty state placeholder; results will be populated in later PRs */} - - Marketplace results will appear here. Use traits to gate provider‑specific UI. - + {plugins.length === 0 ? ( + + {disabled ? 'Marketplace is currently disabled.' : 'No plugins found.'} + + ) : ( + <> + {plugins + .filter(p => (query ? (p.name || '').toLowerCase().includes(query.toLowerCase()) : true)) + .map(p => ( + + {p.name || p.id} + {p.description && ( + {p.description} + )} + + ))} + + )} @@ -47,4 +106,3 @@ export default function PluginMarketplace(): JSX.Element { ); } - From ffbc6975e9639f692512fa672956286cd27f54c6 Mon Sep 17 00:00:00 2001 From: Faxbot Agent Date: Fri, 26 Sep 2025 14:45:59 -0600 Subject: [PATCH 2/3] p4(api): add POST /admin/marketplace/install stub (gated via ADMIN_MARKETPLACE_REMOTE_INSTALL_ENABLED) --- api/app/routers/admin_marketplace.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/api/app/routers/admin_marketplace.py b/api/app/routers/admin_marketplace.py index f4aff289..183e839e 100644 --- a/api/app/routers/admin_marketplace.py +++ b/api/app/routers/admin_marketplace.py @@ -37,3 +37,15 @@ def list_plugins(): # Placeholder: will be populated via trait‑aware registry in later PRs return {"plugins": []} + +@router.post("/install") +def install_plugin(payload: Dict[str, Any] | None = None): + """Remote install a plugin (disabled by default). + + Gate with ADMIN_MARKETPLACE_REMOTE_INSTALL_ENABLED=false by default. + """ + remote_enabled = os.getenv("ADMIN_MARKETPLACE_REMOTE_INSTALL_ENABLED", "false").lower() in {"1", "true", "yes"} + if not remote_enabled: + raise HTTPException(503, detail="Remote install disabled") + # Placeholder only; actual implementation will be added later + return {"ok": False, "message": "not_implemented"} From 0a0373fb5196d6e0610c29c3de0095f46620a834 Mon Sep 17 00:00:00 2001 From: Faxbot Agent Date: Fri, 26 Sep 2025 14:50:38 -0600 Subject: [PATCH 3/3] p5(p0): replace blocking file IO in sinch_service with anyio.open_file for async correctness --- api/app/sinch_service.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/api/app/sinch_service.py b/api/app/sinch_service.py index 41a5a60f..996f829f 100644 --- a/api/app/sinch_service.py +++ b/api/app/sinch_service.py @@ -3,6 +3,7 @@ import logging import os import time +import anyio from .config import settings, reload_settings @@ -80,7 +81,9 @@ async def upload_file(self, file_path: str) -> int: url = f"{base}/projects/{self.project_id}/files" try: async with httpx.AsyncClient(timeout=60.0) as client: - files = {"file": (os.path.basename(file_path), open(file_path, "rb"), "application/pdf")} + async with await anyio.open_file(file_path, 'rb') as f: + content = await f.read() + files = {"file": (os.path.basename(file_path), content, "application/pdf")} if self._use_oauth(): try: token = await self.get_access_token() @@ -154,20 +157,21 @@ async def send_fax_file(self, to_number: str, file_path: str) -> Dict[str, Any]: to = f"+{digits}" url = f"{self.base_url}/projects/{self.project_id}/faxes" async with httpx.AsyncClient(timeout=60.0) as client: - # httpx expects a mapping of field name → (filename, fileobj, content_type) + # httpx expects a mapping of field name → (filename, bytes, content_type) # For the additional text field, pass as data not files - with open(file_path, "rb") as fh: - files = {"file": (os.path.basename(file_path), fh, "application/pdf")} - data = {"to": to} - if self._use_oauth(): - try: - token = await self.get_access_token() - resp = await client.post(url, files=files, data=data, headers={"Authorization": f"Bearer {token}"}) - except Exception as e: - logger.warning("Sinch OAuth send_fax_file failed; falling back to Basic: %s", e) - resp = await client.post(url, files=files, data=data, auth=self._basic_auth()) - else: + async with await anyio.open_file(file_path, 'rb') as fh: + content = await fh.read() + files = {"file": (os.path.basename(file_path), content, "application/pdf")} + data = {"to": to} + if self._use_oauth(): + try: + token = await self.get_access_token() + resp = await client.post(url, files=files, data=data, headers={"Authorization": f"Bearer {token}"}) + except Exception as e: + logger.warning("Sinch OAuth send_fax_file failed; falling back to Basic: %s", e) resp = await client.post(url, files=files, data=data, auth=self._basic_auth()) + else: + resp = await client.post(url, files=files, data=data, auth=self._basic_auth()) if resp.status_code >= 400: raise RuntimeError(f"Sinch create fax error {resp.status_code}: {resp.text}") return resp.json()