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
35 changes: 35 additions & 0 deletions src/middleware/validate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
'use strict';

const AppError = require('../errors/AppError');

function flattenZodIssues(error) {
return error.issues.reduce((fields, issue) => {
const path = issue.path.length > 0 ? issue.path.join('.') : '_root';
fields[path] = fields[path] || [];
fields[path].push(issue.message);
return fields;
}, {});
}

function validate(schema, source = 'body') {
return (req, _res, next) => {
const result = schema.safeParse(req[source] ?? {});
if (!result.success) {
return next(new AppError('VALIDATION_ERROR', 'Validation failed', 400, {
fields: flattenZodIssues(result.error),
}));
}

req.validated = {
...(req.validated || {}),
[source]: result.data,
};
req[source] = result.data;
return next();
};
}

module.exports = {
flattenZodIssues,
validate,
};
91 changes: 48 additions & 43 deletions src/routes/airdrops.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@ const config = require('../config');
const airdropsService = require('../services/airdrops');
const logger = require('../logger');
const AppError = require('../errors/AppError');
const { flattenZodIssues, validate } = require('../middleware/validate');
const {
airdropCreateBodySchema,
airdropRecipientsBodySchema,
airdropUpdateBodySchema,
paginationQuerySchema,
recipientsSchema,
routeIdParamsSchema,
} = require('../validation/schemas');

const router = express.Router();
const upload = multer();
const validateRouteIdParams = validate(routeIdParamsSchema, 'params');
const validatePaginationQuery = validate(paginationQuerySchema, 'query');
const validateRecipientBody = validate(airdropRecipientsBodySchema);

function validateWithCurrentLedger(schemaFactory) {
return async (req, res, next) => {
try {
const currentLedger = await airdropsService.getCurrentLedger();
return validate(schemaFactory(currentLedger))(req, res, next);
} catch (err) {
logger.error('Airdrop validation error', { error: err.message });
return next(err);
const buildRateLimit = require('../middleware/rateLimit');
const { StrKey } = require('stellar-sdk');

Expand Down Expand Up @@ -83,26 +107,17 @@ function validateAirdropCreate(body, currentLedger) {
if (recipientSet.has(r.address)) {
return `recipient ${i}: duplicate address ${r.address}`;
}
recipientSet.add(r.address);
if (typeof r.amount !== 'number' || r.amount <= 0) {
return `recipient ${i}: amount must be a positive number`;
}
sum += r.amount;
}

if (recipients.length > 0 && sum !== total_amount) {
return `sum of recipient amounts (${sum}) must equal total_amount (${total_amount})`;
}

return null;
};
}

function validateAirdropUpdate(body, currentLedger) {
const { expiry_ledger } = body;
if (expiry_ledger !== undefined && (typeof expiry_ledger !== 'number' || expiry_ledger <= currentLedger)) {
return `expiry_ledger must be greater than current ledger (${currentLedger})`;
function parseRecipients(recipients, next) {
const result = recipientsSchema.safeParse(recipients);
if (!result.success) {
return next(new AppError('VALIDATION_ERROR', 'Validation failed', 400, {
fields: flattenZodIssues(result.error),
}));
}
return null;
return result.data;
}

async function parseCSV(buffer) {
Expand Down Expand Up @@ -132,26 +147,20 @@ async function parseCSV(buffer) {
return results;
}

router.post('/airdrops', validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => {
router.post('/airdrops', createAirdropLimit, async (req, res, next) => {
try {
const currentLedger = await airdropsService.getCurrentLedger();
const validationError = validateAirdropCreate(req.body, currentLedger);
if (validationError) {
return next(new AppError('VALIDATION_ERROR', validationError, 400));
}

const airdrop = await airdropsService.create(req.body);
const airdrop = await airdropsService.create(req.validated.body);
return res.status(201).json(airdrop);
} catch (err) {
logger.error('Create airdrop error', { error: err.message });
return next(err);
}
});

router.get('/airdrops', async (req, res, next) => {
router.get('/airdrops', validatePaginationQuery, async (req, res, next) => {
try {
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const { page, limit } = req.validated.query;
const result = await airdropsService.list(page, limit);
return res.json(result);
} catch (err) {
Expand All @@ -160,7 +169,7 @@ router.get('/airdrops', async (req, res, next) => {
}
});

router.get('/airdrops/:id', async (req, res, next) => {
router.get('/airdrops/:id', validateRouteIdParams, async (req, res, next) => {
try {
const airdrop = await airdropsService.get(req.params.id);
if (!airdrop) {
Expand All @@ -173,15 +182,9 @@ router.get('/airdrops/:id', async (req, res, next) => {
}
});

router.patch('/airdrops/:id', async (req, res, next) => {
router.patch('/airdrops/:id', validateRouteIdParams, validateWithCurrentLedger(airdropUpdateBodySchema), async (req, res, next) => {
try {
const currentLedger = await airdropsService.getCurrentLedger();
const validationError = validateAirdropUpdate(req.body, currentLedger);
if (validationError) {
return next(new AppError('VALIDATION_ERROR', validationError, 400));
}

const airdrop = await airdropsService.update(req.params.id, req.body);
const airdrop = await airdropsService.update(req.params.id, req.validated.body);
if (!airdrop) {
return next(new AppError('NOT_FOUND', 'Airdrop not found', 404));
}
Expand All @@ -192,7 +195,7 @@ router.patch('/airdrops/:id', async (req, res, next) => {
}
});

router.delete('/airdrops/:id', async (req, res, next) => {
router.delete('/airdrops/:id', validateRouteIdParams, async (req, res, next) => {
try {
const deleted = await airdropsService.remove(req.params.id);
if (!deleted) {
Expand All @@ -205,7 +208,7 @@ router.delete('/airdrops/:id', async (req, res, next) => {
}
});

router.post('/airdrops/:id/cancel', async (req, res, next) => {
router.post('/airdrops/:id/cancel', validateRouteIdParams, async (req, res, next) => {
try {
const airdrop = await airdropsService.cancel(req.params.id);
if (!airdrop) {
Expand All @@ -218,6 +221,7 @@ router.post('/airdrops/:id/cancel', async (req, res, next) => {
}
});

router.post('/airdrops/:id/recipients', validateRouteIdParams, upload.single('file'), validateRecipientBody, async (req, res, next) => {
router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile, async (req, res, next) => {
try {
const airdrop = await airdropsService.get(req.params.id);
Expand All @@ -228,8 +232,10 @@ router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile
let recipients = [];
if (req.file) {
recipients = await parseCSV(req.file.buffer);
} else if (req.body.recipients) {
recipients = Array.isArray(req.body.recipients) ? req.body.recipients : JSON.parse(req.body.recipients);
recipients = parseRecipients(recipients, next);
if (!recipients) return undefined;
} else if (req.validated.body.recipients) {
recipients = req.validated.body.recipients;
} else {
return next(new AppError('VALIDATION_ERROR', 'recipients or file is required', 400));
}
Expand Down Expand Up @@ -263,15 +269,14 @@ router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile
}
});

router.get('/airdrops/:id/recipients', async (req, res, next) => {
router.get('/airdrops/:id/recipients', validateRouteIdParams, validatePaginationQuery, async (req, res, next) => {
try {
const airdrop = await airdropsService.get(req.params.id);
if (!airdrop) {
return next(new AppError('NOT_FOUND', 'Airdrop not found', 404));
}

const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 20;
const { page, limit } = req.validated.query;
const result = await airdropsService.listRecipients(req.params.id, page, limit);
return res.json(result);
} catch (err) {
Expand Down
48 changes: 7 additions & 41 deletions src/routes/alerts.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,26 @@
const express = require('express');
const { validate } = require('../middleware/validate');
const alertsService = require('../services/alerts');
const logger = require('../logger');
const AppError = require('../errors/AppError');
const { alertCreateBodySchema, paginationQuerySchema, routeIdParamsSchema } = require('../validation/schemas');

const router = express.Router();

const VALID_TYPES = ['above', 'below', 'change_pct'];
const validateRouteIdParams = validate(routeIdParamsSchema, 'params');

const { parsePagination, paginateResponse } = require('../utils/paginate');

function isValidUrl(str) {
router.post('/alerts', validate(alertCreateBodySchema), async (req, res, next) => {
try {
const u = new URL(str);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}

function validateCreateBody(body) {
const { asset, type, threshold_usd, webhook_url, webhook_secret } = body;

if (!asset || typeof asset !== 'string' || !/^[A-Z0-9]{1,12}$/i.test(asset)) {
return 'asset must be 1-12 alphanumeric characters';
}
if (!VALID_TYPES.includes(type)) {
return `type must be one of: ${VALID_TYPES.join(', ')}`;
}
if (typeof threshold_usd !== 'number' || threshold_usd <= 0) {
return 'threshold_usd must be a positive number';
}
if (!webhook_url || !isValidUrl(webhook_url)) {
return 'webhook_url must be a valid URL';
}
if (!webhook_secret || typeof webhook_secret !== 'string' || webhook_secret.length < 8) {
return 'webhook_secret must be at least 8 characters';
}
return null;
}

router.post('/alerts', async (req, res, next) => {
try {
const validationError = validateCreateBody(req.body);
if (validationError) {
return next(new AppError('VALIDATION_ERROR', validationError, 400));
}

const alert = await alertsService.create(req.body);
const alert = await alertsService.create(req.validated.body);
return res.status(201).json(alert);
} catch (err) {
logger.error('Create alert error', { error: err.message });
return next(err);
}
});

router.get('/alerts', async (req, res, next) => {
router.get('/alerts', validate(paginationQuerySchema, 'query'), async (req, res, next) => {
try {
const pagination = parsePagination(req.query);
const result = await alertsService.listPaginated(pagination);
Expand All @@ -70,7 +36,7 @@ router.get('/alerts', async (req, res, next) => {
}
});

router.delete('/alerts/:id', async (req, res, next) => {
router.delete('/alerts/:id', validateRouteIdParams, async (req, res, next) => {
try {
const deleted = await alertsService.remove(req.params.id);
if (!deleted) {
Expand Down
33 changes: 8 additions & 25 deletions src/routes/keys.js
Original file line number Diff line number Diff line change
@@ -1,24 +1,16 @@
const express = require('express');
const { requireApiKey } = require('../middleware/auth');
const { validate } = require('../middleware/validate');
const apiKeys = require('../services/apiKeys');
const logger = require('../logger');
const AppError = require('../errors/AppError');
const { keyCreateBodySchema, routeIdParamsSchema } = require('../validation/schemas');

const router = express.Router();
const validateRouteIdParams = validate(routeIdParamsSchema, 'params');

router.use('/keys', requireApiKey({ scopes: ['admin'] }));

function validateScopes(scopes) {
if (scopes === undefined) return null;
if (!Array.isArray(scopes) || scopes.length === 0) {
return 'scopes must be a non-empty array of strings';
}
if (scopes.some((scope) => typeof scope !== 'string' || !scope.trim())) {
return 'scopes must be a non-empty array of strings';
}
return null;
}

router.get('/keys', async (_req, res, next) => {
try {
const keys = await apiKeys.listKeys();
Expand All @@ -29,22 +21,13 @@ router.get('/keys', async (_req, res, next) => {
}
});

router.post('/keys', async (req, res, next) => {
router.post('/keys', validate(keyCreateBodySchema), async (req, res, next) => {
try {
const { label, scopes } = req.body || {};
const normalizedLabel = typeof label === 'string' ? label.trim() : '';
if (!normalizedLabel || normalizedLabel.length > 80) {
return next(new AppError('VALIDATION_ERROR', 'label must be a non-empty string up to 80 characters', 400));
}

const scopeError = validateScopes(scopes);
if (scopeError) {
return next(new AppError('VALIDATION_ERROR', scopeError, 400));
}
const { label, scopes } = req.validated.body;

const created = await apiKeys.createKey({
label: normalizedLabel,
scopes: scopes ? scopes.map((scope) => scope.trim()) : ['default'],
label,
scopes: scopes || ['default'],
});
return res.status(201).json(created);
} catch (err) {
Expand All @@ -53,7 +36,7 @@ router.post('/keys', async (req, res, next) => {
}
});

router.delete('/keys/:id', async (req, res, next) => {
router.delete('/keys/:id', validateRouteIdParams, async (req, res, next) => {
try {
const deleted = await apiKeys.revokeKey(req.params.id);
if (!deleted) {
Expand Down
Loading