From a65b48f05cf07623c7f331ed17e71f28cafb2912 Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Mon, 19 Oct 2026 22:17:48 +0300 Subject: [PATCH 1/4] fix(webhooks): handle timeout errors distinctly from other network errors Resolves #568 --- src/services/webhookDispatcher.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 = From 7ccf4b5c50b4b268e78612c9412014fa837319fc Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Mon, 19 Oct 2026 22:18:34 +0300 Subject: [PATCH 2/4] fix(auth): add API key scoping to airdrop operations Enforces 'airdrops' scope requirement on all POST, PATCH, and DELETE airdrop endpoints to prevent over-privileged API keys from performing sensitive operations. Resolves #569 --- src/routes/airdrops.js | 6 ++++++ 1 file changed, 6 insertions(+) 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, From 92fe266264d6b0a4986ab3edcb7998ae3ea4c4a6 Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Mon, 19 Oct 2026 22:19:16 +0300 Subject: [PATCH 3/4] fix(database): add composite indexes for query optimization Creates composite indexes on frequently queried column combinations to improve query performance and reduce database load during filtering and sorting operations. Resolves #570 --- ...0260803000000_optimize_database_queries.js | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/db/migrations/20260803000000_optimize_database_queries.js 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}`); + } +}; From 59317dc3e404d9b7da34265f4a8917899de940b5 Mon Sep 17 00:00:00 2001 From: fortezzalaboratory Date: Mon, 19 Oct 2026 22:19:46 +0300 Subject: [PATCH 4/4] fix(errors): add error message localization support Implements i18n service for error messages with support for English, Spanish, and French. Error responses are automatically localized based on the Accept-Language header. Resolves #571 --- src/middleware/errorHandler.js | 12 ++++-- src/services/i18n.js | 78 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/services/i18n.js 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/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, +};