     1→/**
     2→ * Voice Processing Optimization - Consolidated Endpoint
     3→ *
     4→ * This endpoint replaces the previous 3-step sequential process:
     5→ * 1. /api/ai/transcribe-voice (Whisper)
     6→ * 2. /api/ai/parse-profile-voice (GPT-4o-mini parsing)
     7→ * 3. /api/ai/generate-profile (GPT-4o-mini generation)
     8→ *
     9→ * New optimized flow:
    10→ * - Single endpoint handles transcription (if audio provided) + parsing + generation
    11→ * - Reduces network overhead and total processing time by ~30-50%
    12→ * - Provides fallback to manual completion if AI processing fails
    13→ * - Preserves transcript data across failures for better UX
    14→ *
    15→ * Performance improvement: ~5-12 seconds → ~3-7 seconds
    16→ */
    17→
    18→import { NextRequest, NextResponse } from "next/server";
    19→import { logger } from "@/lib/logger";
    20→import OpenAI from "openai";
    21→import { createClient } from "@/lib/supabase/server";
    22→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
    23→import { sanitizeInput } from "@/lib/sanitize";
    24→import { withCSRFProtection } from "@/lib/csrf";
    25→import { TRADES } from "@/lib/constants";
    26→import {
    27→  AI_RATE_LIMIT,
    28→  AI_WINDOW_MS,
    29→  MAX_LENGTH_TRADE,
    30→  MAX_LENGTH_LOCATION,
    31→  MAX_LENGTH_AVAILABILITY,
    32→  MAX_LENGTH_VOICE_TRANSCRIPT,
    33→  MAX_LENGTH_TRANSCRIPT
    34→} from "@/lib/constants";
    35→
    36→if (!process.env.OPENAI_API_KEY) {
    37→  throw new Error("OPENAI_API_KEY environment variable is not set");
    38→}
    39→
    40→const openai = new OpenAI({
    41→  apiKey: process.env.OPENAI_API_KEY,
    42→});
    43→
    44→// Rate limit: 5 requests per 15 minutes per IP (expensive AI call)
    45→const VALID_AVAILABILITY = ["full-time", "casual", "weekends", "flexible"];
    46→
    47→const handler = async (request: NextRequest) => {
    48→  try {
    49→    // --- Rate limiting ---
    50→    const clientIP = getClientIP(request);
    51→    const rateLimit = await checkRateLimit(`ai-voice-complete:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    52→
    53→    if (!rateLimit.allowed) {
    54→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    55→      return createRateLimitResponse(clientIP, retryAfter);
    56→    }
    57→
    58→    // --- Auth check ---
    59→    const supabase = await createClient();
    60→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    61→    if (authError || !user) {
    62→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    63→    }
    64→
    65→    // Parse request based on content type
    66→    let audioFile: File | null = null;
    67→    let existingTranscript: string | null = null;
    68→
    69→    const contentType = request.headers.get("content-type") || "";
    70→
    71→    if (contentType.includes("multipart/form-data")) {
    72→      // Handle audio file upload
    73→      const formData = await request.formData();
    74→      audioFile = formData.get("audio") as File | null;
    75→      existingTranscript = formData.get("transcript") as string | null;
    76→
    77→      if (!audioFile && !existingTranscript) {
    78→        return NextResponse.json(
    79→          { error: "No audio file or transcript provided" },
    80→          { status: 400 }
    81→        );
    82→      }
    83→    } else {
    84→      // Handle JSON with existing transcript
    85→      const body = await request.json();
    86→      existingTranscript = body.transcript;
    87→
    88→      if (!existingTranscript) {
    89→        return NextResponse.json(
    90→          { error: "No transcript provided" },
    91→          { status: 400 }
    92→        );
    93→      }
    94→    }
    95→
    96→    let transcript: string;
    97→
    98→    // Step 1: Transcribe audio if needed
    99→    if (audioFile) {
   100→      try {
   101→        const transcription = await openai.audio.transcriptions.create({
   102→          file: audioFile,
   103→          model: "whisper-1",
   104→          language: "en",
   105→        });
   106→        transcript = transcription.text;
   107→      } catch (error) {
   108→        logger.error("OpenAI transcription error:", error);
   109→        return NextResponse.json(
   110→          { error: "Failed to transcribe audio" },
   111→          { status: 500 }
   112→        );
   113→      }
   114→    } else {
   115→      transcript = existingTranscript!;
   116→    }
   117→
   118→    if (!transcript.trim()) {
   119→      return NextResponse.json(
   120→        { error: "No speech detected in audio" },
   121→        { status: 400 }
   122→      );
   123→    }
   124→
   125→    // Sanitize transcript
   126→    const sanitizedTranscript = sanitizeInput(transcript, { maxLength: MAX_LENGTH_TRANSCRIPT });
   127→
   128→    // Step 2: Parse profile and generate complete profile in single GPT call
   129→    try {
   130→      const completion = await openai.chat.completions.create({
   131→        model: "gpt-4o-mini",
   132→        messages: [
   133→          {
   134→            role: "system",
   135→            content: `You are a profile parser and generator for an Australian construction hiring app.
   136→
   137→First, extract structured data from the worker's spoken description, then generate a complete profile.
   138→
   139→Valid trades: ${TRADES.join(", ")}
   140→Valid availability: ${VALID_AVAILABILITY.join(", ")}
   141→
   142→Return ONLY valid JSON in this exact format:
   143→{
   144→  "parsed_data": {
   145→    "trades": ["array of valid trades mentioned, or empty array"],
   146→    "trade": "primary trade (first one mentioned) or null",
   147→    "experience_years": number or null,
   148→    "location": "suburb/area string or null",
   149→    "availability": "one of the valid availability options or null",
   150→    "additional_details": "any other details mentioned — specific skills, years per trade, preferences, work history, specialties. Capture everything not covered by the structured fields above. null if nothing extra."
   151→  },
   152→  "generated_profile": {
   153→    "bio": "professional 2-3 sentence bio (50-80 words) written in FIRST PERSON",
   154→    "skills": ["array of 6-10 relevant technical skills"],
   155→    "certifications": ["array of 3-5 common/recommended certifications"]
   156→  }
   157→}
   158→
   159→Match trades loosely — e.g. "sparky" = "Electrician", "chippie" = "Carpenter", "bricky" = "Bricklayer", "steel fixer"/"steelfixer"/"reo" = "Steel Fixer", "formworker"/"formwork"/"shuttering"/"shutter hand" = "Formworker".
   160→IMPORTANT: Extract ALL trades mentioned, not just one. If someone says "steel fixing and shuttering", return both "Steel Fixer" and "Formworker".
   161→For experience, extract the number of years mentioned (use the highest if multiple are given for different trades).
   162→For location, extract any suburb, city, or area name mentioned.
   163→For availability, match to the closest option.
   164→
   165→For the bio: Make it sound professional but approachable. Focus on practical skills and experience. Write in FIRST PERSON ("I", "my", "me"). Incorporate specific details from the transcript to make it personal and unique. If multiple trades are mentioned, reference them appropriately.`,
   166→          },
   167→          {
   168→            role: "user",
   169→            content: sanitizedTranscript,
   170→          },
   171→        ],
   172→        temperature: 0.3,
   173→        response_format: { type: "json_object" },
   174→      });
   175→
   176→      const content = completion.choices[0]?.message?.content;
   177→      if (!content) {
   178→        throw new Error("No response from AI");
   179→      }
   180→
   181→      const result = JSON.parse(content);
   182→
   183→      // Validate and clean parsed data
   184→      const parsedData = result.parsed_data || {};
   185→      const generatedProfile = result.generated_profile || {};
   186→
   187→      // Validate trades array
   188→      if (Array.isArray(parsedData.trades)) {
   189→        parsedData.trades = parsedData.trades.filter((t: string) => (TRADES as readonly string[]).includes(t));
   190→      } else {
   191→        parsedData.trades = [];
   192→      }
   193→
   194→      // Validate primary trade
   195→      if (parsedData.trade && !(TRADES as readonly string[]).includes(parsedData.trade)) {
   196→        parsedData.trade = parsedData.trades[0] || null;
   197→      } else if (!parsedData.trade && parsedData.trades.length > 0) {
   198→        parsedData.trade = parsedData.trades[0];
   199→      }
   200→
   201→      // Validate availability
   202→      if (parsedData.availability && !VALID_AVAILABILITY.includes(parsedData.availability)) {
   203→        parsedData.availability = null;
   204→      }
   205→
   206→      // Validate experience_years
   207→      if (parsedData.experience_years !== null && (typeof parsedData.experience_years !== "number" || parsedData.experience_years < 0)) {
   208→        parsedData.experience_years = null;
   209→      }
   210→
   211→      return NextResponse.json({
   212→        transcript: sanitizedTranscript,
   213→        parsed_data: parsedData,
   214→        generated_profile: {
   215→          bio: generatedProfile.bio || "",
   216→          skills: generatedProfile.skills || [],
   217→          certifications: generatedProfile.certifications || [],
   218→        },
   219→        // Include timing for performance monitoring
   220→        timing: {
   221→          had_audio: !!audioFile,
   222→          processing_complete: true,
   223→        }
   224→      });
   225→
   226→    } catch (error) {
   227→      logger.error("OpenAI processing error:", error);
   228→
   229→      // If AI parsing fails, return the transcript so user can complete manually
   230→      return NextResponse.json({
   231→        transcript: sanitizedTranscript,
   232→        parsed_data: {
   233→          trades: [],
   234→          trade: null,
   235→          experience_years: null,
   236→          location: null,
   237→          availability: null,
   238→          additional_details: null,
   239→        },
   240→        generated_profile: {
   241→          bio: "",
   242→          skills: [],
   243→          certifications: [],
   244→        },
   245→        error: "AI processing failed. Please complete your profile manually.",
   246→        fallback: true,
   247→        timing: {
   248→          had_audio: !!audioFile,
   249→          processing_complete: false,
   250→        }
   251→      });
   252→    }
   253→
   254→  } catch (error) {
   255→    logger.error("Voice complete error:", error);
   256→
   257→    if (error instanceof Error) {
   258→      if (error.message.includes("API key")) {
   259→        return NextResponse.json(
   260→          { error: "AI service configuration error" },
   261→          { status: 503 }
   262→        );
   263→      }
   264→      if (error.message.includes("rate limit") || error.message.includes("429")) {
   265→        return NextResponse.json(
   266→          { error: "AI service is busy. Please try again in a moment." },
   267→          { status: 429 }
   268→        );
   269→      }
   270→    }
   271→
   272→    return NextResponse.json(
   273→      { error: "Failed to process voice input. Please try again." },
   274→      { status: 500 }
   275→    );
   276→  }
   277→};
   278→
   279→// Apply CSRF protection to the handler
   280→export const POST = withCSRFProtection(handler);

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { NextRequest, NextResponse } from "next/server";
     2→import { logger } from "@/lib/logger";
     3→import OpenAI from "openai";
     4→import { createClient } from "@/lib/supabase/server";
     5→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
     6→import { sanitizeInput } from "@/lib/sanitize";
     7→import { withCSRFProtection } from "@/lib/csrf";
     8→import { TRADES } from "@/lib/constants";
     9→import {
    10→  AI_RATE_LIMIT,
    11→  AI_WINDOW_MS,
    12→  MAX_LENGTH_TRADE,
    13→  MAX_LENGTH_LOCATION,
    14→  MAX_LENGTH_AVAILABILITY,
    15→  MAX_LENGTH_VOICE_TRANSCRIPT,
    16→  HTTP_STATUS_INTERNAL_SERVER_ERROR
    17→} from "@/lib/constants";
    18→
    19→if (!process.env.OPENAI_API_KEY) {
    20→  throw new Error("OPENAI_API_KEY environment variable is not set");
    21→}
    22→
    23→const openai = new OpenAI({
    24→  apiKey: process.env.OPENAI_API_KEY,
    25→});
    26→
    27→// Rate limit: 5 requests per 15 minutes per IP (expensive AI call)
    28→
    29→const handler = async (request: NextRequest) => {
    30→  try {
    31→    // --- Rate limiting ---
    32→    const clientIP = getClientIP(request);
    33→    const rateLimit = await checkRateLimit(`ai-profile:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    34→
    35→    if (!rateLimit.allowed) {
    36→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    37→      return createRateLimitResponse(clientIP, retryAfter);
    38→    }
    39→
    40→    // --- Auth check ---
    41→    const supabase = await createClient();
    42→    const { data: { user }, error: authError } = await supabase.auth.getUser();
    43→    if (authError || !user) {
    44→      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    45→    }
    46→
    47→    const { trade, trades, experience, location, availability, voiceTranscript } = await request.json();
    48→
    49→    if (!trade || experience === undefined || !location || !availability) {
    50→      return NextResponse.json(
    51→        { error: "Missing required fields" },
    52→        { status: 400 }
    53→      );
    54→    }
    55→
    56→    // Sanitize inputs to prevent prompt injection
    57→    const sanitizedTrade = sanitizeInput(trade, { maxLength: MAX_LENGTH_TRADE });
    58→    const sanitizedLocation = sanitizeInput(location, { maxLength: MAX_LENGTH_LOCATION });
    59→    const sanitizedAvailability = sanitizeInput(availability, { maxLength: MAX_LENGTH_AVAILABILITY });
    60→    const sanitizedTranscript = voiceTranscript ? sanitizeInput(voiceTranscript, { maxLength: MAX_LENGTH_VOICE_TRANSCRIPT }) : null;
    61→
    62→    // Validate primary trade
    63→    if (!(TRADES as readonly string[]).includes(sanitizedTrade)) {
    64→      return NextResponse.json(
    65→        { error: `Invalid trade. Must be one of: ${TRADES.join(", ")}` },
    66→        { status: 400 }
    67→      );
    68→    }
    69→
    70→    // Sanitize and validate additional trades
    71→    const allTrades: string[] = Array.isArray(trades)
    72→      ? trades
    73→          .map((t: string) => sanitizeInput(t, { maxLength: MAX_LENGTH_TRADE }))
    74→          .filter((t: string) => (TRADES as readonly string[]).includes(t))
    75→      : [sanitizedTrade];
    76→
    77→    // Ensure at least the primary trade is included
    78→    if (allTrades.length === 0) {
    79→      allTrades.push(sanitizedTrade);
    80→    }
    81→
    82→    const tradesStr = allTrades.join(", ");
    83→
    84→    // Validate experience is a number
    85→    const experienceYears = parseInt(experience, 10);
    86→    if (isNaN(experienceYears) || experienceYears < 0 || experienceYears > 100) {
    87→      return NextResponse.json(
    88→        { error: "Invalid experience value" },
    89→        { status: 400 }
    90→      );
    91→    }
    92→
    93→    const transcriptSection = sanitizedTranscript
    94→      ? `\n\nOriginal voice description from the worker (use this to make the bio personal and unique — reference specific details they mentioned):\n"${sanitizedTranscript}"`
    95→      : "";
    96→
    97→    const prompt = `You are an expert construction industry recruiter writing a profile for an Australian construction worker.
    98→
    99→Worker Information:
   100→- Trade(s): ${tradesStr}
   101→- Years of Experience: ${experienceYears} years
   102→- Location: ${sanitizedLocation}
   103→- Availability: ${sanitizedAvailability}${transcriptSection}
   104→
   105→Generate a JSON response with:
   106→1. bio: A professional 2-3 sentence bio (50-80 words) written in FIRST PERSON ("I", "my", "me"). It's the worker's own bio on their profile. Make it unique — if voice details are provided, incorporate specific things they mentioned (multiple trades, specialties, work style). Do NOT use third person ("they", "their", "them").
   107→2. skills: An array of 6-10 relevant technical skills for their trade(s) (specific tools, techniques, materials). If multiple trades, include skills from each.
   108→3. certifications: An array of 3-5 common/recommended certifications for their trade(s) (actual Australian construction certifications)
   109→
   110→Make it sound professional but approachable. Focus on practical skills and experience.
   111→
   112→Return ONLY valid JSON in this exact format:
   113→{
   114→  "bio": "string",
   115→  "skills": ["skill1", "skill2", ...],
   116→  "certifications": ["cert1", "cert2", ...]
   117→}`;
   118→
   119→    const completion = await openai.chat.completions.create({
   120→      model: "gpt-4o-mini",
   121→      messages: [
   122→        {
   123→          role: "system",
   124→          content: "You are a construction industry expert. Always respond with valid JSON only.",
   125→        },
   126→        {
   127→          role: "user",
   128→          content: prompt,
   129→        },
   130→      ],
   131→      temperature: 0.7,
   132→      response_format: { type: "json_object" },
   133→    });
   134→
   135→    const content = completion.choices[0]?.message?.content;
   136→    if (!content) {
   137→      throw new Error("No response from AI");
   138→    }
   139→
   140→    const generated = JSON.parse(content);
   141→
   142→    return NextResponse.json({
   143→      bio: generated.bio || "",
   144→      skills: generated.skills || [],
   145→      certifications: generated.certifications || [],
   146→    });
   147→  } catch (error) {
   148→    logger.error("AI generation error:", error);
   149→
   150→    // Return more specific error messages
   151→    if (error instanceof Error) {
   152→      if (error.message.includes("API key")) {
   153→        return NextResponse.json(
   154→          { error: "AI service configuration error" },
   155→          { status: 503 }
   156→        );
   157→      }
   158→      if (error.message.includes("rate limit") || error.message.includes("429")) {
   159→        return NextResponse.json(
   160→          { error: "AI service is busy. Please try again in a moment." },
   161→          { status: 429 }
   162→        );
   163→      }
   164→    }
   165→
   166→    return NextResponse.json(
   167→      { error: "Failed to generate profile. Please try again." },
   168→      { status: 500 }
   169→    );
   170→  }
   171→}
   172→
   173→// Apply CSRF protection to the handler
   174→export const POST = withCSRFProtection(handler);
   175→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Voice Profile Parsing Endpoint
     3→ *
     4→ * DEPRECATED: This endpoint is maintained for backward compatibility.
     5→ * For new implementations, use /api/ai/voice-complete which provides
     6→ * consolidated transcription + parsing + generation for better performance.
     7→ *
     8→ * Performance: Use voice-complete for 30-50% faster processing
     9→ */
    10→
    11→import { NextRequest, NextResponse } from "next/server";
    12→import { logger } from "@/lib/logger";
    13→import OpenAI from "openai";
    14→import { createClient } from "@/lib/supabase/server";
    15→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
    16→import { withErrorHandler, APIError, ErrorCodes } from "@/lib/api-errors";
    17→import { sanitizeInput } from "@/lib/sanitize";
    18→import { validateCSRFToken, isSameOrigin } from "@/lib/csrf";
    19→import { TRADES } from "@/lib/constants";
    20→import {
    21→  AI_RATE_LIMIT,
    22→  RATE_LIMIT_WINDOW_MS as AI_WINDOW_MS,
    23→  MAX_LENGTH_TRANSCRIPT
    24→} from "@/lib/constants";
    25→
    26→// Cast helper to use NextRequest methods while satisfying withErrorHandler signature
    27→type Handler = (request: Request) => Promise<Response>;
    28→
    29→if (!process.env.OPENAI_API_KEY) {
    30→  throw new Error("OPENAI_API_KEY environment variable is not set");
    31→}
    32→
    33→const openai = new OpenAI({
    34→  apiKey: process.env.OPENAI_API_KEY,
    35→});
    36→
    37→// Rate limit: 10 requests per 15 minutes per user
    38→
    39→const VALID_AVAILABILITY = ["full-time", "casual", "weekends", "flexible"];
    40→
    41→async function handler(request: Request) {
    42→  const nextRequest = request as NextRequest;
    43→  // CSRF protection: validate same-origin and token
    44→  if (!isSameOrigin(request)) {
    45→    return NextResponse.json({ error: 'Cross-origin request not allowed' }, { status: 403 });
    46→  }
    47→  const csrfValid = await validateCSRFToken(request);
    48→  if (!csrfValid) {
    49→    return NextResponse.json({ error: 'Invalid or missing CSRF token' }, { status: 403 });
    50→  }
    51→
    52→  // Auth check
    53→  const supabase = await createClient();
    54→  const { data: { user } } = await supabase.auth.getUser();
    55→  if (!user) {
    56→    throw new APIError("Unauthorized", 401, ErrorCodes.UNAUTHORIZED);
    57→  }
    58→
    59→  // Rate limit
    60→  const clientIP = getClientIP(request);
    61→  const rateLimit = await checkRateLimit(`parse-voice:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    62→  if (!rateLimit.allowed) {
    63→    const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    64→    return createRateLimitResponse(clientIP, retryAfter);
    65→  }
    66→
    67→  const body = await request.json();
    68→  const { transcript } = body;
    69→
    70→  if (!transcript || typeof transcript !== "string") {
    71→    throw new APIError("Missing transcript", 400, ErrorCodes.INVALID_INPUT);
    72→  }
    73→
    74→  // Sanitize transcript to prevent prompt injection
    75→  const sanitizedTranscript = sanitizeInput(transcript, { maxLength: MAX_LENGTH_TRANSCRIPT });
    76→
    77→  try {
    78→    const completion = await openai.chat.completions.create({
    79→      model: "gpt-4o-mini",
    80→      messages: [
    81→        {
    82→          role: "system",
    83→          content: `You are a profile parser for an Australian construction hiring app. Extract structured data from a worker's spoken description.
    84→
    85→Valid trades: ${TRADES.join(", ")}
    86→Valid availability: ${VALID_AVAILABILITY.join(", ")}
    87→
    88→Return ONLY valid JSON in this exact format:
    89→{
    90→  "trades": ["array of valid trades mentioned, or empty array"],
    91→  "trade": "primary trade (first one mentioned) or null",
    92→  "experience_years": number or null,
    93→  "location": "suburb/area string or null",
    94→  "availability": "one of the valid availability options or null",
    95→  "additional_details": "any other details mentioned — specific skills, years per trade, preferences, work history, specialties. Capture everything not covered by the structured fields above. null if nothing extra."
    96→}
    97→
    98→Match trades loosely — e.g. "sparky" = "Electrician", "chippie" = "Carpenter", "bricky" = "Bricklayer", "steel fixer"/"steelfixer"/"reo" = "Steel Fixer", "formworker"/"formwork"/"shuttering"/"shutter hand" = "Formworker".
    99→IMPORTANT: Extract ALL trades mentioned, not just one. If someone says "steel fixing and shuttering", return both "Steel Fixer" and "Formworker".
   100→For experience, extract the number of years mentioned (use the highest if multiple are given for different trades).
   101→For location, extract any suburb, city, or area name mentioned.
   102→For availability, match to the closest option.
   103→If a field is not mentioned, set it to null (or empty array for trades).`,
   104→        },
   105→        {
   106→          role: "user",
   107→          content: sanitizedTranscript,
   108→        },
   109→      ],
   110→      temperature: 0.3,
   111→      response_format: { type: "json_object" },
   112→    });
   113→
   114→    const content = completion.choices[0]?.message?.content;
   115→    if (!content) {
   116→      throw new APIError("No response from AI", 500, ErrorCodes.EXTERNAL_SERVICE_ERROR);
   117→    }
   118→
   119→    const parsed = JSON.parse(content);
   120→
   121→    // Validate trades array
   122→    if (Array.isArray(parsed.trades)) {
   123→      parsed.trades = parsed.trades.filter((t: string) => (TRADES as readonly string[]).includes(t));
   124→    } else {
   125→      parsed.trades = [];
   126→    }
   127→
   128→    // Validate primary trade (use first from trades array if invalid)
   129→    if (parsed.trade && !(TRADES as readonly string[]).includes(parsed.trade)) {
   130→      parsed.trade = parsed.trades[0] || null;
   131→    } else if (!parsed.trade && parsed.trades.length > 0) {
   132→      parsed.trade = parsed.trades[0];
   133→    }
   134→
   135→    // Validate availability
   136→    if (parsed.availability && !VALID_AVAILABILITY.includes(parsed.availability)) {
   137→      parsed.availability = null;
   138→    }
   139→
   140→    // Validate experience_years
   141→    if (parsed.experience_years !== null && (typeof parsed.experience_years !== "number" || parsed.experience_years < 0)) {
   142→      parsed.experience_years = null;
   143→    }
   144→
   145→    return Response.json(parsed);
   146→  } catch (error) {
   147→    logger.error("OpenAI parsing error:", error);
   148→    if (error instanceof APIError) {
   149→      throw error;
   150→    }
   151→    throw new APIError(
   152→      "Failed to parse voice input",
   153→      500,
   154→      ErrorCodes.EXTERNAL_SERVICE_ERROR
   155→    );
   156→  }
   157→}
   158→
   159→export const POST = withErrorHandler(handler);
   160→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Voice Transcription Endpoint
     3→ *
     4→ * DEPRECATED: This endpoint is maintained for backward compatibility.
     5→ * For new implementations, use /api/ai/voice-complete which provides
     6→ * consolidated transcription + parsing + generation for better performance.
     7→ *
     8→ * Performance: Use voice-complete for 30-50% faster processing
     9→ */
    10→
    11→import { NextRequest, NextResponse } from "next/server";
    12→import { logger } from "@/lib/logger";
    13→import OpenAI from "openai";
    14→import { createClient } from "@/lib/supabase/server";
    15→import { checkRateLimit, getClientIP, createRateLimitResponse } from "@/lib/rate-limit";
    16→import { withErrorHandler, APIError, ErrorCodes } from "@/lib/api-errors";
    17→import { validateCSRFToken, isSameOrigin } from "@/lib/csrf";
    18→import {
    19→  AI_RATE_LIMIT,
    20→  AI_WINDOW_MS
    21→} from "@/lib/constants";
    22→
    23→if (!process.env.OPENAI_API_KEY) {
    24→  throw new Error("OPENAI_API_KEY environment variable is not set");
    25→}
    26→
    27→const openai = new OpenAI({
    28→  apiKey: process.env.OPENAI_API_KEY,
    29→});
    30→
    31→// Rate limit: 10 requests per 15 minutes per user
    32→
    33→async function handler(request: Request) {
    34→  // CSRF protection: validate same-origin and token
    35→  if (!isSameOrigin(request)) {
    36→    return NextResponse.json({ error: 'Cross-origin request not allowed' }, { status: 403 });
    37→  }
    38→  const csrfValid = await validateCSRFToken(request);
    39→  if (!csrfValid) {
    40→    return NextResponse.json({ error: 'Invalid or missing CSRF token' }, { status: 403 });
    41→  }
    42→
    43→  // Auth check
    44→  const supabase = await createClient();
    45→  const { data: { user } } = await supabase.auth.getUser();
    46→  if (!user) {
    47→    throw new APIError("Unauthorized", 401, ErrorCodes.UNAUTHORIZED);
    48→  }
    49→
    50→  // Rate limit
    51→  const clientIP = getClientIP(request);
    52→  const rateLimit = await checkRateLimit(`transcribe:${clientIP}`, AI_RATE_LIMIT, AI_WINDOW_MS);
    53→  if (!rateLimit.allowed) {
    54→    const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    55→    return createRateLimitResponse(clientIP, retryAfter);
    56→  }
    57→
    58→  const formData = await request.formData();
    59→  const audioFile = formData.get("audio") as File | null;
    60→
    61→  if (!audioFile) {
    62→    throw new APIError("No audio file provided", 400, ErrorCodes.INVALID_INPUT);
    63→  }
    64→
    65→  try {
    66→    const transcription = await openai.audio.transcriptions.create({
    67→      file: audioFile,
    68→      model: "whisper-1",
    69→      language: "en",
    70→    });
    71→
    72→    return Response.json({ text: transcription.text });
    73→  } catch (error) {
    74→    logger.error("OpenAI transcription error:", error);
    75→    throw new APIError(
    76→      "Failed to transcribe audio",
    77→      500,
    78→      ErrorCodes.EXTERNAL_SERVICE_ERROR
    79→    );
    80→  }
    81→}
    82→
    83→export const POST = withErrorHandler(handler);
    84→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import { getSupabaseAdmin, createClient } from '@/lib/supabase/server';
     2→import { NextRequest, NextResponse } from 'next/server';
     3→import { logger } from '@/lib/logger';
     4→import { checkRateLimit, getClientIP, createRateLimitResponse } from '@/lib/rate-limit';
     5→import { createPaymentIntent } from '@/lib/stripe/server';
     6→import { withCSRFProtection } from '@/lib/csrf';
     7→import {
     8→  PAYMENT_AMOUNT,
     9→  RATE_LIMIT_REQUESTS_PER_USER as PAYMENT_RATE_LIMIT,
    10→  RATE_LIMIT_WINDOW_MS as PAYMENT_WINDOW_MS,
    11→  HTTP_STATUS_INTERNAL_SERVER_ERROR
    12→} from '@/lib/constants';
    13→
    14→const handler = async (request: NextRequest) => {
    15→  try {
    16→    // --- Rate limiting (by IP as initial check) ---
    17→    const clientIP = getClientIP(request);
    18→    const ipRateLimit = await checkRateLimit(`payment-ip:${clientIP}`, PAYMENT_RATE_LIMIT, PAYMENT_WINDOW_MS);
    19→
    20→    if (!ipRateLimit.allowed) {
    21→      const retryAfter = Math.ceil((ipRateLimit.resetTime - Date.now()) / 1000);
    22→      return createRateLimitResponse(clientIP, retryAfter);
    23→    }
    24→
    25→    const supabase = await createClient();
    26→    const {
    27→      data: { user },
    28→    } = await supabase.auth.getUser();
    29→
    30→    if (!user) {
    31→      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    32→    }
    33→
    34→    // --- Stricter rate limiting by user ID ---
    35→    const userRateLimit = await checkRateLimit(`payment-user:${user.id}`, PAYMENT_RATE_LIMIT, PAYMENT_WINDOW_MS);
    36→
    37→    if (!userRateLimit.allowed) {
    38→      const retryAfter = Math.ceil((userRateLimit.resetTime - Date.now()) / 1000);
    39→      return createRateLimitResponse(user.id, retryAfter);
    40→    }
    41→
    42→    const body = await request.json();
    43→    const { match_id: matchId, company_id: companyId } = body;
    44→    const amount = PAYMENT_AMOUNT; // Hardcoded amount to prevent client override
    45→
    46→    if (!matchId || !companyId) {
    47→      return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
    48→    }
    49→
    50→    // Verify user owns the company
    51→    const { data: company } = await supabase
    52→      .from('companies')
    53→      .select('id, name')
    54→      .eq('id', companyId)
    55→      .eq('profile_id', user.id)
    56→      .single();
    57→
    58→    if (!company) {
    59→      return NextResponse.json({ error: 'Company not found or unauthorized' }, { status: 403 });
    60→    }
    61→
    62→    // Verify match exists and belongs to company's job (can be in any status except rejected)
    63→    const { data: match } = await supabase
    64→      .from('matches')
    65→      .select('status, jobs!inner(company_id, title)')
    66→      .eq('id', matchId)
    67→      .neq('status', 'rejected')
    68→      .eq('jobs.company_id', companyId)
    69→      .single();
    70→
    71→    if (!match) {
    72→      return NextResponse.json({ error: 'Match not found, already rejected, or unauthorized' }, { status: 403 });
    73→    }
    74→
    75→    // Check if payment already exists and is charged (prevent duplicate charges)
    76→    const { data: existingPayment } = await supabase
    77→      .from('payments')
    78→      .select('id, status')
    79→      .eq('match_id', matchId)
    80→      .eq('status', 'charged')
    81→      .maybeSingle();
    82→
    83→    if (existingPayment) {
    84→      return NextResponse.json({ error: 'Payment already completed for this hire' }, { status: 400 });
    85→    }
    86→
    87→    // Create Stripe PaymentIntent
    88→    const jobTitle = (match.jobs as { title?: string }).title || 'Job';
    89→    const { clientSecret, paymentIntentId } = await createPaymentIntent({
    90→      amount,
    91→      matchId: matchId,
    92→      companyId: companyId,
    93→      companyName: company.name,
    94→      description: `Hire for ${jobTitle}`,
    95→    });
    96→
    97→    // Atomic upsert: insert or update if conflict on match_id
    98→    // This prevents race conditions from concurrent payment creation requests
    99→    const supabaseService = getSupabaseAdmin();
   100→
   101→    const { data: payment, error } = await supabaseService
   102→      .from('payments')
   103→      .upsert(
   104→        {
   105→          match_id: matchId,
   106→          company_id: companyId,
   107→          amount,
   108→          status: 'pending',
   109→          stripe_payment_id: paymentIntentId,
   110→        },
   111→        {
   112→          onConflict: 'match_id',
   113→          ignoreDuplicates: false,
   114→        }
   115→      )
   116→      .select("id, match_id, company_id, amount, status, stripe_payment_id, created_at")
   117→      .single();
   118→
   119→    if (error) {
   120→      return NextResponse.json({ error: error.message }, { status: HTTP_STATUS_INTERNAL_SERVER_ERROR });
   121→    }
   122→
   123→    return NextResponse.json({
   124→      payment,
   125→      clientSecret,
   126→      publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
   127→    });
   128→  } catch (error) {
   129→    logger.error('Payment creation error:', error);
   130→    if (error instanceof Error && error.message.includes('Stripe')) {
   131→      return NextResponse.json({ error: 'Payment service unavailable' }, { status: 503 });
   132→    }
   133→    return NextResponse.json({ error: 'Internal server error' }, { status: HTTP_STATUS_INTERNAL_SERVER_ERROR });
   134→  }
   135→}
   136→
   137→// Apply CSRF protection to the handler
   138→export const POST = withCSRFProtection(handler);
   139→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Email verification API endpoint
     3→ * Placeholder for email verification
     4→ */
     5→
     6→import { NextRequest, NextResponse } from 'next/server'
     7→import { logger } from '@/lib/logger'
     8→import { checkRateLimit, getClientIP, createRateLimitResponse } from '@/lib/rate-limit'
     9→import { withCSRFProtection } from '@/lib/csrf'
    10→import {
    11→  VERIFY_RATE_LIMIT,
    12→  VERIFY_WINDOW_MS,
    13→  HTTP_STATUS_INTERNAL_SERVER_ERROR
    14→} from '@/lib/constants'
    15→
    16→const handler = async (request: NextRequest) => {
    17→  try {
    18→    // --- Rate limiting ---
    19→    const clientIP = getClientIP(request);
    20→    const rateLimit = await checkRateLimit(`verify-email:${clientIP}`, VERIFY_RATE_LIMIT, VERIFY_WINDOW_MS);
    21→
    22→    if (!rateLimit.allowed) {
    23→      const retryAfter = Math.ceil((rateLimit.resetTime - Date.now()) / 1000);
    24→      return createRateLimitResponse(clientIP, retryAfter);
    25→    }
    26→
    27→    const { userId, token } = await request.json()
    28→
    29→    if (!userId || !token) {
    30→      return NextResponse.json({ error: 'Missing required fields' }, { status: 400 })
    31→    }
    32→
    33→    // In production, implement proper token validation
    34→    // For now, return success
    35→
    36→    return NextResponse.json({
    37→      success: true,
    38→      message: 'Email verification endpoint ready'
    39→    })
    40→
    41→  } catch (error) {
    42→    logger.error('Email verification error:', error)
    43→    return NextResponse.json(
    44→      { error: 'Internal server error' },
    45→      { status: HTTP_STATUS_INTERNAL_SERVER_ERROR }
    46→    )
    47→  }
    48→}
    49→
    50→// Apply CSRF protection to the handler
    51→export const POST = withCSRFProtection(handler);
    52→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


Now let me also check the helper libraries that these routes depend on for a complete security picture.


     1→import { NextRequest, NextResponse } from 'next/server';
     2→import { randomBytes, timingSafeEqual as cryptoTimingSafeEqual } from 'crypto';
     3→import { logger } from './logger';
     4→
     5→// CSRF token configuration
     6→const CSRF_TOKEN_LENGTH = 32;
     7→const CSRF_COOKIE_NAME = 'csrf-token';
     8→const CSRF_HEADER_NAME = 'x-csrf-token';
     9→const CSRF_FORM_FIELD_NAME = 'csrf_token';
    10→
    11→/**
    12→ * Generate a cryptographically secure CSRF token
    13→ */
    14→export function generateCSRFToken(): string {
    15→  return randomBytes(CSRF_TOKEN_LENGTH).toString('hex');
    16→}
    17→
    18→/**
    19→ * Create a CSRF token and set it in a cookie
    20→ */
    21→export function createCSRFToken(): { token: string; cookie: string } {
    22→  const token = generateCSRFToken();
    23→  let cookie = `${CSRF_COOKIE_NAME}=${token}; Path=/; SameSite=Strict; Max-Age=86400`; // 24 hours
    24→
    25→  // Add secure flag in production
    26→  if (process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_URL?.startsWith('https://')) {
    27→    cookie += '; Secure';
    28→  }
    29→
    30→  return { token, cookie };
    31→}
    32→
    33→/**
    34→ * Get CSRF token from cookie
    35→ */
    36→export function getCSRFCookieToken(request: Request | NextRequest): string | null {
    37→  // Check if it's a NextRequest with cookies property
    38→  if ('cookies' in request && typeof request.cookies.get === 'function') {
    39→    const cookie = request.cookies.get(CSRF_COOKIE_NAME);
    40→    return cookie?.value || null;
    41→  }
    42→
    43→  // Fall back to parsing Cookie header for standard Request
    44→  const cookieHeader = request.headers.get('cookie');
    45→  if (!cookieHeader) return null;
    46→
    47→  const cookies = cookieHeader.split(';');
    48→  for (const cookie of cookies) {
    49→    const [name, value] = cookie.trim().split('=');
    50→    if (name === CSRF_COOKIE_NAME) {
    51→      return value;
    52→    }
    53→  }
    54→  return null;
    55→}
    56→
    57→/**
    58→ * Get CSRF token from request (header or form data)
    59→ */
    60→export function getCSRFRequestToken(request: Request | NextRequest): string | null {
    61→  // Check header first
    62→  const headerToken = request.headers.get(CSRF_HEADER_NAME);
    63→  if (headerToken) return headerToken;
    64→
    65→  // For form data, check in the request body (will need to parse appropriately)
    66→  // This is handled in the validation function for JSON requests
    67→  return null;
    68→}
    69→
    70→/**
    71→ * Validate CSRF token from request
    72→ * For JSON requests: checks x-csrf-token header
    73→ * For form requests: checks form field
    74→ */
    75→export async function validateCSRFToken(request: Request | NextRequest): Promise<boolean> {
    76→  const cookieToken = getCSRFCookieToken(request);
    77→
    78→  if (!cookieToken) {
    79→    logger.error('CSRF validation failed: No token in cookie');
    80→    return false;
    81→  }
    82→
    83→  let requestToken: string | null = null;
    84→
    85→  // For JSON requests
    86→  if (request.headers.get('content-type')?.includes('application/json')) {
    87→    requestToken = request.headers.get(CSRF_HEADER_NAME);
    88→
    89→    // If no header, check in JSON body
    90→    if (!requestToken) {
    91→      try {
    92→        const body = await request.clone().json();
    93→        requestToken = body[CSRF_FORM_FIELD_NAME] || null;
    94→      } catch {
    95→        // Ignore JSON parsing errors
    96→      }
    97→    }
    98→  }
    99→
   100→  // For form data requests
   101→  else if (request.headers.get('content-type')?.includes('multipart/form-data')) {
   102→    try {
   103→      const formData = await request.clone().formData();
   104→      requestToken = formData.get(CSRF_FORM_FIELD_NAME) as string;
   105→    } catch {
   106→      // Ignore form parsing errors
   107→    }
   108→  }
   109→
   110→  if (!requestToken) {
   111→    logger.error('CSRF validation failed: No token in request');
   112→    return false;
   113→  }
   114→
   115→  // Constant-time comparison to prevent timing attacks
   116→  const tokensMatch = timingSafeEqual(cookieToken, requestToken);
   117→
   118→  if (!tokensMatch) {
   119→    logger.error('CSRF validation failed: Token mismatch');
   120→    return false;
   121→  }
   122→
   123→  return true;
   124→}
   125→
   126→/**
   127→ * Timing-safe string comparison using Node.js crypto.timingSafeEqual
   128→ * Prevents timing attacks by using constant-time comparison
   129→ */
   130→function timingSafeEqual(a: string, b: string): boolean {
   131→  // crypto.timingSafeEqual requires buffers of equal length
   132→  // Pad the shorter string to match lengths to avoid leaking length info
   133→  const maxLength = Math.max(a.length, b.length);
   134→  const bufferA = Buffer.alloc(maxLength);
   135→  const bufferB = Buffer.alloc(maxLength);
   136→
   137→  bufferA.write(a);
   138→  bufferB.write(b);
   139→
   140→  try {
   141→    // This performs constant-time comparison
   142→    return cryptoTimingSafeEqual(bufferA, bufferB) && a.length === b.length;
   143→  } catch {
   144→    // If timingSafeEqual throws (shouldn't happen with equal-length buffers), fail closed
   145→    return false;
   146→  }
   147→}
   148→
   149→/**
   150→ * Check if request is same-origin
   151→ */
   152→export function isSameOrigin(request: Request | NextRequest): boolean {
   153→  const origin = request.headers.get('origin');
   154→  const referer = request.headers.get('referer');
   155→
   156→  if (!origin && !referer) {
   157→    // No origin/referer headers - reject the request to prevent CSRF bypass
   158→    return false;
   159→  }
   160→
   161→  const checkUrl = origin || referer;
   162→  if (!checkUrl) return false;
   163→
   164→  try {
   165→    const parsedUrl = new URL(checkUrl);
   166→    const appUrl = new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000');
   167→
   168→    return parsedUrl.origin === appUrl.origin;
   169→  } catch {
   170→    return false;
   171→  }
   172→}
   173→
   174→/**
   175→ * CSRF protection middleware
   176→ * Use this for routes that need CSRF protection
   177→ */
   178→export function withCSRFProtection<T extends NextRequest>(
   179→  handler: (request: T) => Promise<NextResponse | Response>,
   180→  options?: {
   181→    requireSameOrigin?: boolean;
   182→    skipValidation?: boolean;
   183→  }
   184→): (request: T) => Promise<NextResponse | Response> {
   185→  return async (request: T) => {
   186→    // Skip CSRF for GET, HEAD, OPTIONS requests
   187→    if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) {
   188→      return handler(request);
   189→    }
   190→
   191→    // Check if request is same-origin (optional, but recommended)
   192→    if (options?.requireSameOrigin !== false && !isSameOrigin(request)) {
   193→      return NextResponse.json(
   194→        { error: 'Cross-origin request not allowed' },
   195→        { status: 403 }
   196→      );
   197→    }
   198→
   199→    // Validate CSRF token (unless explicitly skipped)
   200→    if (!options?.skipValidation) {
   201→      const isValid = await validateCSRFToken(request);
   202→      if (!isValid) {
   203→        return NextResponse.json(
   204→          { error: 'Invalid or missing CSRF token' },
   205→          { status: 403 }
   206→        );
   207→      }
   208→    }
   209→
   210→    return handler(request);
   211→  };
   212→}
   213→
   214→/**
   215→ * Create a CSRF token endpoint
   216→ * This should be called when a user session starts
   217→ */
   218→export async function createCSRFEndpoint(request: NextRequest): Promise<NextResponse> {
   219→  if (request.method !== 'GET') {
   220→    return NextResponse.json({ error: 'Method not allowed' }, { status: 405 });
   221→  }
   222→
   223→  const { token, cookie } = createCSRFToken();
   224→
   225→  const response = NextResponse.json({ token });
   226→  response.headers.append('Set-Cookie', cookie);
   227→
   228→  return response;
   229→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Sanitizes user input to prevent prompt injection attacks
     3→ * Strips control characters, limits length, and escapes template injection
     4→ */
     5→
     6→/**
     7→ * Strip control characters (0x00-0x1F, 0x7F)
     8→ *
     9→ * @param str - Input string to clean
    10→ * @returns String with control characters removed
    11→ *
    12→ * @example
    13→ * stripControlChars('Hello\x00World\x1F')
    14→ * // Returns: 'HelloWorld'
    15→ */
    16→function stripControlChars(str: string): string {
    17→  return str.replace(/[\x00-\x1F\x7F]/g, '');
    18→}
    19→
    20→/**
    21→ * Escape template literals to prevent injection
    22→ * Prevents template literal injection by escaping backticks, $, and backslashes
    23→ *
    24→ * @param str - Input string to escape
    25→ * @returns Escaped string safe for template literals
    26→ *
    27→ * @example
    28→ * escapeTemplateLiterals('Hello ${user.name}')
    29→ * // Returns: 'Hello \${user.name}'
    30→ *
    31→ * @example
    32→ * escapeTemplateLiterals('Backtick: `code`')
    33→ * // Returns: 'Backtick: \`code\`'
    34→ */
    35→function escapeTemplateLiterals(str: string): string {
    36→  return str
    37→    .replace(/\\/g, '\\\\')  // Escape backslashes first
    38→    .replace(/\$/g, '\\$')   // Escape $ to prevent ${} injection
    39→    .replace(/`/g, '\\`');   // Escape backticks
    40→}
    41→
    42→/**
    43→ * Sanitize string input to prevent security issues and normalize data
    44→ * Strips control characters, escapes template literals, trims whitespace, and enforces length limits
    45→ *
    46→ * @param input - String input to sanitize
    47→ * @param options - Sanitization options
    48→ * @param options.maxLength - Maximum allowed length (truncates if longer)
    49→ * @param options.stripControls - Whether to strip control characters (default: true)
    50→ * @param options.escapeTemplates - Whether to escape template literals (default: true)
    51→ * @returns Sanitized string
    52→ *
    53→ * @example
    54→ * sanitizeInput('Hello\x00World')
    55→ * // Returns: 'HelloWorld'
    56→ *
    57→ * @example
    58→ * sanitizeInput('  Hello ${injection}  ', { maxLength: 10 })
    59→ * // Returns: 'Hello \${injection}'
    60→ *
    61→ * @example
    62→ * sanitizeInput('user input', { stripControls: false, escapeTemplates: false })
    63→ * // Returns: 'user input'
    64→ */
    65→export function sanitizeInput(
    66→  input: string,
    67→  options: {
    68→    maxLength?: number;
    69→    stripControls?: boolean;
    70→    escapeTemplates?: boolean;
    71→  } = {}
    72→): string {
    73→  if (typeof input !== 'string') {
    74→    return '';
    75→  }
    76→
    77→  let sanitized = input;
    78→
    79→  // Strip control characters by default
    80→  if (options.stripControls !== false) {
    81→    sanitized = stripControlChars(sanitized);
    82→  }
    83→
    84→  // Escape template literals by default
    85→  if (options.escapeTemplates !== false) {
    86→    sanitized = escapeTemplateLiterals(sanitized);
    87→  }
    88→
    89→  // Trim whitespace
    90→  sanitized = sanitized.trim();
    91→
    92→  // Limit length if specified
    93→  if (options.maxLength && sanitized.length > options.maxLength) {
    94→    sanitized = sanitized.substring(0, options.maxLength);
    95→  }
    96→
    97→  return sanitized;
    98→}
    99→
   100→/**
   101→ * Strip phone numbers and email addresses from text to prevent
   102→ * contractors and workers from bypassing the platform.
   103→ *
   104→ * Handles Australian phone formats: 04xx xxx xxx, +614xxxxxxxx, (02) xxxx xxxx, etc.
   105→ * Handles email addresses: user@domain.com
   106→ *
   107→ * @param input - String to strip contact info from
   108→ * @returns String with phone numbers and emails replaced with [removed]
   109→ */
   110→export function stripContactInfo(input: string): string {
   111→  if (typeof input !== 'string') return '';
   112→
   113→  let result = input;
   114→
   115→  // Australian mobile: 04xx xxx xxx, 04xxxxxxxx, +614xxxxxxxx
   116→  result = result.replace(/(\+?61\s?|0)4\d{2}[\s.-]?\d{3}[\s.-]?\d{3}/g, '[removed]');
   117→
   118→  // Australian landline: (0x) xxxx xxxx, 0x xxxx xxxx
   119→  result = result.replace(/\(?0[2-9]\)?[\s.-]?\d{4}[\s.-]?\d{4}/g, '[removed]');
   120→
   121→  // General international numbers: +xx xxxxxxxxxx (10+ digits with optional separators)
   122→  result = result.replace(/\+\d{1,3}[\s.-]?\d[\d\s.-]{8,}/g, '[removed]');
   123→
   124→  // Catch remaining digit sequences that look like phone numbers (8-12 digits with separators)
   125→  result = result.replace(/\b\d{4}[\s.-]\d{3}[\s.-]\d{3}\b/g, '[removed]');
   126→
   127→  // Email addresses
   128→  result = result.replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[removed]');
   129→
   130→  // Clean up multiple consecutive [removed] markers
   131→  result = result.replace(/(\[removed\]\s*){2,}/g, '[removed] ');
   132→
   133→  return result;
   134→}
   135→
   136→/**
   137→ * Sanitize multiple string inputs at once with field-specific options
   138→ * Useful for form data where different fields have different validation rules
   139→ *
   140→ * @param inputs - Object with string values to sanitize
   141→ * @param fieldOptions - Per-field sanitization options (keyed by field name)
   142→ * @returns Object with sanitized values
   143→ *
   144→ * @example
   145→ * sanitizeInputs(
   146→ *   { name: 'John Doe', bio: 'Hello\x00World', email: '  john@example.com  ' },
   147→ *   { bio: { maxLength: 500 }, email: { maxLength: 100 } }
   148→ * )
   149→ * // Returns: { name: 'John Doe', bio: 'HelloWorld', email: 'john@example.com' }
   150→ */
   151→export function sanitizeInputs(
   152→  inputs: Record<string, string>,
   153→  fieldOptions: Record<string, { maxLength?: number }> = {}
   154→): Record<string, string> {
   155→  const sanitized: Record<string, string> = {};
   156→
   157→  for (const [key, value] of Object.entries(inputs)) {
   158→    const options = fieldOptions[key] || {};
   159→    sanitized[key] = sanitizeInput(value, options);
   160→  }
   161→
   162→  return sanitized;
   163→}

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Rate limiting for API endpoints
     3→ *
     4→ * Production: Uses Upstash Redis for distributed rate limiting across
     5→ * serverless invocations. Falls back to in-memory Map() if Redis is
     6→ * unavailable (local dev or connection failure).
     7→ *
     8→ * Env vars (pick one):
     9→ *   UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN  (preferred)
    10→ *   LIMITER_STORAGE_URI  (legacy redis:// URL — auto-converted)
    11→ */
    12→
    13→import { Ratelimit } from "@upstash/ratelimit";
    14→import { Redis } from "@upstash/redis";
    15→
    16→// ---------------------------------------------------------------------------
    17→// Constants
    18→// ---------------------------------------------------------------------------
    19→const WINDOW_MS = 15 * 60 * 1000; // 15 minutes
    20→const MAX_REQUESTS = 100;
    21→
    22→// ---------------------------------------------------------------------------
    23→// In-memory fallback (local dev / Redis failure)
    24→// ---------------------------------------------------------------------------
    25→
    26→interface RateLimitEntry {
    27→  count: number;
    28→  resetTime: number;
    29→}
    30→
    31→const rateLimitStore = new Map<string, RateLimitEntry>();
    32→
    33→function memoryCheckRateLimit(
    34→  identifier: string,
    35→  maxRequests: number,
    36→  windowMs: number
    37→): { allowed: boolean; remaining: number; resetTime: number; totalLimit: number } {
    38→  const now = Date.now();
    39→  const entry = rateLimitStore.get(identifier);
    40→
    41→  if (!entry || now > entry.resetTime) {
    42→    rateLimitStore.set(identifier, { count: 1, resetTime: now + windowMs });
    43→    return { allowed: true, remaining: maxRequests - 1, resetTime: now + windowMs, totalLimit: maxRequests };
    44→  }
    45→
    46→  if (entry.count >= maxRequests) {
    47→    return { allowed: false, remaining: 0, resetTime: entry.resetTime, totalLimit: maxRequests };
    48→  }
    49→
    50→  entry.count++;
    51→  return { allowed: true, remaining: maxRequests - entry.count, resetTime: entry.resetTime, totalLimit: maxRequests };
    52→}
    53→
    54→// Cleanup interval to prevent memory leaks in long-running processes
    55→const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
    56→let cleanupInterval: NodeJS.Timeout | null = null;
    57→
    58→export function startRateLimitCleanup(): void {
    59→  if (cleanupInterval) return;
    60→  cleanupInterval = setInterval(() => {
    61→    const now = Date.now();
    62→    for (const [key, entry] of rateLimitStore.entries()) {
    63→      if (now > entry.resetTime) rateLimitStore.delete(key);
    64→    }
    65→  }, CLEANUP_INTERVAL_MS);
    66→  if (cleanupInterval.unref) cleanupInterval.unref();
    67→}
    68→
    69→export function stopRateLimitCleanup(): void {
    70→  if (cleanupInterval) {
    71→    clearInterval(cleanupInterval);
    72→    cleanupInterval = null;
    73→  }
    74→}
    75→
    76→// ---------------------------------------------------------------------------
    77→// Redis client (singleton, lazy-init)
    78→// ---------------------------------------------------------------------------
    79→
    80→let redis: Redis | null = null;
    81→let redisAvailable = true; // flipped to false on connection failure
    82→
    83→function getRedis(): Redis | null {
    84→  if (!redisAvailable) return null;
    85→  if (redis) return redis;
    86→
    87→  const restUrl = process.env.UPSTASH_REDIS_REST_URL;
    88→  const restToken = process.env.UPSTASH_REDIS_REST_TOKEN;
    89→
    90→  if (restUrl && restToken) {
    91→    redis = new Redis({ url: restUrl, token: restToken });
    92→    return redis;
    93→  }
    94→
    95→  // Parse legacy redis:// URI into Upstash REST format
    96→  // Expected: redis://default:<token>@<host>.upstash.io:6379
    97→  const legacyUri = process.env.LIMITER_STORAGE_URI;
    98→  if (legacyUri) {
    99→    try {
   100→      const parsed = new URL(legacyUri);
   101→      const host = parsed.hostname;
   102→      const token = decodeURIComponent(parsed.password);
   103→      if (host && token) {
   104→        redis = new Redis({ url: `https://${host}`, token });
   105→        return redis;
   106→      }
   107→    } catch {
   108→      // Parsing failed — fall through to memory
   109→    }
   110→  }
   111→
   112→  // No Redis config — use memory
   113→  redisAvailable = false;
   114→  return null;
   115→}
   116→
   117→// ---------------------------------------------------------------------------
   118→// Upstash Ratelimit instances (cached per unique window config)
   119→// ---------------------------------------------------------------------------
   120→
   121→const limiterCache = new Map<string, Ratelimit>();
   122→
   123→function getLimiter(maxRequests: number, windowMs: number): Ratelimit | null {
   124→  const client = getRedis();
   125→  if (!client) return null;
   126→
   127→  const key = `${maxRequests}:${windowMs}`;
   128→  let limiter = limiterCache.get(key);
   129→  if (limiter) return limiter;
   130→
   131→  const windowSec = Math.ceil(windowMs / 1000);
   132→  const windowStr = `${windowSec} s` as `${number} s`;
   133→
   134→  limiter = new Ratelimit({
   135→    redis: client,
   136→    limiter: Ratelimit.slidingWindow(maxRequests, windowStr),
   137→    prefix: "rr:rl",
   138→  });
   139→
   140→  limiterCache.set(key, limiter);
   141→  return limiter;
   142→}
   143→
   144→// ---------------------------------------------------------------------------
   145→// Public API (unchanged signatures)
   146→// ---------------------------------------------------------------------------
   147→
   148→export function getClientIP(request: Request): string {
   149→  const forwarded = request.headers.get("x-forwarded-for");
   150→  if (forwarded) return forwarded.split(",")[0].trim();
   151→  const realIP = request.headers.get("x-real-ip");
   152→  if (realIP) return realIP;
   153→  return "unknown";
   154→}
   155→
   156→export async function checkRateLimit(
   157→  identifier: string,
   158→  maxRequests?: number,
   159→  windowMs?: number
   160→): Promise<{
   161→  allowed: boolean;
   162→  remaining: number;
   163→  resetTime: number;
   164→  totalLimit: number;
   165→}> {
   166→  const limit = maxRequests ?? MAX_REQUESTS;
   167→  const window = windowMs ?? WINDOW_MS;
   168→
   169→  const limiter = getLimiter(limit, window);
   170→  if (limiter) {
   171→    try {
   172→      const result = await limiter.limit(identifier);
   173→      return {
   174→        allowed: result.success,
   175→        remaining: result.remaining,
   176→        resetTime: result.reset,
   177→        totalLimit: result.limit,
   178→      };
   179→    } catch {
   180→      // Redis failed — fall through to memory
   181→    }
   182→  }
   183→
   184→  return memoryCheckRateLimit(identifier, limit, window);
   185→}
   186→
   187→export function getRateLimitHeaders(result: {
   188→  allowed: boolean;
   189→  remaining: number;
   190→  resetTime: number;
   191→  totalLimit: number;
   192→}): Record<string, string> {
   193→  return {
   194→    "X-RateLimit-Limit": result.totalLimit.toString(),
   195→    "X-RateLimit-Remaining": Math.max(0, result.remaining).toString(),
   196→    "X-RateLimit-Reset": new Date(result.resetTime).toISOString(),
   197→  };
   198→}
   199→
   200→export function createRateLimitResponse(
   201→  _identifier: string,
   202→  retryAfterSeconds?: number
   203→): Response {
   204→  const headers = new Headers({
   205→    "Content-Type": "application/json",
   206→    "X-RateLimit-Limit": MAX_REQUESTS.toString(),
   207→    "X-RateLimit-Remaining": "0",
   208→  });
   209→
   210→  if (retryAfterSeconds) {
   211→    headers.set("Retry-After", retryAfterSeconds.toString());
   212→  }
   213→
   214→  return new Response(
   215→    JSON.stringify({
   216→      error: "Rate limit exceeded. Please try again later.",
   217→      retryAfter: retryAfterSeconds,
   218→    }),
   219→    { status: 429, headers }
   220→  );
   221→}
   222→
   223→// Auto-start memory cleanup
   224→startRateLimitCleanup();
   225→
   226→export { MAX_REQUESTS, WINDOW_MS };
   227→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→import Stripe from 'stripe';
     2→import { logger } from '@/lib/logger';
     3→
     4→// Initialize Stripe with secret key
     5→export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
     6→  apiVersion: '2026-01-28.clover', // Use latest API version
     7→  typescript: true,
     8→});
     9→
    10→// Validate Stripe is configured
    11→export function validateStripeConfig(): void {
    12→  if (!process.env.STRIPE_SECRET_KEY) {
    13→    throw new Error('STRIPE_SECRET_KEY is not configured');
    14→  }
    15→  if (!process.env.STRIPE_WEBHOOK_SECRET) {
    16→    throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
    17→  }
    18→}
    19→
    20→// Create a PaymentIntent for hiring a worker
    21→export async function createPaymentIntent({
    22→  amount,
    23→  matchId,
    24→  companyId,
    25→  companyName,
    26→  description,
    27→}: {
    28→  amount: number;
    29→  matchId: string;
    30→  companyId: string;
    31→  companyName: string;
    32→  description: string;
    33→}) {
    34→  try {
    35→    const paymentIntent = await stripe.paymentIntents.create({
    36→      amount: Math.round(amount * 100), // Convert to cents
    37→      currency: 'aud',
    38→      automatic_payment_methods: {
    39→        enabled: true,
    40→      },
    41→      metadata: {
    42→        match_id: matchId,
    43→        company_id: companyId,
    44→        company_name: companyName,
    45→        description,
    46→      },
    47→      description: `RateRight Hire Fee - ${description}`,
    48→    });
    49→
    50→    return {
    51→      clientSecret: paymentIntent.client_secret,
    52→      paymentIntentId: paymentIntent.id,
    53→    };
    54→  } catch (error) {
    55→    logger.error('Error creating PaymentIntent:', error);
    56→    throw new Error('Failed to create payment intent');
    57→  }
    58→}
    59→
    60→// Retrieve a PaymentIntent
    61→export async function retrievePaymentIntent(paymentIntentId: string) {
    62→  try {
    63→    return await stripe.paymentIntents.retrieve(paymentIntentId);
    64→  } catch (error) {
    65→    logger.error('Error retrieving PaymentIntent:', error);
    66→    throw new Error('Failed to retrieve payment intent');
    67→  }
    68→}
    69→
    70→// Construct webhook event with signature verification
    71→export function constructWebhookEvent(
    72→  payload: string | Buffer,
    73→  signature: string
    74→): Stripe.Event {
    75→  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
    76→
    77→  if (!webhookSecret) {
    78→    throw new Error('STRIPE_WEBHOOK_SECRET is not configured');
    79→  }
    80→
    81→  try {
    82→    return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
    83→  } catch (error) {
    84→    logger.error('Webhook signature verification failed:', error);
    85→    throw new Error('Invalid webhook signature');
    86→  }
    87→}
    88→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


I now have a complete picture. Let me also quickly check the constants file for any relevant values.


2:export const PAYMENT_AMOUNT = 50.0;
9:export const SEND_MESSAGE_RATE_LIMIT = 60; // 60 messages per 15 min window per IP
10:export const VERIFY_RATE_LIMIT = 5; // 5 email verification attempts per window
11:export const VERIFY_WINDOW_MS = RATE_LIMIT_WINDOW_MS;
14:export const AI_RATE_LIMIT = 5;
15:export const AI_WINDOW_MS = RATE_LIMIT_WINDOW_MS;
18:export const MAX_LENGTH_TRADE = 100;
19:export const MAX_LENGTH_LOCATION = 200;
20:export const MAX_LENGTH_AVAILABILITY = 50;
21:export const MAX_LENGTH_TRANSCRIPT = 5000;
22:export const MAX_LENGTH_VOICE_TRANSCRIPT = 2000;
23:export const MAX_LENGTH_MESSAGE_CONTENT = 5000;
24:export const MAX_LENGTH_EMAIL = 254;


I now have a thorough understanding of every API route and their supporting libraries. Here is the complete audit.

---

# API Route Security Audit -- /home/ccuser/the-50-dollar-app/src/app/api/

## Summary

21 route files reviewed. The codebase shows solid security fundamentals -- most routes have authentication, rate limiting, CSRF protection, and input sanitization. However, there are several issues ranging from critical (a completely broken email verification endpoint) to moderate (authorization gaps, missing validations, and an SSRF vector).

---

## 1. `/home/ccuser/the-50-dollar-app/src/app/api/auth/verify-email/route.ts`

**CRITICAL: Email verification is a no-op placeholder**

```typescript
// In production, implement proper token validation
// For now, return success

return NextResponse.json({
  success: true,
  message: 'Email verification endpoint ready'
})
```

- **Issue:** The endpoint accepts any `userId` and `token` combination and always returns success. There is zero actual verification happening. If any part of the application relies on this endpoint to confirm email ownership, it is completely bypassable.
- **Issue:** No authentication check. Any unauthenticated user can call this endpoint with any `userId`.
- **Issue:** `userId` and `token` are extracted from `request.json()` but never validated for type or format. A non-string `userId` or `token` would pass the truthy check.

---

## 2. `/home/ccuser/the-50-dollar-app/src/app/api/geocode/route.ts`

**HIGH: No authentication, no CSRF protection, potential abuse vector**

```typescript
export async function POST(req: NextRequest) {
  // No auth check anywhere
  const body = await req.json();
  const { suburb } = body;
```

- **Issue:** This POST endpoint has **no authentication** and **no rate limiting**. Anyone on the internet can call it repeatedly. Since it proxies to the Nominatim API, an attacker could use this as an amplification proxy to hit OpenStreetMap's infrastructure, potentially getting the server's IP banned.
- **Issue:** No CSRF protection on this POST endpoint.
- **Issue:** The `suburb` string is not sanitized beyond a type check. While it is passed via URL params to Nominatim (which handles encoding), the raw user input is echoed back in the 404 error response: `{ error: "Location not found", suburb }`. This could be used for reflected data injection if the response is consumed in a context that doesn't escape it.

---

## 3. `/home/ccuser/the-50-dollar-app/src/app/api/ratings-rootfix/route.ts`

**MODERATE: Supabase error message leaked to client**

```typescript
if (error) {
  console.error("Error fetching ratings:", error);
  return NextResponse.json({ error: error.message }, { status: 500 });
}
```

- **Issue:** `error.message` from Supabase is returned directly to the client. This can leak internal database schema information (table names, column names, constraint names) to attackers.
- **Issue:** Uses a custom inline logger instead of the shared `@/lib/logger`, which is inconsistent but not a security issue per se.

---

## 4. `/home/ccuser/the-50-dollar-app/src/app/api/ratings/route.ts`

**MODERATE: Same Supabase error leak; IDOR on userId**

```typescript
return NextResponse.json({ error: error.message }, { status: 500 });
```

- **Issue:** Same Supabase error message leak as `ratings-rootfix`.
- **Issue (IDOR):** The `userId` query param controls whose ratings are fetched. While the user must be authenticated, any authenticated user can view any other user's ratings. This may be intentional (public ratings), but there is no authorization check that the requester should have access to the target user's ratings.
- **Issue:** Neither `ratings` nor `ratings-rootfix` have rate limiting, making them susceptible to scraping.

---

## 5. `/home/ccuser/the-50-dollar-app/src/app/api/payments/verify/route.ts`

**MODERATE: Authorization check may not work correctly due to field name casing**

```typescript
if (!payment || (payment.companies as { profileId?: string }).profileId !== user.id) {
```

- **Issue:** The Supabase query selects `companies!inner(profile_id)` which returns the column as `profile_id` (snake_case, matching the database). But the code casts to `{ profileId?: string }` and accesses `.profileId` (camelCase). **This comparison will always fail** unless Supabase is configured with some automatic case transformation. If it always fails, the endpoint always returns 403, meaning legitimate users cannot verify their payments either. If it somehow passes, the authorization is broken. Either way, this is a bug.

---

## 6. `/home/ccuser/the-50-dollar-app/src/app/api/webhooks/stripe/route.ts`

**LOW: Generally well-implemented, minor issues**

- **Good:** Signature verification is correctly implemented via `constructWebhookEvent`.
- **Good:** Uses `request.text()` to get raw body for signature verification.
- **Issue (Minor):** In `handlePaymentSuccess`, if the `payments` table update succeeds but returns no rows (e.g., `stripe_payment_id` not found), the function throws an error. This causes a 500 response to Stripe, which will retry the webhook. This is actually correct behavior, but the error message could be more diagnostic.
- **Issue (Minor):** `handlePaymentFailure` silently swallows errors. If updating the payment status fails, the webhook returns 200 to Stripe, meaning the failure state is never recorded.
- **Issue (Race condition):** `handlePaymentSuccess` first updates the payment, then updates the match in a separate query. If the match update fails, the payment is marked as "charged" but the match is not updated to "hired". There is no transaction or rollback mechanism.

---

## 7. `/home/ccuser/the-50-dollar-app/src/app/api/company-logo/route.ts`

**MODERATE: SSRF via domain parameter; no authentication**

```typescript
const cleanDomain = domain.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
const clearbitUrl = `https://logo.clearbit.com/${cleanDomain}`;
const clearbitExists = await checkLogoExists(clearbitUrl);
```

- **Issue (SSRF):** The `domain` parameter is user-controlled and only has basic cleaning (strip protocol and path). An attacker could pass something like `internal-service.local` or `169.254.169.254` (AWS metadata endpoint) and the server will make an outbound HTTP request to that address via the `checkLogoExists` HEAD request. The Clearbit URL construction mitigates this somewhat for that specific fetch, but the Google favicon URL (`https://www.google.com/s2/favicons?domain=${cleanDomain}&sz=128`) passes the domain as a query parameter, which is less dangerous but still an oracle.
- **Issue:** No authentication required. Any user (or bot) can probe domains through this endpoint.
- **Issue:** In-memory `logoCache` is unbounded in size -- it only has time-based expiry. An attacker could fill it with millions of unique domain entries, causing memory exhaustion in the Node.js process.

---

## 8. `/home/ccuser/the-50-dollar-app/src/app/api/messages/start/route.ts`

**HIGH: SQL injection-like issue in Supabase `.or()` filter**

```typescript
.or(
  `and(worker_id.eq.${user.id},jobs.companies.profile_id.eq.${recipientId}),` +
  `and(worker_id.eq.${recipientId},jobs.companies.profile_id.eq.${user.id})`
)
```

- **Issue:** `recipientId` comes directly from the JSON request body with no validation or sanitization. It is interpolated into the PostgREST filter string. While Supabase's PostgREST layer does parameterize the actual SQL queries, a malicious `recipientId` value could manipulate the PostgREST filter syntax itself. For example, a `recipientId` containing `)` or `,` could potentially alter the filter logic. The `user.id` is safe (comes from auth), but `recipientId` should be validated as a UUID before interpolation.
- **Issue:** `initialMessage` is not sanitized. The `initialMessage.trim()` is inserted directly into the database. While Supabase's client uses parameterized queries (protecting against SQL injection), there is no content sanitization for XSS if the message is rendered in a browser.
- **Issue:** No length validation on `initialMessage`. A user could send a multi-megabyte initial message.

---

## 9. `/home/ccuser/the-50-dollar-app/src/app/api/messages/[id]/route.ts`

**LOW: No CSRF protection needed (GET only), minor issues**

- **Good:** Verifies conversation participation before returning messages.
- **Issue (Minor):** The `conversationId` from the URL path is not validated as a UUID format. Invalid format would just return no results, but validation-first is better practice.
- **Issue (Minor):** The `limit` parameter uses `parseInt` which will parse `"100abc"` as `100`. Not exploitable but sloppy.

---

## 10. `/home/ccuser/the-50-dollar-app/src/app/api/messages/[id]/send/route.ts`

**LOW: Well-secured, one minor issue**

- **Good:** CSRF protection (manual `isSameOrigin` + `validateCSRFToken`).
- **Good:** Zod schema validation with proper constraints.
- **Good:** Input sanitization on message content.
- **Good:** Participant verification.
- **Issue (Minor):** The attachment `url` field is validated as a URL by Zod, but there is no check that the URL points to an expected domain (e.g., Supabase storage). An attacker could store an attachment referencing an arbitrary external URL that could be used for phishing or tracking when other users click it.

---

## 11. `/home/ccuser/the-50-dollar-app/src/app/api/messages/route.ts`

**LOW: Minor issue**

- **Good:** Rate limiting, auth check.
- **Issue (Minor):** The `last_seen_at` update is a fire-and-forget call that doesn't check for errors. If it fails, the user's online status would be stale, but no error is returned.

---

## 12. `/home/ccuser/the-50-dollar-app/src/app/api/abn-lookup/route.ts`

**LOW: Well-implemented, minor observation**

- **Good:** Rate limiting, input validation via ABN checksum, email validation with Zod, caching, proper error handling.
- **Issue (Minor):** No authentication required. Anyone can look up ABN data. This may be intentional for the signup flow, but it means the rate limit is the only defense against enumeration.
- **Issue (Minor):** The risk scoring in `verifyDirector` uses free email providers as a signal (`+20` risk score). This list is hardcoded and does not include many common free providers (e.g., `zohomail.com`, `tutanota.com`, `yandex.com`).

---

## 13. `/home/ccuser/the-50-dollar-app/src/app/api/match/find-matches/route.ts`

**MODERATE: RPC error details leaked to client**

```typescript
return NextResponse.json(
  { error: "Failed to find matches", details: rpcError.message },
  { status: 500 }
);
```

- **Issue:** `rpcError.message` from the Supabase RPC call is returned to the client. This can expose internal database function names, parameter errors, or PostgreSQL error details.
- **Issue (Minor):** The `jobId` is not validated as a UUID format before being passed to the database queries.

---

## 14. `/home/ccuser/the-50-dollar-app/src/app/api/payments/create/route.ts`

**MODERATE: Missing matchId/companyId format validation**

- **Good:** Dual rate limiting (IP + user ID), CSRF protection, duplicate payment check, ownership verification, hardcoded payment amount.
- **Issue:** `matchId` and `companyId` are not validated for UUID format. While Supabase will reject malformed UUIDs at the query level, this means the Stripe `createPaymentIntent` call is made before the match ownership is fully verified (the PaymentIntent is created at line 89, before the match query at line 63 -- wait, actually the match query IS before the PaymentIntent creation, so this is fine for the current flow). Still, validating UUIDs early would be better practice.
- **Issue:** The Supabase error message is returned directly to the client on line 120: `return NextResponse.json({ error: error.message }, ...)`

---

## 15. `/home/ccuser/the-50-dollar-app/src/app/api/ai/generate-job/route.ts`

**LOW: Well-secured**

- **Good:** CSRF protection, rate limiting, auth, input sanitization, `stripContactInfo` on output.
- **Issue (Minor):** The `trade` input is sanitized but not validated against the `TRADES` constant list, unlike in `generate-profile`. An attacker could pass an arbitrary trade string that gets sent to OpenAI.

---

## 16. `/home/ccuser/the-50-dollar-app/src/app/api/ai/voice-complete/route.ts`

**LOW: Well-secured (identical to voice-complete-rootfix)**

- **Good:** CSRF protection, rate limiting, auth, input sanitization, validation of AI output against allowed trades/availability.
- **Issue (Minor):** No file size/type validation on the uploaded audio file. The Whisper API will reject invalid files, but the server still has to receive and process the upload. An attacker could upload very large files to consume bandwidth/memory.

---

## 17. `/home/ccuser/the-50-dollar-app/src/app/api/ai/voice-complete-rootfix/route.ts`

**Same as voice-complete above.** This appears to be a duplicate/parallel route with identical code.

---

## 18. `/home/ccuser/the-50-dollar-app/src/app/api/ai/generate-profile/route.ts`

**LOW: Well-secured**

- **Good:** CSRF, rate limiting, auth, sanitization, trade validation against allowed list, experience range validation.
- **Issue (Minor):** The `location` and `availability` fields are sanitized but not validated against an allowlist. This is acceptable for location (freeform) but availability should be validated against the `VALID_AVAILABILITY` list similar to what the voice endpoints do.

---

## 19. `/home/ccuser/the-50-dollar-app/src/app/api/ai/parse-profile-voice/route.ts`

**LOW: Well-secured (deprecated endpoint)**

- **Good:** CSRF (manual), rate limiting, auth, sanitization.
- **Issue (Minor):** Uses `withErrorHandler` wrapper but also manually checks CSRF before it. The `withErrorHandler` wraps the raw `handler` function without `withCSRFProtection`, so the CSRF check is done manually inside the handler. This is functionally correct but inconsistent with other routes that use `withCSRFProtection`.

---

## 20. `/home/ccuser/the-50-dollar-app/src/app/api/ai/transcribe-voice/route.ts`

**LOW: Well-secured (deprecated endpoint)**

- **Good:** CSRF (manual), rate limiting, auth.
- **Issue (Minor):** Same inconsistency as parse-profile-voice -- manual CSRF check inside `withErrorHandler` instead of using `withCSRFProtection`.
- **Issue (Minor):** No audio file size or MIME type validation before sending to OpenAI.

---

## 21. `/home/ccuser/the-50-dollar-app/src/app/api/auth/csrf/route.ts`

**LOW: Well-implemented**

- **Good:** Auth check before token generation, proper error handling.
- No significant issues.

---

## Cross-Cutting Issues in Supporting Libraries

### `/home/ccuser/the-50-dollar-app/src/lib/csrf.ts`

**MODERATE: CSRF cookie missing HttpOnly flag**

```typescript
let cookie = `${CSRF_COOKIE_NAME}=${token}; Path=/; SameSite=Strict; Max-Age=86400`;
```

- **Issue:** The CSRF cookie is **not HttpOnly**. This is actually intentional for the double-submit cookie pattern (JavaScript needs to read it), so this is not a bug. However, `SameSite=Strict` could cause issues if users arrive via links from other sites (the cookie won't be sent on the first navigation), leading to CSRF token failures on the first request.

### `/home/ccuser/the-50-dollar-app/src/lib/rate-limit.ts`

**MODERATE: IP-based rate limiting can be trivially bypassed**

```typescript
export function getClientIP(request: Request): string {
  const forwarded = request.headers.get("x-forwarded-for");
  if (forwarded) return forwarded.split(",")[0].trim();
```

- **Issue:** `X-Forwarded-For` is trusted directly. If there is no reverse proxy that strips/overwrites this header, any client can set an arbitrary `X-Forwarded-For` header to bypass all IP-based rate limits. This depends on the deployment architecture -- if behind a trusted proxy (like Cloudflare or Vercel), this may be fine, but it should be documented.

---

## Priority Summary

| Severity | File | Issue |
|----------|------|-------|
| **CRITICAL** | `auth/verify-email/route.ts` | Email verification is a no-op -- always returns success with no actual token validation. No auth check. |
| **HIGH** | `geocode/route.ts` | No authentication, no rate limiting, no CSRF on a POST endpoint. Can be used as a proxy. |
| **HIGH** | `messages/start/route.ts` | `recipientId` interpolated into PostgREST filter string without UUID validation. `initialMessage` not sanitized or length-limited. |
| **MODERATE** | `payments/verify/route.ts` | Authorization check likely broken due to `profile_id` vs `profileId` casing mismatch -- may always return 403. |
| **MODERATE** | `company-logo/route.ts` | SSRF vector via domain parameter; unbounded in-memory cache; no auth. |
| **MODERATE** | `ratings-rootfix/route.ts` | Supabase error messages leaked to client. No rate limiting. |
| **MODERATE** | `ratings/route.ts` | Same Supabase error leak. No rate limiting. Potential IDOR. |
| **MODERATE** | `match/find-matches/route.ts` | RPC error message leaked to client. |
| **MODERATE** | `payments/create/route.ts` | Supabase error message leaked to client. |
| **MODERATE** | `rate-limit.ts` (library) | `X-Forwarded-For` header trusted without validation -- all IP-based rate limits bypassable if no upstream proxy strips this header. |
| **LOW** | `webhooks/stripe/route.ts` | Race condition between payment and match updates; silent error swallowing in failure handler. |
| **LOW** | `ai/generate-job/route.ts` | Trade input not validated against allowlist. |
| **LOW** | `ai/voice-complete*/route.ts` | No audio file size/type validation before processing. |
| **LOW** | `messages/[id]/send/route.ts` | Attachment URLs not validated against expected domain. |