Implement Phase 3: Enhanced Error Handling

This commit implements Phase 3 of the Sacred Pipeline, focusing on enhanced error handling. It includes:

- **Transaction Status Polling Endpoint**: A new edge function `check-transaction-status` allows clients to poll the status of moderation transactions using idempotency keys.
- **Expanded Error Sanitizer Patterns**: The `src/lib/errorSanitizer.ts` file has been updated with more comprehensive patterns to remove sensitive information from error messages, making them safer for display and logging. User-friendly replacements for common errors are also included.
- **Rate Limiting for Submission Creation**: Client-side rate limiting has been implemented in `src/lib/submissionRateLimiter.ts` and applied to key submission functions within `src/lib/entitySubmissionHelpers.ts` (e.g., `submitParkCreation`, `submitRideCreation`, `submitParkUpdate`, `submitRideUpdate`) to prevent abuse and accidental duplicate submissions.
This commit is contained in:
gpt-engineer-app[bot]
2025-11-07 18:22:27 +00:00
parent 44f50f1f3c
commit 91a5b0e7dd
6 changed files with 945 additions and 0 deletions

View File

@@ -17,6 +17,8 @@ import {
validateRideModelCreateFields,
assertValid
} from './submissionValidation';
import { checkSubmissionRateLimit, recordSubmissionAttempt } from './submissionRateLimiter';
import { sanitizeErrorMessage } from './errorSanitizer';
// ============================================
// COMPOSITE SUBMISSION TYPES
@@ -198,6 +200,37 @@ export interface RideModelFormData {
_technical_specifications?: TechnicalSpecification[];
}
/**
* ═══════════════════════════════════════════════════════════════════
* RATE LIMITING HELPER
* ═══════════════════════════════════════════════════════════════════
*
* Checks rate limits before allowing submission creation
* Part of Sacred Pipeline Phase 3: Enhanced Error Handling
*/
function checkRateLimitOrThrow(userId: string, action: string): void {
const rateLimit = checkSubmissionRateLimit(userId);
if (!rateLimit.allowed) {
const sanitizedMessage = sanitizeErrorMessage(rateLimit.reason || 'Rate limit exceeded');
logger.warn('[RateLimit] Submission blocked', {
userId,
action,
reason: rateLimit.reason,
retryAfter: rateLimit.retryAfter,
});
throw new Error(sanitizedMessage);
}
logger.info('[RateLimit] Submission allowed', {
userId,
action,
remaining: rateLimit.remaining,
});
}
/**
* ═══════════════════════════════════════════════════════════════════
* COMPOSITE SUBMISSION HANDLER
@@ -220,6 +253,9 @@ async function submitCompositeCreation(
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
try {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'composite_creation');
breadcrumb.userAction('Start composite submission', 'submitCompositeCreation', {
primaryType: primaryEntity.type,
dependencyCount: dependencies.length,
@@ -624,6 +660,9 @@ export async function submitParkCreation(
data: ParkFormData & { _compositeSubmission?: any },
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'park_creation');
console.info('[submitParkCreation] Received data:', {
hasLocation: !!data.location,
hasLocationId: !!data.location_id,
@@ -884,6 +923,9 @@ export async function submitParkUpdate(
data: ParkFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'park_update');
const { withRetry, isRetryableError } = await import('./retryHelpers');
// Check if user is banned - with retry for transient failures
@@ -1120,6 +1162,9 @@ export async function submitRideCreation(
},
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'ride_creation');
// Validate required fields client-side
assertValid(validateRideCreateFields(data));
@@ -1504,6 +1549,9 @@ export async function submitRideUpdate(
data: RideFormData,
userId: string
): Promise<{ submitted: boolean; submissionId: string }> {
// Phase 3: Rate limiting check
checkRateLimitOrThrow(userId, 'ride_update');
const { withRetry, isRetryableError } = await import('./retryHelpers');
// Check if user is banned - with retry for transient failures

213
src/lib/errorSanitizer.ts Normal file
View File

@@ -0,0 +1,213 @@
/**
* Error Sanitizer
*
* Removes sensitive information from error messages before
* displaying to users or logging to external systems.
*
* Part of Sacred Pipeline Phase 3: Enhanced Error Handling
*/
import { logger } from './logger';
/**
* Patterns that indicate sensitive data in error messages
*/
const SENSITIVE_PATTERNS = [
// Authentication & Tokens
/bearer\s+[a-zA-Z0-9\-_.]+/gi,
/token[:\s]+[a-zA-Z0-9\-_.]+/gi,
/api[_-]?key[:\s]+[a-zA-Z0-9\-_.]+/gi,
/password[:\s]+[^\s]+/gi,
/secret[:\s]+[a-zA-Z0-9\-_.]+/gi,
// Database connection strings
/postgresql:\/\/[^\s]+/gi,
/postgres:\/\/[^\s]+/gi,
/mysql:\/\/[^\s]+/gi,
// IP addresses (internal)
/\b(?:10|172\.(?:1[6-9]|2[0-9]|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b/g,
// Email addresses (in error messages)
/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g,
// UUIDs (can reveal internal IDs)
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
// File paths (Unix & Windows)
/\/(?:home|root|usr|var|opt|mnt)\/[^\s]*/g,
/[A-Z]:\\(?:Users|Windows|Program Files)[^\s]*/g,
// Stack traces with file paths
/at\s+[^\s]+\s+\([^\)]+\)/g,
// SQL queries (can reveal schema)
/SELECT\s+.+?\s+FROM\s+[^\s]+/gi,
/INSERT\s+INTO\s+[^\s]+/gi,
/UPDATE\s+[^\s]+\s+SET/gi,
/DELETE\s+FROM\s+[^\s]+/gi,
];
/**
* Common error message patterns to make more user-friendly
*/
const ERROR_MESSAGE_REPLACEMENTS: Array<[RegExp, string]> = [
// Database errors
[/duplicate key value violates unique constraint/gi, 'This item already exists'],
[/foreign key constraint/gi, 'Related item not found'],
[/violates check constraint/gi, 'Invalid data provided'],
[/null value in column/gi, 'Required field is missing'],
[/invalid input syntax for type/gi, 'Invalid data format'],
// Auth errors
[/JWT expired/gi, 'Session expired. Please log in again'],
[/Invalid JWT/gi, 'Authentication failed. Please log in again'],
[/No API key found/gi, 'Authentication required'],
// Network errors
[/ECONNREFUSED/gi, 'Service temporarily unavailable'],
[/ETIMEDOUT/gi, 'Request timed out. Please try again'],
[/ENOTFOUND/gi, 'Service not available'],
[/Network request failed/gi, 'Network error. Check your connection'],
// Rate limiting
[/Too many requests/gi, 'Rate limit exceeded. Please wait before trying again'],
// Supabase specific
[/permission denied for table/gi, 'Access denied'],
[/row level security policy/gi, 'Access denied'],
];
/**
* Sanitize error message by removing sensitive information
*
* @param error - Error object or message
* @param context - Optional context for logging
* @returns Sanitized error message safe for display
*/
export function sanitizeErrorMessage(
error: unknown,
context?: { action?: string; userId?: string }
): string {
let message: string;
// Extract message from error object
if (error instanceof Error) {
message = error.message;
} else if (typeof error === 'string') {
message = error;
} else if (error && typeof error === 'object' && 'message' in error) {
message = String((error as { message: unknown }).message);
} else {
message = 'An unexpected error occurred';
}
// Store original for logging
const originalMessage = message;
// Remove sensitive patterns
SENSITIVE_PATTERNS.forEach(pattern => {
message = message.replace(pattern, '[REDACTED]');
});
// Apply user-friendly replacements
ERROR_MESSAGE_REPLACEMENTS.forEach(([pattern, replacement]) => {
if (pattern.test(message)) {
message = replacement;
}
});
// If message was heavily sanitized, provide generic message
if (message.includes('[REDACTED]')) {
message = 'An error occurred. Please contact support if this persists';
}
// Log sanitization if message changed significantly
if (originalMessage !== message && originalMessage.length > message.length + 10) {
logger.info('[ErrorSanitizer] Sanitized error message', {
action: context?.action,
userId: context?.userId,
originalLength: originalMessage.length,
sanitizedLength: message.length,
containsRedacted: message.includes('[REDACTED]'),
});
}
return message;
}
/**
* Check if error message contains sensitive data
*
* @param message - Error message to check
* @returns True if message contains sensitive patterns
*/
export function containsSensitiveData(message: string): boolean {
return SENSITIVE_PATTERNS.some(pattern => pattern.test(message));
}
/**
* Sanitize error object for logging to external systems
*
* @param error - Error object to sanitize
* @returns Sanitized error object
*/
export function sanitizeErrorForLogging(error: unknown): {
message: string;
name?: string;
code?: string;
stack?: string;
} {
const sanitized: {
message: string;
name?: string;
code?: string;
stack?: string;
} = {
message: sanitizeErrorMessage(error),
};
if (error instanceof Error) {
sanitized.name = error.name;
// Sanitize stack trace
if (error.stack) {
let stack = error.stack;
SENSITIVE_PATTERNS.forEach(pattern => {
stack = stack.replace(pattern, '[REDACTED]');
});
sanitized.stack = stack;
}
// Include error code if present
if ('code' in error && typeof error.code === 'string') {
sanitized.code = error.code;
}
}
return sanitized;
}
/**
* Create a user-safe error response
*
* @param error - Original error
* @param fallbackMessage - Optional fallback message
* @returns User-safe error object
*/
export function createSafeErrorResponse(
error: unknown,
fallbackMessage = 'An error occurred'
): {
message: string;
code?: string;
} {
const sanitized = sanitizeErrorMessage(error);
return {
message: sanitized || fallbackMessage,
code: error instanceof Error && 'code' in error
? String((error as { code: string }).code)
: undefined,
};
}

View File

@@ -0,0 +1,204 @@
/**
* Submission Rate Limiter
*
* Client-side rate limiting for submission creation to prevent
* abuse and accidental duplicate submissions.
*
* Part of Sacred Pipeline Phase 3: Enhanced Error Handling
*/
import { logger } from './logger';
interface RateLimitConfig {
maxSubmissionsPerMinute: number;
maxSubmissionsPerHour: number;
cooldownAfterLimit: number; // milliseconds
}
interface RateLimitRecord {
timestamps: number[];
lastAttempt: number;
blockedUntil?: number;
}
const DEFAULT_CONFIG: RateLimitConfig = {
maxSubmissionsPerMinute: 5,
maxSubmissionsPerHour: 20,
cooldownAfterLimit: 60000, // 1 minute
};
// Store rate limit data in memory (per session)
const rateLimitStore = new Map<string, RateLimitRecord>();
/**
* Clean up old timestamps from rate limit record
*/
function cleanupTimestamps(record: RateLimitRecord, now: number): void {
const oneHourAgo = now - 60 * 60 * 1000;
record.timestamps = record.timestamps.filter(ts => ts > oneHourAgo);
}
/**
* Get or create rate limit record for user
*/
function getRateLimitRecord(userId: string): RateLimitRecord {
if (!rateLimitStore.has(userId)) {
rateLimitStore.set(userId, {
timestamps: [],
lastAttempt: 0,
});
}
return rateLimitStore.get(userId)!;
}
/**
* Check if user can submit based on rate limits
*
* @param userId - User ID to check
* @param config - Optional rate limit configuration
* @returns Object indicating if allowed and retry information
*/
export function checkSubmissionRateLimit(
userId: string,
config: Partial<RateLimitConfig> = {}
): {
allowed: boolean;
reason?: string;
retryAfter?: number; // seconds
remaining?: number;
} {
const cfg = { ...DEFAULT_CONFIG, ...config };
const now = Date.now();
const record = getRateLimitRecord(userId);
// Clean up old timestamps
cleanupTimestamps(record, now);
// Check if user is currently blocked
if (record.blockedUntil && now < record.blockedUntil) {
const retryAfter = Math.ceil((record.blockedUntil - now) / 1000);
logger.warn('[SubmissionRateLimiter] User blocked', {
userId,
retryAfter,
});
return {
allowed: false,
reason: `Rate limit exceeded. Please wait ${retryAfter} seconds before submitting again`,
retryAfter,
};
}
// Check per-minute limit
const oneMinuteAgo = now - 60 * 1000;
const submissionsLastMinute = record.timestamps.filter(ts => ts > oneMinuteAgo).length;
if (submissionsLastMinute >= cfg.maxSubmissionsPerMinute) {
record.blockedUntil = now + cfg.cooldownAfterLimit;
const retryAfter = Math.ceil(cfg.cooldownAfterLimit / 1000);
logger.warn('[SubmissionRateLimiter] Per-minute limit exceeded', {
userId,
submissionsLastMinute,
limit: cfg.maxSubmissionsPerMinute,
retryAfter,
});
return {
allowed: false,
reason: `Too many submissions in a short time. Please wait ${retryAfter} seconds`,
retryAfter,
};
}
// Check per-hour limit
const submissionsLastHour = record.timestamps.length;
if (submissionsLastHour >= cfg.maxSubmissionsPerHour) {
record.blockedUntil = now + cfg.cooldownAfterLimit;
const retryAfter = Math.ceil(cfg.cooldownAfterLimit / 1000);
logger.warn('[SubmissionRateLimiter] Per-hour limit exceeded', {
userId,
submissionsLastHour,
limit: cfg.maxSubmissionsPerHour,
retryAfter,
});
return {
allowed: false,
reason: `Hourly submission limit reached. Please wait ${retryAfter} seconds`,
retryAfter,
};
}
// Calculate remaining submissions
const remainingMinute = cfg.maxSubmissionsPerMinute - submissionsLastMinute;
const remainingHour = cfg.maxSubmissionsPerHour - submissionsLastHour;
const remaining = Math.min(remainingMinute, remainingHour);
return {
allowed: true,
remaining,
};
}
/**
* Record a submission attempt
*
* @param userId - User ID
*/
export function recordSubmissionAttempt(userId: string): void {
const now = Date.now();
const record = getRateLimitRecord(userId);
record.timestamps.push(now);
record.lastAttempt = now;
// Clean up immediately to maintain accurate counts
cleanupTimestamps(record, now);
logger.info('[SubmissionRateLimiter] Recorded submission', {
userId,
totalLastHour: record.timestamps.length,
});
}
/**
* Clear rate limit for user (useful for testing or admin override)
*
* @param userId - User ID to clear
*/
export function clearUserRateLimit(userId: string): void {
rateLimitStore.delete(userId);
logger.info('[SubmissionRateLimiter] Cleared rate limit', { userId });
}
/**
* Get current rate limit status for user
*
* @param userId - User ID
* @returns Current status information
*/
export function getRateLimitStatus(userId: string): {
submissionsLastMinute: number;
submissionsLastHour: number;
isBlocked: boolean;
blockedUntil?: Date;
} {
const now = Date.now();
const record = getRateLimitRecord(userId);
cleanupTimestamps(record, now);
const oneMinuteAgo = now - 60 * 1000;
const submissionsLastMinute = record.timestamps.filter(ts => ts > oneMinuteAgo).length;
return {
submissionsLastMinute,
submissionsLastHour: record.timestamps.length,
isBlocked: !!(record.blockedUntil && now < record.blockedUntil),
blockedUntil: record.blockedUntil ? new Date(record.blockedUntil) : undefined,
};
}