Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
This commit introduces a new module, `auditOwner.js`, which includes functions for resolving audit owner details and generating display names for actors. The `resolveAuditOwner` function determines the owner type based on the actor's object type, defaulting to 'user' if not specified. The `actorDisplayName` function formats the display name based on the actor's properties, enhancing the clarity of audit logs. Additionally, the `AUDIT_OWNER_TYPES` constant is exported for use in other modules. Updates to existing files incorporate these new functions for improved audit logging and notification handling.
252 lines
8.2 KiB
JavaScript
252 lines
8.2 KiB
JavaScript
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;
|
|
}
|