Constaia
Integraciones

Firebase Functions

Valida documentos con Constaia en Cloud Functions for Firebase v2: trigger de Storage con URL firmada, onRequest con base64, defineSecret y webhook.

Vas a montar con Cloud Functions for Firebase (2.ª generación):

  • analyzeUpload (onObjectFinalized): el usuario sube su documento a Cloud Storage con el SDK de Firebase; la función genera una URL firmada, se la pasa a Constaia como fileUrl y escribe el veredicto en Firestore. La app lo recibe en tiempo real.
  • verifyDni (onRequest): alternativa síncrona para ficheros pequeños enviados en base64, autenticada con el ID token de Firebase Auth.
  • constaiaWebhook (onRequest): verifica la firma con req.rawBody y deduplica en Firestore.

La clave se guarda con defineSecret: nunca va en la app ni en el código.

Instalación

cd functions
npm i @constaia/sdk

firebase-functions y firebase-admin ya vienen en un proyecto creado con firebase init functions.

Secretos

firebase functions:secrets:set CONSTAIA_API_KEY
firebase functions:secrets:set CONSTAIA_WEBHOOK_SECRET

El CLI te pide el valor (ck_test_..., whsec_...). Cada función declara en secrets los que usa y los lee con .value() en tiempo de ejecución. Para el emulador, crea functions/.secret.local con las mismas variables.

Reglas de Storage

Cada usuario solo puede subir a su carpeta, ficheros de hasta 20 MB y de tipo imagen o PDF. Nadie puede leerlos desde el cliente.

storage.rules
rules_version = '2';
service firebase.storage {
  match /b/{bucket}/o {
    match /kyc/{uid}/{fileName} {
      allow create: if request.auth != null && request.auth.uid == uid
        && request.resource.size < 20 * 1024 * 1024
        && request.resource.contentType.matches('image/.*|application/pdf');
      allow read, update, delete: if false;
    }
  }
}

Funciones

functions/src/index.ts
import { initializeApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { getStorage } from "firebase-admin/storage";
import { logger } from "firebase-functions";
import { defineSecret } from "firebase-functions/params";
import { onRequest } from "firebase-functions/v2/https";
import { onObjectFinalized } from "firebase-functions/v2/storage";
import type { Response } from "express";
import {
  AuthenticationError,
  Constaia,
  ConstaiaError,
  InsufficientCreditsError,
  InvalidRequestError,
  PermissionError,
  RateLimitError,
  verifyWebhook,
  WebhookVerificationError,
  type Analysis,
  type WebhookEvent,
} from "@constaia/sdk";

initializeApp();

const constaiaKey = defineSecret("CONSTAIA_API_KEY");
const webhookSecret = defineSecret("CONSTAIA_WEBHOOK_SECRET");
const REGION = "europe-west1";

// El secreto solo existe en tiempo de ejecución: crea el cliente al primer uso.
let client: Constaia | undefined;
const constaia = () => (client ??= new Constaia({ apiKey: constaiaKey.value() }));

function publicResult(analysis: Analysis) {
  const { id, object, status, document, verdict, warnings } = analysis;
  return { id, object, status, document, verdict, warnings };
}

function sendError(res: Response, error: unknown) {
  const send = (status: number, code: string, message: string) =>
    res.status(status).json({ error: { code, message } });
  if (error instanceof InvalidRequestError) return send(422, error.code ?? "invalid_request", error.message);
  if (error instanceof RateLimitError) {
    res.set("Retry-After", String(Math.ceil(error.retryAfter ?? 1)));
    return send(429, "rate_limited", "Demasiadas peticiones. Inténtalo en unos segundos.");
  }
  if (error instanceof InsufficientCreditsError) {
    logger.error("Constaia sin créditos", { requestId: error.requestId });
    return send(503, "unavailable", "La validación no está disponible ahora mismo.");
  }
  if (error instanceof AuthenticationError || error instanceof PermissionError) {
    logger.error("Revisa CONSTAIA_API_KEY", { code: error.code, requestId: error.requestId });
    return send(500, "misconfigured", "Error de configuración del servidor.");
  }
  if (error instanceof ConstaiaError) {
    logger.error("Constaia", { name: error.name, code: error.code, requestId: error.requestId });
    return send(502, "upstream_error", "No se ha podido analizar el documento. Inténtalo de nuevo.");
  }
  throw error;
}

async function checksFor(uid: string) {
  // El titular esperado sale de tu perfil de usuario, no del fichero ni del cliente.
  const profile = (await getFirestore().doc(`users/${uid}`).get()).data();
  const fullName = typeof profile?.fullName === "string" ? profile.fullName : "";
  return { notExpired: true, ...(fullName ? { holder: { fullName } } : {}) };
}

// A) Subida a Storage → análisis → resultado en Firestore.
export const analyzeUpload = onObjectFinalized(
  { region: REGION, secrets: [constaiaKey], timeoutSeconds: 120 },
  async (event) => {
    const { bucket, name } = event.data;
    const match = name.match(/^kyc\/([^/]+)\/([^/]+)$/);
    if (!match) return;
    const [, uid, fileName] = match;

    const file = getStorage().bucket(bucket).file(name);
    const [fileUrl] = await file.getSignedUrl({ action: "read", expires: Date.now() + 10 * 60 * 1000 });
    const result = getFirestore().doc(`users/${uid}/documents/${fileName}`);

    try {
      const analysis = await constaia().analyze(
        { fileUrl },
        { expect: "es_dni", checks: await checksFor(uid), storage: "none", language: "es", metadata: { uid } },
      );
      await result.set(
        {
          analysisId: analysis.id,
          status: analysis.status,
          verdict: analysis.verdict ?? null,
          warnings: analysis.warnings,
          updatedAt: FieldValue.serverTimestamp(),
        },
        { merge: true },
      );
    } catch (error) {
      if (!(error instanceof InvalidRequestError)) throw error;
      await result.set({ status: "rejected", error: error.message, updatedAt: FieldValue.serverTimestamp() });
    }
    await file.delete({ ignoreNotFound: true });
  },
);

// B) Petición síncrona con el fichero en base64 (solo ficheros pequeños).
export const verifyDni = onRequest(
  { region: REGION, secrets: [constaiaKey], timeoutSeconds: 120, cors: ["https://tu-dominio.com"] },
  async (req, res) => {
    if (req.method !== "POST") {
      res.status(405).end();
      return;
    }
    const token = req.get("authorization")?.replace(/^Bearer /i, "") ?? "";
    const user = await getAuth().verifyIdToken(token).catch(() => null);
    if (!user) {
      res.status(401).json({ error: { code: "unauthorized", message: "Inicia sesión." } });
      return;
    }
    const { base64, filename } = req.body ?? {};
    if (typeof base64 !== "string" || typeof filename !== "string") {
      res.status(400).json({ error: { code: "missing_file", message: "Falta el fichero." } });
      return;
    }

    try {
      const analysis = await constaia().analyze(
        { base64, filename },
        { expect: "es_dni", checks: await checksFor(user.uid), storage: "none", language: "es", metadata: { uid: user.uid } },
      );
      res.status(analysis.status === "completed" ? 200 : 202).json(publicResult(analysis));
    } catch (error) {
      sendError(res, error);
    }
  },
);

// C) Webhook: resultados asíncronos, revisiones y avisos de créditos.
export const constaiaWebhook = onRequest({ region: REGION, secrets: [webhookSecret] }, async (req, res) => {
  let event: WebhookEvent;
  try {
    event = await verifyWebhook(req.rawBody, req.headers, webhookSecret.value());
  } catch (error) {
    if (!(error instanceof WebhookVerificationError)) throw error;
    res.status(400).send("Invalid signature");
    return;
  }

  const seen = getFirestore().doc(`constaiaWebhooks/${req.get("webhook-id")}`);
  try {
    await seen.create({ type: event.type, receivedAt: FieldValue.serverTimestamp() });
  } catch (error) {
    if ((error as { code?: number }).code === 6) {
      res.status(200).end(); // ALREADY_EXISTS: ya procesado
      return;
    }
    throw error;
  }

  try {
    if (event.type === "analysis.completed" || event.type === "analysis.review_required") {
      await getFirestore().doc(`analyses/${event.data.id}`).set({
        uid: event.data.metadata?.uid ?? null,
        verdict: event.data.verdict ?? null,
        updatedAt: FieldValue.serverTimestamp(),
      });
    } else if (event.type === "credits.low") {
      logger.warn("Quedan pocos créditos de Constaia");
    }
  } catch (error) {
    await seen.delete(); // permite que el reintento lo procese
    throw error;
  }
  res.status(204).end();
});

Puntos clave:

  • URL firmada como fileUrl. El fichero no pasa por la función: Constaia lo descarga (https, máximo 20 MB, 15 s) y el nombre del objeto, que va al final de la URL, se conserva.
  • Permiso para firmar URLs. getSignedUrl necesita que la cuenta de servicio de la función tenga iam.serviceAccounts.signBlob (rol Service Account Token Creator sobre sí misma).
  • Borrado. El trigger borra el fichero después del análisis y storage: "none" evita que Constaia lo guarde. Ver Almacenamiento y privacidad.
  • req.rawBody contiene los bytes exactos; req.body ya viene parseado y no sirve para la firma.
  • Región. europe-west1 mantiene el procesamiento de tu lado en la UE.

En la app

web/upload.ts
import { getAuth } from "firebase/auth";
import { doc, getFirestore, onSnapshot } from "firebase/firestore";
import { getStorage, ref, uploadBytes } from "firebase/storage";

export async function uploadDni(file: File, onResult: (data: unknown) => void) {
  const uid = getAuth().currentUser!.uid;
  await uploadBytes(ref(getStorage(), `kyc/${uid}/${file.name}`), file, { contentType: file.type });

  return onSnapshot(doc(getFirestore(), `users/${uid}/documents/${file.name}`), (snap) => {
    if (snap.exists()) onResult(snap.data()); // { status, verdict, warnings, … }
  });
}

La app nunca ve la clave de Constaia; solo sube a su carpeta y lee su propio documento (añade la regla de Firestore correspondiente).

Despliegue

firebase deploy --only functions,storage

Registra la URL de constaiaWebhook en el panel o con constaia.webhookEndpoints.create y guarda el whsec_… con firebase functions:secrets:set CONSTAIA_WEBHOOK_SECRET.

Errores

Error del SDKCuándoQué devuelve tu ruta
InvalidRequestErrorFichero vacío, ilegible, formato no admitido, más de 20 MB, demasiadas páginas u opciones mal formadas (400/409/413/415/422)422 (o 413) con el mensaje, para que el usuario suba otro fichero
RateLimitErrorSuperas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After429 con Retry-After
InsufficientCreditsErrorNo quedan créditos (402)503 al usuario y una alerta para ti: recarga en el panel
AuthenticationError, PermissionErrorClave ausente, revocada o incorrecta (401/403)500: es un fallo de configuración tuyo, no del usuario
APIError, APIConnectionError, APITimeoutErrorError 5xx de Constaia o de red, tras agotar los reintentos502 y un mensaje de reintentar

Todas extienden ConstaiaError y exponen status, code y requestId. Registra siempre el requestId: es lo que te pedirá soporte. Detalle de cada código en Errores.

En el trigger de Storage no hay un cliente esperando: los errores de fichero (InvalidRequestError) se guardan en Firestore como rejected y el resto se relanzan para que aparezcan en Cloud Logging. Para reintentos automáticos, activa retry: true en el trigger.

Probar en modo test

Con CONSTAIA_API_KEY=ck_test_..., sube desde la app un fichero llamado dni_valid.jpg, dni_expired.jpg o blurry.jpg y observa users/{uid}/documents/{fileName}:

Ficheroverdict.status
dni_valid.jpgvalid (si fullName del perfil es María García López o está vacío)
dni_expired.jpginvalid: not_expired con severidad error
blurry.jpgreview: motivo low_quality

El emulador de Storage genera URLs locales que Constaia no puede descargar. Prueba el trigger en un proyecto real con clave de test, o usa verifyDni en el emulador:

curl -X POST "http://127.0.0.1:5001/<project-id>/europe-west1/verifyDni" \
  -H "authorization: Bearer $ID_TOKEN" -H "content-type: application/json" \
  -d "{\"filename\":\"dni_valid.jpg\",\"base64\":\"$(base64 < dni_valid.jpg | tr -d '\n')\"}"

Todos los nombres de fichero en Modo test.

Checklist de producción

  • Reglas de Storage y Firestore que limitan cada usuario a su carpeta y su documento.
  • Rate limit por usuario (por ejemplo, un contador en Firestore) antes de analizar: cada análisis gasta créditos.
  • timeoutSeconds de 60 o más en las funciones que llaman a Constaia.
  • PDF de más de 30 páginas: async: true y resultado por el webhook.
  • Clave ck_live_ solo en Secret Manager (defineSecret).
  • Alerta de Cloud Logging ante errores de créditos y el evento credits.low.

Siguientes pasos

En esta página