Expo API Routes · EAS Hosting

NutriTrack API Routes

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.

4 endpoints
Expo Router v3
TypeScript estricto
Cloudflare Workers runtime
Supabase + Clerk + Stripe
Estructura del proyecto
app/ — NutriTrack
app/
├─api/
│ ├─nutrition/
│ │ └─[foodId]+api.tsGET /api/nutrition/:foodId
│ ├─ai/
│ │ └─macro-advice+api.tsPOST /api/ai/macro-advice
│ ├─stripe/
│ │ └─webhook+api.tsPOST /api/stripe/webhook
│ └─users/
│ └─[id]/summary+api.tsGET /api/users/:id/summary
├─(tabs)/
│ ├─index.tsx
│ └─profile.tsx
└─_layout.tsx
utils/
├─auth.tsClerk JWT middleware
└─db.tsSupabase client (edge)
Implementación de endpoints
GET /api/nutrition/[foodId] Proxy seguro a USDA FoodData Central
app/api/nutrition/[foodId]+api.ts
// 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 Recomendaciones IA via Claude — clave secreta protegida
app/api/ai/macro-advice+api.ts
// 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 Webhook Stripe — activa plan Premium en Supabase
app/api/stripe/webhook+api.ts
// 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 Resumen semanal protegido con Clerk JWT
app/api/users/[id]/summary+api.ts
// 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),
  };
}
Reglas del runtime (Cloudflare Workers)
⚠️
Sin sistema de archivos
El módulo fs no existe en Workers. Usa APIs web estándar: fetch, crypto.subtle, TextEncoder.
Timeout 30 segundos
Cada request tiene un límite de CPU de 30 s. Diseña endpoints pequeños y rápidos; delega tareas largas a colas.
🔌
Sin conexiones persistentes
No puedes mantener conexiones TCP entre requests. Usa clientes sin estado como Supabase REST o Turso HTTP.
🔑
Secretos seguros
process.env.X funciona en Workers. Configura en .env local o con eas env:create para producción.
🌍
Edge global
EAS Hosting despliega en la red de Cloudflare (~300 PoPs). Latencia mínima para usuarios de móvil en todo el mundo.
🔐
Verificación HMAC nativa
Usa crypto.subtle para HMAC-SHA256 (Stripe webhooks) sin dependencias de Node. Disponible nativamente en Workers.
Variables de entorno — NutriTrack
USDA_API_KEY sk-usda-xxxxxxxxxxxxxxxxxxxxxxxx Secreto
ANTHROPIC_API_KEY sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxx Secreto
STRIPE_WEBHOOK_SECRET whsec_xxxxxxxxxxxxxxxxxxxxxxxx Secreto
CLERK_SECRET_KEY sk_live_xxxxxxxxxxxxxxxxxxxxxxxx Secreto
SUPABASE_URL https://abcdefgh.supabase.co URL pública
SUPABASE_SERVICE_KEY eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Secreto
Despliegue en EAS Hosting
1
Instalar EAS CLI
npm install -g eas-cli
Herramienta de línea de comandos para compilar y desplegar.
2
Login & configurar
eas login
Autenticarse con la cuenta Expo y vincular el proyecto.
3
Crear secretos
eas env:create --name X
Subir cada variable de entorno a EAS de forma cifrada.
4
Desplegar
eas deploy
Build + deploy a Cloudflare Workers en un solo comando.
Bases de datos compatibles con el edge
Turso
SQLite distribuido · libSQL HTTP
SQLite Edge Replicación
Cloudflare D1
SQLite nativo en Workers
SQLite Sin latencia CF Binding
PlanetScale
MySQL serverless · branching
MySQL Branching HTTP Driver
Neon
Postgres serverless · serverless driver
Postgres Branching Serverless
Upstash Redis
Redis HTTP · rate limiting & cache
Redis Cache Rate limit