Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/db/migrations/20260803000000_optimize_database_queries.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';

/**
* Add composite indexes and query optimizations (#570).
*
* Adds composite indexes for common query patterns combining filtering
* and sorting operations to improve query performance.
*/

exports.up = async function up(knex) {
// Recipients: list by airdrop with ordering
await knex.raw('CREATE INDEX IF NOT EXISTS idx_recipients_airdrop_created ON recipients (airdrop_id, created_at DESC)');

// Alerts: list by asset with status filtering and ordering
await knex.raw('CREATE INDEX IF NOT EXISTS idx_alerts_asset_status_created ON alerts (asset, status, created_at DESC)');

// Webhooks: active webhooks by event type (composite for filtering)
await knex.raw('CREATE INDEX IF NOT EXISTS idx_webhooks_active_event ON webhooks (active, event_type)');

// API key audit: filter by key and created_at range efficiently
await knex.raw('CREATE INDEX IF NOT EXISTS idx_api_key_audit_key_created ON api_key_audit_logs (key_id, created_at DESC)');
};

exports.down = async function down(knex) {
const indexes = [
'idx_recipients_airdrop_created',
'idx_alerts_asset_status_created',
'idx_webhooks_active_event',
'idx_api_key_audit_key_created',
];
for (const idx of indexes) {
await knex.raw(`DROP INDEX IF EXISTS ${idx}`);
}
};
12 changes: 8 additions & 4 deletions src/middleware/errorHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
const AppError = require('../errors/AppError');
const logger = require('../logger');
const errorTracker = require('../services/errorTracker');
const { translate, getLocale } = require('../services/i18n');

/**
* Standardized error response format (#491).
Expand Down Expand Up @@ -45,9 +46,10 @@ function notFoundHandler(req, _res, next) {
function errorHandler(err, req, res, _next) {
const isAppError = err instanceof AppError;
const isPayloadTooLarge = !isAppError && (err.type === 'entity.too.large' || err.status === 413 || err.statusCode === 413);
const locale = getLocale(req);
let status = 500;
let code = 'INTERNAL_ERROR';
let message = 'An unexpected error occurred';
let message = translate('INTERNAL_ERROR', locale);

if (isAppError) {
status = err.statusCode;
Expand All @@ -56,14 +58,14 @@ function errorHandler(err, req, res, _next) {
} else if (err.isCircuitBreakerError || err.name === 'CircuitBreakerOpenError') {
status = 503;
code = 'SERVICE_UNAVAILABLE';
message = err.message || 'Service temporarily unavailable';
message = err.message || translate('SERVICE_UNAVAILABLE', locale);
if (err.retryAfter) {
res.setHeader('Retry-After', String(err.retryAfter));
}
} else if (isPayloadTooLarge) {
status = 413;
code = 'PAYLOAD_TOO_LARGE';
message = 'Request body is too large';
message = translate('PAYLOAD_TOO_LARGE', locale);
} else if (err.status || err.statusCode) {
status = err.status || err.statusCode;
const STATUS_CODES = { 400: 'VALIDATION_ERROR', 401: 'UNAUTHORIZED', 403: 'FORBIDDEN', 404: 'NOT_FOUND', 413: 'PAYLOAD_TOO_LARGE', 429: 'RATE_LIMITED', 503: 'SERVICE_UNAVAILABLE' };
Expand Down Expand Up @@ -91,8 +93,10 @@ function errorHandler(err, req, res, _next) {

// #449 — Sanitize the message before sending to the client to prevent
// stack traces, file paths, or other internal details from leaking.
// #571 — Localize standard error messages based on the client's language preference.
const safeMessage = sanitizeMessage(message);
const error = { code, message: safeMessage, request_id: req.id };
const localizedMessage = translate(code, locale) !== code ? translate(code, locale) : safeMessage;
const error = { code, message: localizedMessage, request_id: req.id };
if (isAppError && err.details && Object.keys(err.details).length > 0) {
// Also sanitize details to prevent stack traces in nested fields.
const safeDetails = {};
Expand Down
6 changes: 6 additions & 0 deletions src/routes/airdrops.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const { idempotencyMiddleware } = require("../services/idempotency");
const logger = require("../logger");
const AppError = require("../errors/AppError");
const { flattenZodIssues, validate } = require("../middleware/validate");
const { requireApiKey } = require("../middleware/auth");
const {
airdropCreateBodySchema,
airdropRecipientsBodySchema,
Expand Down Expand Up @@ -300,6 +301,7 @@ async function parseCSV(buffer) {

router.post(
"/airdrops",
requireApiKey({ scopes: ["airdrops"] }),
routeTimeout(),
createAirdropLimit,
idempotencyMiddleware("airdrop"),
Expand Down Expand Up @@ -343,6 +345,7 @@ router.get("/airdrops/:id", validateRouteIdParams, async (req, res, next) => {

router.patch(
"/airdrops/:id",
requireApiKey({ scopes: ["airdrops"] }),
validateRouteIdParams,
validateWithCurrentLedger(airdropUpdateBodySchema),
async (req, res, next) => {
Expand All @@ -366,6 +369,7 @@ router.patch(

router.delete(
"/airdrops/:id",
requireApiKey({ scopes: ["airdrops"] }),
validateRouteIdParams,
async (req, res, next) => {
try {
Expand All @@ -385,6 +389,7 @@ router.delete(

router.post(
"/airdrops/:id/cancel",
requireApiKey({ scopes: ["airdrops"] }),
validateRouteIdParams,
async (req, res, next) => {
try {
Expand All @@ -404,6 +409,7 @@ router.post(

router.post(
"/airdrops/:id/recipients",
requireApiKey({ scopes: ["airdrops"] }),
routeTimeout(),
validateRouteIdParams,
addRecipientsLimit,
Expand Down
78 changes: 78 additions & 0 deletions src/services/i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'use strict';

/**
* Internationalization (i18n) service for error messages (#571).
*
* Supports error message localization based on Accept-Language header.
* Currently supports: English (en), Spanish (es), French (fr).
*/

const translations = {
en: {
UNAUTHORIZED: 'Missing or invalid API key',
FORBIDDEN: 'Insufficient API key scope',
NOT_FOUND: 'Resource does not exist',
VALIDATION_ERROR: 'Request validation failed',
PAYLOAD_TOO_LARGE: 'Request body is too large',
RATE_LIMITED: 'Too many requests, please try again later',
SERVICE_UNAVAILABLE: 'Service temporarily unavailable',
INTERNAL_ERROR: 'An unexpected error occurred',
WEBHOOK_LIMIT_EXCEEDED: 'Webhook limit exceeded for this subscriber',
WEBHOOK_NOT_FOUND: 'Webhook not found',
AIRDROP_NOT_FOUND: 'Airdrop not found',
},
es: {
UNAUTHORIZED: 'Clave de API faltante o inválida',
FORBIDDEN: 'Alcance de clave de API insuficiente',
NOT_FOUND: 'El recurso no existe',
VALIDATION_ERROR: 'Validación de solicitud fallida',
PAYLOAD_TOO_LARGE: 'El cuerpo de la solicitud es demasiado grande',
RATE_LIMITED: 'Demasiadas solicitudes, intente más tarde',
SERVICE_UNAVAILABLE: 'Servicio temporalmente no disponible',
INTERNAL_ERROR: 'Ocurrió un error inesperado',
WEBHOOK_LIMIT_EXCEEDED: 'Límite de webhook excedido para este suscriptor',
WEBHOOK_NOT_FOUND: 'Webhook no encontrado',
AIRDROP_NOT_FOUND: 'Airdrop no encontrado',
},
fr: {
UNAUTHORIZED: 'Clé API manquante ou invalide',
FORBIDDEN: 'Portée de clé API insuffisante',
NOT_FOUND: 'La ressource n\'existe pas',
VALIDATION_ERROR: 'Validation de la demande échouée',
PAYLOAD_TOO_LARGE: 'Le corps de la demande est trop volumineux',
RATE_LIMITED: 'Trop de demandes, réessayez plus tard',
SERVICE_UNAVAILABLE: 'Service temporairement indisponible',
INTERNAL_ERROR: 'Une erreur inattendue s\'est produite',
WEBHOOK_LIMIT_EXCEEDED: 'Limite de webhook dépassée pour cet abonné',
WEBHOOK_NOT_FOUND: 'Webhook non trouvé',
AIRDROP_NOT_FOUND: 'Airdrop non trouvé',
},
};

function parseAcceptLanguage(header) {
if (!header) return 'en';
const parts = header.split(',').map((part) => {
const [lang, q] = part.trim().split(';');
const quality = q ? parseFloat(q.split('=')[1]) : 1.0;
return { lang: lang.trim().split('-')[0].toLowerCase(), quality };
});
parts.sort((a, b) => b.quality - a.quality);
return parts.length > 0 ? parts[0].lang : 'en';
}

function getLocale(req) {
const acceptLanguage = req.get('accept-language');
const preferred = parseAcceptLanguage(acceptLanguage);
return Object.prototype.hasOwnProperty.call(translations, preferred) ? preferred : 'en';
}

function translate(code, locale = 'en') {
const localeTranslations = translations[locale] || translations.en;
return localeTranslations[code] || translations.en[code] || code;
}

module.exports = {
translate,
getLocale,
translations,
};
7 changes: 6 additions & 1 deletion src/services/webhookDispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,12 @@ async function attempt(deliveryId, sequence) {
const res = await postOnce(webhook.url, headers, body, webhook.timeoutMs);
responseStatus = res.status;
} catch (err) {
networkError = err.message || "network error";
const errorCode = err?.code || err?.errno;
if (errorCode === 'ETIMEDOUT') {
networkError = 'webhook request timeout';
} else {
networkError = err.message || "network error";
}
}

const succeeded =
Expand Down