import crypto from 'crypto'; import { getApiBaseUrl, getBasicAuthHeader, makeRequest, logger, formatDebugPayload, getMarketplaceDebugContext } from './shared.js'; const NOTIFICATION_BASE = '/commerce/notification/v1'; const APPLICATION_SCOPE = 'https://api.ebay.com/oauth/api_scope/commerce.notification.subscription'; const HANDLED_TOPIC_MATCHERS = [ 'MARKETPLACE_ACCOUNT_DELETION', 'AUTHORIZATION_REVOCATION', 'ITEM_AVAILABILITY', 'ITEM_PRICE_REVISION', 'ORDER', ]; function getVerificationToken(marketplace) { return ( marketplace.config?.verificationToken || marketplace.config?.verificationToken || '' ); } function getWebhookEndpoint(marketplace) { return marketplace.config?.webhookUrl || ''; } export function buildWebhookChallengeResponse(marketplace, { challengeCode, endpoint } = {}) { const verificationToken = getVerificationToken(marketplace); const destination = endpoint || getWebhookEndpoint(marketplace); if (!challengeCode || !verificationToken || !destination) { throw new Error( 'eBay webhook challenge requires challengeCode, verificationToken, and endpoint' ); } const challengeResponse = crypto .createHash('sha256') .update(challengeCode + verificationToken + destination) .digest('hex'); return { challengeResponse }; } async function getApplicationToken(marketplace) { logger.debug('eBay application token request', getMarketplaceDebugContext(marketplace)); const startedAt = Date.now(); const response = await fetch(`${getApiBaseUrl(marketplace)}/identity/v1/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Authorization: getBasicAuthHeader(marketplace), }, body: new URLSearchParams({ grant_type: 'client_credentials', scope: APPLICATION_SCOPE, }).toString(), }); const data = await response.json(); const durationMs = Date.now() - startedAt; if (!response.ok || data.error) { logger.error('eBay application token request failed', { status: response.status, durationMs, ...getMarketplaceDebugContext(marketplace), response: formatDebugPayload(data), }); throw new Error(data.error_description || data.error || 'Failed to mint eBay application token'); } logger.debug(`eBay application token request succeeded (${durationMs}ms)`, { ...getMarketplaceDebugContext(marketplace), expiresIn: data.expires_in, tokenType: data.token_type, }); return data.access_token; } async function notificationRequest(marketplace, { method = 'GET', path, body, useApplicationToken = false }) { if (!useApplicationToken) { return makeRequest({ marketplace, method, path, body, acceptableStatuses: [404] }); } const token = await getApplicationToken(marketplace); const url = `${getApiBaseUrl(marketplace)}${path}`; const startedAt = Date.now(); logger.debug(`eBay notification API ${method} ${path}`, { ...getMarketplaceDebugContext(marketplace), useApplicationToken: true, body: body ? formatDebugPayload(body) : undefined, }); const response = await fetch(url, { method, headers: { Authorization: `Bearer ${token}`, Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), }, ...(body && method !== 'GET' ? { body: JSON.stringify(body) } : {}), }); const durationMs = Date.now() - startedAt; if (response.status === 204 || response.status === 404) { logger.debug( `eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, getMarketplaceDebugContext(marketplace) ); return response.status === 404 ? null : null; } const data = await response.json().catch(() => null); if (!response.ok) { const message = data?.errors?.[0]?.message || data?.error_description || response.statusText; logger.error(`eBay notification API error (${response.status}): ${message}`, { path, durationMs, ...getMarketplaceDebugContext(marketplace), response: data ? formatDebugPayload(data) : undefined, }); throw new Error(`eBay notification API error (${response.status}): ${message}`); } logger.debug(`eBay notification API ${method} ${path} -> ${response.status} (${durationMs}ms)`, { ...getMarketplaceDebugContext(marketplace), response: data ? formatDebugPayload(data) : undefined, }); return data; } function topicIsHandled(topicId = '') { const id = String(topicId).toUpperCase(); return HANDLED_TOPIC_MATCHERS.some((matcher) => id.includes(matcher)); } export async function ensureWebhookSubscriptions(marketplace) { const endpoint = getWebhookEndpoint(marketplace); const verificationToken = getVerificationToken(marketplace); if (!endpoint || !verificationToken) { logger.warn( `Skipping eBay webhook subscription for "${marketplace.name}" (missing webhookUrl or verificationToken)` ); return { skipped: true }; } const destinations = await notificationRequest(marketplace, { path: `${NOTIFICATION_BASE}/destination`, }); let destination = (destinations?.destinations || []).find( (item) => item.deliveryConfig?.endpoint === endpoint ); if (!destination) { destination = await notificationRequest(marketplace, { method: 'POST', path: `${NOTIFICATION_BASE}/destination`, body: { name: `FarmControl ${marketplace.name}`.slice(0, 200), status: 'ENABLED', deliveryConfig: { endpoint, verificationToken, }, }, }); } const destinationId = destination?.destinationId || destination?.id; if (!destinationId) { throw new Error('Failed to create or resolve eBay notification destination'); } const topics = await notificationRequest(marketplace, { path: `${NOTIFICATION_BASE}/topic` }); const subscriptions = await notificationRequest(marketplace, { path: `${NOTIFICATION_BASE}/subscription`, }); const existingTopicIds = new Set( (subscriptions?.subscriptions || []).map((item) => item.topicId) ); const created = []; for (const topic of topics?.topics || []) { if (topic.status && String(topic.status).toUpperCase() !== 'ENABLED') continue; if (!topicIsHandled(topic.topicId)) continue; if (existingTopicIds.has(topic.topicId)) continue; const useApplicationToken = String(topic.scope || '').toUpperCase() === 'APPLICATION'; try { await notificationRequest(marketplace, { method: 'POST', path: `${NOTIFICATION_BASE}/subscription`, useApplicationToken, body: { topicId: topic.topicId, status: 'ENABLED', destinationId, payload: { format: 'JSON', schemaVersion: topic.supportedPayloads?.[0]?.schemaVersion || '1.0' }, }, }); created.push(topic.topicId); } catch (err) { logger.warn(`Failed to subscribe to eBay topic ${topic.topicId}: ${err.message}`); } } logger.info( `eBay webhook destination ${destinationId} ready for "${marketplace.name}" (${created.length} new subscription(s))` ); return { destinationId, created }; } async function getPublicKey(marketplace, kid) { return makeRequest({ marketplace, path: `${NOTIFICATION_BASE}/public_key/${encodeURIComponent(kid)}`, }); } export async function verifyNotificationSignature(marketplace, rawBody, signatureHeader) { if (!signatureHeader) { return false; } try { const decodedJson = Buffer.from(signatureHeader, 'base64').toString('utf8'); const decoded = JSON.parse(decodedJson); const kid = decoded.kid; const signature = decoded.signature; if (!kid || !signature) { return false; } const publicKey = await getPublicKey(marketplace, kid); const pem = publicKey?.key; if (!pem) { return false; } const digest = (decoded.digest || publicKey.digest || 'SHA256').replace('-', ''); const verifier = crypto.createVerify(digest); verifier.update(rawBody); verifier.end(); return verifier.verify(pem, signature, 'base64'); } catch (err) { logger.warn(`eBay notification signature verification failed: ${err.message}`); return false; } } export function canVerifyNotificationSignature(marketplace) { return !!marketplace.config?.accessToken; }