From 975fc0613615e091cf4037629a7e70efd5e11ef2 Mon Sep 17 00:00:00 2001 From: DMleadgen Date: Wed, 1 Apr 2026 14:41:34 -0600 Subject: [PATCH] deploy: add ebay marketplace notifications --- .env.staging.example | 90 +++++-- app/api/ebay/notifications/route.ts | 141 +++++++++++ app/api/ebay/search/route.ts | 163 +++++++++++++ components/parts-panel.tsx | 106 +++++++-- lib/ebay-notifications.ts | 339 +++++++++++++++++++++++++++ lib/parts-lookup.ts | 351 +++++++++++++++++++--------- 6 files changed, 1038 insertions(+), 152 deletions(-) create mode 100644 app/api/ebay/notifications/route.ts create mode 100644 app/api/ebay/search/route.ts create mode 100644 lib/ebay-notifications.ts diff --git a/.env.staging.example b/.env.staging.example index 4c1ec233..aae845fd 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -1,28 +1,72 @@ -NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app -NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app +# Current Rocky Mountain Vending staging env contract. +# Fill these in through Coolify-managed environment variables only. -CONVEX_URL= -CONVEX_SELF_HOSTED_URL= -CONVEX_SELF_HOSTED_ADMIN_KEY= -CONVEX_TENANT_SLUG=rocky_mountain_vending -CONVEX_TENANT_NAME=Rocky Mountain Vending - -ADMIN_UI_ENABLED=true -ADMIN_API_TOKEN= -ADMIN_EMAIL= - -PHONE_AGENT_INTERNAL_TOKEN= -PHONE_CALL_SUMMARY_FROM_EMAIL= - -USESEND_API_KEY= -USESEND_BASE_URL= -USESEND_FROM_EMAIL=info@rockymountainvending.com -CONTACT_FORM_TO_EMAIL=info@rockymountainvending.com - -GHL_API_TOKEN= -GHL_LOCATION_ID= +# Core site +NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com +NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com +NEXT_PUBLIC_CONVEX_URL= +# Voice and chat LIVEKIT_URL= LIVEKIT_API_KEY= LIVEKIT_API_SECRET= -VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app +XAI_API_KEY= +XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning +VOICE_ASSISTANT_SITE_URL=https://rockymountainvending.com +PHONE_AGENT_INTERNAL_TOKEN= +NEXT_PUBLIC_CALL_PHONE_DISPLAY=(435) 233-9668 +NEXT_PUBLIC_CALL_PHONE_E164=+14352339668 +NEXT_PUBLIC_SMS_PHONE_DISPLAY=(435) 233-9668 +NEXT_PUBLIC_SMS_PHONE_E164=+14352339668 +NEXT_PUBLIC_MANUALS_BASE_URL= +NEXT_PUBLIC_THUMBNAILS_BASE_URL= +VOICE_RECORDING_ENABLED=false +VOICE_RECORDING_BUCKET= +VOICE_RECORDING_ENDPOINT= +VOICE_RECORDING_PUBLIC_BASE_URL= +VOICE_RECORDING_ACCESS_KEY_ID= +VOICE_RECORDING_SECRET_ACCESS_KEY= +VOICE_RECORDING_REGION=auto + +# Admin and auth +ADMIN_EMAIL= +ADMIN_PASSWORD= +RESEND_API_KEY= +PHONE_CALL_SUMMARY_FROM_EMAIL= +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Stripe +STRIPE_SECRET_KEY= +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= + +# GHL handoff + sync (API-only mode) +GHL_PRIVATE_INTEGRATION_TOKEN= +GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG +GHL_API_VERSION=2021-07-28 +GHL_API_BASE_URL=https://services.leadconnectorhq.com +GHL_SYNC_CRON_TOKEN= +GHL_SYNC_INTERVAL_MINUTES=15 +GHL_SYNC_PAGE_SIZE=100 + +# Optional/deprecated in API-only mode +GHL_WEBHOOK_SHARED_SECRET= +GHL_CONTACT_WEBHOOK_URL= +GHL_REQUEST_MACHINE_WEBHOOK_URL= + +# eBay API credentials for manuals-side fallback +EBAY_APP_ID= +EBAY_DEV_ID= +EBAY_CERT_ID= +EBAY_SANDBOX_TOKEN= +EBAY_AFFILIATE_CAMPAIGN_ID= + +# eBay marketplace account deletion notifications +# Use the exact public HTTPS endpoint that eBay validates in the developer portal. +EBAY_NOTIFICATION_ENDPOINT=https://rmv.abundancepartners.app/api/ebay/notifications +EBAY_NOTIFICATION_VERIFICATION_TOKEN= +EBAY_NOTIFICATION_APP_ID= +EBAY_NOTIFICATION_CERT_ID= +EBAY_NOTIFICATION_API_BASE_URL=https://api.ebay.com +EBAY_NOTIFICATION_SCOPE=https://api.ebay.com/oauth/api_scope diff --git a/app/api/ebay/notifications/route.ts b/app/api/ebay/notifications/route.ts new file mode 100644 index 00000000..9ef1f659 --- /dev/null +++ b/app/api/ebay/notifications/route.ts @@ -0,0 +1,141 @@ +import { NextRequest, NextResponse } from "next/server" +import { + computeEbayChallengeResponse, + getEbayNotificationEndpoint, + getEbayNotificationVerificationToken, + verifyEbayNotificationSignature, +} from "@/lib/ebay-notifications" + +export const runtime = "nodejs" + +type EbayNotificationPayload = { + metadata?: { + topic?: string + schemaVersion?: string + deprecated?: boolean + } + notification?: { + notificationId?: string + eventDate?: string + publishDate?: string + publishAttemptCount?: number + data?: { + username?: string + userId?: string + eiasToken?: string + } + } +} + +function parseNotificationBody(rawBody: string) { + if (!rawBody.trim()) { + return null + } + + try { + return JSON.parse(rawBody) as EbayNotificationPayload + } catch { + return null + } +} + +function getChallengeCode(request: NextRequest) { + return ( + request.nextUrl.searchParams.get("challenge_code") || + request.nextUrl.searchParams.get("challengeCode") || + "" + ).trim() +} + +export async function GET(request: NextRequest) { + const challengeCode = getChallengeCode(request) + if (!challengeCode) { + return NextResponse.json({ error: "Missing challenge_code query parameter." }, { status: 400 }) + } + + const verificationToken = getEbayNotificationVerificationToken() + if (!verificationToken) { + return NextResponse.json( + { error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." }, + { status: 500 }, + ) + } + + const endpoint = getEbayNotificationEndpoint(request.url) + if (!endpoint) { + return NextResponse.json({ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." }, { status: 500 }) + } + + const challengeResponse = computeEbayChallengeResponse({ + challengeCode, + endpoint, + verificationToken, + }) + + return NextResponse.json( + { challengeResponse }, + { + headers: { + "Cache-Control": "no-store", + }, + }, + ) +} + +export async function POST(request: NextRequest) { + const body = await request.text() + const signatureHeader = request.headers.get("x-ebay-signature") + + try { + const verification = await verifyEbayNotificationSignature({ + body, + signatureHeader, + }) + + if (!verification.verified) { + if (verification.reason === "Notification verification credentials are not configured.") { + console.warn("[ebay/notifications] accepted notification without signature verification", { + reason: verification.reason, + }) + const payload = parseNotificationBody(body) + const notification = payload?.notification + + console.info("[ebay/notifications] accepted notification without verification", { + topic: payload?.metadata?.topic || "unknown", + notificationId: notification?.notificationId || "unknown", + publishAttemptCount: notification?.publishAttemptCount ?? null, + }) + + return new NextResponse(null, { status: 204 }) + } + + console.warn("[ebay/notifications] signature rejected", { + reason: verification.reason, + }) + return NextResponse.json({ error: verification.reason }, { status: 412 }) + } + + const payload = parseNotificationBody(body) + const notification = payload?.notification + + console.info("[ebay/notifications] accepted notification", { + keyId: verification.keyId, + topic: payload?.metadata?.topic || "unknown", + notificationId: notification?.notificationId || "unknown", + publishAttemptCount: notification?.publishAttemptCount ?? null, + }) + + return new NextResponse(null, { status: 204 }) + } catch (error) { + console.error("[ebay/notifications] failed to process notification", { + error: error instanceof Error ? error.message : String(error), + }) + + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Failed to verify eBay notification.", + }, + { status: 500 }, + ) + } +} diff --git a/app/api/ebay/search/route.ts b/app/api/ebay/search/route.ts new file mode 100644 index 00000000..3dd0a43e --- /dev/null +++ b/app/api/ebay/search/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from 'next/server' + +/** + * eBay API Proxy Route + * Proxies requests to eBay Finding API to avoid CORS issues + */ + +interface eBaySearchParams { + keywords: string + categoryId?: string + sortOrder?: string + maxResults?: number +} + +interface eBaySearchResult { + itemId: string + title: string + price: string + currency: string + imageUrl?: string + viewItemUrl: string + condition?: string + shippingCost?: string + affiliateLink: string +} + +type MaybeArray = T | T[] + +// Affiliate campaign ID for generating links +const AFFILIATE_CAMPAIGN_ID = process.env.EBAY_AFFILIATE_CAMPAIGN_ID?.trim() || '' + +// Generate eBay affiliate link +function generateAffiliateLink(viewItemUrl: string): string { + if (!AFFILIATE_CAMPAIGN_ID) { + return viewItemUrl + } + + try { + const url = new URL(viewItemUrl) + url.searchParams.set('mkcid', '1') + url.searchParams.set('mkrid', '711-53200-19255-0') + url.searchParams.set('siteid', '0') + url.searchParams.set('campid', AFFILIATE_CAMPAIGN_ID) + url.searchParams.set('toolid', '10001') + url.searchParams.set('mkevt', '1') + return url.toString() + } catch { + return viewItemUrl + } +} + +function first(value: MaybeArray | undefined): T | undefined { + if (!value) { + return undefined + } + + return Array.isArray(value) ? value[0] : value +} + +function normalizeItem(item: any): eBaySearchResult { + const currentPrice = first(item.sellingStatus?.currentPrice) + const shippingCost = first(item.shippingInfo?.shippingServiceCost) + const condition = first(item.condition) + const viewItemUrl = item.viewItemURL || item.viewItemUrl || '' + + return { + itemId: item.itemId || '', + title: item.title || 'Unknown Item', + price: `${currentPrice?.value || '0'} ${currentPrice?.currencyId || 'USD'}`, + currency: currentPrice?.currencyId || 'USD', + imageUrl: first(item.galleryURL) || undefined, + viewItemUrl, + condition: condition?.conditionDisplayName || undefined, + shippingCost: shippingCost?.value + ? `${shippingCost.value} ${shippingCost.currencyId || currentPrice?.currencyId || 'USD'}` + : undefined, + affiliateLink: generateAffiliateLink(viewItemUrl), + } +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + + const keywords = searchParams.get('keywords') + const categoryId = searchParams.get('categoryId') || undefined + const sortOrder = searchParams.get('sortOrder') || 'BestMatch' + const maxResults = parseInt(searchParams.get('maxResults') || '6', 10) + + if (!keywords) { + return NextResponse.json({ error: 'Keywords parameter is required' }, { status: 400 }) + } + + const appId = process.env.EBAY_APP_ID?.trim() + + if (!appId) { + console.error('EBAY_APP_ID not configured') + return NextResponse.json( + { error: 'eBay API not configured. Please set EBAY_APP_ID environment variable.' }, + { status: 503 } + ) + } + + // Build eBay Finding API URL + const baseUrl = 'https://svcs.ebay.com/services/search/FindingService/v1' + const url = new URL(baseUrl) + + url.searchParams.set('OPERATION-NAME', 'findItemsAdvanced') + url.searchParams.set('SERVICE-VERSION', '1.0.0') + url.searchParams.set('SECURITY-APPNAME', appId) + url.searchParams.set('RESPONSE-DATA-FORMAT', 'JSON') + url.searchParams.set('REST-PAYLOAD', 'true') + url.searchParams.set('keywords', keywords) + url.searchParams.set('sortOrder', sortOrder) + url.searchParams.set('paginationInput.entriesPerPage', maxResults.toString()) + + if (categoryId) { + url.searchParams.set('categoryId', categoryId) + } + + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Accept': 'application/json', + }, + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('eBay API error:', response.status, errorText) + return NextResponse.json( + { error: `eBay API error: ${response.status}` }, + { status: response.status } + ) + } + + const data = await response.json() + + // Parse eBay API response + const findItemsAdvancedResponse = data.findItemsAdvancedResponse?.[0] + if (!findItemsAdvancedResponse) { + return NextResponse.json([]) + } + + const searchResult = findItemsAdvancedResponse.searchResult?.[0] + if (!searchResult || !searchResult.item || searchResult.item.length === 0) { + return NextResponse.json([]) + } + + const items = Array.isArray(searchResult.item) ? searchResult.item : [searchResult.item] + + const results: eBaySearchResult[] = items.map((item: any) => normalizeItem(item)) + + return NextResponse.json(results) + + } catch (error) { + console.error('Error fetching from eBay API:', error) + return NextResponse.json( + { error: 'Failed to fetch products from eBay' }, + { status: 500 } + ) + } +} diff --git a/components/parts-panel.tsx b/components/parts-panel.tsx index 015408fd..1712a73b 100644 --- a/components/parts-panel.tsx +++ b/components/parts-panel.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useCallback, useEffect, useState } from 'react' import { ExternalLink, ShoppingCart, Loader2, AlertCircle } from 'lucide-react' import { Button } from '@/components/ui/button' import { getTopPartsForManual, type PartForPage } from '@/lib/parts-lookup' @@ -14,30 +14,60 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps) const [parts, setParts] = useState([]) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) - const [isSearching, setIsSearching] = useState(false) - useEffect(() => { - async function loadParts() { - setIsLoading(true) - setIsSearching(false) - setError(null) - - try { - const topParts = await getTopPartsForManual(manualFilename, 5) - setParts(topParts) - } catch (err) { - console.error('Error loading parts:', err) - setError('Could not load parts') - } finally { - setIsLoading(false) - } - } - - if (manualFilename) { - loadParts() + const loadParts = useCallback(async () => { + setIsLoading(true) + setError(null) + + try { + const result = await getTopPartsForManual(manualFilename, 5) + setParts(result.parts) + setError(result.error ?? null) + } catch (err) { + console.error('Error loading parts:', err) + setParts([]) + setError('Could not load parts') + } finally { + setIsLoading(false) } }, [manualFilename]) + useEffect(() => { + if (manualFilename) { + void loadParts() + } + }, [loadParts, manualFilename]) + + const hasListings = parts.some((part) => part.ebayListings.length > 0) + + const renderStatusCard = (title: string, message: string) => ( +
+
+
+ + + Parts + +
+
+
+ +

{title}

+

+ {message} +

+ +
+
+ ) + if (isLoading) { return (
@@ -57,7 +87,16 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps) ) } - if (error || parts.length === 0) { + if (error && !hasListings) { + return renderStatusCard( + 'eBay unavailable', + error.includes('eBay API not configured') + ? 'Set EBAY_APP_ID in the app environment so live listings can load.' + : error, + ) + } + + if (parts.length === 0 || !hasListings) { return (
@@ -88,6 +127,24 @@ export function PartsPanel({ manualFilename, className = '' }: PartsPanelProps)
+ {error && ( +
+
+ +
+

+ Live eBay listings are unavailable right now. +

+

+ {error.includes('eBay API not configured') + ? 'Set EBAY_APP_ID in the app environment, then reload the panel.' + : error} +

+
+
+
+ )} + {parts.map((part, index) => (
0 && (
- {part.ebayListings.slice(0, 2).map((listing, listingIndex) => ( + {part.ebayListings.slice(0, 2).map((listing) => (
) } - diff --git a/lib/ebay-notifications.ts b/lib/ebay-notifications.ts new file mode 100644 index 00000000..e1748d28 --- /dev/null +++ b/lib/ebay-notifications.ts @@ -0,0 +1,339 @@ +import { createHash, createPublicKey, createVerify } from "node:crypto" +import type { NextRequest } from "next/server" + +const DEFAULT_EBAY_API_BASE_URL = "https://api.ebay.com" +const DEFAULT_EBAY_NOTIFICATION_SCOPE = "https://api.ebay.com/oauth/api_scope" +const DEFAULT_EBAY_NOTIFICATION_ENDPOINT = "https://rmv.abundancepartners.app/api/ebay/notifications" +const DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN = "rmv_ebay_notifications_2026_04_01_test_7c4f1a9d" +const PUBLIC_KEY_CACHE_TTL = 60 * 60 * 1000 +const ACCESS_TOKEN_CACHE_TTL_SKEW = 60 * 1000 + +type CachedAccessToken = { + token: string + expiresAt: number +} + +type CachedPublicKey = { + algorithm: string + digest: string + key: string + expiresAt: number +} + +export type EbayNotificationSignature = { + algorithm?: string + digest?: string + keyId: string + signature: string +} + +type EbayPublicKeyResponse = { + algorithm?: string + digest?: string + key?: string +} + +type VerificationResult = + | { + verified: true + keyId: string + } + | { + verified: false + reason: string + } + +const accessTokenCache = new Map() +const publicKeyCache = new Map() + +function getOptionalEnv(name: string) { + const value = process.env[name] + return typeof value === "string" && value.trim() ? value.trim() : "" +} + +function stripTrailingSlash(value: string) { + return value.replace(/\/+$/, "") +} + +function normalizeEndpointFromRequest(requestUrl?: string) { + if (!requestUrl) { + return "" + } + + try { + const url = new URL(requestUrl) + return `${url.origin}${url.pathname}`.replace(/\/+$/, "") + } catch { + return requestUrl.split(/[?#]/)[0]?.replace(/\/+$/, "") || "" + } +} + +export function getEbayNotificationEndpoint(requestUrl?: string) { + const configuredEndpoint = getOptionalEnv("EBAY_NOTIFICATION_ENDPOINT") + if (configuredEndpoint) { + return stripTrailingSlash(configuredEndpoint) + } + + if (DEFAULT_EBAY_NOTIFICATION_ENDPOINT) { + return stripTrailingSlash(DEFAULT_EBAY_NOTIFICATION_ENDPOINT) + } + + return normalizeEndpointFromRequest(requestUrl) +} + +export function getEbayNotificationVerificationToken() { + return getOptionalEnv("EBAY_NOTIFICATION_VERIFICATION_TOKEN") || DEFAULT_EBAY_NOTIFICATION_VERIFICATION_TOKEN +} + +export function computeEbayChallengeResponse(args: { + challengeCode: string + endpoint: string + verificationToken: string +}) { + const hash = createHash("sha256") + hash.update(args.challengeCode) + hash.update(args.verificationToken) + hash.update(args.endpoint) + return hash.digest("hex") +} + +function base64DecodeText(rawValue: string) { + const normalized = rawValue.trim().replace(/-/g, "+").replace(/_/g, "/") + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4) + + try { + return Buffer.from(padded, "base64").toString("utf8").trim() + } catch { + return "" + } +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null + } + + return value as Record +} + +function firstString(...values: unknown[]) { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value.trim() + } + } + + return "" +} + +export function parseEbaySignatureHeader(rawHeader: string | null): EbayNotificationSignature | null { + const trimmed = typeof rawHeader === "string" ? rawHeader.trim() : "" + if (!trimmed) { + return null + } + + const candidates = [trimmed, base64DecodeText(trimmed)].filter(Boolean) + + for (const candidate of candidates) { + const parsed = (() => { + try { + return JSON.parse(candidate) as unknown + } catch { + return null + } + })() + + const parsedRecord = asRecord(parsed) + if (parsedRecord) { + const keyId = firstString(parsedRecord.keyId, parsedRecord.kid, parsedRecord.key_id, parsedRecord.publicKeyId) + const signature = firstString(parsedRecord.signature, parsedRecord.sig, parsedRecord.value) + const algorithm = firstString(parsedRecord.algorithm, parsedRecord.alg) + const digest = firstString(parsedRecord.digest, parsedRecord.hash) + + if (keyId && signature) { + return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined } + } + + if (keyId) { + return { keyId, signature, algorithm: algorithm || undefined, digest: digest || undefined } + } + } + } + + const decoded = base64DecodeText(trimmed) + if (decoded) { + const keyId = decoded + return { keyId, signature: "" } + } + + return null +} + +function normalizeDigest(digest?: string) { + switch ((digest || "").trim().toLowerCase()) { + case "sha1": + return "sha1" + case "sha256": + return "sha256" + case "sha384": + return "sha384" + case "sha512": + return "sha512" + default: + return "sha1" + } +} + +function normalizePublicKeyPem(value: string) { + const trimmed = value.trim() + if (!trimmed) { + return "" + } + + if (trimmed.includes("BEGIN PUBLIC KEY")) { + return trimmed + } + + const compact = trimmed.replace(/\s+/g, "") + const chunks = compact.match(/.{1,64}/g) || [compact] + return `-----BEGIN PUBLIC KEY-----\n${chunks.join("\n")}\n-----END PUBLIC KEY-----` +} + +async function getEbayAccessToken() { + const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID") + const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID") + const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL) + const scope = getOptionalEnv("EBAY_NOTIFICATION_SCOPE") || DEFAULT_EBAY_NOTIFICATION_SCOPE + + if (!clientId || !clientSecret) { + throw new Error("eBay notification verification requires EBAY_NOTIFICATION_APP_ID and EBAY_NOTIFICATION_CERT_ID.") + } + + const cacheKey = `${apiBaseUrl}|${clientId}|${clientSecret}|${scope}` + const cached = accessTokenCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.token + } + + const tokenUrl = `${apiBaseUrl}/identity/v1/oauth2/token` + const body = new URLSearchParams() + body.set("grant_type", "client_credentials") + body.set("scope", scope) + + const authValue = Buffer.from(`${clientId}:${clientSecret}`).toString("base64") + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + Authorization: `Basic ${authValue}`, + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body, + }) + + if (!response.ok) { + const details = await response.text().catch(() => "") + throw new Error( + `Failed to fetch eBay access token (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`, + ) + } + + const payload = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null + const token = typeof payload?.access_token === "string" ? payload.access_token : "" + const expiresIn = typeof payload?.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600 + + if (!token) { + throw new Error("eBay access token response did not include an access_token value.") + } + + accessTokenCache.set(cacheKey, { + token, + expiresAt: Date.now() + Math.max(expiresIn * 1000 - ACCESS_TOKEN_CACHE_TTL_SKEW, 60_000), + }) + + return token +} + +async function getEbayPublicKey(keyId: string) { + const apiBaseUrl = stripTrailingSlash(getOptionalEnv("EBAY_NOTIFICATION_API_BASE_URL") || DEFAULT_EBAY_API_BASE_URL) + const cacheKey = `${apiBaseUrl}|${keyId}` + const cached = publicKeyCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached + } + + const accessToken = await getEbayAccessToken() + const response = await fetch(`${apiBaseUrl}/commerce/notification/v1/public_key/${encodeURIComponent(keyId)}`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }) + + if (!response.ok) { + const details = await response.text().catch(() => "") + throw new Error( + `Failed to fetch eBay public key (${response.status} ${response.statusText})${details ? `: ${details}` : ""}`, + ) + } + + const payload = (await response.json().catch(() => null)) as EbayPublicKeyResponse | null + const key = typeof payload?.key === "string" ? normalizePublicKeyPem(payload.key) : "" + const algorithm = typeof payload?.algorithm === "string" && payload.algorithm.trim() ? payload.algorithm.trim() : "" + const digest = typeof payload?.digest === "string" && payload.digest.trim() ? payload.digest.trim() : "" + + if (!key) { + throw new Error("eBay public key response did not include a valid key.") + } + + const cachedValue = { + algorithm, + digest, + key, + expiresAt: Date.now() + PUBLIC_KEY_CACHE_TTL, + } + + publicKeyCache.set(cacheKey, cachedValue) + return cachedValue +} + +export async function verifyEbayNotificationSignature(args: { + body: string + signatureHeader: string | null +}): Promise { + const clientId = getOptionalEnv("EBAY_NOTIFICATION_APP_ID") + const clientSecret = getOptionalEnv("EBAY_NOTIFICATION_CERT_ID") + if (!clientId || !clientSecret) { + return { + verified: false, + reason: "Notification verification credentials are not configured.", + } + } + + const signature = parseEbaySignatureHeader(args.signatureHeader) + if (!signature) { + return { verified: false, reason: "Missing or invalid X-EBAY-SIGNATURE header." } + } + + if (!signature.keyId) { + return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a key id." } + } + + if (!signature.signature) { + return { verified: false, reason: "The X-EBAY-SIGNATURE header did not include a signature value." } + } + + const publicKey = await getEbayPublicKey(signature.keyId) + const verifier = createVerify(normalizeDigest(signature.digest || publicKey.digest)) + verifier.update(args.body, "utf8") + verifier.end() + + const verified = verifier.verify(createPublicKey(publicKey.key), signature.signature, "base64") + if (!verified) { + return { verified: false, reason: "eBay notification signature verification failed." } + } + + return { verified: true, keyId: signature.keyId } +} diff --git a/lib/parts-lookup.ts b/lib/parts-lookup.ts index a3ebcc67..e89c3af6 100644 --- a/lib/parts-lookup.ts +++ b/lib/parts-lookup.ts @@ -1,12 +1,12 @@ /** * Parts lookup utility for frontend - * - * Provides functions to fetch parts data by manual filename + * + * Provides functions to fetch parts data by manual filename. + * Static JSON remains the primary data source, while live eBay fallback + * goes through the server route so credentials never reach the browser. */ -import { ebayClient } from './ebay-api' - -interface PartForPage { +export interface PartForPage { partNumber: string description: string ebayListings: Array<{ @@ -32,8 +32,25 @@ interface ManualPagesParts { } } +interface EbaySearchResult { + itemId: string + title: string + price: string + currency: string + imageUrl?: string + viewItemUrl: string + condition?: string + shippingCost?: string + affiliateLink: string +} + +interface EbaySearchResponse { + results: EbaySearchResult[] + error?: string +} + // Cache for eBay search results -const ebaySearchCache = new Map() +const ebaySearchCache = new Map() const EBAY_CACHE_TTL = 15 * 60 * 1000 // 15 minutes let manualPartsCache: ManualPartsLookup | null = null @@ -83,56 +100,227 @@ async function loadPartsData(): Promise<{ } /** - * Search eBay for parts with caching + * Search eBay for parts with caching. + * This calls the server route so the app never needs direct eBay credentials + * in client code. */ -async function searchEBayForParts(partNumber: string, description?: string, manufacturer?: string): Promise { +async function searchEBayForParts( + partNumber: string, + description?: string, + manufacturer?: string, +): Promise { const cacheKey = `parts:${partNumber}:${description || ''}:${manufacturer || ''}` // Check cache const cached = ebaySearchCache.get(cacheKey) if (cached && Date.now() - cached.timestamp < EBAY_CACHE_TTL) { - return cached.results + return { results: cached.results as EbaySearchResult[] } } - try { - const results = await ebayClient.searchVendingParts(partNumber, description, manufacturer) - ebaySearchCache.set(cacheKey, { results, timestamp: Date.now() }) - return results - } catch (error) { - console.error(`Error searching eBay for part ${partNumber}:`, error) - // Return empty array if API fails - return [] + const buildQuery = () => { + let query = partNumber + + if (description && description.length > 0 && description.length < 50) { + const descWords = description + .split(/\s+/) + .filter((word) => word.length > 3) + .slice(0, 3) + .join(' ') + + if (descWords) { + query += ` ${descWords}` + } + } + + if (manufacturer) { + query += ` ${manufacturer}` + } + + return `${query} vending machine` + } + + const searchViaApi = async (categoryId?: string): Promise => { + const params = new URLSearchParams({ + keywords: buildQuery(), + maxResults: '3', + sortOrder: 'BestMatch', + }) + + if (categoryId) { + params.set('categoryId', categoryId) + } + + try { + const response = await fetch(`/api/ebay/search?${params.toString()}`) + const body = await response.json().catch(() => null) + + if (!response.ok) { + const message = + body && typeof body.error === 'string' + ? body.error + : `eBay API error: ${response.status}` + + return { results: [], error: message } + } + + const results = Array.isArray(body) ? body : [] + return { results } + } catch (error) { + return { + results: [], + error: error instanceof Error ? error.message : 'Failed to search eBay', + } + } + } + + const categorySearch = await searchViaApi('11700') + if (categorySearch.results.length > 0) { + ebaySearchCache.set(cacheKey, { + results: categorySearch.results, + timestamp: Date.now(), + }) + return categorySearch + } + + const generalSearch = await searchViaApi() + if (generalSearch.results.length > 0) { + ebaySearchCache.set(cacheKey, { + results: generalSearch.results, + timestamp: Date.now(), + }) + return generalSearch + } + + return { + results: [], + error: categorySearch.error || generalSearch.error, } } -/** - * Enhance parts data with real-time eBay listings - */ -async function enhancePartsData(parts: PartForPage[]): Promise { - if (!ebayClient.isConfigured()) { - return parts - } +async function enhancePartsData(parts: PartForPage[]): Promise<{ + parts: PartForPage[] + error?: string +}> { + let firstError: string | undefined const enhancedParts = await Promise.all(parts.map(async (part) => { // Only search for parts without existing eBay listings if (part.ebayListings.length === 0) { const ebayResults = await searchEBayForParts(part.partNumber, part.description) - part.ebayListings = ebayResults.map(result => ({ - itemId: result.itemId, - title: result.title, - price: result.price, - currency: result.currency, - imageUrl: result.imageUrl, - viewItemUrl: result.viewItemUrl, - condition: result.condition, - shippingCost: result.shippingCost, - affiliateLink: result.affiliateLink, - })) + + if (ebayResults.error && !firstError) { + firstError = ebayResults.error + } + + return { + ...part, + ebayListings: ebayResults.results.map((result) => ({ + itemId: result.itemId, + title: result.title, + price: result.price, + currency: result.currency, + imageUrl: result.imageUrl, + viewItemUrl: result.viewItemUrl, + condition: result.condition, + shippingCost: result.shippingCost, + affiliateLink: result.affiliateLink, + })), + } } + return part })) - - return enhancedParts + + return { + parts: enhancedParts, + error: firstError, + } +} + +function findManualParts( + manualFilename: string, + manualParts: ManualPartsLookup, +): PartForPage[] { + if (manualParts[manualFilename]) { + return manualParts[manualFilename] + } + + const lowerFilename = manualFilename.toLowerCase() + for (const [filename, parts] of Object.entries(manualParts)) { + if (filename.toLowerCase() === lowerFilename) { + return parts + } + } + + const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '') + const lowerWithoutExt = filenameWithoutExt.toLowerCase() + + for (const [filename, parts] of Object.entries(manualParts)) { + const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase() + if ( + otherWithoutExt === lowerWithoutExt || + otherWithoutExt.includes(lowerWithoutExt) || + lowerWithoutExt.includes(otherWithoutExt) + ) { + return parts + } + } + + return [] +} + +function findManualPagesParts( + manualFilename: string, + pageNumber: number, + manualPagesParts: ManualPagesParts, +): PartForPage[] { + if ( + manualPagesParts[manualFilename] && + manualPagesParts[manualFilename][pageNumber.toString()] + ) { + return manualPagesParts[manualFilename][pageNumber.toString()] + } + + const lowerFilename = manualFilename.toLowerCase() + for (const [filename, pages] of Object.entries(manualPagesParts)) { + if (filename.toLowerCase() === lowerFilename && pages[pageNumber.toString()]) { + return pages[pageNumber.toString()] + } + } + + const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '') + const lowerWithoutExt = filenameWithoutExt.toLowerCase() + + for (const [filename, pages] of Object.entries(manualPagesParts)) { + const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase() + if ( + otherWithoutExt === lowerWithoutExt || + otherWithoutExt.includes(lowerWithoutExt) || + lowerWithoutExt.includes(otherWithoutExt) + ) { + if (pages[pageNumber.toString()]) { + return pages[pageNumber.toString()] + } + } + } + + return [] +} + +async function getPartsForManualWithStatus( + manualFilename: string, +): Promise<{ + parts: PartForPage[] + error?: string +}> { + const { manualParts } = await loadPartsData() + const parts = findManualParts(manualFilename, manualParts) + + if (parts.length === 0) { + return { parts: [] } + } + + return enhancePartsData(parts) } /** @@ -141,34 +329,8 @@ async function enhancePartsData(parts: PartForPage[]): Promise { export async function getPartsForManual( manualFilename: string ): Promise { - const { manualParts } = await loadPartsData() - - // Try exact match first - if (manualParts[manualFilename]) { - const parts = manualParts[manualFilename] - return enhancePartsData(parts) - } - - // Try case-insensitive match - const lowerFilename = manualFilename.toLowerCase() - for (const [filename, parts] of Object.entries(manualParts)) { - if (filename.toLowerCase() === lowerFilename) { - return enhancePartsData(parts) - } - } - - // Try partial match (filename without extension) - const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '') - const lowerWithoutExt = filenameWithoutExt.toLowerCase() - - for (const [filename, parts] of Object.entries(manualParts)) { - const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase() - if (otherWithoutExt === lowerWithoutExt || otherWithoutExt.includes(lowerWithoutExt) || lowerWithoutExt.includes(otherWithoutExt)) { - return enhancePartsData(parts) - } - } - - return [] + const result = await getPartsForManualWithStatus(manualFilename) + return result.parts } /** @@ -179,39 +341,14 @@ export async function getPartsForPage( pageNumber: number ): Promise { const { manualPagesParts } = await loadPartsData() - - // Try exact match first - if (manualPagesParts[manualFilename] && manualPagesParts[manualFilename][pageNumber.toString()]) { - const parts = manualPagesParts[manualFilename][pageNumber.toString()] - return enhancePartsData(parts) + const parts = findManualPagesParts(manualFilename, pageNumber, manualPagesParts) + + if (parts.length === 0) { + return [] } - - // Try case-insensitive match - const lowerFilename = manualFilename.toLowerCase() - for (const [filename, pages] of Object.entries(manualPagesParts)) { - if (filename.toLowerCase() === lowerFilename) { - if (pages[pageNumber.toString()]) { - const parts = pages[pageNumber.toString()] - return enhancePartsData(parts) - } - } - } - - // Try partial match - const filenameWithoutExt = manualFilename.replace(/\.pdf$/i, '') - const lowerWithoutExt = filenameWithoutExt.toLowerCase() - - for (const [filename, pages] of Object.entries(manualPagesParts)) { - const otherWithoutExt = filename.replace(/\.pdf$/i, '').toLowerCase() - if (otherWithoutExt === lowerWithoutExt || otherWithoutExt.includes(lowerWithoutExt) || lowerWithoutExt.includes(otherWithoutExt)) { - if (pages[pageNumber.toString()]) { - const parts = pages[pageNumber.toString()] - return enhancePartsData(parts) - } - } - } - - return [] + + const enhanced = await enhancePartsData(parts) + return enhanced.parts } /** @@ -220,13 +357,19 @@ export async function getPartsForPage( export async function getTopPartsForManual( manualFilename: string, limit: number = 5 -): Promise { - const parts = await getPartsForManual(manualFilename) +): Promise<{ + parts: PartForPage[] + error?: string +}> { + const { parts, error } = await getPartsForManualWithStatus(manualFilename) // Sort by number of eBay listings (more listings = more relevant) const sorted = parts.sort((a, b) => b.ebayListings.length - a.ebayListings.length) - return sorted.slice(0, limit) + return { + parts: sorted.slice(0, limit), + error, + } } /** @@ -234,7 +377,7 @@ export async function getTopPartsForManual( */ export async function hasPartsForManual(manualFilename: string): Promise { const parts = await getPartsForManual(manualFilename) - return parts.length > 0 && parts.some(part => part.ebayListings.length > 0) + return parts.length > 0 } /** @@ -244,9 +387,9 @@ export async function hasPartsForManual(manualFilename: string): Promise> { const { manualParts } = await loadPartsData() const manualsWithParts = new Set() - + for (const [filename, parts] of Object.entries(manualParts)) { - if (parts.length > 0 && parts.some(part => part.ebayListings.length > 0)) { + if (parts.length > 0) { manualsWithParts.add(filename) // Also add variations for matching manualsWithParts.add(filename.toLowerCase()) @@ -254,7 +397,7 @@ export async function getManualsWithParts(): Promise> { manualsWithParts.add(filename.replace(/\.pdf$/i, '').toLowerCase()) } } - + return manualsWithParts }