// Centralized validation utilities for MCP Sigmund
// This module consolidates all validation logic to reduce duplication
import { ValidationError } from './error-handler.js';
// Common validation patterns
export class ValidationPatterns {
static readonly IDENTIFIER = /^[a-zA-Z0-9_-]+$/;
static readonly DATE_ISO = /^\d{4}-\d{2}-\d{2}$/;
static readonly HOSTNAME = /^[a-zA-Z0-9.-]+$/;
static readonly CATEGORY = /^[a-zA-Z0-9\s&-_]+$/;
static readonly POSTGRES_URL =
/^postgres(ql)?:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/(.+)$/;
}
// Base validation class
export abstract class BaseValidator {
protected static validateRequired(value: unknown, fieldName: string): void {
if (value === undefined || value === null || value === '') {
throw new ValidationError(`${fieldName} is required`);
}
}
protected static validateType(
value: unknown,
expectedType: string,
fieldName: string
): void {
if (typeof value !== expectedType) {
throw new ValidationError(`${fieldName} must be a ${expectedType}`);
}
}
protected static validatePattern(
value: string,
pattern: RegExp,
fieldName: string,
description: string
): void {
if (!pattern.test(value)) {
throw new ValidationError(`${fieldName} ${description}`);
}
}
protected static validateRange(
value: number,
min: number,
max: number,
fieldName: string
): void {
if (value < min || value > max) {
throw new ValidationError(
`${fieldName} must be between ${min} and ${max}`
);
}
}
}
// String validation utilities
export class StringValidator extends BaseValidator {
static validateString(
value: unknown,
fieldName: string,
maxLength: number = 255
): string {
this.validateRequired(value, fieldName);
this.validateType(value, 'string', fieldName);
const stringValue = value as string;
const sanitized = stringValue
.replace(/[<>"'&]/g, '') // Remove HTML/XML characters
.replace(/[;()]/g, '') // Remove SQL injection characters
.trim()
.substring(0, maxLength);
if (sanitized.length === 0) {
throw new ValidationError(
`${fieldName} cannot be empty after sanitization`
);
}
return sanitized;
}
static validateIdentifier(value: unknown, fieldName: string): string {
const sanitized = this.validateString(value, fieldName, 100);
this.validatePattern(
sanitized,
ValidationPatterns.IDENTIFIER,
fieldName,
'can only contain letters, numbers, hyphens, and underscores'
);
return sanitized;
}
static validateCategory(value: unknown, fieldName: string): string {
const sanitized = this.validateString(value, fieldName, 100);
this.validatePattern(
sanitized,
ValidationPatterns.CATEGORY,
fieldName,
'can only contain letters, numbers, spaces, ampersands, hyphens, and underscores'
);
return sanitized;
}
static validateHostname(value: unknown, fieldName: string): string {
const sanitized = this.validateString(value, fieldName, 255);
this.validatePattern(
sanitized,
ValidationPatterns.HOSTNAME,
fieldName,
'has invalid format'
);
return sanitized;
}
}
// Numeric validation utilities
export class NumericValidator extends BaseValidator {
static validateNumber(
value: unknown,
fieldName: string,
min?: number,
max?: number
): number {
this.validateRequired(value, fieldName);
const num = Number(value);
if (isNaN(num)) {
throw new ValidationError(`${fieldName} must be a valid number`);
}
if (min !== undefined && max !== undefined) {
this.validateRange(num, min, max, fieldName);
} else if (min !== undefined) {
if (num < min) {
throw new ValidationError(`${fieldName} must be at least ${min}`);
}
} else if (max !== undefined) {
if (num > max) {
throw new ValidationError(`${fieldName} must be at most ${max}`);
}
}
return num;
}
static validateInteger(
value: unknown,
fieldName: string,
min?: number,
max?: number
): number {
const num = this.validateNumber(value, fieldName, min, max);
if (!Number.isInteger(num)) {
throw new ValidationError(`${fieldName} must be an integer`);
}
return num;
}
static validatePort(value: unknown, fieldName: string): number {
return this.validateInteger(value, fieldName, 1, 65535);
}
}
// Date validation utilities
export class DateValidator extends BaseValidator {
static validateDate(value: unknown, fieldName: string): string {
this.validateRequired(value, fieldName);
this.validateType(value, 'string', fieldName);
const dateString = value as string;
this.validatePattern(
dateString,
ValidationPatterns.DATE_ISO,
fieldName,
'must be in YYYY-MM-DD format'
);
const date = new Date(dateString);
if (isNaN(date.getTime())) {
throw new ValidationError(`${fieldName} is not a valid date`);
}
return dateString;
}
static validateDateRange(dateFrom?: string, dateTo?: string): void {
if (dateFrom && dateTo) {
const fromDate = new Date(this.validateDate(dateFrom, 'dateFrom'));
const toDate = new Date(this.validateDate(dateTo, 'dateTo'));
if (fromDate > toDate) {
throw new ValidationError('dateFrom cannot be after dateTo');
}
} else if (dateFrom) {
this.validateDate(dateFrom, 'dateFrom');
} else if (dateTo) {
this.validateDate(dateTo, 'dateTo');
}
}
}
// URL validation utilities
export class URLValidator extends BaseValidator {
static validatePostgreSQLURL(value: unknown, fieldName: string): string {
this.validateRequired(value, fieldName);
this.validateType(value, 'string', fieldName);
const urlString = value as string;
this.validatePattern(
urlString,
ValidationPatterns.POSTGRES_URL,
fieldName,
'must be a valid PostgreSQL connection string'
);
try {
const url = new URL(urlString);
// Validate protocol
if (!url.protocol.startsWith('postgres')) {
throw new ValidationError(
`${fieldName} must use postgresql:// protocol`
);
}
// Validate required components
if (!url.hostname) {
throw new ValidationError(`${fieldName} must include hostname`);
}
if (!url.port) {
throw new ValidationError(`${fieldName} must include port`);
}
NumericValidator.validatePort(url.port, 'port');
if (!url.pathname || url.pathname === '/') {
throw new ValidationError(`${fieldName} must include database name`);
}
if (!url.username) {
throw new ValidationError(`${fieldName} must include username`);
}
if (!url.password) {
throw new ValidationError(`${fieldName} must include password`);
}
// Validate hostname and database name
StringValidator.validateHostname(url.hostname, 'hostname');
const dbName = url.pathname.substring(1);
StringValidator.validateIdentifier(dbName, 'database name');
StringValidator.validateIdentifier(url.username, 'username');
} catch (error) {
if (error instanceof ValidationError) {
throw error;
}
throw new ValidationError(
`Invalid ${fieldName}: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
return urlString;
}
}
// Banking-specific validation utilities
export class BankingValidator extends BaseValidator {
static validateQuery(query: string): void {
const validQueries = [
'accounts',
'transactions',
'balance',
'overview',
'spending_analysis',
'cashflow_analysis',
'providers',
];
if (!validQueries.includes(query)) {
throw new ValidationError(
`Invalid query: ${query}. Valid queries are: ${validQueries.join(', ')}`
);
}
}
static validateDataOperation(operation: string): void {
const validOperations = [
'get_stats',
'validate_data',
'export_data',
'cleanup_data',
];
if (!validOperations.includes(operation)) {
throw new ValidationError(
`Invalid operation: ${operation}. Valid operations are: ${validOperations.join(', ')}`
);
}
}
static validateLimit(limit?: number): void {
if (limit !== undefined) {
NumericValidator.validateNumber(limit, 'limit', 1, 10000);
}
}
static validateMonths(months?: number): void {
if (months !== undefined) {
NumericValidator.validateNumber(months, 'months', 1, 60);
}
}
static validateProvider(provider?: string): void {
if (provider !== undefined) {
StringValidator.validateIdentifier(provider, 'provider');
}
}
}
// Composite validation functions for common use cases
export class CompositeValidator {
static validateBankingQueryParams(params: {
limit?: number;
dateFrom?: string;
dateTo?: string;
months?: number;
category?: string;
accountId?: string;
userId?: string;
provider?: string;
}): {
limit?: number;
dateFrom?: string;
dateTo?: string;
months?: number;
category?: string;
accountId?: string;
userId?: string;
provider?: string;
} {
const validated: any = {};
if (params.limit !== undefined) {
validated.limit = NumericValidator.validateNumber(
params.limit,
'limit',
1,
10000
);
}
if (params.dateFrom !== undefined) {
validated.dateFrom = DateValidator.validateDate(
params.dateFrom,
'dateFrom'
);
}
if (params.dateTo !== undefined) {
validated.dateTo = DateValidator.validateDate(params.dateTo, 'dateTo');
}
if (params.months !== undefined) {
validated.months = NumericValidator.validateNumber(
params.months,
'months',
1,
60
);
}
if (params.category !== undefined) {
validated.category = StringValidator.validateCategory(
params.category,
'category'
);
}
if (params.accountId !== undefined) {
validated.accountId = StringValidator.validateIdentifier(
params.accountId,
'accountId'
);
}
if (params.userId !== undefined) {
validated.userId = StringValidator.validateIdentifier(
params.userId,
'userId'
);
}
if (params.provider !== undefined) {
validated.provider = StringValidator.validateIdentifier(
params.provider,
'provider'
);
}
// Validate date range if both dates are provided
DateValidator.validateDateRange(validated.dateFrom, validated.dateTo);
return validated;
}
}