/**
* Input Validation Utility
*/
import { MCPError } from '../types/mcp-tools.js';
import { formatError } from './errors.js';
/**
* Supported audio MIME types
*/
export const SUPPORTED_MIME_TYPES = [
'audio/mpeg', // MP3
'audio/wav', // WAV
'audio/x-wav', // WAV alt
'audio/flac', // FLAC
'audio/ogg', // OGG
'audio/mp4', // M4A
'audio/x-m4a', // M4A alt
] as const;
/**
* Supported audio file extensions
*/
export const SUPPORTED_EXTENSIONS = ['.mp3', '.wav', '.flac', '.ogg', '.m4a'] as const;
/**
* Maximum file size in bytes (100MB)
*/
export const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
/**
* Maximum file size in MB (for display)
*/
export const MAX_FILE_SIZE_MB = 100;
/**
* URL validation regex (HTTP/HTTPS only)
*/
const URL_REGEX = /^https?:\/\/.+/i;
/**
* Validation result
*/
export interface ValidationResult {
valid: boolean;
error?: MCPError;
}
/**
* Validate audio URL format and extension
*/
export function validateAudioUrl(url: string): ValidationResult {
// Check if URL is provided
if (!url || url.trim() === '') {
return {
valid: false,
error: formatError('INVALID_URL', 'Audio URL is required'),
};
}
const trimmedUrl = url.trim();
// Check URL format
if (!URL_REGEX.test(trimmedUrl)) {
return {
valid: false,
error: formatError('INVALID_URL', 'URL must start with http:// or https://'),
};
}
// Try to parse URL
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmedUrl);
} catch {
return {
valid: false,
error: formatError('INVALID_URL', 'Invalid URL format'),
};
}
// Check file extension
const pathname = parsedUrl.pathname.toLowerCase();
const hasValidExtension = SUPPORTED_EXTENSIONS.some((ext) => pathname.endsWith(ext));
if (!hasValidExtension) {
// Allow URLs without extension (might be served with proper Content-Type)
// but warn if no extension and no query params (likely a page, not a file)
const hasQueryParams = parsedUrl.search.length > 0;
const hasExtension = pathname.includes('.');
if (!hasExtension && !hasQueryParams) {
return {
valid: false,
error: formatError(
'UNSUPPORTED_FORMAT',
`URL does not appear to point to an audio file. Accepted formats: ${SUPPORTED_EXTENSIONS.join(', ')}`
),
};
}
}
return { valid: true };
}
/**
* Validate job ID format
*/
export function validateJobId(jobId: string): ValidationResult {
// Check if job ID is provided
if (!jobId || jobId.trim() === '') {
return {
valid: false,
error: formatError('JOB_NOT_FOUND', 'Job ID is required'),
};
}
const trimmedJobId = jobId.trim();
// Basic format validation (alphanumeric with dashes/underscores)
if (!/^[a-zA-Z0-9_-]+$/.test(trimmedJobId)) {
return {
valid: false,
error: formatError('JOB_NOT_FOUND', 'Invalid job ID format'),
};
}
return { valid: true };
}
/**
* Check if file extension is supported
*/
export function isSupportedExtension(filename: string): boolean {
const lower = filename.toLowerCase();
return SUPPORTED_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
/**
* Get file extension from URL or filename
*/
export function getFileExtension(urlOrFilename: string): string | null {
try {
const url = new URL(urlOrFilename);
const pathname = url.pathname;
const lastDot = pathname.lastIndexOf('.');
if (lastDot === -1) return null;
return pathname.slice(lastDot).toLowerCase();
} catch {
// Not a valid URL, treat as filename
const lastDot = urlOrFilename.lastIndexOf('.');
if (lastDot === -1) return null;
return urlOrFilename.slice(lastDot).toLowerCase();
}
}
/**
* Format supported formats for error messages
*/
export function formatSupportedFormats(): string {
return SUPPORTED_EXTENSIONS.map((ext) => ext.slice(1).toUpperCase()).join(', ');
}