Implementación server-side de endpoints seguros con Expo Router + EAS Hosting (Cloudflare Workers). Secretos protegidos, validación en servidor, webhooks y IA sin exponer claves.
// GET /api/nutrition/:foodId // Protege la API key de USDA en el servidor const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", }; export function OPTIONS() { return new Response(null, { headers: corsHeaders }); } export async function GET( request: Request, { foodId }: { foodId: string } ) { try { const apiKey = process.env.USDA_API_KEY; // nunca al cliente const res = await fetch( `https://api.nal.usda.gov/fdc/v1/food/${foodId}?api_key=${apiKey}` ); if (!res.ok) { return Response.json( { error: "Alimento no encontrado" }, { status: 404, headers: corsHeaders } ); } const { description, foodNutrients } = await res.json(); // Filtramos solo macros relevantes (calorias, P, C, G) const macros = foodNutrients .filter((n: any) => [1008, 1003, 1005, 1004].includes(n.nutrient.id) ) .map((n: any) => ({ name: n.nutrient.name, amount: n.amount, unit: n.nutrient.unitName, })); return Response.json( { foodId, description, macros }, { headers: corsHeaders } ); } catch (err) { console.error("[nutrition] Error:", err); return Response.json( { error: "Error interno" }, { status: 500, headers: corsHeaders } ); } }
// POST /api/ai/macro-advice // Body: { userId, calories, protein, carbs, fat, goal } import { requireAuth } from "../../../utils/auth"; interface MacroPayload { calories: number; protein: number; carbs: number; fat: number; goal: "deficit" | "mantenimiento" | "volumen"; } export async function POST(request: Request) { const { userId } = await requireAuth(request); const body: MacroPayload = await request.json(); if (!body.calories || !body.goal) { return Response.json( { error: "Faltan campos obligatorios" }, { status: 400 } ); } const prompt = `Usuario ${userId} tiene hoy: - Calorías: ${body.calories} kcal - Proteína: ${body.protein}g | Carbos: ${body.carbs}g | Grasa: ${body.fat}g - Objetivo: ${body.goal} Da 3 recomendaciones concretas y motivadoras (≤ 60 palabras cada una).`; const aiRes = await fetch( "https://api.anthropic.com/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": process.env.ANTHROPIC_API_KEY!, "anthropic-version": "2023-06-01", }, body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 512, messages: [{ role: "user", content: prompt }], }), } ); const { content } = await aiRes.json(); return Response.json({ userId, advice: content[0]?.text ?? "", model: "claude-sonnet-4-6", }); }
// POST /api/stripe/webhook // Verifica firma Stripe y actualiza suscripción en Supabase import { supabase } from "../../../utils/db"; export async function POST(request: Request) { const sig = request.headers.get("stripe-signature"); const body = await request.text(); // raw text para verificar HMAC if (!sig) { return Response.json( { error: "Sin firma Stripe" }, { status: 400 } ); } // Verificación HMAC con Web Crypto (Cloudflare Workers, no Node) const secret = process.env.STRIPE_WEBHOOK_SECRET!; const isValid = await verifyStripeSignature(body, sig, secret); if (!isValid) { return Response.json( { error: "Firma inválida" }, { status: 401 } ); } const event = JSON.parse(body); if (event.type === "payment_intent.succeeded") { const { customer_email } = event.data.object.metadata; await supabase .from("users") .update({ plan: "premium", premium_since: new Date().toISOString() }) .eq("email", customer_email); } return Response.json({ received: true }, { status: 200 }); } async function verifyStripeSignature( payload: string, header: string, secret: string ): Promise<boolean> { const [, ts, v1] = header.split(",").map(p => p.split("=")); const signed = `${ts}.${payload}`; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const mac = await crypto.subtle.sign( "HMAC", key, new TextEncoder().encode(signed) ); const computed = Array.from(new Uint8Array(mac)) .map(b => b.toString(16).padStart(2, "0")).join(""); return computed === v1; }
// GET /api/users/:id/summary // Requiere JWT de Clerk, consulta Supabase import { requireAuth } from "../../../../utils/auth"; import { supabase } from "../../../../utils/db"; export async function GET( request: Request, { id }: { id: string } ) { const { userId } = await requireAuth(request); // Solo puede ver su propio resumen if (userId !== id) { return Response.json( { error: "Prohibido" }, { status: 403 } ); } const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); const { data, error } = await supabase .from("nutrition_logs") .select("date, calories, protein, carbs, fat") .eq("user_id", userId) .gte("date", sevenDaysAgo.toISOString()) .order("date", { ascending: true }); if (error) { console.error("[summary] Supabase error:", error); return Response.json( { error: "Error al obtener datos" }, { status: 500 } ); } const avg = averageMacros(data ?? []); return Response.json({ userId, period: "7d", days: data, avg }); } function averageMacros(rows: any[]) { if (!rows.length) return {}; const sum = rows.reduce((acc, r) => ({ calories: acc.calories + r.calories, protein: acc.protein + r.protein, carbs: acc.carbs + r.carbs, fat: acc.fat + r.fat, }), { calories: 0, protein: 0, carbs: 0, fat: 0 }); const n = rows.length; return { calories: +(sum.calories / n).toFixed(1), protein: +(sum.protein / n).toFixed(1), carbs: +(sum.carbs / n).toFixed(1), fat: +(sum.fat / n).toFixed(1), }; }
fs no existe en Workers. Usa APIs web estándar: fetch, crypto.subtle, TextEncoder.process.env.X funciona en Workers. Configura en .env local o con eas env:create para producción.crypto.subtle para HMAC-SHA256 (Stripe webhooks) sin dependencias de Node. Disponible nativamente en Workers.