diff --git a/src/db/migrations/20260803000000_optimize_database_queries.js b/src/db/migrations/20260803000000_optimize_database_queries.js new file mode 100644 index 0000000..8996c73 --- /dev/null +++ b/src/db/migrations/20260803000000_optimize_database_queries.js @@ -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}`); + } +}; diff --git a/src/middleware/errorHandler.js b/src/middleware/errorHandler.js index c47476a..29ed2ac 100644 --- a/src/middleware/errorHandler.js +++ b/src/middleware/errorHandler.js @@ -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). @@ -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; @@ -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' }; @@ -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 = {}; diff --git a/src/routes/airdrops.js b/src/routes/airdrops.js index 71294d5..b372984 100644 --- a/src/routes/airdrops.js +++ b/src/routes/airdrops.js @@ -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, @@ -300,6 +301,7 @@ async function parseCSV(buffer) { router.post( "/airdrops", + requireApiKey({ scopes: ["airdrops"] }), routeTimeout(), createAirdropLimit, idempotencyMiddleware("airdrop"), @@ -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) => { @@ -366,6 +369,7 @@ router.patch( router.delete( "/airdrops/:id", + requireApiKey({ scopes: ["airdrops"] }), validateRouteIdParams, async (req, res, next) => { try { @@ -385,6 +389,7 @@ router.delete( router.post( "/airdrops/:id/cancel", + requireApiKey({ scopes: ["airdrops"] }), validateRouteIdParams, async (req, res, next) => { try { @@ -404,6 +409,7 @@ router.post( router.post( "/airdrops/:id/recipients", + requireApiKey({ scopes: ["airdrops"] }), routeTimeout(), validateRouteIdParams, addRecipientsLimit, diff --git a/src/services/i18n.js b/src/services/i18n.js new file mode 100644 index 0000000..5b533c4 --- /dev/null +++ b/src/services/i18n.js @@ -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, +}; diff --git a/src/services/webhookDispatcher.js b/src/services/webhookDispatcher.js index 9a29fbe..4a7815d 100644 --- a/src/services/webhookDispatcher.js +++ b/src/services/webhookDispatcher.js @@ -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 =