Tom Butcher 5ff93be27c
Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
Add audit owner resolution and actor display name functions
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.
2026-08-24 02:49:38 +01:00

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;
}