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 comofileUrly 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 conreq.rawBodyy 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/sdkfirebase-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_SECRETEl 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.
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
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.
getSignedUrlnecesita que la cuenta de servicio de la función tengaiam.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.rawBodycontiene los bytes exactos;req.bodyya viene parseado y no sirve para la firma.- Región.
europe-west1mantiene el procesamiento de tu lado en la UE.
En la app
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,storageRegistra 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 SDK | Cuándo | Qué devuelve tu ruta |
|---|---|---|
InvalidRequestError | Fichero 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 |
RateLimitError | Superas las peticiones por segundo de tu clave (429). El SDK ya reintenta dos veces respetando Retry-After | 429 con Retry-After |
InsufficientCreditsError | No quedan créditos (402) | 503 al usuario y una alerta para ti: recarga en el panel |
AuthenticationError, PermissionError | Clave ausente, revocada o incorrecta (401/403) | 500: es un fallo de configuración tuyo, no del usuario |
APIError, APIConnectionError, APITimeoutError | Error 5xx de Constaia o de red, tras agotar los reintentos | 502 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}:
| Fichero | verdict.status |
|---|---|
dni_valid.jpg | valid (si fullName del perfil es María García López o está vacío) |
dni_expired.jpg | invalid: not_expired con severidad error |
blurry.jpg | review: 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.
-
timeoutSecondsde 60 o más en las funciones que llaman a Constaia. - PDF de más de 30 páginas:
async: truey 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
Google Cloud Functions
Valida documentos con Constaia en Google Cloud Functions (Cloud Run functions): multipart con busboy, secretos en Secret Manager y webhook con rawBody.
Supabase Edge Functions
Valida documentos con Constaia en Supabase Edge Functions: subida a Storage, URL firmada como fileUrl, secretos con supabase secrets y webhook.