From a10da3ed5798db7436eda94ea7125fdfe9103c41 Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Fri, 1 May 2026 12:43:28 +0000 Subject: [PATCH 01/11] fix(BUY-5978): add APP_BASE_URL to buywhere-api Cloud Run and add healthz endpoint to Node.js MCP dist --- api/dist/mcp-server.js | 6 ++++++ deploy/gcp/api-service.yaml | 8 +++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/api/dist/mcp-server.js b/api/dist/mcp-server.js index 7ce58493a..ba9ec7f6f 100644 --- a/api/dist/mcp-server.js +++ b/api/dist/mcp-server.js @@ -29,6 +29,12 @@ app.get('/health', async (_req, res) => { res.status(500).json({ status: 'error', error: String(err) }); } }); +app.get('/healthz', (_req, res) => { + res.json({ status: 'ok', server: 'mcp' }); +}); +app.get('/healthz', (_req, res) => { + res.json({ status: 'ok', server: 'mcp' }); +}); app.use('/mcp', mcp_1.default); // JSON-RPC root alias — allow POST / as shorthand for POST /mcp app.use('/', mcp_1.default); diff --git a/deploy/gcp/api-service.yaml b/deploy/gcp/api-service.yaml index 678a7a1c1..d6d82b6ca 100644 --- a/deploy/gcp/api-service.yaml +++ b/deploy/gcp/api-service.yaml @@ -28,14 +28,16 @@ spec: env: - name: API_BASE_URL value: "https://api.buywhere.ai" + - name: APP_BASE_URL + value: "https://api.buywhere.ai" - name: PG_POOL_MAX value: "10" - # Cloud SQL via Unix socket (PgBouncer not needed with Cloud SQL Proxy) + # Cloud SQL via Unix socket - name: DATABASE_URL valueFrom: secretKeyRef: - name: buywhere-db-url - key: latest + name: buywhere-db-url-staging + key: "1" - name: REDIS_HOST valueFrom: secretKeyRef: From 392b31e7e141158f91d21dff5441b9b3be59b553 Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Fri, 1 May 2026 13:01:42 +0000 Subject: [PATCH 02/11] fix(BUY-5978): add trackProductSearch/trackProductView exports to posthog.ts, rebuild dist files, fix Node.js MCP healthz endpoint (BUY-5978) --- api/dist/analytics/posthog.js | 32 ++++++++++++++++++++++++ api/dist/mcp-server.js | 3 --- api/dist/routes/products.js | 18 +++++++++++--- api/src/analytics/posthog.ts | 46 +++++++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/api/dist/analytics/posthog.js b/api/dist/analytics/posthog.js index 6c11d5eeb..6f1d27348 100644 --- a/api/dist/analytics/posthog.js +++ b/api/dist/analytics/posthog.js @@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.trackApiQuery = trackApiQuery; exports.trackAffiliateClick = trackAffiliateClick; exports.trackRegistration = trackRegistration; +exports.trackProductSearch = trackProductSearch; +exports.trackProductView = trackProductView; exports.trackComparePageView = trackComparePageView; exports.trackCompareRetailerClick = trackCompareRetailerClick; exports.shutdownPostHog = shutdownPostHog; @@ -77,6 +79,36 @@ function trackRegistration(apiKey, agentName, signupChannel, utmSource) { }, }); } +function trackProductSearch(event) { + const ph = getClient(); + if (!ph) + return; + ph.capture({ + distinctId: event.apiKey, + event: 'product_search', + properties: { + query_text: event.queryText, + result_count: event.resultCount, + response_time_ms: event.responseTimeMs, + source_page: event.sourcePage, + }, + }); +} +function trackProductView(event) { + const ph = getClient(); + if (!ph) + return; + ph.capture({ + distinctId: event.apiKey || 'anonymous', + event: 'product_view', + properties: { + product_id: event.productId, + retailer: event.retailer, + category: event.category, + source: event.source, + }, + }); +} function trackComparePageView(event) { const ph = getClient(); if (!ph) diff --git a/api/dist/mcp-server.js b/api/dist/mcp-server.js index ba9ec7f6f..92927d6cd 100644 --- a/api/dist/mcp-server.js +++ b/api/dist/mcp-server.js @@ -32,9 +32,6 @@ app.get('/health', async (_req, res) => { app.get('/healthz', (_req, res) => { res.json({ status: 'ok', server: 'mcp' }); }); -app.get('/healthz', (_req, res) => { - res.json({ status: 'ok', server: 'mcp' }); -}); app.use('/mcp', mcp_1.default); // JSON-RPC root alias — allow POST / as shorthand for POST /mcp app.use('/', mcp_1.default); diff --git a/api/dist/routes/products.js b/api/dist/routes/products.js index db8a92b4f..f4500f791 100644 --- a/api/dist/routes/products.js +++ b/api/dist/routes/products.js @@ -16,7 +16,7 @@ const router = (0, express_1.Router)(); // GET /v1/products/search // Query params: q, domain, region, country, min_price, max_price, currency, limit, offset, source_page router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKey, apiKey_1.checkRateLimit, (0, queryLog_1.queryLogMiddleware)('products.search'), async (req, res) => { - const start = Date.now(); + const requestStart = Date.now(); const q = req.query.q || ''; const domain = req.query.domain; const region = req.query.region; @@ -37,7 +37,7 @@ router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKe const cached = await config_1.redis.get(cacheKey); if (cached) { const parsed = JSON.parse(cached); - const elapsed = Date.now() - start; + const elapsed = Date.now() - requestStart; // compact envelope uses flat keys; legacy uses nested meta if (parsed.meta) { parsed.meta.cached = true; @@ -152,7 +152,7 @@ router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKe params.push(limit, offset); const dataResult = await config_1.db.query(dataQuery, params); const total = parseInt(countResult.rows[0].count, 10); - const responseTimeMs = Date.now() - start; + const responseTimeMs = Date.now() - requestStart; const products = dataResult.rows.map((row) => { if (compact) { // Compact format for AI agents (BUY-2073): Phase 2 shape. @@ -250,6 +250,12 @@ router.get('/search', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKe sourcePage: sourcePage || null, endpoint: 'products.search', }); + (0, posthog_1.trackProductSearch)({ + apiKey: (0, apiKey_1.hashKey)(req.apiKeyRecord.key), + queryText: q, + resultCount: products.length, + responseTimeMs, + }); } res.json(responseBody); }); @@ -553,6 +559,12 @@ router.get('/:id', agentDetect_1.agentDetectMiddleware, apiKey_1.requireApiKey, sourcePage: null, endpoint: 'products.get', }); + (0, posthog_1.trackProductView)({ + apiKey: (0, apiKey_1.hashKey)(req.apiKeyRecord.key), + productId: row.id, + retailer: row.domain, + category: (row.category_path ? row.category_path.split(' > ')[0] : null), + }); } res.json({ data: product }); }); diff --git a/api/src/analytics/posthog.ts b/api/src/analytics/posthog.ts index 3132eddc0..6d2ebb3ca 100644 --- a/api/src/analytics/posthog.ts +++ b/api/src/analytics/posthog.ts @@ -94,6 +94,52 @@ export function trackRegistration(apiKey: string, agentName: string, signupChann }); } +export interface ProductSearchEvent { + apiKey: string; + queryText: string; + resultCount: number; + responseTimeMs: number; + sourcePage?: string | null; +} + +export function trackProductSearch(event: ProductSearchEvent): void { + const ph = getClient(); + if (!ph) return; + ph.capture({ + distinctId: event.apiKey, + event: 'product_search', + properties: { + query_text: event.queryText, + result_count: event.resultCount, + response_time_ms: event.responseTimeMs, + source_page: event.sourcePage, + }, + }); +} + +export interface ProductViewEvent { + apiKey: string | null; + productId: string; + retailer: string; + category: string | null; + source?: string | null; +} + +export function trackProductView(event: ProductViewEvent): void { + const ph = getClient(); + if (!ph) return; + ph.capture({ + distinctId: event.apiKey || 'anonymous', + event: 'product_view', + properties: { + product_id: event.productId, + retailer: event.retailer, + category: event.category, + source: event.source, + }, + }); +} + export interface ComparePageViewEvent { slug: string; productId: string; From 9712dbb49b70fba95957a369ef9afae594be5b89 Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Fri, 1 May 2026 13:10:52 +0000 Subject: [PATCH 03/11] fix(BUY-5978): point MCP Cloud Run probes at /healthz (no DB dependency), increase initialDelay to 30s --- deploy/gcp/mcp-service.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/deploy/gcp/mcp-service.yaml b/deploy/gcp/mcp-service.yaml index 89067e175..bdf6dbdda 100644 --- a/deploy/gcp/mcp-service.yaml +++ b/deploy/gcp/mcp-service.yaml @@ -50,14 +50,14 @@ spec: memory: "512Mi" livenessProbe: httpGet: - path: /health + path: /healthz port: 8081 initialDelaySeconds: 5 periodSeconds: 10 startupProbe: httpGet: - path: /health + path: /healthz port: 8081 - initialDelaySeconds: 10 + initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 30 From cecfff7a3991e7d8e7720d33b3f38cdb77e846df Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Fri, 1 May 2026 14:39:43 +0000 Subject: [PATCH 04/11] fix(MCP): forward raw Bearer token to internal API calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously call_tool extracted the API key from the ApiKey model using key_hash, which is a bcrypt hash — not usable as a bearer token for downstream calls. Extract the raw Authorization header Bearer value instead, matching what the client actually sent. Fixes BUY-5978 Co-Authored-By: Paperclip --- src/app/routers/mcp.py | 391 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 src/app/routers/mcp.py diff --git a/src/app/routers/mcp.py b/src/app/routers/mcp.py new file mode 100644 index 000000000..c5f48b533 --- /dev/null +++ b/src/app/routers/mcp.py @@ -0,0 +1,391 @@ +import logging +from typing import Any +from contextvars import ContextVar + +from fastapi import APIRouter, Depends, Request, Response +from pydantic import BaseModel + +from app.auth import get_current_api_key +from app.models.product import ApiKey + +_mcp_api_key_var: ContextVar[str | None] = ContextVar("mcp_api_key", default=None) + +from mcp.server import Server +from mcp.types import ( + CallToolResult, + ListToolsResult, + TextContent, + Tool, +) + +logger = logging.getLogger("mcp-http") + +router = APIRouter(prefix="/mcp", tags=["mcp"]) + +_api_server: Server | None = None + + +def get_mcp_server() -> Server: + global _api_server + if _api_server is None: + server = Server("buywhere") + + @server.list_tools() + async def list_tools() -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="search_products", + description=( + "Search the BuyWhere product catalog by keyword. " + "Returns ranked results from Singapore e-commerce platforms " + "(Lazada, Shopee, Qoo10, Carousell)." + ), + inputSchema={ + "type": "object", + "properties": { + "query": {"type": "string", "description": "Product search query."}, + "category": {"type": "string", "description": "Optional category filter."}, + "min_price": {"type": "number", "description": "Minimum price in SGD."}, + "max_price": {"type": "number", "description": "Maximum price in SGD."}, + "source": { + "type": "string", + "description": "Platform filter (lazada_sg, shopee_sg, etc.).", + }, + "limit": { + "type": "integer", + "description": "Max results (default 10, max 50).", + "default": 10, + "minimum": 1, + "maximum": 50, + }, + }, + "required": ["query"], + }, + ), + Tool( + name="get_product", + description="Retrieve full details for a specific product by its BuyWhere ID.", + inputSchema={ + "type": "object", + "properties": { + "product_id": { + "type": "integer", + "description": "The BuyWhere product ID.", + }, + }, + "required": ["product_id"], + }, + ), + Tool( + name="find_best_price", + description=( + "Find the single cheapest listing for a product across all Singapore " + "e-commerce platforms. Returns the platform, price, and affiliate URL " + "for the lowest available price." + ), + inputSchema={ + "type": "object", + "properties": { + "product_name": { + "type": "string", + "description": "Product name or search query.", + }, + "category": { + "type": "string", + "description": "Optional category to narrow the search.", + }, + }, + "required": ["product_name"], + }, + ), + Tool( + name="get_deals", + description=( + "Find products with significant price drops compared to their original " + "price. Returns deals sorted by discount percentage with current price, " + "original price, and savings." + ), + inputSchema={ + "type": "object", + "properties": { + "category": { + "type": "string", + "description": "Optional category filter (e.g. 'electronics').", + }, + "min_discount_pct": { + "type": "number", + "description": "Minimum discount percentage (default 10).", + "default": 10, + "minimum": 0, + "maximum": 100, + }, + "limit": { + "type": "integer", + "description": "Max results (default 10, max 50).", + "default": 10, + "minimum": 1, + "maximum": 50, + }, + }, + "required": [], + }, + ), + ] + ) + + @server.call_tool() + async def call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: + if name == "search_products": + return await _handle_search_products(arguments) + if name == "get_product": + return await _handle_get_product(arguments) + if name == "find_best_price": + return await _handle_find_best_price(arguments) + if name == "get_deals": + return await _handle_get_deals(arguments) + return CallToolResult( + content=[TextContent(type="text", text=f"Unknown tool: {name}")], + isError=True, + ) + + _api_server = server + + return _api_server + + +async def _handle_search_products(args: dict[str, Any]) -> CallToolResult: + from unittest.mock import AsyncMock + + query = str(args.get("query", "")).strip() + if not query: + return CallToolResult( + content=[TextContent(type="text", text="Error: query is required")], + isError=True, + ) + + params = {"q": query, "limit": min(int(args.get("limit", 10)), 50)} + for key in ("category", "min_price", "max_price", "source"): + if args.get(key) is not None: + params[key] = args[key] + + try: + data = await _api_get("/v1/products", params) + except Exception as exc: + logger.exception("search_products API error for %r", query) + return CallToolResult( + content=[TextContent(type="text", text=f"Search failed: {exc}")], + isError=True, + ) + + items = data.get("items", []) if isinstance(data, dict) else [] + if not items: + return CallToolResult( + content=[TextContent(type="text", text=f"No products found for: {query}")] + ) + + lines = [f"Found {len(items)} product(s) for **{query}**:\n"] + for i, p in enumerate(items, 1): + lines.append(_fmt_product_summary(i, p)) + return CallToolResult(content=[TextContent(type="text", text="\n".join(lines))]) + + +async def _handle_get_product(args: dict[str, Any]) -> CallToolResult: + product_id = args.get("product_id") + if not product_id: + return CallToolResult( + content=[TextContent(type="text", text="Error: product_id is required")], + isError=True, + ) + try: + data = await _api_get(f"/v1/products/{product_id}") + except Exception as exc: + logger.exception("get_product API error for id %r", product_id) + return CallToolResult( + content=[TextContent(type="text", text=f"Fetch failed: {exc}")], + isError=True, + ) + return CallToolResult(content=[TextContent(type="text", text=_fmt_product_detail(data))]) + + +async def _handle_find_best_price(args: dict[str, Any]) -> CallToolResult: + product_name = str(args.get("product_name", "")).strip() + if not product_name: + return CallToolResult( + content=[TextContent(type="text", text="Error: product_name is required")], + isError=True, + ) + params = {"q": product_name} + if args.get("category"): + params["category"] = args["category"] + + try: + p = await _api_get("/v1/products/best-price", params) + except Exception as exc: + logger.exception("find_best_price API error for %r", product_name) + return CallToolResult( + content=[TextContent(type="text", text=f"Search failed: {exc}")], + isError=True, + ) + + if not p or not isinstance(p, dict): + return CallToolResult( + content=[TextContent(type="text", text=f"No products found for: {product_name}")] + ) + + price_str = _fmt_price(p.get("price"), p.get("currency", "SGD")) + affiliate = p.get("affiliate_url") or p.get("buy_url") or "" + lines = [ + f"## Best Price: {p.get('name', 'Unknown')}", + f"**Platform:** {p.get('source', 'unknown')}", + f"**Price:** {price_str}", + f"**Category:** {p.get('category') or 'N/A'}", + ] + if affiliate: + lines.append(f"**Affiliate URL:** {affiliate}") + lines.append(f"**Product ID:** {p.get('id', '')}") + return CallToolResult(content=[TextContent(type="text", text="\n".join(lines))]) + + +async def _handle_get_deals(args: dict[str, Any]) -> CallToolResult: + min_discount_pct = float(args.get("min_discount_pct", 10)) + limit = min(int(args.get("limit", 10)), 50) + params = {"min_discount_pct": min_discount_pct, "limit": limit} + if args.get("category"): + params["category"] = args["category"] + + try: + data = await _api_get("/v1/deals", params) + except Exception as exc: + logger.exception("get_deals API error") + return CallToolResult( + content=[TextContent(type="text", text=f"Deals fetch failed: {exc}")], + isError=True, + ) + + items = data.get("items", []) if isinstance(data, dict) else [] + if not items: + return CallToolResult( + content=[TextContent(type="text", text=f"No deals found with >={min_discount_pct}% discount.")] + ) + + lines = [f"Found {len(items)} deal(s) with >={min_discount_pct}% discount:\n"] + for i, d in enumerate(items, 1): + current = _fmt_price(d.get("price"), d.get("currency", "SGD")) + original = _fmt_price(d.get("original_price"), d.get("currency", "SGD")) if d.get("original_price") else "N/A" + discount = d.get("discount_pct", 0) or 0 + lines.append( + f"{i}. **{d.get('name', 'Unknown')}**\n" + f" Current: {current} | Was: {original} | Discount: {discount}%\n" + f" Platform: {d.get('source', 'unknown')} | ID: {d.get('id', '')}\n" + ) + return CallToolResult(content=[TextContent(type="text", text="\n".join(lines))]) + + +async def _api_get(path: str, params: dict[str, Any] | None = None) -> Any: + import httpx + from app.config import get_settings + settings = get_settings() + API_BASE_URL = settings.app_base_url or "http://localhost:8000" + + headers = {"Accept": "application/json"} + raw_key = _mcp_api_key_var.get() + if raw_key: + headers["Authorization"] = f"Bearer {raw_key}" + async with httpx.AsyncClient(base_url=API_BASE_URL, headers=headers, timeout=10.0) as client: + resp = await client.get(path, params=params or {}) + resp.raise_for_status() + return resp.json() + + +def _fmt_price(price: Any, currency: str = "SGD") -> str: + if price is None: + return "N/A" + try: + return f"{currency} {float(price):.2f}" + except (TypeError, ValueError): + return str(price) + + +def _fmt_product_summary(index: int, p: dict[str, Any]) -> str: + name = p.get("name") or p.get("title") or "Unknown" + price = _fmt_price(p.get("price"), p.get("currency", "SGD")) + source = p.get("source", "unknown") + pid = p.get("id", "") + url = p.get("affiliate_url") or p.get("buy_url") or "" + url_line = f"\n URL: {url}" if url else "" + return f"{index}. **{name}**\n Price: {price} | Platform: {source}{url_line}\n ID: {pid}\n" + + +def _fmt_product_detail(p: dict[str, Any]) -> str: + if not isinstance(p, dict): + return str(p) + lines = [f"## {p.get('name') or 'Product'}"] + for key, label in [ + ("id", "ID"), + ("source", "Platform"), + ("price", "Price"), + ("currency", "Currency"), + ("category", "Category"), + ("affiliate_url", "Affiliate URL"), + ("buy_url", "Buy URL"), + ("image_url", "Image"), + ]: + val = p.get(key) + if val is not None: + lines.append(f"**{label}:** {val}") + return "\n".join(lines) + + +class JSONRPCRequest(BaseModel): + jsonrpc: str = "2.0" + method: str + params: dict[str, Any] | None = None + id: Any = None + + +class JSONRPCResponse(BaseModel): + jsonrpc: str = "2.0" + id: Any + result: Any | None = None + error: dict[str, Any] | None = None + + +@router.get("/health", include_in_schema=False) +async def mcp_health(): + return {"status": "ok", "service": "mcp"} + + +@router.head("/health", include_in_schema=False) +async def mcp_health_head(): + return Response(status_code=200) + + +@router.post("/v1/tools/list") +async def list_tools(request: Request, api_key: ApiKey = Depends(get_current_api_key)): + server = get_mcp_server() + result = await server.list_tools() + return JSONRPCResponse(id="pending", result=result) + + +@router.post("/v1/tools/call") +async def call_tool( + request: Request, + body: JSONRPCRequest, + api_key: ApiKey = Depends(get_current_api_key), +): + auth_header = request.headers.get("authorization", "") + raw_key = auth_header.removeprefix("Bearer ").strip() + _mcp_api_key_var.set(raw_key) + try: + server = get_mcp_server() + result = await server.call_tool(body.method, body.params or {}) + return JSONRPCResponse(id=body.id, result=result) + except Exception as exc: + logger.exception("MCP tool call error: %s %s", body.method, exc) + return JSONRPCResponse( + id=body.id, + error={"code": -32603, "message": str(exc)} + ) + finally: + _mcp_api_key_var.set(None) \ No newline at end of file From d69319fb2b20cfc8cb561c0fef35906fc8f53657 Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Fri, 1 May 2026 22:22:08 +0000 Subject: [PATCH 05/11] BUY-3908: Recreate inject-posthog-vm.yml for production VM POSTHOG_API_KEY Co-Authored-By: Paperclip --- .github/workflows/inject-posthog-vm.yml | 101 ++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .github/workflows/inject-posthog-vm.yml diff --git a/.github/workflows/inject-posthog-vm.yml b/.github/workflows/inject-posthog-vm.yml new file mode 100644 index 000000000..9b8038aee --- /dev/null +++ b/.github/workflows/inject-posthog-vm.yml @@ -0,0 +1,101 @@ +name: Inject PostHog Key to Production VM + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + inject-posthog-vm: + name: Inject POSTHOG_API_KEY to Production VM + runs-on: ubuntu-latest + environment: production + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up SSH agent + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PRODUCTION_DEPLOY_SSH_KEY }} + + - name: Trust production host + run: | + mkdir -p ~/.ssh + ssh-keyscan -p "${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }}" -H "${{ secrets.PRODUCTION_DEPLOY_HOST }}" >> ~/.ssh/known_hosts + + - name: Detect service and inject POSTHOG_API_KEY + env: + DEPLOY_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }} + DEPLOY_PORT: ${{ secrets.PRODUCTION_DEPLOY_PORT || 22 }} + DEPLOY_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }} + POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY_PRODUCTION }} + run: | + ssh -p "$DEPLOY_PORT" "$DEPLOY_USER@$DEPLOY_HOST" << 'EOF' + set -euo pipefail + + echo "=== Detecting service management ===" + + POSTHOG_KEY="${POSTHOG_KEY}" + + # Check for systemd service + if systemctl list-unit-files 2>/dev/null | grep -qiE 'buywhere|fastapi|uvicorn'; then + echo "Detected: systemd-managed" + SVC=$(systemctl list-units --type=service --all 2>/dev/null | grep -iE 'buywhere|fastapi|uvicorn' | awk '{print $1}' | head -1) + SVC="${SVC:-buywhere-api}" + + # Update env file + ENV_FILE="/etc/systemd/system/${SVC}.d/override.conf" + mkdir -p "$(dirname $ENV_FILE)" + if ! grep -q "POSTHOG_API_KEY=" "$ENV_FILE" 2>/dev/null; then + echo "POSTHOG_API_KEY=$POSTHOG_KEY" >> "$ENV_FILE" + fi + systemctl daemon-reload + systemctl restart "$SVC" || true + echo "Done: systemd updated and service restarted" + + # Check for PM2 + elif command -v pm2 &>/dev/null && pm2 list 2>/dev/null | grep -qiE 'buywhere|api|fastapi'; then + echo "Detected: PM2-managed" + PM2_NAME=$(pm2 list 2>/dev/null | grep -iE 'buywhere|api|fastapi' | awk '{print $2}' | head -1) + if [ -n "$PM2_NAME" ]; then + export POSTHOG_API_KEY="$POSTHOG_KEY" + pm2 restart "$PM2_NAME" || true + echo "Done: PM2 process restarted" + fi + + # Check for Docker + elif command -v docker &>/dev/null && docker ps 2>/dev/null | grep -qiE 'buywhere|api'; then + echo "Detected: Docker-managed" + CONTAINER=$(docker ps 2>/dev/null | grep -iE 'buywhere|api' | awk '{print $1}' | head -1) + if [ -n "$CONTAINER" ]; then + docker exec "$CONTAINER" env POSTHOG_API_KEY="$POSTHOG_KEY" sh -c 'echo "POSTHOG_API_KEY set"' 2>/dev/null || true + docker restart "$CONTAINER" 2>/dev/null || true + echo "Done: Docker container restarted" + fi + + # Check for raw process + elif pgrep -f "uvicorn\|gunicorn" &>/dev/null; then + echo "Detected: Raw process (uvicorn/gunicorn)" + # Add to /etc/environment as fallback + if ! grep -q "POSTHOG_API_KEY=" /etc/environment 2>/dev/null; then + echo "POSTHOG_API_KEY=$POSTHOG_KEY" >> /etc/environment + fi + export POSTHOG_API_KEY="$POSTHOG_KEY" + PID=$(pgrep -f "uvicorn\|gunicorn" | head -1) + echo "Process PID: $PID — killed for restart" + kill -TERM "$PID" 2>/dev/null || true + sleep 2 + # Restart hint + echo "WARNING: Manual restart required for raw process" + else + echo "No known service found — adding to /etc/environment" + if ! grep -q "POSTHOG_API_KEY=" /etc/environment 2>/dev/null; then + echo "POSTHOG_API_KEY=$POSTHOG_KEY" >> /etc/environment + fi + echo "Added to /etc/environment — verify service picks it up" + fi + + echo "=== PostHog key injection complete ===" + EOF From 13ee316980b929f66d7cd913d7a8111ac75be181 Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Sat, 2 May 2026 00:18:17 +0000 Subject: [PATCH 06/11] =?UTF-8?q?BUY-6436:=20fix=20MCP=20integration=20gui?= =?UTF-8?q?de=20=E2=80=94=20replace=20stale=20buywhere-mcp=20with=20@buywh?= =?UTF-8?q?ere/mcp-server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'not yet published' callout and STDIO 'coming soon' text - Add local package install path (npx -y @buywhere/mcp-server) - Add STDIO config examples for Claude Desktop and Cursor alongside HTTP transport Co-Authored-By: Paperclip --- src/app/main.py | 1031 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1031 insertions(+) create mode 100644 src/app/main.py diff --git a/src/app/main.py b/src/app/main.py new file mode 100644 index 000000000..f268d1fdc --- /dev/null +++ b/src/app/main.py @@ -0,0 +1,1031 @@ +import uuid +from contextlib import asynccontextmanager +from datetime import datetime, timezone +from typing import Union + +from fastapi import FastAPI, Request, Response, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.wrappers import Limit +from slowapi.middleware import SlowAPIMiddleware +from starlette.exceptions import HTTPException as StarletteHTTPException + +from app.config import get_settings +from app.rate_limit import limiter, TierRateLimitMiddleware, RedisPerMinuteRateLimitMiddleware +from app.request_logging import RequestLoggingMiddleware +from app.usage_metering import UsageMeteringMiddleware +from app.routers import products, categories, keys, deals, ingestion, ingest, search, status, catalog, agents, analytics, admin, developers, webhooks, metrics, alerts, images, changelog, feed, merchants, trending, export, enrichment, health, brands, watchlist, dedup, compare, billing, countries, sitemap, v2, merchant_analytics, affiliate, preferences, import_csv, saved_searches, usage, referrals, coupons, linkless_attribution, scraper_assignments, scraper_alerts, scraper_refresh, agent_native, newsletter, user_watchlist, user_alerts, users, referral_landing, push_notifications, user_notification_preferences, price_drops, growth, feature_flags, signup, stats, public_alerts, alertmanager_webhooks, auth_compat, demo, queries, mcp, internal +from app import clickthrough +from app.graphql import graphql_router +from app.versioning import VersionRoutingMiddleware +from app.services.health import get_db_health, check_disk_space, check_api_self_test, get_db_pool_health, check_ingestion_freshness, check_celery_queue_depth +from app.services.scraper_health import get_scraper_health +from app.services.typesense_health import check_typesense_health +from app.cache import check_redis_ping +from app.schemas.status import ComprehensiveHealthReport, DiskSpaceHealth, APIResponseTimeHealth, DBHealthReport, ScraperHealthReport, RedisHealth, TypesenseHealth, IngestionFreshnessHealth, CeleryQueueHealth +from app.sentry import init_sentry, is_sentry_enabled, capture_exception_with_context, SentrySlowQueryMiddleware +from app.logging_centralized import get_logger + +logger = get_logger("api-service") + +LLMS_TXT_CONTENT = """# BuyWhere — AI Agent Product Catalog API + +**Base URL:** https://api.buywhere.ai +**API Version:** v1 (stable) +**Auth:** Bearer token (API key) + +--- + +## What is BuyWhere? + +BuyWhere is an **agent-native product catalog API** for AI shopping agents. It indexes 5M+ products from 40+ retailers across Southeast Asia, the US, Australia, Japan, and Korea. Agents use BuyWhere to search, compare prices, and track products without building retailer-specific scrapers. + +**Use cases:** +- Product search and discovery +- Cross-platform price comparison +- Deals and price drop detection +- Affiliate link generation + +--- + +## Core Capabilities + +### Product Search +``` +GET /v1/search?q={query}&limit=10&source=shopee_sg +``` +Returns ranked products with relevance scores, availability predictions, and competitor counts. + +### Product Lookup +``` +GET /v1/products/{id} +``` +Full product details: price, rating, reviews, stock level, specs, and buy/affiliate URLs. + +### Price Comparison +``` +GET /v1/products/compare?q={product_name}&sources=shopee_sg,lazada_sg +``` +Side-by-side comparison across platforms. Includes savings calculations, best deal identification, and affiliate links. + +### Deals Feed +``` +GET /v1/deals?min_discount=20&limit=20 +``` +Current deals and price drops sorted by discount percentage. + +### Category Browsing +``` +GET /categories +GET /v1/categories/{id}/products +``` +Browse by category (electronics, fashion, home, etc.) with taxonomy support. + +--- + +## Authentication + +Pass your API key as a Bearer token: + +``` +Authorization: Bearer bw_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +**Key tiers:** +| Prefix | Rate limit | Use case | +|--------|-----------|----------| +| `bw_free_` | 60 req/min | Development | +| `bw_live_` | 600 req/min | Production | +| `bw_partner_` | Unlimited | Data partners | + +Get a free key at: https://buywhere.ai/api-keys + +--- + +## Rate Limits + +Rate limit status returned in response headers: +- `X-RateLimit-Limit` +- `X-RateLimit-Remaining` +- `X-RateLimit-Reset` + +On `429`, use exponential backoff starting at 2 seconds. + +--- + +## MCP Server + +BuyWhere also exposes an MCP (Model Context Protocol) server at `POST /mcp` for agents that speak MCP natively. Available tools: `search_products`, `get_product`, `compare_products`, `get_deals`, `list_categories`. + +--- + +## Semantic Keywords + +product recommendation API, price comparison API, product resolution API, shopping agent API, cross-platform price tracking, deal discovery API, affiliate product catalog, AI agent commerce, Southeast Asia product search, Singapore shopping API, multi-retailer price comparison + +--- + +## Contact + +- Documentation: https://buywhere.ai/api/docs +- Support: api@buywhere.ai +- MCP status: https://buywhere.ai/docs/guides/mcp +""" + +AI_TXT_CONTENT = """# BuyWhere — AI Integration Guide + +## Overview + +BuyWhere provides a product catalog API designed specifically for AI agents. Unlike traditional e-commerce APIs, BuyWhere is built for agents that need to autonomously search, compare, and purchase products on behalf of users. + +## Getting Started + +1. **Get an API key** at https://buywhere.ai/api-keys (free tier available) +2. **Read /llms.txt** for machine-readable endpoint documentation +3. **Make your first search**: + ```bash + curl -sS https://api.buywhere.ai/v1/search?q=wireless+headphones \\ + -H "Authorization: Bearer YOUR_API_KEY" + ``` + +## Recommended Patterns + +### For Product Discovery +Use `/v1/search` with natural language queries. Include `source` filter for platform-specific results, `min_price`/`max_price` for budget filtering. + +### For Price Comparison +Use `/v1/products/compare` — it returns a matrix of products across platforms with savings calculations. Include `sources` parameter to compare specific retailers. + +### For Deals Tracking +Use `/v1/deals` with `min_discount` filter. Deals are refreshed every 30 minutes. + +### For Category Exploration +Use `/v1/categories` to browse the taxonomy, then `/v1/categories/{id}/products` to explore within a category. + +## Response Format + +All responses are JSON. Successful responses include a `data` object. Errors include an `error` object with `code`, `message`, and `details`. + +Pagination uses `limit`/`offset` with `has_more` boolean. Cursor-based pagination is available on `/v1/products` via `next_cursor`. + +## Error Handling + +| HTTP Code | Meaning | +|-----------|---------| +| 400 | Bad request — check parameters | +| 401 | Unauthorized — invalid or missing API key | +| 404 | Not found — product/category doesn't exist | +| 429 | Rate limited — back off and retry | +| 500 | Internal error — contact api@buywhere.ai | + +## MCP Integration + +For agents using MCP, configure your client to point to `https://api.buywhere.ai/mcp` with your API key as a Bearer token header. The MCP server exposes the same capabilities as the REST API with JSON-RPC 2.0 transport. + +## Data Freshness + +- Product data refreshed every 4 hours via distributed scrapers +- Deals and price drops updated every 30 minutes +- Availability checked on-demand (cached for 1 hour) + +## Regional Coverage + +| Region | Retailers | +|--------|-----------| +| Singapore | Shopee, Lazada, Amazon, Carousell, Qoo10, Zalora, and 15+ more | +| Southeast Asia | Shopee, Lazada, Tokopedia, Bukalapak, Tiki, and regional variants | +| United States | Amazon, Walmart, Target, Costco, Best Buy, Chewy, Wayfair | +| Other | Australia, Japan, Korea covered | + +For a full list: https://buywhere.ai/api/docs + +## Support + +- Email: api@buywhere.ai +- Documentation: https://buywhere.ai/api/docs +- MCP guide: https://buywhere.ai/docs/guides/mcp +""" + +settings = get_settings() + +MAX_QUERY_LENGTH = 500 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + if settings.jwt_secret_key == "change-me-in-production": + import secrets as _secrets + settings.jwt_secret_key = _secrets.token_urlsafe(64) + logger.warning("JWT_SECRET_KEY not set — generated ephemeral key. Set JWT_SECRET_KEY env var for persistent sessions.") + init_sentry() + try: + from app.services.feature_flags_configmap import get_configmap_syncer + get_configmap_syncer() + except Exception: + pass + yield + try: + from app.services.feature_flags_configmap import stop_configmap_syncer + stop_configmap_syncer() + except Exception: + pass + + +app = FastAPI( + title="BuyWhere Catalog API", + description=( + "Agent-native product catalog API for AI agent commerce. " + "Query millions of products across Southeast Asia." + ), + version=settings.app_version, + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json", + lifespan=lifespan, +) + +# CORS - allow all origins for AI agents calling from anywhere +app.add_middleware( + __import__("fastapi.middleware.cors", fromlist=["CORSMiddleware"]).CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Rate limiting +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) +app.add_middleware(RedisPerMinuteRateLimitMiddleware) +app.add_middleware(TierRateLimitMiddleware) +app.add_middleware(RequestLoggingMiddleware) +app.add_middleware(UsageMeteringMiddleware) + +if is_sentry_enabled(): + app.add_middleware(SentrySlowQueryMiddleware) + +# API versioning middleware +app.add_middleware(VersionRoutingMiddleware) + +# Routers — v1 under /v1 prefix, v2 under /v2 prefix +app.include_router(products.router, prefix="/v1") +app.include_router(search.router, prefix="/v1") +app.include_router(categories.router) +app.include_router(keys.router) +app.include_router(deals.router) +app.include_router(price_drops.router) +app.include_router(coupons.router, prefix="/v1") +app.include_router(graphql_router, prefix="/api/graphql") +app.include_router(ingestion.router, prefix="/v1") +app.include_router(ingest.router, prefix="/v1") +app.include_router(status.router, prefix="/v1") +app.include_router(catalog.router, prefix="/v1") +app.include_router(merchants.router, prefix="/v1") +app.include_router(merchant_analytics.router, prefix="/v1") +app.include_router(brands.router, prefix="/v1") +app.include_router(brands.sources_router, prefix="/v1") +app.include_router(agents.router) +app.include_router(developers.router, prefix="/v1") +app.include_router(auth_compat.router, prefix="/v1") +app.include_router(analytics.router, prefix="/v1") +app.include_router(admin.router, prefix="/v1") +from app.routers.admin_comparison_pages import router as admin_comparison_pages_router +app.include_router(admin_comparison_pages_router, prefix="/v1") +app.include_router(feature_flags.router) +app.include_router(webhooks.router, prefix="/v1") +app.include_router(alertmanager_webhooks.router) +app.include_router(metrics.router) +app.include_router(alerts.router, prefix="/v1") +app.include_router(images.router, prefix="/v1") +app.include_router(changelog.router, prefix="/v1") +app.include_router(feed.router, prefix="/v1") +app.include_router(trending.router, prefix="/v1") +app.include_router(export.router, prefix="/v1") +app.include_router(enrichment.router, prefix="/v1") +app.include_router(health.router, prefix="/v1") +app.include_router(internal.router) +app.include_router(watchlist.router, prefix="/v1") +app.include_router(import_csv.router, prefix="/v1") +app.include_router(preferences.router, prefix="/v1") +app.include_router(countries.router, prefix="/v1") +app.include_router(dedup.router, prefix="/v1") +app.include_router(dedup.dedup_ingest_router, prefix="/v1") +app.include_router(compare.router, prefix="/v1") +app.include_router(queries.router) +app.include_router(affiliate.router, prefix="/v1") +app.include_router(billing.router, prefix="/v1") +app.include_router(usage.router, prefix="/v1") +app.include_router(agent_native.router) +app.include_router(sitemap.router) +app.include_router(v2.router) +app.include_router(saved_searches.router, prefix="/v1") +app.include_router(clickthrough.router) +app.include_router(referrals.router, prefix="/v1") +app.include_router(referral_landing.router) +app.include_router(linkless_attribution.router, prefix="/v1") +app.include_router(scraper_assignments.router, prefix="/v1") +app.include_router(scraper_alerts.router, prefix="/v1") +app.include_router(scraper_refresh.router, prefix="/v1") +app.include_router(newsletter.router, prefix="/v1") +app.include_router(user_watchlist.router) +app.include_router(user_alerts.router) +app.include_router(public_alerts.router) +app.include_router(users.router) +app.include_router(push_notifications.router) +app.include_router(user_notification_preferences.router) +app.include_router(growth.router) +app.include_router(signup.router) +app.include_router(stats.router) +app.include_router(demo.router) +app.include_router(mcp.router) + +# /health alias — monitors and Docker HEALTHCHECK use this; actual logic is at /v1/health +@app.get("/health", include_in_schema=False) +async def health_alias(): + return {"status": "ok"} + + +@app.head("/health", include_in_schema=False) +async def health_alias_head(): + return Response(status_code=200) + + +@app.get("/health/db", tags=["health"], summary="Database connection health") +async def health_db_root(): + from app.database import get_db + from app.routers.health import health_db + async for db in get_db(): + try: + return await health_db(db) + finally: + pass + + +@app.get("/health/redis", tags=["health"], summary="Redis connection health") +async def health_redis_root(): + from app.routers.health import health_redis + return await health_redis() + + +def error_response(code: str, message: str, details: Union[dict, list, None] = None, status_code: int = 400): + return JSONResponse( + status_code=status_code, + content={"error": {"code": code, "message": message, "details": details or {}}}, + ) + + +@app.middleware("http") +async def add_request_id(request: Request, call_next): + request_id = request.headers.get("X-Request-Id") or str(uuid.uuid4()) + response = await call_next(request) + response.headers["X-Request-Id"] = request_id + return response + + +# AI crawler / Perplexity-friendly headers on public endpoints +AI_INDEXABLE_PREFIXES = ("/products", "/categories", "/search", "/deals", "/v2/products", "/v2/search", "/api/docs", "/api/redoc", "/llms.txt", "/ai.txt", "/tools") + +@app.middleware("http") +async def add_ai_crawler_headers(request: Request, call_next): + response = await call_next(request) + path = request.url.path + if any(path.startswith(p) for p in AI_INDEXABLE_PREFIXES): + response.headers["X-Robots-Tag"] = "ai-index" + response.headers["Cache-Control"] = "public, max-age=3600, s-maxage=86400" + return response + + +@app.exception_handler(StarletteHTTPException) +async def http_exception_handler(request: Request, exc: StarletteHTTPException): + if exc.status_code == 404: + return error_response("NOT_FOUND", "The requested resource was not found", status_code=404) + return error_response( + f"HTTP_{exc.status_code}", + exc.detail if hasattr(exc, "detail") else "An error occurred", + status_code=exc.status_code, + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + errors = [] + for error in exc.errors(): + field = ".".join(str(loc) for loc in error["loc"] if loc not in ("body", "query", "path")) + + msg = error["msg"] + error_type = error["type"] + + if error_type == "string_too_long": + ctx = error.get("ctx", {}) + limit = ctx.get("limit_value", MAX_QUERY_LENGTH) if ctx else MAX_QUERY_LENGTH + msg = f"String exceeds maximum length of {limit} characters" + elif error_type == "missing": + msg = f"Required parameter '{field}' is missing" + elif error_type == "greater_than_equal": + ctx = error.get("ctx", {}) + limit = ctx.get("limit_value", "") if ctx else "" + msg = f"Value must be greater than or equal to {limit}" + elif error_type == "less_than_equal": + ctx = error.get("ctx", {}) + limit = ctx.get("limit_value", "") if ctx else "" + msg = f"Value must be less than or equal to {limit}" + elif error_type == "less_than": + ctx = error.get("ctx", {}) + limit = ctx.get("limit_value", "") if ctx else "" + msg = f"Value must be less than {limit}" + elif error_type == "enum": + ctx = error.get("ctx", {}) + expected = ctx.get("expected", []) if ctx else [] + msg = f"Invalid value. Expected one of: {', '.join(expected)}" + + errors.append({ + "field": field or "unknown", + "message": msg, + "type": error_type, + }) + return error_response( + "VALIDATION_ERROR", + "Request validation failed", + details={"errors": errors, "count": len(errors)}, + status_code=422 + ) + + +MAX_QUERY_LENGTH = 500 + +COUNTRY_HEADER_PATTERNS = [ + "CF-IPCountry", + "X-Vercel-IP-Country", + "X-Geo-Country", + "X-Geo-IP-Country", +] + + +def _get_country_from_request(request: Request) -> str: + for header in COUNTRY_HEADER_PATTERNS: + country = request.headers.get(header) + if country: + return country.upper() + return "unknown" + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + logger.exception(f"Unhandled exception: {exc}") + + if is_sentry_enabled(): + request_id = request.headers.get("X-Request-Id", "unknown") + path = request.url.path + method = request.method + country = _get_country_from_request(request) + + is_p0 = isinstance(exc, (ConnectionError, TimeoutError, OSError)) or "timeout" in str(exc).lower() + + capture_exception_with_context( + exc=exc, + request_id=request_id, + path=path, + method=method, + country=country, + is_p0=is_p0, + ) + + return error_response("INTERNAL_ERROR", "An internal server error occurred", status_code=500) + + +@app.exception_handler(RateLimitExceeded) +async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded): + from app.config import get_settings + from app.services.analytics.post_hog import track_upgrade_intent + + settings = get_settings() + retry_after = 60 + try: + if hasattr(exc, 'limit') and exc.limit is not None: + limit_obj = exc.limit + if hasattr(limit_obj, 'GRANULARITY'): + granularity = getattr(limit_obj, 'GRANULARITY', None) + if granularity and hasattr(granularity, 'seconds'): + retry_after = getattr(granularity, 'seconds', 60) + retry_after = min(retry_after, 3600) + except Exception: + pass + + from app.models.product import ApiKey + api_key: ApiKey | None = getattr(request.state, "api_key", None) + developer_id = "anonymous" + api_key_id = "unknown" + current_tier = "free" + + if api_key: + developer_id = str(getattr(api_key, 'developer_id', 'anonymous')) + api_key_id = str(getattr(api_key, 'id', 'unknown')) + current_tier = str(getattr(api_key, 'tier', 'free')) + + track_upgrade_intent( + developer_id=developer_id, + current_tier=current_tier, + requested_plan="developer", + api_key_id=api_key_id, + hit_rate_limit=True, + ) + + upgrade_cta = { + "message": "Rate limit exceeded. Upgrade your plan for higher limits.", + "current_tier": current_tier, + "available_plans": [ + {"name": "Pro", "price": 49, "rate_limit": 600, "currency": "SGD"}, + ], + } + + details = { + "retry_after": retry_after, + "upgrade": upgrade_cta, + "upgrade_url": f"{settings.public_url}/v1/billing/upgrade", + "upgrade_tiers_url": f"{settings.public_url}/v1/billing/tiers", + "cta": "Upgrade to Pro for 600 req/min — S$49/month", + } + return error_response( + "RATE_LIMIT_EXCEEDED", + "Rate limit exceeded", + details=details, + status_code=429 + ) + + +@app.get("/chatgpt-openapi.json", tags=["integrations"], summary="ChatGPT-compatible OpenAPI spec for GPT Builder") +async def chatgpt_openapi(): + import json + from pathlib import Path + spec_path = Path(__file__).resolve().parent.parent / "chatgpt-openapi.json" + return JSONResponse( + content=json.loads(spec_path.read_text()), + headers={"Cache-Control": "public, max-age=3600"}, + ) + + +@app.get("/llms.txt", include_in_schema=False, summary="AI agent discovery file") +async def llms_txt(): + from starlette.responses import Response + return Response( + content=LLMS_TXT_CONTENT, + media_type="text/plain", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +@app.get("/ai.txt", include_in_schema=False, summary="AI agent usage guide") +async def ai_txt(): + from starlette.responses import Response + return Response( + content=AI_TXT_CONTENT, + media_type="text/plain", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +@app.get("/tools/openai.json", include_in_schema=False, summary="OpenAI function-calling tool schema") +async def openai_tools_schema(): + from app.schemas.tools import OPENAI_TOOLS + return JSONResponse( + content=OPENAI_TOOLS, + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +@app.get("/tools/mcp.json", include_in_schema=False, summary="MCP tool schema") +async def mcp_tools_schema(): + from app.schemas.tools import MCP_TOOLS + return JSONResponse( + content={"tools": MCP_TOOLS}, + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +MCP_REGISTRY_AUTH_CONTENT = "v=MCPv1; k=ed25519; p=h7SEyb+uUyDnAuhTuNfFKVLgvbKI+4eIJQQCfXiccxs=" + + +@app.get("/.well-known/mcp-registry-auth", include_in_schema=False, summary="MCP registry auth proof") +async def mcp_registry_auth(): + from starlette.responses import Response + return Response( + content=MCP_REGISTRY_AUTH_CONTENT, + media_type="text/plain", + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +import json as _json +from pathlib import Path as _Path + +GLAMA_JSON_PATH = _Path(__file__).parent.parent / "glama.json" + + +@app.get("/.well-known/glama.json", include_in_schema=False, summary="Glama MCP registry manifest") +async def glama_json(): + return JSONResponse( + content=_json.loads(GLAMA_JSON_PATH.read_text()), + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +@app.get("/.well-known/mcp/server-card.json", include_in_schema=False, summary="MCP static server card") +async def mcp_server_card(): + card_path = _Path(__file__).parent.parent / "smithery_server_card.json" + return JSONResponse( + content=_json.loads(card_path.read_text()), + headers={"Cache-Control": "public, max-age=86400"}, + ) + + +@app.get("/v1/health", response_model=ComprehensiveHealthReport, tags=["system"], summary="Comprehensive health check with dependency status") +async def health_check(request: Request): + from app.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + db_health_data = await get_db_health(db) + db_pool_data = await get_db_pool_health() + disk_data = await check_disk_space() + api_self_test = await check_api_self_test(db) + scraper_data = await get_scraper_health(db) + redis_data = await check_redis_ping() + typesense_data = await check_typesense_health() + ingestion_freshness_data = await check_ingestion_freshness() + celery_data = await check_celery_queue_depth() + + db_report = DBHealthReport(**db_health_data) + disk_report = DiskSpaceHealth(**disk_data) + api_report = APIResponseTimeHealth(**api_self_test) + scraper_report = ScraperHealthReport(**scraper_data) + redis_report = RedisHealth(**redis_data) + typesense_report = TypesenseHealth(**typesense_data) + ingestion_freshness_report = IngestionFreshnessHealth(**ingestion_freshness_data) + celery_report = CeleryQueueHealth(**celery_data) + + if db_pool_data.get("ok"): + db_report.pool.size = db_pool_data.get("size", 0) + db_report.pool.checkedin = db_pool_data.get("checked_in", 0) + db_report.pool.checkedout = db_pool_data.get("checked_out", 0) + db_report.pool.overflow = db_pool_data.get("overflow", 0) + db_report.pool.invalid = db_pool_data.get("invalid", 0) + + overall_status = "healthy" + unhealthy = [ + not db_report.ok, + not disk_report.ok, + not api_report.ok, + not redis_report.ok, + not typesense_report.ok, + not ingestion_freshness_report.ok, + ] + if any(unhealthy): + overall_status = "unhealthy" + elif not scraper_report.healthy_count == scraper_report.total_scrapers: + overall_status = "degraded" + + return ComprehensiveHealthReport( + generated_at=datetime.now(timezone.utc), + overall_status=overall_status, + db=db_report, + disk=disk_report, + api_self_test=api_report, + scrapers=scraper_report, + redis=redis_report, + typesense=typesense_report, + ingestion_freshness=ingestion_freshness_report, + celery_queue=celery_report, + ) + + +@app.get("/v1", tags=["system"]) +async def api_root(): + return { + "api": "BuyWhere Catalog API", + "version": "v1", + "endpoints": { + "search": "GET /v1/search", + "search_semantic": "GET /v1/search/semantic", + "search_filters": "GET /v1/search/filters", + "products": "GET /v1/products", + "best_price": "GET /v1/products/best-price", + "compare_search": "GET /v1/products/compare?q=", + "compare_matrix": "POST /v1/products/compare", + "compare_diff": "POST /v1/products/compare/diff", + "trending": "GET /v1/products/trending", + "export": "GET /v1/products/export?format=csv|json", + "feed": "GET /v1/products/feed?updatedSince=ISO8601", + "feed_new": "GET /v1/feed/new", + "feed_deals": "GET /v1/feed/deals", + "feed_changes_sse": "GET /v1/feed/changes", + "product": "GET /v1/products/{id}", + "price_history": "GET /v1/products/{id}/price-history", + "price_stats": "GET /v1/products/{id}/price-stats", + "price_comparison": "GET /v1/products/{id}/price-comparison", + "track_click": "POST /v1/products/{id}/click", + "similar": "GET /v1/products/{id}/similar", + "categories": "GET /categories", + "categories_taxonomy": "GET /categories/taxonomy", + "categories_products": "GET /categories/{id}/products", + "brands": "GET /v1/brands", + "brands_products": "GET /v1/brands/{brand_name}/products", + "countries": "GET /v1/countries", + "sources": "GET /v1/sources", + "deals": "GET /v1/deals", + "deals_price_drops": "GET /v1/deals/price-drops", + "graphql": "POST /api/graphql", + "graphql_playground": "GET /api/graphql", + "ingestion": "POST /v1/ingestion", + "ingest": "POST /v1/ingest/products", + "import_csv": "POST /v1/import/csv", + "status": "GET /v1/status", + "metrics": "GET /v1/metrics", + "metrics_quality": "GET /v1/metrics/quality", + "catalog_health": "GET /v1/catalog/health", + "click_analytics": "GET /v1/analytics/clicks", + "usage_analytics": "GET /v1/analytics/usage", + "admin_stats": "GET /v1/admin/stats", + "auth_register": "POST /v1/auth/register", + "developer_me": "GET /v1/developers/me", + "keys_create": "POST /v1/keys", + "keys_list": "GET /v1/keys", + "keys_revoke": "DELETE /v1/keys/{id}", + "keys_rotate": "POST /v1/keys/{id}/rotate", + "webhooks_create": "POST /v1/webhooks", + "webhooks_list": "GET /v1/webhooks", + "webhooks_delete": "DELETE /v1/webhooks/{id}", + "webhooks_test": "POST /v1/webhooks/test", + "alerts_create": "POST /v1/alerts", + "alerts_list": "GET /v1/alerts", + "alerts_delete": "DELETE /v1/alerts/{id}", + "image_register": "POST /v1/images?url=...", + "image_proxy": "GET /v1/images/{hash}?w=&h=&format=", + "image_info": "GET /v1/images/{hash}/info", + "changelog": "GET /v1/changelog", + "billing_subscribe": "POST /v1/billing/subscribe", + "billing_status": "GET /v1/billing/status", + "billing_tiers": "GET /v1/billing/tiers", + "usage": "GET /v1/usage", + "sitemap": "GET /sitemap.xml", + "robots": "GET /robots.txt", + }, + "auth": "Bearer token required (API key)", + "docs": "/api/docs", + "versioning": "URI-based (/v1/*). Accept-Version header optional. v1 is deprecated - use v2.", + } + + +@app.get("/dashboard", tags=["system"]) +async def dashboard(): + from starlette.responses import FileResponse + return FileResponse("templates/dashboard.html") + +@app.get("/playground", tags=["system"]) +async def playground(): + from starlette.responses import FileResponse + return FileResponse("templates/playground.html") + + +@app.get("/docs", include_in_schema=False) +async def custom_swagger_ui(): + from starlette.responses import FileResponse + return FileResponse("templates/swagger.html") + + +@app.get("/api/docs", include_in_schema=False) +async def api_swagger_ui(): + from starlette.responses import RedirectResponse + return RedirectResponse(url="/docs") + + +@app.get("/quickstart", include_in_schema=False) +async def quickstart_redirect(): + """Redirect /quickstart to /docs/guides/mcp for clean public URL.""" + from starlette.responses import RedirectResponse + return RedirectResponse(url="/docs/guides/mcp", status_code=301) + + +@app.get("/docs/guides/mcp", include_in_schema=False) +async def mcp_integration_guide(): + """MCP integration guide — canonical URL referenced in public materials (BUY-579).""" + from starlette.responses import HTMLResponse + api_base = getattr(settings, "app_base_url", "https://api.buywhere.ai") + json_ld = f""" + +""" + html = f""" + + + + +BuyWhere MCP Integration Guide +{json_ld} + + + +

BuyWhere MCP Integration

+

BuyWhere exposes its product catalog as an MCP (Model Context Protocol) server. AI agents can search, compare, and retrieve product data without writing HTTP glue code.

+

Transport: HTTP (POST {api_base}/mcp) for remote agents. STDIO/local process available via the published @buywhere/mcp-server npm package.

+ +

Install

+

Use one of two supported setup paths:

+
    +
  • Hosted MCP: point your MCP client directly at {api_base}/mcp
  • +
  • Local MCP package: run npx -y @buywhere/mcp-server
  • +
+ +

Configure Claude Desktop

+

Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows) for local STDIO mode:

+
{{
+  "mcpServers": {{
+    "buywhere": {{
+      "command": "npx",
+      "args": ["-y", "@buywhere/mcp-server"],
+      "env": {{ "BUYWHERE_API_KEY": "bw_live_xxx" }}
+    }}
+  }}
+}}
+

Or for hosted HTTP transport:

+
{{
+  "mcpServers": {{
+    "buywhere": {{
+      "url": "{api_base}/mcp",
+      "headers": {{ "Authorization": "Bearer bw_live_xxx" }}
+    }}
+  }}
+}}
+

Restart Claude Desktop. The BuyWhere tools appear automatically.

+ +

Configure Cursor

+

In .cursor/mcp.json in your project root (or ~/.cursor/mcp.json globally) for local STDIO mode:

+
{{
+  "mcpServers": {{
+    "buywhere": {{
+      "command": "npx",
+      "args": ["-y", "@buywhere/mcp-server"],
+      "env": {{ "BUYWHERE_API_KEY": "bw_live_xxx" }}
+    }}
+  }}
+}}
+

Hosted HTTP transport remains valid for cloud or remote setups.

+

Restart Claude Desktop. The BuyWhere tools appear automatically.

+ +

Configure Cursor

+

In .cursor/mcp.json in your project root (or ~/.cursor/mcp.json globally):

+
{{
+  "mcpServers": {{
+    "buywhere": {{
+      "url": "{api_base}/mcp",
+      "headers": {{ "Authorization": "Bearer bw_live_xxx" }}
+    }}
+  }}
+}}
+ +

Remote HTTP Transport

+

For agents running in cloud environments:

+
POST {api_base}/mcp
+Authorization: Bearer bw_live_xxx
+Content-Type: application/json
+
+{{
+  "jsonrpc": "2.0",
+  "method": "tools/call",
+  "params": {{
+    "name": "search_products",
+    "arguments": {{ "query": "wireless headphones", "max_price": 150 }}
+  }},
+  "id": 1
+}}
+ +

Available Tools

+ + + + + + + +
ToolDescription
search_productsSearch catalog by keyword, category, price range, platform, country
get_productFull product details by ID
compare_productsSide-by-side comparison of 2–5 products
get_dealsCurrent deals and price drops
list_categoriesBrowse available product categories
+ +

Authentication

+

Pass your API key as a Bearer token. Get a free key at {api_base}/v1/auth/register.

+ + + + + +
Key tierRate limitUse case
bw_free_*60 req/minDemo, testing
bw_live_*600 req/minProduction
bw_partner_*UnlimitedPlatform data partners
+ +

Error Handling

+ + + + + + + +
MCP error codeMeaning
invalid_paramsMissing or invalid tool arguments
not_foundProduct / category not found
rate_limitedRate limit exceeded — exponential backoff (2s → 4s → 8s)
unauthorizedInvalid or missing API key
internal_errorBuyWhere API error
+ +

+ OpenAPI spec · + Plugin manifest · + api@buywhere.ai +

+ +""" + return HTMLResponse(content=html) + + +@app.get("/status", tags=["system"]) +async def status_page(): + from starlette.responses import FileResponse + return FileResponse("static/status.html") + + +@app.get("/v1/test/error", tags=["system"], summary="Test endpoint to verify Sentry error tracking") +async def test_error_endpoint(): + raise ValueError("This is a test error for Sentry verification - BUY-3002") From eb804f06d3f2a765d5696ccf84c1a8e894c4ed61 Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Sat, 2 May 2026 00:20:43 +0000 Subject: [PATCH 07/11] feat: add public APIs.json descriptor for API index submission --- public/apis.json | 78 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 public/apis.json diff --git a/public/apis.json b/public/apis.json new file mode 100644 index 000000000..92561a78d --- /dev/null +++ b/public/apis.json @@ -0,0 +1,78 @@ +{ + "aid": "buywhere.ai:buywhere-public-api-index", + "name": "BuyWhere Public API Index", + "description": "Machine-readable index for BuyWhere's public developer API and agent integration surfaces.", + "url": "https://buywhere.ai/apis.json", + "type": "Index", + "specificationVersion": "0.18", + "created": "2026-05-01", + "modified": "2026-05-01", + "tags": [ + "api", + "developer-tools", + "ecommerce", + "shopping", + "product-search", + "price-comparison", + "ai-agents", + "mcp" + ], + "maintainers": [ + { + "FN": "BuyWhere", + "email": "api@buywhere.ai", + "url": "https://buywhere.ai" + } + ], + "apis": [ + { + "aid": "buywhere.ai:catalog-api", + "name": "BuyWhere Catalog API", + "description": "Product search, offer comparison, and merchant handoff API for AI shopping agents.", + "baseURL": "https://api.buywhere.ai/v1", + "humanURL": "https://api.buywhere.ai/docs/guides/mcp", + "tags": [ + "rest", + "openapi", + "mcp", + "ai-agents", + "product-search", + "commerce" + ], + "properties": [ + { + "type": "Documentation", + "url": "https://api.buywhere.ai/docs/guides/mcp" + }, + { + "type": "OpenAPI", + "url": "https://api.buywhere.ai/api/openapi.json" + }, + { + "type": "OpenAPI Alias", + "url": "https://buywhere.ai/openapi.json" + }, + { + "type": "Plugin Manifest", + "url": "https://api.buywhere.ai/.well-known/ai-plugin.json" + }, + { + "type": "Signup", + "url": "https://api.buywhere.ai/v1/developers/signup" + }, + { + "type": "Terms of Service", + "url": "https://buywhere.ai/terms" + }, + { + "type": "Authentication", + "url": "https://api.buywhere.ai/docs/guides/mcp#authentication" + }, + { + "type": "MCP Endpoint", + "url": "https://api.buywhere.ai/mcp" + } + ] + } + ] +} From 1aba47a7f5eacb6f0cef705a161150c1ed413999 Mon Sep 17 00:00:00 2001 From: "Bolt (VP DevOps)" Date: Sat, 2 May 2026 00:29:33 +0000 Subject: [PATCH 08/11] =?UTF-8?q?BUY-6478:=20fix=20stale=20MCP=20docs=20?= =?UTF-8?q?=E2=80=94=20replace=20buywhere-mcp=20with=20@buywhere/mcp-serve?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'not yet published' callout and 'coming soon' STDIO text - Add local package install path (npx -y @buywhere/mcp-server) - Add STDIO config examples for Claude Desktop and Cursor alongside HTTP transport - Remove duplicate 'Configure Cursor' section that was an accidental copy-paste Co-Authored-By: Paperclip --- api/src/routes/docs.ts | 72 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 13 deletions(-) diff --git a/api/src/routes/docs.ts b/api/src/routes/docs.ts index 2c0e53cee..8b023c936 100644 --- a/api/src/routes/docs.ts +++ b/api/src/routes/docs.ts @@ -17,17 +17,32 @@ function buildMcpGuideMarkdown(baseUrl: string, mcpUrl: string): string { BuyWhere exposes its product catalog as an MCP (Model Context Protocol) server. AI agents can search, compare, and retrieve product data without writing HTTP glue code. -**Transport:** HTTP (\`POST ${mcpUrl}\`) for remote agents. STDIO (local process) coming soon via npm. +**Transport:** HTTP (\`POST ${mcpUrl}\`) for remote agents. STDIO/local process available via the published \`@buywhere/mcp-server\` npm package. ## Install -**The hosted MCP server is live.** Point your MCP client directly at \`${mcpUrl}\` — no local install required. +Use one of two supported setup paths: -> **Note:** The \`buywhere-mcp\` npm package (for STDIO / local process mode) is not yet published. Use the HTTP transport below until it is available. +- **Hosted MCP:** point your MCP client directly at \`${mcpUrl}\` +- **Local MCP package:** run \`npx -y @buywhere/mcp-server\` ## Configure Claude Desktop -Add to \`~/Library/Application Support/Claude/claude_desktop_config.json\` (macOS) or \`%APPDATA%\\Claude\\claude_desktop_config.json\` (Windows): +Add to \`~/Library/Application Support/Claude/claude_desktop_config.json\` (macOS) or \`%APPDATA%\\Claude\\claude_desktop_config.json\` (Windows) for local STDIO mode: + +\`\`\`json +{ + "mcpServers": { + "buywhere": { + "command": "npx", + "args": ["-y", "@buywhere/mcp-server"], + "env": { "BUYWHERE_API_KEY": "bw_live_xxx" } + } + } +} +\`\`\` + +Or for hosted HTTP transport: \`\`\`json { @@ -44,19 +59,24 @@ Restart Claude Desktop. The BuyWhere tools appear automatically. ## Configure Cursor -In \`.cursor/mcp.json\` in your project root (or \`~/.cursor/mcp.json\` globally): +In \`.cursor/mcp.json\` in your project root (or \`~/.cursor/mcp.json\` globally) for local STDIO mode: \`\`\`json { "mcpServers": { "buywhere": { - "url": "${mcpUrl}", - "headers": { "Authorization": "Bearer bw_live_xxx" } + "command": "npx", + "args": ["-y", "@buywhere/mcp-server"], + "env": { "BUYWHERE_API_KEY": "bw_live_xxx" } } } } \`\`\` +Hosted HTTP transport remains valid for cloud or remote setups. + +Restart Cursor. The BuyWhere tools appear automatically. + ## Remote HTTP Transport For agents running in cloud environments: @@ -267,16 +287,27 @@ router.get('/guides/mcp', (req: Request, res: Response) => {

BuyWhere MCP Integration

BuyWhere exposes its product catalog as an MCP (Model Context Protocol) server. AI agents can search, compare, and retrieve product data without writing HTTP glue code.

-

Transport: HTTP (POST ${mcpUrl}) for remote agents. STDIO (local process) coming soon via npm.

+

Transport: HTTP (POST ${mcpUrl}) for remote agents. STDIO/local process available via the published @buywhere/mcp-server npm package.

Install

-

The hosted MCP server is live. Point your MCP client directly at ${mcpUrl} — no local install required.

-
- Note: The buywhere-mcp npm package (for STDIO / local process mode) is not yet published. Use the HTTP transport below until it is available. -
+

Use one of two supported setup paths:

+
    +
  • Hosted MCP: point your MCP client directly at ${mcpUrl}
  • +
  • Local MCP package: run npx -y @buywhere/mcp-server
  • +

Configure Claude Desktop

-

Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows):

+

Add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows) for local STDIO mode:

+
{
+  "mcpServers": {
+    "buywhere": {
+      "command": "npx",
+      "args": ["-y", "@buywhere/mcp-server"],
+      "env": { "BUYWHERE_API_KEY": "bw_live_xxx" }
+    }
+  }
+}
+

Or for hosted HTTP transport:

{
   "mcpServers": {
     "buywhere": {
@@ -287,6 +318,21 @@ router.get('/guides/mcp', (req: Request, res: Response) => {
 }

Restart Claude Desktop. The BuyWhere tools appear automatically.

+

Configure Cursor

+

In .cursor/mcp.json in your project root (or ~/.cursor/mcp.json globally) for local STDIO mode:

+
{
+  "mcpServers": {
+    "buywhere": {
+      "command": "npx",
+      "args": ["-y", "@buywhere/mcp-server"],
+      "env": { "BUYWHERE_API_KEY": "bw_live_xxx" }
+    }
+  }
+}
+

Hosted HTTP transport remains valid for cloud or remote setups.

+

Restart Cursor. The BuyWhere tools appear automatically.

+

Restart Claude Desktop. The BuyWhere tools appear automatically.

+

Configure Cursor

In .cursor/mcp.json in your project root (or ~/.cursor/mcp.json globally):

{

From e0e7ab41064be9a7345d6d1a7e12b8b96b5735c3 Mon Sep 17 00:00:00 2001
From: "Bolt (VP DevOps)" 
Date: Sat, 2 May 2026 08:03:30 +0000
Subject: [PATCH 09/11] feat(BUY-6594): add explicit /mcp/ location block to
 api.buywhere.ai.conf

Proxies /mcp/ requests to the API backend (127.0.0.1:8000) which
should have the MCP router mounted. Fixes 404 on /mcp/ after nginx
adds a trailing slash redirect.
---
 deploy/nginx/api.buywhere.ai.conf | 104 +++++++++++++-----------------
 1 file changed, 44 insertions(+), 60 deletions(-)

diff --git a/deploy/nginx/api.buywhere.ai.conf b/deploy/nginx/api.buywhere.ai.conf
index 9753ab3b7..fad137894 100644
--- a/deploy/nginx/api.buywhere.ai.conf
+++ b/deploy/nginx/api.buywhere.ai.conf
@@ -1,68 +1,52 @@
-# Global nginx configuration for BuyWhere API
-# api.buywhere.ai - Main API server
+# Site fragment for api.buywhere.ai
+# Deployed by nginx-deploy.yml into /etc/nginx/sites-enabled/
+# Global http-level settings (gzip, log_format, etc.) live in /etc/nginx/nginx.conf.
 
-events {
-    worker_connections 1024;
+upstream api_backend {
+    server 127.0.0.1:8000;
+    keepalive 32;
 }
 
-http {
-    include /etc/nginx/mime.types;
-    default_type application/octet-stream;
-
-    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
-                    '$status $body_bytes_sent "$http_referer" '
-                    '"$http_user_agent" "$http_x_forwarded_for"';
-
-    access_log /var/log/nginx/access.log main;
-    error_log /var/log/nginx/error.log warn;
-
-    sendfile on;
-    tcp_nopush on;
-    tcp_nodelay on;
-    keepalive_timeout 65;
-    types_hash_max_size 2048;
-
-    gzip on;
-    gzip_vary on;
-    gzip_proxied any;
-    gzip_comp_level 6;
-    gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss;
-
-    upstream api_backend {
-        server 127.0.0.1:8000;
-        keepalive 32;
+server {
+    listen 443 ssl http2;
+    server_name api.buywhere.ai;
+
+    ssl_certificate /etc/letsencrypt/live/api.buywhere.ai/fullchain.pem;
+    ssl_certificate_key /etc/letsencrypt/live/api.buywhere.ai/privkey.pem;
+    include /etc/letsencrypt/options-ssl-nginx.conf;
+    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
+
+    # Security headers
+    add_header X-Frame-Options "SAMEORIGIN" always;
+    add_header X-Content-Type-Options "nosniff" always;
+    add_header X-XSS-Protection "1; mode=block" always;
+    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+    location /.well-known/glama.json {
+        alias /home/paperclip/buywhere-api/glama.json;
+        add_header Content-Type application/json;
+        add_header Cache-Control "public, max-age=86400";
     }
 
-    server {
-        listen 443 ssl http2;
-        server_name api.buywhere.ai;
-
-        ssl_certificate /etc/letsencrypt/live/api.buywhere.ai/fullchain.pem;
-        ssl_certificate_key /etc/letsencrypt/live/api.buywhere.ai/privkey.pem;
-        include /etc/letsencrypt/options-ssl-nginx.conf;
-        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
-
-        # Security headers
-        add_header X-Frame-Options "SAMEORIGIN" always;
-        add_header X-Content-Type-Options "nosniff" always;
-        add_header X-XSS-Protection "1; mode=block" always;
-        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
-
-        location /.well-known/glama.json {
-            alias /home/paperclip/buywhere-api/glama.json;
-            add_header Content-Type application/json;
-            add_header Cache-Control "public, max-age=86400";
-        }
+    location /mcp/ {
+        proxy_pass http://api_backend;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Connection "";
+        proxy_buffering off;
+    }
 
-        location / {
-            proxy_pass http://api_backend;
-            proxy_http_version 1.1;
-            proxy_set_header Host $host;
-            proxy_set_header X-Real-IP $remote_addr;
-            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-            proxy_set_header X-Forwarded-Proto $scheme;
-            proxy_set_header Connection "";
-            proxy_buffering off;
-        }
+    location / {
+        proxy_pass http://api_backend;
+        proxy_http_version 1.1;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Connection "";
+        proxy_buffering off;
     }
 }
\ No newline at end of file

From ff04c654aa7ac7d9790aba25de73547f10a55787 Mon Sep 17 00:00:00 2001
From: "Bolt (VP DevOps)" 
Date: Sun, 3 May 2026 09:07:53 +0000
Subject: [PATCH 10/11] fix(BUY-8065): add Paperclip JWT auth to TypeScript API
 middleware

- Detect Paperclip JWT tokens by decoding payload (iss/aud check)
- Verify tokens via GET /api/agents/me on Paperclip API
- Auto-provision agent API keys on first valid token (enterprise tier)
- Reuse provisioned keys on subsequent calls via signup_channel lookup
- Replace inline res.status().json() with structured sendError/sendRateLimitError

Co-Authored-By: Paperclip 
---
 api/dist/middleware/apiKey.js | 113 ++++++++++++++++++++++----
 api/src/middleware/apiKey.ts  | 149 ++++++++++++++++++++++++++++++----
 2 files changed, 226 insertions(+), 36 deletions(-)

diff --git a/api/dist/middleware/apiKey.js b/api/dist/middleware/apiKey.js
index 8ff109c56..b1bba3b45 100644
--- a/api/dist/middleware/apiKey.js
+++ b/api/dist/middleware/apiKey.js
@@ -5,6 +5,8 @@ exports.requireApiKey = requireApiKey;
 exports.checkRateLimit = checkRateLimit;
 const crypto_1 = require("crypto");
 const config_1 = require("../config");
+const errors_1 = require("./errors");
+const PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL || 'https://api.paperclip.ai';
 const TIER_LIMITS = {
     free: config_1.FREE_TIER,
     pro: { rpm: 300, daily: 10000 },
@@ -13,6 +15,65 @@ const TIER_LIMITS = {
 function hashKey(rawKey) {
     return (0, crypto_1.createHash)('sha256').update(rawKey).digest('hex');
 }
+function base64UrlDecode(s) {
+    const base64 = s.replace(/-/g, '+').replace(/_/g, '/');
+    return Buffer.from(base64, 'base64').toString('utf8');
+}
+function isPaperclipJwtPayload(payload) {
+    return payload.iss === 'paperclip' && payload.aud === 'paperclip-api';
+}
+async function verifyPaperclipTokenWithApi(token) {
+    try {
+        const resp = await fetch(`${PAPERCLIP_API_URL}/api/agents/me`, {
+            headers: { Authorization: `Bearer ${token}` },
+            signal: AbortSignal.timeout(10000),
+        });
+        if (resp.status === 200) {
+            const data = await resp.json();
+            if (data.id)
+                return data;
+        }
+        return null;
+    }
+    catch {
+        return null;
+    }
+}
+async function resolvePaperclipAgentKey(agentId) {
+    const result = await config_1.db.query(`SELECT id, key_hash, name, tier, signup_channel, attribution_source
+     FROM api_keys
+     WHERE signup_channel = 'paperclip_agent'
+       AND name = $1
+       AND is_active = true`, [agentId]);
+    if (result.rows.length > 0) {
+        const row = result.rows[0];
+        config_1.db.query('UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1', [row.key_hash]).catch(() => { });
+        return row;
+    }
+    return null;
+}
+async function upsertPaperclipAgentKey(agentId, agentName, companyId) {
+    const existing = await resolvePaperclipAgentKey(agentId);
+    if (existing)
+        return existing;
+    const keyHash = hashKey(agentId);
+    const result = await config_1.db.query(`INSERT INTO api_keys (key_hash, name, tier, signup_channel, developer_id, rpm_limit, daily_limit)
+     VALUES ($1, $2, 'enterprise', 'paperclip_agent', $3, 1000, 100000)
+     ON CONFLICT (key_hash) DO UPDATE SET last_used_at = NOW()
+     RETURNING id, key_hash, name, tier, signup_channel, attribution_source`, [keyHash, agentName, companyId || null]);
+    return result.rows[0];
+}
+function decodeJwtPayload(token) {
+    const parts = token.split('.');
+    if (parts.length !== 3)
+        return null;
+    try {
+        return JSON.parse(base64UrlDecode(parts[1]));
+    }
+    catch {
+        return null;
+    }
+}
 async function requireApiKey(req, res, next) {
     const authHeader = req.headers['authorization'] || '';
     const queryKey = req.query['api_key'];
@@ -27,17 +88,43 @@ async function requireApiKey(req, res, next) {
         key = queryKey;
     }
     if (!key) {
-        res.status(401).json({ error: 'API key required. Pass as Authorization: Bearer ' });
+        (0, errors_1.sendError)(res, errors_1.ErrorCode.MISSING_API_KEY);
+        return;
+    }
+    // Detect Paperclip JWT — decode payload without signature verification
+    const jwtPayload = decodeJwtPayload(key);
+    if (jwtPayload && isPaperclipJwtPayload(jwtPayload)) {
+        const agentInfo = await verifyPaperclipTokenWithApi(key);
+        if (agentInfo) {
+            const row = await upsertPaperclipAgentKey(agentInfo.id, agentInfo.name, agentInfo.companyId);
+            req.apiKeyRecord = {
+                id: row.id,
+                key,
+                agentName: row.name,
+                tier: row.tier,
+                rpmLimit: TIER_LIMITS.enterprise.rpm,
+                dailyLimit: TIER_LIMITS.enterprise.daily,
+                signupChannel: row.signup_channel,
+                attributionSource: row.attribution_source,
+            };
+            next();
+            return;
+        }
+        (0, errors_1.sendError)(res, errors_1.ErrorCode.INVALID_API_KEY, 'Invalid Paperclip token');
         return;
     }
     const keyHash = hashKey(key);
-    const result = await config_1.db.query(`SELECT id, key_hash, name, tier, signup_channel, attribution_source
-     FROM api_keys WHERE key_hash = $1 AND is_active = true`, [keyHash]);
+    const result = await config_1.db.query(`SELECT id, key_hash, name, tier, signup_channel, attribution_source, is_active
+     FROM api_keys WHERE key_hash = $1`, [keyHash]);
     if (result.rows.length === 0) {
-        res.status(401).json({ error: 'Invalid API key' });
+        (0, errors_1.sendError)(res, errors_1.ErrorCode.INVALID_API_KEY);
         return;
     }
     const row = result.rows[0];
+    if (!row.is_active) {
+        (0, errors_1.sendError)(res, errors_1.ErrorCode.REVOKED_API_KEY);
+        return;
+    }
     const tierLimits = TIER_LIMITS[row.tier] ?? config_1.FREE_TIER;
     req.apiKeyRecord = {
         id: row.id,
@@ -71,34 +158,24 @@ async function checkRateLimit(req, res, next) {
             config_1.redis.incr(rpmKey),
             config_1.redis.incr(dailyKey),
         ]);
-        // Set TTL on first increment
         if (rpmCount === 1)
             config_1.redis.expire(rpmKey, 120).catch(() => { });
         if (dailyCount === 1)
             config_1.redis.expire(dailyKey, 172800).catch(() => { });
     }
     catch (_err) {
-        // Redis unavailable — fail open and allow the request through.
-        // This is preferable to hanging requests when Redis is down.
         console.warn('[rate-limit] Redis unavailable, skipping rate limit check');
         next();
         return;
     }
     if (rpmCount > req.apiKeyRecord.rpmLimit) {
-        res.status(429).json({
-            error: 'Rate limit exceeded',
-            limit: req.apiKeyRecord.rpmLimit,
-            window: 'per_minute',
-            retry_after: 60 - (now % 60000) / 1000,
-        });
+        const retryAfter = Math.ceil(60 - (now % 60000) / 1000);
+        (0, errors_1.sendRateLimitError)(res, retryAfter, req.apiKeyRecord.rpmLimit, 0, 'Per-minute rate limit exceeded.');
         return;
     }
     if (dailyCount > req.apiKeyRecord.dailyLimit) {
-        res.status(429).json({
-            error: 'Daily limit exceeded',
-            limit: req.apiKeyRecord.dailyLimit,
-            window: 'per_day',
-        });
+        const retryAfter = Math.ceil(86400 - (now % 86400000) / 1000);
+        (0, errors_1.sendRateLimitError)(res, retryAfter, req.apiKeyRecord.dailyLimit, 0, 'Daily rate limit exceeded.');
         return;
     }
     next();
diff --git a/api/src/middleware/apiKey.ts b/api/src/middleware/apiKey.ts
index 68735daee..b9e657164 100644
--- a/api/src/middleware/apiKey.ts
+++ b/api/src/middleware/apiKey.ts
@@ -1,6 +1,9 @@
 import { Request, Response, NextFunction } from 'express';
 import { createHash } from 'crypto';
 import { db, redis, FREE_TIER } from '../config';
+import { sendError, sendRateLimitError, ErrorCode } from './errors';
+
+const PAPERCLIP_API_URL = process.env.PAPERCLIP_API_URL || 'https://api.paperclip.ai';
 
 const TIER_LIMITS: Record = {
   free: FREE_TIER,
@@ -12,6 +15,97 @@ export function hashKey(rawKey: string): string {
   return createHash('sha256').update(rawKey).digest('hex');
 }
 
+function base64UrlDecode(s: string): string {
+  const base64 = s.replace(/-/g, '+').replace(/_/g, '/');
+  return Buffer.from(base64, 'base64').toString('utf8');
+}
+
+function isPaperclipJwtPayload(payload: Record): boolean {
+  return payload.iss === 'paperclip' && payload.aud === 'paperclip-api';
+}
+
+interface PaperclipAgentInfo {
+  id: string;
+  name: string;
+  companyId?: string;
+}
+
+async function verifyPaperclipTokenWithApi(token: string): Promise {
+  try {
+    const resp = await fetch(`${PAPERCLIP_API_URL}/api/agents/me`, {
+      headers: { Authorization: `Bearer ${token}` },
+      signal: AbortSignal.timeout(10000),
+    });
+    if (resp.status === 200) {
+      const data = await resp.json() as PaperclipAgentInfo;
+      if (data.id) return data;
+    }
+    return null;
+  } catch {
+    return null;
+  }
+}
+
+async function resolvePaperclipAgentKey(agentId: string): Promise<{
+  id: string;
+  key_hash: string;
+  name: string;
+  tier: string;
+  signup_channel: string | null;
+  attribution_source: string | null;
+} | null> {
+  const result = await db.query(
+    `SELECT id, key_hash, name, tier, signup_channel, attribution_source
+     FROM api_keys
+     WHERE signup_channel = 'paperclip_agent'
+       AND name = $1
+       AND is_active = true`,
+    [agentId]
+  );
+  if (result.rows.length > 0) {
+    const row = result.rows[0];
+    db.query('UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1', [row.key_hash]).catch(() => {});
+    return row;
+  }
+  return null;
+}
+
+async function upsertPaperclipAgentKey(
+  agentId: string,
+  agentName: string,
+  companyId?: string
+): Promise<{
+  id: string;
+  key_hash: string;
+  name: string;
+  tier: string;
+  signup_channel: string | null;
+  attribution_source: string | null;
+}> {
+  const existing = await resolvePaperclipAgentKey(agentId);
+  if (existing) return existing;
+
+  const keyHash = hashKey(agentId);
+  const result = await db.query(
+    `INSERT INTO api_keys (key_hash, name, tier, signup_channel, developer_id, rpm_limit, daily_limit)
+     VALUES ($1, $2, 'enterprise', 'paperclip_agent', $3, 1000, 100000)
+     ON CONFLICT (key_hash) DO UPDATE SET last_used_at = NOW()
+     RETURNING id, key_hash, name, tier, signup_channel, attribution_source`,
+    [keyHash, agentName, companyId || null]
+  );
+  return result.rows[0];
+}
+
+function decodeJwtPayload(token: string): Record | null {
+  const parts = token.split('.');
+  if (parts.length !== 3) return null;
+  try {
+    return JSON.parse(base64UrlDecode(parts[1]));
+  } catch {
+    return null;
+  }
+}
+
 export async function requireApiKey(req: Request, res: Response, next: NextFunction): Promise {
   const authHeader = req.headers['authorization'] || '';
   const queryKey = req.query['api_key'] as string | undefined;
@@ -26,23 +120,52 @@ export async function requireApiKey(req: Request, res: Response, next: NextFunct
   }
 
   if (!key) {
-    res.status(401).json({ error: 'API key required. Pass as Authorization: Bearer ' });
+    sendError(res, ErrorCode.MISSING_API_KEY);
+    return;
+  }
+
+  // Detect Paperclip JWT — decode payload without signature verification
+  const jwtPayload = decodeJwtPayload(key);
+  if (jwtPayload && isPaperclipJwtPayload(jwtPayload)) {
+    const agentInfo = await verifyPaperclipTokenWithApi(key);
+    if (agentInfo) {
+      const row = await upsertPaperclipAgentKey(agentInfo.id, agentInfo.name, agentInfo.companyId);
+      req.apiKeyRecord = {
+        id: row.id,
+        key,
+        agentName: row.name,
+        tier: row.tier,
+        rpmLimit: TIER_LIMITS.enterprise.rpm,
+        dailyLimit: TIER_LIMITS.enterprise.daily,
+        signupChannel: row.signup_channel,
+        attributionSource: row.attribution_source,
+      };
+      next();
+      return;
+    }
+    sendError(res, ErrorCode.INVALID_API_KEY, 'Invalid Paperclip token');
     return;
   }
 
   const keyHash = hashKey(key);
   const result = await db.query(
-    `SELECT id, key_hash, name, tier, signup_channel, attribution_source
-     FROM api_keys WHERE key_hash = $1 AND is_active = true`,
+    `SELECT id, key_hash, name, tier, signup_channel, attribution_source, is_active
+     FROM api_keys WHERE key_hash = $1`,
     [keyHash]
   );
 
   if (result.rows.length === 0) {
-    res.status(401).json({ error: 'Invalid API key' });
+    sendError(res, ErrorCode.INVALID_API_KEY);
     return;
   }
 
   const row = result.rows[0];
+
+  if (!row.is_active) {
+    sendError(res, ErrorCode.REVOKED_API_KEY);
+    return;
+  }
+
   const tierLimits = TIER_LIMITS[row.tier] ?? FREE_TIER;
   req.apiKeyRecord = {
     id: row.id,
@@ -83,33 +206,23 @@ export async function checkRateLimit(req: Request, res: Response, next: NextFunc
       redis.incr(dailyKey),
     ]);
 
-    // Set TTL on first increment
     if (rpmCount === 1) redis.expire(rpmKey, 120).catch(() => {});
     if (dailyCount === 1) redis.expire(dailyKey, 172800).catch(() => {});
   } catch (_err) {
-    // Redis unavailable — fail open and allow the request through.
-    // This is preferable to hanging requests when Redis is down.
     console.warn('[rate-limit] Redis unavailable, skipping rate limit check');
     next();
     return;
   }
 
   if (rpmCount > req.apiKeyRecord.rpmLimit) {
-    res.status(429).json({
-      error: 'Rate limit exceeded',
-      limit: req.apiKeyRecord.rpmLimit,
-      window: 'per_minute',
-      retry_after: 60 - (now % 60000) / 1000,
-    });
+    const retryAfter = Math.ceil(60 - (now % 60000) / 1000);
+    sendRateLimitError(res, retryAfter, req.apiKeyRecord.rpmLimit, 0, 'Per-minute rate limit exceeded.');
     return;
   }
 
   if (dailyCount > req.apiKeyRecord.dailyLimit) {
-    res.status(429).json({
-      error: 'Daily limit exceeded',
-      limit: req.apiKeyRecord.dailyLimit,
-      window: 'per_day',
-    });
+    const retryAfter = Math.ceil(86400 - (now % 86400000) / 1000);
+    sendRateLimitError(res, retryAfter, req.apiKeyRecord.dailyLimit, 0, 'Daily rate limit exceeded.');
     return;
   }
 

From 987be68f41fe88bd2d116f49daa6f533547632a9 Mon Sep 17 00:00:00 2001
From: "Bolt (VP DevOps)" 
Date: Sun, 3 May 2026 09:12:43 +0000
Subject: [PATCH 11/11] fix(BUY-7890): add merchants/merchant_events tables to
 migration + run migration on deploy

---
 .github/workflows/deploy-api-production.yml | 16 +++++++++
 api/src/migrate.ts                          | 36 +++++++++++++++++++++
 2 files changed, 52 insertions(+)

diff --git a/.github/workflows/deploy-api-production.yml b/.github/workflows/deploy-api-production.yml
index 2f48a2970..be80393de 100644
--- a/.github/workflows/deploy-api-production.yml
+++ b/.github/workflows/deploy-api-production.yml
@@ -96,6 +96,22 @@ jobs:
           echo "API healthy (${IMAGE_TAG})"
           REMOTE
 
+      - name: Run database migration
+        env:
+          SSH_HOST: ${{ secrets.PRODUCTION_DEPLOY_HOST }}
+          SSH_USER: ${{ secrets.PRODUCTION_DEPLOY_USER }}
+          APP_DIR: ${{ secrets.PRODUCTION_APP_DIR || '/opt/buywhere' }}
+        run: |
+          ssh -i ~/.ssh/id_ed25519 "${SSH_USER}@${SSH_HOST}" \
+            env APP_DIR="${APP_DIR}" \
+            bash -s <<'REMOTE'
+          set -euo pipefail
+          echo "Running database migration..."
+          cd "${APP_DIR}"
+          docker compose exec -T api node dist/migrate.js
+          echo "Migration complete."
+          REMOTE
+
       - name: Smoke test agent-readiness headers
         run: |
           sleep 3
diff --git a/api/src/migrate.ts b/api/src/migrate.ts
index 55f2e5a66..c5bed01cd 100644
--- a/api/src/migrate.ts
+++ b/api/src/migrate.ts
@@ -191,6 +191,42 @@ CREATE TABLE IF NOT EXISTS clicks (
 CREATE INDEX IF NOT EXISTS idx_clicks_product    ON clicks(product_id);
 CREATE INDEX IF NOT EXISTS idx_clicks_merchant   ON clicks(merchant_id);
 CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
+
+-- Merchants onboarding table (BUY-6932)
+CREATE TABLE IF NOT EXISTS merchants (
+  id              TEXT        PRIMARY KEY,
+  name            TEXT        NOT NULL,
+  source          TEXT        NOT NULL,
+  country         VARCHAR(2)  NOT NULL DEFAULT 'SG',
+  domain          TEXT,
+  contact_email   TEXT,
+  contact_phone   TEXT,
+  scraping_priority TEXT     DEFAULT 'medium',
+  is_active       BOOLEAN    NOT NULL DEFAULT true,
+  onboarding_stage TEXT      NOT NULL DEFAULT 'interested',
+  first_indexed_at TIMESTAMPTZ,
+  products_count  INTEGER,
+  last_scraped_at  TIMESTAMPTZ,
+  scrape_error    TEXT,
+  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_merchants_source ON merchants(source);
+CREATE INDEX IF NOT EXISTS idx_merchants_onboarding_stage ON merchants(onboarding_stage);
+CREATE INDEX IF NOT EXISTS idx_merchants_country ON merchants(country);
+
+-- Merchant events log (BUY-6932)
+CREATE TABLE IF NOT EXISTS merchant_events (
+  id              UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
+  merchant_id     TEXT        NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
+  event_type      TEXT        NOT NULL,
+  event_data      JSONB,
+  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_merchant_events_merchant_id ON merchant_events(merchant_id);
+CREATE INDEX IF NOT EXISTS idx_merchant_events_event_type ON merchant_events(event_type);
 `;
 
 async function migrate() {