Constaia
Integraciones

Electron

Integra Constaia en una app de Electron sin empaquetar la clave: el renderer pide el documento por IPC y el proceso main lo sube a tu backend.

Una app de Electron es un cliente: se instala en el ordenador del usuario, igual que una web o una app móvil. Por eso la clave de Constaia no puede ir dentro, tampoco en el proceso main. El flujo correcto:

  1. El renderer pide al proceso main, por IPC, que valide un documento.
  2. El main abre el diálogo de fichero, lo lee y lo sube a tu backend con el token de sesión del usuario.
  3. Tu backend llama a Constaia con la clave y devuelve el veredicto.

Por qué la clave no puede ir en la app

  • El paquete se puede abrir. app.asar es un archivo sin cifrar; cualquiera lo extrae con npx @electron/asar extract. Ofuscar el código o guardar la clave en variables de entorno de compilación no cambia nada: acaba en el binario.
  • Una clave filtrada da acceso a toda tu cuenta. Con ella se gastan tus créditos y se pueden leer los análisis guardados de todos tus usuarios con GET /v1/analyses, incluidos los datos extraídos.
  • No se puede revocar solo para un usuario. Tendrías que rotarla y publicar una versión nueva de la app.
  • El SDK lo impide en el renderer: en un contexto con window y document, new Constaia() con una clave ck_ lanza el error secret_key_in_browser.

Próximamente: claves publicables

Las claves publicables (seguras para clientes) y los enlaces de verificación (POST /v1/verification-links) todavía no están disponibles. Hoy, todas las llamadas a Constaia salen de tu servidor.

Backend

Necesitas una ruta como POST /api/verify-dni que exija la sesión del usuario, decida expect y checks y llame a Constaia. Sigue la guía de tu stack: Node.js, Express, Fastify, NestJS, Hono o AWS Lambda. La clave (CONSTAIA_API_KEY) y el secreto del webhook (CONSTAIA_WEBHOOK_SECRET) viven solo allí.

Proceso main

src/main.ts
import { app, BrowserWindow, dialog, ipcMain } from "electron";
import { readFile } from "node:fs/promises";
import { basename, extname, join } from "node:path";

// TU backend, nunca api.constaia.com.
const API_BASE = "https://api.tu-app.com";
const MIME: Record<string, string> = {
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".png": "image/png",
  ".webp": "image/webp",
  ".heic": "image/heic",
  ".pdf": "application/pdf",
};

ipcMain.handle("document:verify", async (event, sessionToken: string) => {
  const win = BrowserWindow.fromWebContents(event.sender)!;
  const { canceled, filePaths } = await dialog.showOpenDialog(win, {
    properties: ["openFile"],
    filters: [{ name: "Documentos", extensions: ["jpg", "jpeg", "png", "webp", "heic", "pdf"] }],
  });
  if (canceled || !filePaths[0]) return { canceled: true };

  const path = filePaths[0];
  const data = await readFile(path);
  if (data.byteLength > 20 * 1024 * 1024) {
    return { error: { code: "file_too_large", message: "Máximo 20 MB." } };
  }

  const form = new FormData();
  form.append("file", new Blob([data], { type: MIME[extname(path).toLowerCase()] }), basename(path));

  try {
    const res = await fetch(`${API_BASE}/api/verify-dni`, {
      method: "POST",
      headers: { authorization: `Bearer ${sessionToken}` },
      body: form,
      signal: AbortSignal.timeout(90_000),
    });
    const body = await res.json().catch(() => null);
    if (!res.ok) return { error: body?.error ?? { code: "http_error", message: `HTTP ${res.status}` } };
    return { result: body };
  } catch {
    return { error: { code: "network_error", message: "No hay conexión con el servidor." } };
  }
});

function createWindow() {
  const win = new BrowserWindow({
    width: 900,
    height: 700,
    webPreferences: { preload: join(__dirname, "preload.js"), contextIsolation: true, sandbox: true },
  });
  win.loadFile("index.html");
}

app.whenReady().then(createWindow);
app.on("window-all-closed", () => {
  if (process.platform !== "darwin") app.quit();
});
  • El diálogo lo abre el main: el renderer no puede pedir que se suba una ruta arbitraria del disco.
  • basename(path) conserva el nombre original, que tu backend reenvía a Constaia (en modo test decide la respuesta).
  • El timeout de 90 s cubre un análisis síncrono de hasta 30 s más la subida.

Preload

src/preload.ts
import { contextBridge, ipcRenderer } from "electron";

contextBridge.exposeInMainWorld("documents", {
  verify: (sessionToken: string) => ipcRenderer.invoke("document:verify", sessionToken),
});

Solo se expone una función concreta, no ipcRenderer entero.

Renderer

src/renderer.ts
type Verdict = { status: "valid" | "invalid" | "review"; reasons: { code: string; message: string }[] };
type VerifyResponse =
  | { canceled: true }
  | { result: { id: string; status: string; verdict: Verdict | null; warnings: string[] } }
  | { error: { code: string; message: string } };

declare global {
  interface Window {
    documents: { verify(sessionToken: string): Promise<VerifyResponse> };
  }
}
declare function getSessionToken(): Promise<string>; // tu login

const button = document.querySelector<HTMLButtonElement>("#verify")!;
const output = document.querySelector<HTMLDivElement>("#result")!;

button.addEventListener("click", async () => {
  button.disabled = true;
  output.textContent = "Analizando…";
  // El token de sesión de TU backend, obtenido en tu pantalla de login.
  const response = await window.documents.verify(await getSessionToken());
  button.disabled = false;

  if ("canceled" in response) {
    output.textContent = "";
  } else if ("error" in response) {
    output.textContent = response.error.message;
  } else {
    const verdict = response.result.verdict;
    const labels = { valid: "Documento válido", invalid: "Documento no válido", review: "Lo revisaremos manualmente" };
    output.textContent = verdict
      ? `${labels[verdict.status]}. ${verdict.reasons.map((r) => r.message).join(" ")}`
      : "Análisis en curso; te avisaremos cuando termine.";
  }
});

export {};

getSessionToken() es tu función de autenticación. Si prefieres una interfaz ya hecha, el widget funciona dentro del renderer: <constaia-upload endpoint="https://api.tu-app.com/api/verify-dni"> con el atributo headers para el token o with-credentials para cookies. Tu backend debe permitir CORS desde el origen de la app.

Probar en modo test

  1. Arranca tu backend con CONSTAIA_API_KEY=ck_test_....
  2. En la app, pulsa el botón y elige un fichero llamado dni_valid.jpg: verás "Documento válido".
  3. Repite con dni_expired.jpg (invalid, motivo not_expired) y blurry.jpg (review, motivo low_quality).

Cualquier JPEG o PNG real renombrado sirve. Más nombres en Modo test.

Errores

Tu backend traduce los errores del SDK (sin créditos, límite de peticiones, fichero ilegible…) a respuestas HTTP con { "error": { "code", "message" } }. El main las pasa tal cual al renderer, que muestra message. Los códigos están en Errores.

Checklist de producción

  • Ninguna clave ck_ en el código, en .env empaquetados ni en el app.asar (búscala antes de publicar).
  • La ruta del backend exige sesión y aplica rate limit por usuario.
  • contextIsolation: true, sandbox: true y un preload que expone solo lo necesario.
  • Límite de 20 MB en el main y en el backend; timeouts de 60 s o más en todo el camino.
  • El backend devuelve solo veredicto y motivos, no los datos extraídos, salvo que la app los necesite.

Siguientes pasos

En esta página