Spaces:
Runtime error
Runtime error
| import { getSettings } from "../db/settings"; | |
| import dns from "node:dns/promises"; | |
| import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; | |
| import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; | |
| /** | |
| * Plugin Marketplace β browse, search, install plugins from a registry. | |
| * | |
| * Phase 1: Local registry with seed data. | |
| * Phase 2: Remote registry with ratings/downloads. | |
| * | |
| * @module plugins/marketplace | |
| */ | |
| /** Resolve a hostname to every address it maps to (A + AAAA). Injectable for tests. */ | |
| export type MarketplaceLookupFn = (hostname: string) => Promise<Array<{ address: string }>>; | |
| const defaultLookup: MarketplaceLookupFn = (hostname) => | |
| dns.lookup(hostname, { all: true, verbatim: true }); | |
| /** | |
| * SSRF guard for a custom marketplace registry URL. Must be http(s) and must not | |
| * target a private/loopback/link-local/ULA address. Unlike a literal-only or | |
| * IPv4-only check, this resolves BOTH IPv4 (A) and IPv6 (AAAA) records and rejects | |
| * if ANY resolved address is private β closing the public-hostname β private-IP | |
| * bypass (IPv6 included: `::1`, `fc00::/7`, `fe80::/10`, IPv4-mapped) via the | |
| * canonical `isPrivateHost`. DNS failure rejects (fail-closed). The fetch itself | |
| * additionally runs through `safeOutboundFetch({ guard: "public-only" })`, which | |
| * re-applies the guard and blocks redirects (no public β private 30x pivot). | |
| */ | |
| export async function isSafeMarketplaceUrl( | |
| urlStr: string, | |
| lookupFn: MarketplaceLookupFn = defaultLookup | |
| ): Promise<boolean> { | |
| let parsed: URL; | |
| try { | |
| parsed = new URL(urlStr); | |
| } catch { | |
| return false; | |
| } | |
| if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { | |
| return false; | |
| } | |
| // Literal IP hostnames (IPv4 + IPv6, incl. IPv4-mapped) are classified directly. | |
| if (isPrivateHost(parsed.hostname)) { | |
| return false; | |
| } | |
| // Resolve A + AAAA and reject if the hostname maps to any private address. | |
| try { | |
| const records = await lookupFn(parsed.hostname); | |
| if (!records.length) return false; | |
| for (const { address } of records) { | |
| if (isPrivateHost(address)) return false; | |
| } | |
| } catch { | |
| // DNS resolution failure β reject to be safe. | |
| return false; | |
| } | |
| return true; | |
| } | |
| // Marketplace β local seed registry. Remote registry in Phase 2. | |
| // ββ Types ββ | |
| export interface MarketplaceEntry { | |
| name: string; | |
| version: string; | |
| description: string; | |
| author: string; | |
| license: string; | |
| downloadUrl: string; | |
| repository?: string; | |
| tags: string[]; | |
| downloads: number; | |
| rating: number; // 0-5 | |
| verified: boolean; | |
| lastUpdated: string; | |
| } | |
| // ββ Seed Data ββ | |
| const SEED_REGISTRY: MarketplaceEntry[] = [ | |
| { | |
| name: "request-logger", | |
| version: "1.0.0", | |
| description: "Logs all requests and responses with timing", | |
| author: "omniroute", | |
| license: "MIT", | |
| downloadUrl: "", | |
| tags: ["logging", "debugging"], | |
| downloads: 0, | |
| rating: 5, | |
| verified: true, | |
| lastUpdated: "2026-05-29", | |
| }, | |
| { | |
| name: "rate-limiter", | |
| version: "1.0.0", | |
| description: "Per-model rate limiting with sliding window", | |
| author: "omniroute", | |
| license: "MIT", | |
| downloadUrl: "", | |
| tags: ["rate-limit", "security"], | |
| downloads: 0, | |
| rating: 5, | |
| verified: true, | |
| lastUpdated: "2026-05-29", | |
| }, | |
| { | |
| name: "cost-tracker", | |
| version: "1.0.0", | |
| description: "Track token costs per request and per model", | |
| author: "omniroute", | |
| license: "MIT", | |
| downloadUrl: "", | |
| tags: ["analytics", "cost"], | |
| downloads: 0, | |
| rating: 4, | |
| verified: true, | |
| lastUpdated: "2026-05-29", | |
| }, | |
| { | |
| name: "theme-manager", | |
| version: "1.0.0", | |
| description: "Dynamic UI theme management via CSS variable injection", | |
| author: "omniroute", | |
| license: "MIT", | |
| downloadUrl: "", | |
| tags: ["theme", "ui", "css", "customization"], | |
| downloads: 0, | |
| rating: 5, | |
| verified: true, | |
| lastUpdated: "2026-06-09", | |
| }, | |
| ]; | |
| // ββ API ββ | |
| /** | |
| * List all available plugins in the marketplace. | |
| */ | |
| export async function listMarketplacePlugins(): Promise<MarketplaceEntry[]> { | |
| try { | |
| const settings = await getSettings(); | |
| const url = typeof settings.pluginMarketplaceUrl === "string" ? settings.pluginMarketplaceUrl : null; | |
| if (url) { | |
| if (!(await isSafeMarketplaceUrl(url))) { | |
| console.warn("Custom marketplace URL rejected (SSRF guard):", url); | |
| return [...SEED_REGISTRY]; | |
| } | |
| const res = await safeOutboundFetch(url, { guard: "public-only", timeoutMs: 5000 }); | |
| if (!res.ok) { | |
| console.warn("Custom marketplace returned non-OK status:", res.status); | |
| return [...SEED_REGISTRY]; | |
| } | |
| const data = await res.json(); | |
| if (Array.isArray(data)) { | |
| return data.filter((entry: unknown) => | |
| entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).name === "string" | |
| ) as MarketplaceEntry[]; | |
| } | |
| if (data && typeof data === "object" && Array.isArray((data as Record<string, unknown>).plugins)) { | |
| return ((data as Record<string, unknown>).plugins as unknown[]).filter((entry: unknown) => | |
| entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).name === "string" | |
| ) as MarketplaceEntry[]; | |
| } | |
| console.warn("Custom marketplace returned unrecognized format"); | |
| } | |
| } catch (err) { | |
| console.error("Failed to fetch from custom plugin marketplace:", err); | |
| } | |
| return [...SEED_REGISTRY]; | |
| } | |
| /** | |
| * Search marketplace plugins by query. | |
| */ | |
| export async function searchMarketplace(query: string): Promise<MarketplaceEntry[]> { | |
| const plugins = await listMarketplacePlugins(); | |
| const q = query.toLowerCase(); | |
| return plugins.filter( | |
| (p) => | |
| p.name.includes(q) || | |
| p.description.toLowerCase().includes(q) || | |
| p.tags.some((t) => t.includes(q)) | |
| ); | |
| } | |
| /** | |
| * Get a specific marketplace entry. | |
| */ | |
| export async function getMarketplaceEntry(name: string): Promise<MarketplaceEntry | undefined> { | |
| const plugins = await listMarketplacePlugins(); | |
| return plugins.find((p) => p.name === name); | |
| } | |
| /** | |
| * Check if marketplace is available. | |
| */ | |
| export function isMarketplaceAvailable(): boolean { | |
| return true; // Always available (falls back to seed) | |
| } | |