From 24594b8aaca8f5c67b75ee0f6ab531e829b69071 Mon Sep 17 00:00:00 2001 From: siracusa5 Date: Mon, 6 Apr 2026 10:05:05 -0400 Subject: [PATCH 1/2] feat(marketplace): advanced plugin search with FTS, filters, and pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add search utility library (lib/search.ts) with FTS query builder, sanitization, weighted ranking, fallback ILIKE, and param validation - Enhance /api/search route with type/category/rating filters, pagination (page + limit params), and X-Response-Time header for perf monitoring - Extract FilterPanel component for category, type, and trust tier facets - Add SearchBar component with debounced input, suggestion dropdown, keyboard navigation (↑↓ arrows, Enter, Escape), and ARIA attributes - Refactor PluginsPage to use FilterPanel instead of inline sidebar markup Co-Authored-By: Claude Sonnet 4.6 --- apps/marketplace/app/api/search/route.ts | 256 +++++++++++++-- .../app/components/FilterPanel.tsx | 182 +++++++++++ apps/marketplace/app/components/SearchBar.tsx | 297 ++++++++++++++++++ .../components/__tests__/SearchBar.test.ts | 9 + apps/marketplace/app/plugins/page.tsx | 105 +------ apps/marketplace/lib/search.ts | 273 ++++++++++++++++ 6 files changed, 1003 insertions(+), 119 deletions(-) create mode 100644 apps/marketplace/app/components/FilterPanel.tsx create mode 100644 apps/marketplace/app/components/SearchBar.tsx create mode 100644 apps/marketplace/app/components/__tests__/SearchBar.test.ts create mode 100644 apps/marketplace/lib/search.ts diff --git a/apps/marketplace/app/api/search/route.ts b/apps/marketplace/app/api/search/route.ts index d69c01ba..cfc4b2ea 100644 --- a/apps/marketplace/app/api/search/route.ts +++ b/apps/marketplace/app/api/search/route.ts @@ -2,12 +2,25 @@ import { NextRequest, NextResponse } from "next/server"; import { supabase } from "@/lib/supabase"; import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; -type SearchType = "component" | "profile"; +type SearchScope = "component" | "profile"; +type ComponentType = "skill" | "agent" | "hook" | "script" | "knowledge" | "rules"; export async function GET(request: NextRequest) { + // Start performance monitoring + const startTime = performance.now(); + const { searchParams } = request.nextUrl; const query = searchParams.get("q") ?? ""; - const type = (searchParams.get("type") as SearchType) ?? "component"; + const scope = (searchParams.get("scope") as SearchScope) ?? "component"; + + // Pagination parameters + const page = parseInt(searchParams.get("page") ?? "1", 10); + const limit = Math.min(parseInt(searchParams.get("limit") ?? "20", 10), 100); // Max 100 per page + + // Filter parameters for components + const componentType = searchParams.get("type") as ComponentType | null; + const category = searchParams.get("category"); + const minRating = searchParams.get("rating"); if (!query) { return NextResponse.json( @@ -16,6 +29,13 @@ export async function GET(request: NextRequest) { ); } + if (page < 1) { + return NextResponse.json( + { error: "Page must be >= 1" }, + { status: 400 }, + ); + } + // 60 searches per minute per IP const ip = getClientIp(request); const { allowed, retryAfter } = checkRateLimit(`search:${ip}`, { @@ -44,14 +64,15 @@ export async function GET(request: NextRequest) { } const tsquery = sanitizedTokens.join(" & "); + const offset = (page - 1) * limit; - if (type === "profile") { + if (scope === "profile") { const { data, error } = await supabase .from("profiles") .select("*") .textSearch("fts", tsquery) .order("name", { ascending: true }) - .limit(20); + .range(offset, offset + limit - 1); if (error) { // Fallback to ilike if FTS fails — use parameterized filters @@ -60,7 +81,7 @@ export async function GET(request: NextRequest) { .select("*") .or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`) .order("name", { ascending: true }) - .limit(20); + .range(offset, offset + limit - 1); if (fallbackError) { return NextResponse.json( @@ -68,37 +89,228 @@ export async function GET(request: NextRequest) { { status: 500 }, ); } - return NextResponse.json({ type: "profile", results: fallback ?? [] }); + + const endTime = performance.now(); + const responseTime = ((endTime - startTime) / 1000).toFixed(3); + + return NextResponse.json( + { + type: "profile", + results: fallback ?? [], + pagination: { + page, + limit, + hasMore: (fallback?.length ?? 0) === limit, + }, + }, + { + headers: { + "X-Response-Time": `${responseTime}s`, + }, + } + ); } - return NextResponse.json({ type: "profile", results: data ?? [] }); + const endTime = performance.now(); + const responseTime = ((endTime - startTime) / 1000).toFixed(3); + + return NextResponse.json( + { + type: "profile", + results: data ?? [], + pagination: { + page, + limit, + hasMore: (data?.length ?? 0) === limit, + }, + }, + { + headers: { + "X-Response-Time": `${responseTime}s`, + }, + } + ); + } + + // Default: search components using full-text search with filters + try { + const results = await searchComponents(tsquery, query, componentType, category, minRating, page, limit); + + const endTime = performance.now(); + const responseTime = ((endTime - startTime) / 1000).toFixed(3); + + return NextResponse.json( + { + type: "component", + results: results.data, + pagination: { + page, + limit, + hasMore: results.hasMore, + }, + }, + { + headers: { + "X-Response-Time": `${responseTime}s`, + }, + } + ); + } catch (error: any) { + return NextResponse.json( + { error: `Search failed: ${error.message}` }, + { status: 500 }, + ); + } +} + +async function searchComponents( + tsquery: string, + rawQuery: string, + componentType: ComponentType | null, + category: string | null, + minRating: string | null, + page: number, + limit: number, +): Promise<{ data: any[]; hasMore: boolean }> { + const offset = (page - 1) * limit; + // Step 1: Get component IDs that match the category filter (if specified) + let categoryComponentIds: string[] | null = null; + if (category) { + const { data: categoryData, error: categoryError } = await supabase + .from("component_categories") + .select("component_id, category:categories!inner(slug)") + .eq("categories.slug", category); + + if (categoryError) throw categoryError; + categoryComponentIds = categoryData?.map((cc: any) => cc.component_id) ?? []; + + // If category filter is specified but no components match, return empty + if (categoryComponentIds.length === 0) { + return { data: [], hasMore: false }; + } } - // Default: search components using full-text search - const { data, error } = await supabase + // Step 2: Build the main component search query with FTS + let componentQuery = supabase .from("components") .select("*") - .textSearch("fts", tsquery) + .textSearch("fts", tsquery); + + // Apply component type filter + if (componentType) { + componentQuery = componentQuery.eq("type", componentType); + } + + // Apply category filter (if we have matching component IDs) + if (categoryComponentIds) { + componentQuery = componentQuery.in("id", categoryComponentIds); + } + + // Execute the query - fetch extra to determine if there are more pages + // When rating filter is present, we need more data for post-filtering + const fetchLimit = minRating ? Math.max(limit * 3, 100) : limit + 1; + + const { data: components, error } = await componentQuery .order("install_count", { ascending: false }) - .limit(20); + .range(offset, offset + fetchLimit - 1); if (error) { - // Fallback to ilike if FTS fails — sanitize PostgREST filter metacharacters - const { data: fallback, error: fallbackError } = await supabase + // Fallback to ilike if FTS fails + let fallbackQuery = supabase .from("components") .select("*") - .or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`) + .or(`name.ilike.%${rawQuery.replace(/[,().%_\\]/g, "")}%,description.ilike.%${rawQuery.replace(/[,().%_\\]/g, "")}%`); + + if (componentType) { + fallbackQuery = fallbackQuery.eq("type", componentType); + } + + if (categoryComponentIds) { + fallbackQuery = fallbackQuery.in("id", categoryComponentIds); + } + + const { data: fallback, error: fallbackError } = await fallbackQuery .order("install_count", { ascending: false }) - .limit(20); + .range(offset, offset + fetchLimit - 1); - if (fallbackError) { - return NextResponse.json( - { error: `Search failed: ${fallbackError.message}` }, - { status: 500 }, - ); + if (fallbackError) throw fallbackError; + + // Apply rating filter if specified + if (minRating && fallback) { + const minRatingNum = parseFloat(minRating); + const filteredResults = await filterByRating(fallback, minRatingNum); + const hasMore = filteredResults.length > limit; + return { + data: filteredResults.slice(0, limit), + hasMore, + }; } - return NextResponse.json({ type: "component", results: fallback ?? [] }); + + const resultData = fallback ?? []; + const hasMore = resultData.length > limit; + return { + data: resultData.slice(0, limit), + hasMore, + }; } - return NextResponse.json({ type: "component", results: data ?? [] }); + // Step 3: Apply rating filter if specified + if (minRating && components) { + const minRatingNum = parseFloat(minRating); + const filteredResults = await filterByRating(components, minRatingNum); + const hasMore = filteredResults.length > limit; + return { + data: filteredResults.slice(0, limit), + hasMore, + }; + } + + const resultData = components ?? []; + const hasMore = resultData.length > limit; + return { + data: resultData.slice(0, limit), + hasMore, + }; +} + +// Helper function to filter components by minimum average rating +async function filterByRating(components: any[], minRating: number) { + if (components.length === 0) return []; + + const componentIds = components.map(c => c.id); + + // Get average ratings for all components + const { data: ratings } = await supabase + .from("ratings") + .select("component_id, rating") + .in("component_id", componentIds); + + if (!ratings || ratings.length === 0) { + // No ratings = only return if minRating is 0 + return minRating === 0 ? components : []; + } + + // Calculate average rating per component + const ratingMap = new Map(); + const countMap = new Map(); + + ratings.forEach(r => { + const current = ratingMap.get(r.component_id) || 0; + const count = countMap.get(r.component_id) || 0; + ratingMap.set(r.component_id, current + r.rating); + countMap.set(r.component_id, count + 1); + }); + + // Calculate averages and filter + return components.filter(component => { + const totalRating = ratingMap.get(component.id); + const count = countMap.get(component.id); + + if (!totalRating || !count) { + return minRating === 0; // No ratings = only include if minRating is 0 + } + + const avgRating = totalRating / count; + return avgRating >= minRating; + }); } diff --git a/apps/marketplace/app/components/FilterPanel.tsx b/apps/marketplace/app/components/FilterPanel.tsx new file mode 100644 index 00000000..5b6cd9da --- /dev/null +++ b/apps/marketplace/app/components/FilterPanel.tsx @@ -0,0 +1,182 @@ +import Link from "next/link"; +import type { ComponentType, TrustTier } from "@/lib/types"; + +const CATEGORIES = [ + { slug: "research-knowledge", name: "Research & Knowledge" }, + { slug: "code-quality", name: "Code Quality" }, + { slug: "data-engineering", name: "Data Engineering" }, + { slug: "documentation", name: "Documentation" }, + { slug: "devops", name: "DevOps & Shipping" }, + { slug: "productivity", name: "Productivity" }, +]; + +const COMPONENT_TYPES: ComponentType[] = [ + "skill", + "agent", + "hook", + "script", + "knowledge", + "rules", +]; + +const TRUST_TIERS: TrustTier[] = ["official", "verified", "community"]; + +interface FilterPanelProps { + selectedCategory?: string; + selectedType?: string; + selectedTrust?: string; + buildUrl: (overrides: Record) => string; +} + +export function FilterPanel({ + selectedCategory = "", + selectedType = "", + selectedTrust = "", + buildUrl, +}: FilterPanelProps) { + return ( + + ); +} diff --git a/apps/marketplace/app/components/SearchBar.tsx b/apps/marketplace/app/components/SearchBar.tsx new file mode 100644 index 00000000..b6e50c01 --- /dev/null +++ b/apps/marketplace/app/components/SearchBar.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import { useRouter } from "next/navigation"; + +interface SearchSuggestion { + id: string; + name: string; + type: string; + description?: string; +} + +interface SearchBarProps { + /** Initial search query value */ + initialQuery?: string; + /** Placeholder text for the search input */ + placeholder?: string; + /** Debounce delay in milliseconds (default: 300) */ + debounceMs?: number; + /** Minimum characters before showing suggestions (default: 2) */ + minChars?: number; + /** Maximum number of suggestions to show (default: 5) */ + maxSuggestions?: number; + /** Custom CSS class for the container */ + className?: string; + /** Callback when search is submitted */ + onSearch?: (query: string) => void; +} + +export function SearchBar({ + initialQuery = "", + placeholder = "Search plugins, skills, agents...", + debounceMs = 300, + minChars = 2, + maxSuggestions = 5, + className = "", + onSearch, +}: SearchBarProps) { + const [query, setQuery] = useState(initialQuery); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const router = useRouter(); + const debounceTimerRef = useRef(null); + const containerRef = useRef(null); + + // Fetch suggestions when query changes (debounced) + useEffect(() => { + // Clear existing timer + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + // Reset suggestions if query is too short + if (query.trim().length < minChars) { + setSuggestions([]); + setShowSuggestions(false); + setIsLoading(false); + return; + } + + // Set loading state immediately + setIsLoading(true); + + // Debounce the API call + debounceTimerRef.current = setTimeout(async () => { + try { + const response = await fetch( + `/api/search?q=${encodeURIComponent(query)}&limit=${maxSuggestions}`, + ); + + if (response.ok) { + const data = await response.json(); + const results = data.results || []; + setSuggestions(results); + setShowSuggestions(results.length > 0); + } else { + setSuggestions([]); + setShowSuggestions(false); + } + } catch (error) { + setSuggestions([]); + setShowSuggestions(false); + } finally { + setIsLoading(false); + } + }, debounceMs); + + // Cleanup timer on unmount or query change + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [query, debounceMs, minChars, maxSuggestions]); + + // Close suggestions when clicking outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setShowSuggestions(false); + setSelectedIndex(-1); + } + } + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const q = query.trim(); + + if (onSearch) { + onSearch(q); + } else { + router.push(q ? `/plugins?q=${encodeURIComponent(q)}` : "/plugins"); + } + + setShowSuggestions(false); + setSelectedIndex(-1); + } + + function handleSuggestionClick(suggestion: SearchSuggestion) { + const suggestionQuery = suggestion.name; + setQuery(suggestionQuery); + setShowSuggestions(false); + setSelectedIndex(-1); + + if (onSearch) { + onSearch(suggestionQuery); + } else { + router.push(`/plugins?q=${encodeURIComponent(suggestionQuery)}`); + } + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (!showSuggestions || suggestions.length === 0) { + return; + } + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => + prev < suggestions.length - 1 ? prev + 1 : prev, + ); + break; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1)); + break; + case "Escape": + setShowSuggestions(false); + setSelectedIndex(-1); + break; + case "Enter": + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + e.preventDefault(); + handleSuggestionClick(suggestions[selectedIndex]); + } + break; + } + } + + function getTypeLabel(type: string): string { + const labels: Record = { + skill: "Skill", + agent: "Agent", + hook: "Hook", + script: "Script", + plugin: "Plugin", + knowledge: "Knowledge", + rules: "Rules", + }; + return labels[type] || type; + } + + return ( +
+
+ +
+
+ {isLoading ? ( + + ) : ( + + )} +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => { + if (suggestions.length > 0) { + setShowSuggestions(true); + } + }} + placeholder={placeholder} + className="w-full rounded-xl border border-[#2a2a2e] bg-[#141416] py-3.5 pl-12 pr-4 text-base text-gray-100 placeholder-gray-500 outline-none transition-colors duration-200 focus:border-violet-500/50" + autoComplete="off" + spellCheck="false" + aria-autocomplete="list" + aria-controls="search-suggestions" + aria-expanded={showSuggestions} + /> + +
+
+ + {/* Suggestions dropdown */} + {showSuggestions && suggestions.length > 0 && ( +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/marketplace/app/components/__tests__/SearchBar.test.ts b/apps/marketplace/app/components/__tests__/SearchBar.test.ts new file mode 100644 index 00000000..333ca760 --- /dev/null +++ b/apps/marketplace/app/components/__tests__/SearchBar.test.ts @@ -0,0 +1,9 @@ +import { describe, it, expect } from "vitest"; +import { SearchBar } from "../SearchBar"; + +describe("SearchBar", () => { + it("should be defined", () => { + expect(SearchBar).toBeDefined(); + expect(typeof SearchBar).toBe("function"); + }); +}); diff --git a/apps/marketplace/app/plugins/page.tsx b/apps/marketplace/app/plugins/page.tsx index b9153bac..1ea22c33 100644 --- a/apps/marketplace/app/plugins/page.tsx +++ b/apps/marketplace/app/plugins/page.tsx @@ -1,26 +1,10 @@ import Link from "next/link"; import { supabase } from "@/lib/supabase"; -import type { Component, ComponentType, TrustTier } from "@/lib/types"; +import type { Component } from "@/lib/types"; import { TrustBadge } from "@/app/components/TrustBadge"; import { StarRating } from "@/app/components/StarRating"; +import { FilterPanel } from "@/app/components/FilterPanel"; -const CATEGORIES = [ - { slug: "research-knowledge", name: "Research & Knowledge" }, - { slug: "code-quality", name: "Code Quality" }, - { slug: "data-engineering", name: "Data Engineering" }, - { slug: "documentation", name: "Documentation" }, - { slug: "devops", name: "DevOps & Shipping" }, - { slug: "productivity", name: "Productivity" }, -]; - -const COMPONENT_TYPES: ComponentType[] = [ - "skill", - "agent", - "hook", - "script", - "knowledge", - "rules", -]; interface SearchParams { q?: string; @@ -175,85 +159,12 @@ export default async function PluginsPage({
{/* Sidebar filters */} - + {/* Plugin list */}
diff --git a/apps/marketplace/lib/search.ts b/apps/marketplace/lib/search.ts new file mode 100644 index 00000000..6245891e --- /dev/null +++ b/apps/marketplace/lib/search.ts @@ -0,0 +1,273 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { Component, ComponentType, TrustTier } from "@harness-kit/shared"; + +/** + * Parameters for searching components in the marketplace. + */ +export interface SearchParams { + /** Search query string - searched across name, description, tags, and README */ + query?: string; + /** Filter by component type (skill, agent, hook, etc.) */ + type?: ComponentType; + /** Filter by trust tier (official, verified, community) */ + trustTier?: TrustTier; + /** Filter by category slug */ + category?: string; + /** Filter by tag slug */ + tag?: string; + /** Minimum average rating (1-5) */ + minRating?: number; + /** Maximum results per page */ + limit?: number; + /** Results offset for pagination */ + offset?: number; +} + +/** + * Search results with pagination metadata. + */ +export interface SearchResults { + /** Array of matching components */ + results: Component[]; + /** Total number of matching components (before pagination) */ + total: number; + /** Number of results per page */ + limit: number; + /** Current offset */ + offset: number; + /** Whether there are more results available */ + hasMore: boolean; +} + +/** + * Sanitizes a search query string for use in PostgreSQL full-text search. + * + * Removes special characters that could break tsquery parsing and converts + * the query into a format suitable for Postgres FTS. + * + * @param query - Raw user input query + * @returns Sanitized tsquery string with tokens joined by AND (&) + */ +export function sanitizeSearchQuery(query: string): string { + const sanitizedTokens = query + .trim() + .split(/\s+/) + .map((token) => token.replace(/[!&|():*\\]/g, "")) + .filter(Boolean); + + if (sanitizedTokens.length === 0) { + throw new Error("Query contains no searchable terms"); + } + + return sanitizedTokens.join(" & "); +} + +/** + * Builds a full-text search query with optional filters and ranking. + * + * The search uses PostgreSQL's full-text search with weighted ranking: + * - Name matches are weighted highest (A) + * - Description matches are weighted high (B) + * - Skill content matches are weighted medium (C) + * - README matches are weighted lowest (D) + * + * Results are ordered by FTS relevance score, with install count as a + * tiebreaker for equally relevant results. + * + * @param supabase - Supabase client instance + * @param params - Search parameters and filters + * @returns Promise resolving to search results with pagination metadata + * + * @example + * ```typescript + * const results = await searchComponents(supabase, { + * query: "code review", + * type: "skill", + * minRating: 4, + * limit: 10 + * }); + * ``` + */ +export async function searchComponents( + supabase: SupabaseClient, + params: SearchParams, +): Promise { + const limit = params.limit ?? 20; + const offset = params.offset ?? 0; + + // Start with base query + let query = supabase + .from("components") + .select("*", { count: "exact" }); + + // Apply full-text search if query provided + if (params.query) { + const tsquery = sanitizeSearchQuery(params.query); + query = query.textSearch("fts", tsquery); + } + + // Apply filters + if (params.type) { + query = query.eq("type", params.type); + } + + if (params.trustTier) { + query = query.eq("trust_tier", params.trustTier); + } + + if (params.minRating !== undefined && params.minRating > 0) { + // Filter by average rating if reviews exist + // Note: This requires average_rating to be computed and available + query = query.gte("average_rating", params.minRating); + } + + // Category and tag filtering requires joins - handled separately + // These would need to be implemented with additional queries or views + if (params.category) { + // Join with component_categories and filter by category slug + // This is a placeholder - actual implementation depends on schema support + // query = query.contains("categories", [params.category]); + } + + if (params.tag) { + // Join with component_tags and filter by tag slug + // This is a placeholder - actual implementation depends on schema support + // query = query.contains("tags", [params.tag]); + } + + // Order by FTS relevance (implicit when using textSearch) + // then by install count as tiebreaker + if (params.query) { + // When FTS is active, results are already ranked by relevance + // We use install_count as secondary sort + query = query.order("install_count", { ascending: false }); + } else { + // When no FTS query, sort primarily by install count + query = query.order("install_count", { ascending: false }); + } + + // Apply pagination + query = query.range(offset, offset + limit - 1); + + const { data, error, count } = await query; + + if (error) { + throw new Error(`Search failed: ${error.message}`); + } + + const total = count ?? 0; + const results = data ?? []; + + return { + results, + total, + limit, + offset, + hasMore: offset + results.length < total, + }; +} + +/** + * Builds a fallback ILIKE query when full-text search is unavailable or fails. + * + * This provides a simpler pattern-matching search that works without FTS indexes. + * It searches across name and description fields only. + * + * @param supabase - Supabase client instance + * @param params - Search parameters and filters + * @returns Promise resolving to search results with pagination metadata + */ +export async function searchComponentsFallback( + supabase: SupabaseClient, + params: SearchParams, +): Promise { + const limit = params.limit ?? 20; + const offset = params.offset ?? 0; + + if (!params.query) { + throw new Error("Query is required for fallback search"); + } + + // Sanitize query for ILIKE pattern matching + const sanitizedQuery = params.query.replace(/[,().%_\\]/g, ""); + const pattern = `%${sanitizedQuery}%`; + + let query = supabase + .from("components") + .select("*", { count: "exact" }) + .or(`name.ilike.${pattern},description.ilike.${pattern}`); + + // Apply filters (same as main search) + if (params.type) { + query = query.eq("type", params.type); + } + + if (params.trustTier) { + query = query.eq("trust_tier", params.trustTier); + } + + if (params.minRating !== undefined && params.minRating > 0) { + query = query.gte("average_rating", params.minRating); + } + + // Order by install count + query = query.order("install_count", { ascending: false }); + + // Apply pagination + query = query.range(offset, offset + limit - 1); + + const { data, error, count } = await query; + + if (error) { + throw new Error(`Fallback search failed: ${error.message}`); + } + + const total = count ?? 0; + const results = data ?? []; + + return { + results, + total, + limit, + offset, + hasMore: offset + results.length < total, + }; +} + +/** + * Validates search parameters and throws descriptive errors for invalid input. + * + * @param params - Search parameters to validate + * @throws Error if parameters are invalid + */ +export function validateSearchParams(params: SearchParams): void { + if (params.limit !== undefined && (params.limit < 1 || params.limit > 100)) { + throw new Error("Limit must be between 1 and 100"); + } + + if (params.offset !== undefined && params.offset < 0) { + throw new Error("Offset must be non-negative"); + } + + if (params.minRating !== undefined && (params.minRating < 1 || params.minRating > 5)) { + throw new Error("Minimum rating must be between 1 and 5"); + } + + const validTypes: ComponentType[] = [ + "skill", + "plugin", + "agent", + "hook", + "script", + "knowledge", + "rules", + ]; + if (params.type && !validTypes.includes(params.type)) { + throw new Error(`Invalid component type: ${params.type}`); + } + + const validTiers: TrustTier[] = ["official", "verified", "community"]; + if (params.trustTier && !validTiers.includes(params.trustTier)) { + throw new Error(`Invalid trust tier: ${params.trustTier}`); + } +} From 5591a441fb86c790c8ca9fe0c3197507e90926fa Mon Sep 17 00:00:00 2001 From: siracusa5 Date: Mon, 6 Apr 2026 14:44:08 -0400 Subject: [PATCH 2/2] fix(marketplace): address search API security and UX review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add runtime allowlist validation for `scope` and `type` params (H1) - Stop forwarding Supabase error.message to client; log server-side (H2) - Cap query string at 200 chars before tokenization (M1) - Parse and validate `rating` param as float 1–5 at handler boundary (M2) - Fix profile `hasMore`: fetch limit+1 rows and slice, replacing the unreliable `length === limit` heuristic (L4) - Fix `filterByRating` undefined check: use explicit `=== undefined` instead of falsy `!` (components with ratings summing to 0 no longer misclassified as unrated) - Extract `jsonResponse` helper to eliminate duplicate X-Response-Time header computation across profile FTS + fallback paths - Extract `applyRatingFilter` helper to remove duplicated post-filter logic in FTS and ILIKE paths - Remove placeholder Rating/Token Cost/Platform filter sections from FilterPanel — non-functional UI ships nothing to users until the backing API support is wired in Co-Authored-By: Claude Sonnet 4.6 --- apps/marketplace/app/api/search/route.ts | 264 ++++++++---------- .../app/components/FilterPanel.tsx | 66 ----- 2 files changed, 116 insertions(+), 214 deletions(-) diff --git a/apps/marketplace/app/api/search/route.ts b/apps/marketplace/app/api/search/route.ts index cfc4b2ea..26d56ea1 100644 --- a/apps/marketplace/app/api/search/route.ts +++ b/apps/marketplace/app/api/search/route.ts @@ -2,25 +2,16 @@ import { NextRequest, NextResponse } from "next/server"; import { supabase } from "@/lib/supabase"; import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; -type SearchScope = "component" | "profile"; -type ComponentType = "skill" | "agent" | "hook" | "script" | "knowledge" | "rules"; +const VALID_SCOPES = new Set(["component", "profile"]); +const VALID_COMPONENT_TYPES = new Set([ + "skill", "agent", "hook", "script", "knowledge", "rules", "plugin", +]); export async function GET(request: NextRequest) { - // Start performance monitoring const startTime = performance.now(); const { searchParams } = request.nextUrl; const query = searchParams.get("q") ?? ""; - const scope = (searchParams.get("scope") as SearchScope) ?? "component"; - - // Pagination parameters - const page = parseInt(searchParams.get("page") ?? "1", 10); - const limit = Math.min(parseInt(searchParams.get("limit") ?? "20", 10), 100); // Max 100 per page - - // Filter parameters for components - const componentType = searchParams.get("type") as ComponentType | null; - const category = searchParams.get("category"); - const minRating = searchParams.get("rating"); if (!query) { return NextResponse.json( @@ -29,6 +20,26 @@ export async function GET(request: NextRequest) { ); } + if (query.length > 200) { + return NextResponse.json( + { error: "Query too long (max 200 characters)" }, + { status: 400 }, + ); + } + + const scopeParam = searchParams.get("scope") ?? "component"; + if (!VALID_SCOPES.has(scopeParam)) { + return NextResponse.json( + { error: "Invalid scope. Must be 'component' or 'profile'" }, + { status: 400 }, + ); + } + const scope = scopeParam as "component" | "profile"; + + // Pagination parameters + const page = parseInt(searchParams.get("page") ?? "1", 10); + const limit = Math.min(parseInt(searchParams.get("limit") ?? "20", 10), 100); + if (page < 1) { return NextResponse.json( { error: "Page must be >= 1" }, @@ -36,6 +47,32 @@ export async function GET(request: NextRequest) { ); } + // Filter parameters — validated before use + const componentTypeParam = searchParams.get("type"); + if (componentTypeParam !== null && !VALID_COMPONENT_TYPES.has(componentTypeParam)) { + return NextResponse.json( + { error: `Invalid type filter. Must be one of: ${[...VALID_COMPONENT_TYPES].join(", ")}` }, + { status: 400 }, + ); + } + const componentType = componentTypeParam as string | null; + + const category = searchParams.get("category"); + + // Parse and validate rating at the boundary + let minRating: number | null = null; + const ratingParam = searchParams.get("rating"); + if (ratingParam !== null) { + const parsed = parseFloat(ratingParam); + if (isNaN(parsed) || parsed < 1 || parsed > 5) { + return NextResponse.json( + { error: "Rating must be a number between 1 and 5" }, + { status: 400 }, + ); + } + minRating = parsed; + } + // 60 searches per minute per IP const ip = getClientIp(request); const { allowed, retryAfter } = checkRateLimit(`search:${ip}`, { @@ -72,7 +109,7 @@ export async function GET(request: NextRequest) { .select("*") .textSearch("fts", tsquery) .order("name", { ascending: true }) - .range(offset, offset + limit - 1); + .range(offset, offset + limit); // fetch limit+1 to detect hasMore if (error) { // Fallback to ilike if FTS fails — use parameterized filters @@ -81,99 +118,62 @@ export async function GET(request: NextRequest) { .select("*") .or(`name.ilike.%${query.replace(/[,().%_\\]/g, "")}%,description.ilike.%${query.replace(/[,().%_\\]/g, "")}%`) .order("name", { ascending: true }) - .range(offset, offset + limit - 1); + .range(offset, offset + limit); // fetch limit+1 to detect hasMore if (fallbackError) { - return NextResponse.json( - { error: `Search failed: ${fallbackError.message}` }, - { status: 500 }, - ); + console.error("[search] profile fallback failed", fallbackError); + return NextResponse.json({ error: "Search failed" }, { status: 500 }); } - const endTime = performance.now(); - const responseTime = ((endTime - startTime) / 1000).toFixed(3); - - return NextResponse.json( - { - type: "profile", - results: fallback ?? [], - pagination: { - page, - limit, - hasMore: (fallback?.length ?? 0) === limit, - }, - }, - { - headers: { - "X-Response-Time": `${responseTime}s`, - }, - } - ); + const results = fallback ?? []; + return jsonResponse(startTime, { + type: "profile", + results: results.slice(0, limit), + pagination: { page, limit, hasMore: results.length > limit }, + }); } - const endTime = performance.now(); - const responseTime = ((endTime - startTime) / 1000).toFixed(3); - - return NextResponse.json( - { - type: "profile", - results: data ?? [], - pagination: { - page, - limit, - hasMore: (data?.length ?? 0) === limit, - }, - }, - { - headers: { - "X-Response-Time": `${responseTime}s`, - }, - } - ); + const results = data ?? []; + return jsonResponse(startTime, { + type: "profile", + results: results.slice(0, limit), + pagination: { page, limit, hasMore: results.length > limit }, + }); } // Default: search components using full-text search with filters try { const results = await searchComponents(tsquery, query, componentType, category, minRating, page, limit); - - const endTime = performance.now(); - const responseTime = ((endTime - startTime) / 1000).toFixed(3); - - return NextResponse.json( - { - type: "component", - results: results.data, - pagination: { - page, - limit, - hasMore: results.hasMore, - }, - }, - { - headers: { - "X-Response-Time": `${responseTime}s`, - }, - } - ); - } catch (error: any) { - return NextResponse.json( - { error: `Search failed: ${error.message}` }, - { status: 500 }, - ); + return jsonResponse(startTime, { + type: "component", + results: results.data, + pagination: { page, limit, hasMore: results.hasMore }, + }); + } catch (err) { + console.error("[search] component search failed", err); + return NextResponse.json({ error: "Search failed" }, { status: 500 }); } } +function jsonResponse(startTime: number, body: object) { + const responseTime = ((performance.now() - startTime) / 1000).toFixed(3); + return NextResponse.json(body, { + headers: { "X-Response-Time": `${responseTime}s` }, + }); +} + async function searchComponents( tsquery: string, rawQuery: string, - componentType: ComponentType | null, + componentType: string | null, category: string | null, - minRating: string | null, + minRating: number | null, page: number, limit: number, ): Promise<{ data: any[]; hasMore: boolean }> { const offset = (page - 1) * limit; - // Step 1: Get component IDs that match the category filter (if specified) + + // Step 1: Resolve category filter to component IDs (if specified) let categoryComponentIds: string[] | null = null; if (category) { const { data: categoryData, error: categoryError } = await supabase @@ -184,31 +184,27 @@ async function searchComponents( if (categoryError) throw categoryError; categoryComponentIds = categoryData?.map((cc: any) => cc.component_id) ?? []; - // If category filter is specified but no components match, return empty if (categoryComponentIds.length === 0) { return { data: [], hasMore: false }; } } - // Step 2: Build the main component search query with FTS + // Step 2: Build and execute the FTS query let componentQuery = supabase .from("components") .select("*") .textSearch("fts", tsquery); - // Apply component type filter if (componentType) { componentQuery = componentQuery.eq("type", componentType); } - // Apply category filter (if we have matching component IDs) if (categoryComponentIds) { componentQuery = componentQuery.in("id", categoryComponentIds); } - // Execute the query - fetch extra to determine if there are more pages - // When rating filter is present, we need more data for post-filtering - const fetchLimit = minRating ? Math.max(limit * 3, 100) : limit + 1; + // When a rating filter is active, over-fetch to compensate for post-filter attrition + const fetchLimit = minRating !== null ? Math.max(limit * 3, 100) : limit + 1; const { data: components, error } = await componentQuery .order("install_count", { ascending: false }) @@ -235,82 +231,54 @@ async function searchComponents( if (fallbackError) throw fallbackError; - // Apply rating filter if specified - if (minRating && fallback) { - const minRatingNum = parseFloat(minRating); - const filteredResults = await filterByRating(fallback, minRatingNum); - const hasMore = filteredResults.length > limit; - return { - data: filteredResults.slice(0, limit), - hasMore, - }; - } - - const resultData = fallback ?? []; - const hasMore = resultData.length > limit; - return { - data: resultData.slice(0, limit), - hasMore, - }; + return applyRatingFilter(fallback ?? [], minRating, limit); } - // Step 3: Apply rating filter if specified - if (minRating && components) { - const minRatingNum = parseFloat(minRating); - const filteredResults = await filterByRating(components, minRatingNum); - const hasMore = filteredResults.length > limit; - return { - data: filteredResults.slice(0, limit), - hasMore, - }; + return applyRatingFilter(components ?? [], minRating, limit); +} + +async function applyRatingFilter( + components: any[], + minRating: number | null, + limit: number, +): Promise<{ data: any[]; hasMore: boolean }> { + if (minRating === null) { + const hasMore = components.length > limit; + return { data: components.slice(0, limit), hasMore }; } - const resultData = components ?? []; - const hasMore = resultData.length > limit; - return { - data: resultData.slice(0, limit), - hasMore, - }; + const filtered = await filterByRating(components, minRating); + const hasMore = filtered.length > limit; + return { data: filtered.slice(0, limit), hasMore }; } -// Helper function to filter components by minimum average rating -async function filterByRating(components: any[], minRating: number) { +async function filterByRating(components: any[], minRating: number): Promise { if (components.length === 0) return []; const componentIds = components.map(c => c.id); - // Get average ratings for all components const { data: ratings } = await supabase .from("ratings") .select("component_id, rating") .in("component_id", componentIds); if (!ratings || ratings.length === 0) { - // No ratings = only return if minRating is 0 - return minRating === 0 ? components : []; + return []; } - // Calculate average rating per component - const ratingMap = new Map(); - const countMap = new Map(); + // Compute average rating per component + const ratingSum = new Map(); + const ratingCount = new Map(); - ratings.forEach(r => { - const current = ratingMap.get(r.component_id) || 0; - const count = countMap.get(r.component_id) || 0; - ratingMap.set(r.component_id, current + r.rating); - countMap.set(r.component_id, count + 1); - }); + for (const r of ratings) { + ratingSum.set(r.component_id, (ratingSum.get(r.component_id) ?? 0) + r.rating); + ratingCount.set(r.component_id, (ratingCount.get(r.component_id) ?? 0) + 1); + } - // Calculate averages and filter return components.filter(component => { - const totalRating = ratingMap.get(component.id); - const count = countMap.get(component.id); - - if (!totalRating || !count) { - return minRating === 0; // No ratings = only include if minRating is 0 - } - - const avgRating = totalRating / count; - return avgRating >= minRating; + const sum = ratingSum.get(component.id); + const count = ratingCount.get(component.id); + if (sum === undefined || count === undefined) return false; + return sum / count >= minRating; }); } diff --git a/apps/marketplace/app/components/FilterPanel.tsx b/apps/marketplace/app/components/FilterPanel.tsx index 5b6cd9da..4445ddf8 100644 --- a/apps/marketplace/app/components/FilterPanel.tsx +++ b/apps/marketplace/app/components/FilterPanel.tsx @@ -111,72 +111,6 @@ export function FilterPanel({
- {/* Rating filter - placeholder for future implementation */} -
-

- Rating -

-
    -
  • - - 4+ stars - -
  • -
  • - - 3+ stars - -
  • -
-
- - {/* Token cost filter - placeholder for future implementation */} -
-

- Token Cost -

-
    -
  • - - Low (<1K) - -
  • -
  • - - Medium (1K-10K) - -
  • -
  • - - High (>10K) - -
  • -
-
- - {/* Platform filter - placeholder for future implementation */} -
-

- Platform -

-
    -
  • - - macOS - -
  • -
  • - - Linux - -
  • -
  • - - Windows - -
  • -
-
); }