/* |-------------------------------------------------------------------------- | Agência PSI — Edge Function: submit-patient-intake (A#20 rev2) |-------------------------------------------------------------------------- | Defesa em camadas SELF-HOSTED (substitui Turnstile): | 1. Honeypot field → frontend renderiza, edge rejeita se vier preenchido | 2. Rate limit por IP → check_rate_limit RPC | 3. Math captcha CONDICIONAL → exigido só após N falhas do mesmo IP | 4. Logging → record_submission_attempt RPC (genérico) | 5. Modo paranoid → saas_security_config.captcha_required_globally | | Endpoints: | POST / → submit do form | POST /captcha-challenge → gera math challenge (id+question) sob demanda |-------------------------------------------------------------------------- */ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2' const SUPABASE_URL = Deno.env.get('SUPABASE_URL') const SUPABASE_SERVICE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') const ENDPOINT_NAME = 'patient_intake' const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY) const CORS_HEADERS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type' } function jsonResponse(body, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...CORS_HEADERS } }) } // SHA-256 estável (rate limit é por hash, nunca pelo IP cru — privacidade) async function hashIp(ip) { if (!ip) return null const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`agenciapsi:${ip}`)) return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 32) } function getClientIp(req) { const xff = req.headers.get('x-forwarded-for') if (xff) return xff.split(',')[0].trim() return req.headers.get('cf-connecting-ip') || req.headers.get('x-real-ip') || null } // ───────────────────────────────────────────────────────────────────────── // Handler principal // ----------------------------------------------------------------------------- Deno.serve(async (req) => { if (req.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS_HEADERS }) if (req.method !== 'POST') return jsonResponse({ error: 'method-not-allowed' }, 405) const url = new URL(req.url) const isCaptchaEndpoint = url.pathname.endsWith('/captcha-challenge') // ── Endpoint de challenge (sem rate limit aqui pra não bloquear UX) ── if (isCaptchaEndpoint) { const { data, error } = await supabase.rpc('generate_math_challenge') if (error) return jsonResponse({ error: 'challenge-failed', message: error.message }, 500) return jsonResponse({ challenge: data }, 200) } // ── Endpoint principal: submit do intake ── let body try { body = await req.json() } catch { return jsonResponse({ error: 'invalid-json' }, 400) } const token = String(body?.token || '').trim() const payload = body?.payload && typeof body.payload === 'object' ? body.payload : null const honeypot = String(body?.website || '').trim() // honeypot field name const captchaId = body?.captcha_id ? String(body.captcha_id) : null const captchaAnswer = body?.captcha_answer != null ? Number(body.captcha_answer) : null const ip = getClientIp(req) const ipHash = await hashIp(ip) const userAgent = String(body?.client_info || req.headers.get('user-agent') || '').slice(0, 500) // ── 1) HONEYPOT: bot tipicamente preenche todos os campos ── if (honeypot.length > 0) { await supabase.rpc('record_submission_attempt', { p_endpoint: ENDPOINT_NAME, p_ip_hash: ipHash, p_success: false, p_blocked_by: 'honeypot', p_error_code: 'honeypot_filled', p_error_msg: null, p_user_agent: userAgent, p_metadata: null }) // Resposta deliberadamente igual à de sucesso pra não dar feedback ao bot return jsonResponse({ ok: true, intake_id: null }, 200) } if (!token || !payload) { await supabase.rpc('record_submission_attempt', { p_endpoint: ENDPOINT_NAME, p_ip_hash: ipHash, p_success: false, p_blocked_by: 'validation', p_error_code: 'missing_fields', p_error_msg: null, p_user_agent: userAgent, p_metadata: null }) return jsonResponse({ error: 'missing-fields' }, 400) } // ── 2) RATE LIMIT + decisão de captcha ── const { data: rl, error: rlErr } = await supabase.rpc('check_rate_limit', { p_ip_hash: ipHash, p_endpoint: ENDPOINT_NAME }) if (rlErr) { // fail-open mas logado console.error('[submit-patient-intake] check_rate_limit failed:', rlErr.message) } if (rl && rl.allowed === false) { await supabase.rpc('record_submission_attempt', { p_endpoint: ENDPOINT_NAME, p_ip_hash: ipHash, p_success: false, p_blocked_by: 'rate_limit', p_error_code: rl.reason || 'blocked', p_error_msg: null, p_user_agent: userAgent, p_metadata: null }) return jsonResponse({ error: 'rate-limited', retry_after_seconds: rl.retry_after_seconds || null }, 429) } // ── 3) MATH CAPTCHA condicional ── if (rl && rl.requires_captcha === true) { if (!captchaId || !Number.isFinite(captchaAnswer)) { await supabase.rpc('record_submission_attempt', { p_endpoint: ENDPOINT_NAME, p_ip_hash: ipHash, p_success: false, p_blocked_by: 'captcha', p_error_code: 'captcha_required', p_error_msg: null, p_user_agent: userAgent, p_metadata: null }) return jsonResponse({ error: 'captcha-required' }, 403) } const { data: ok, error: vErr } = await supabase.rpc('verify_math_challenge', { p_id: captchaId, p_answer: captchaAnswer }) if (vErr || !ok) { await supabase.rpc('record_submission_attempt', { p_endpoint: ENDPOINT_NAME, p_ip_hash: ipHash, p_success: false, p_blocked_by: 'captcha', p_error_code: 'captcha_wrong', p_error_msg: vErr?.message || null, p_user_agent: userAgent, p_metadata: null }) return jsonResponse({ error: 'captcha-wrong' }, 403) } } // ── 4) Chama RPC do intake ── const { data, error } = await supabase.rpc('create_patient_intake_request_v2', { p_token: token, p_payload: payload, p_client_info: userAgent || null }) if (error) { await supabase.rpc('record_submission_attempt', { p_endpoint: ENDPOINT_NAME, p_ip_hash: ipHash, p_success: false, p_blocked_by: 'rpc', p_error_code: error.code || 'rpc_err', p_error_msg: error.message, p_user_agent: userAgent, p_metadata: null }) return jsonResponse({ error: 'rpc-failed', message: error.message }, 400) } await supabase.rpc('record_submission_attempt', { p_endpoint: ENDPOINT_NAME, p_ip_hash: ipHash, p_success: true, p_blocked_by: null, p_error_code: null, p_error_msg: null, p_user_agent: userAgent, p_metadata: null }) return jsonResponse({ ok: true, intake_id: data }, 200) })