Ir al contenido

Console API

Esta es la API que llama apps/console (y que un script o un operador pueden llamar directamente). Fuente de verdad en el repo: docs/console-api.md, apps/api/src/projects/projects.controller.ts y apps/api/src/sessions/sessions.controller.ts.

Dos caminos, ambos aceptados por SessionOrAdminKeyGuard en cada endpoint de abajo, salvo donde se indique:

Sesión de usuario (el camino normal):

Authorization: Bearer <jwt>

Emitido por POST /v1/auth/login. Todo queda acotado automáticamente al tenant del usuario autenticado.

Clave de admin — para operar y diagnosticar sin una cuenta:

x-espejo-admin-key: <ESPEJO_ADMIN_KEY>

Reglas, tomadas de un guard ya probado en producción:

  • Mínimo 32 caracteres — de lo contrario, una variable de entorno vacía o no configurada convierte "" en una clave maestra.
  • Comparada en tiempo constante (timingSafeEqual), nunca con ===.
  • Falla cerrada: si ESPEJO_ADMIN_KEY no está configurada, cada endpoint responde 401. Nunca “no hay clave configurada, así que todo pasa.”
  • Esta clave no otorga ingest. Para escribir sesiones se necesita la clave de ingest del proyecto.
  • Ve todos los tenants — no se aplica ningún filtro. Por eso se rechaza para cualquier cosa que otorgue acceso en nombre de una persona: crear un proyecto, y todo lo que está bajo /v1/mcp/* (ver servidor MCP).

Toda respuesta exitosa: { "success": true, "data": … }. Los errores usan la forma de Nest: { "statusCode", "message", "error" }.

Un id fuera del scope del caller devuelve 404, nunca 403. Un 403 ya confirma que el id existe — la mitad de lo que busca alguien que sondea ids.

{ "success": true, "data": [
{ "id": "uuid", "slug": "angiru", "name": "Angirú",
"mode": "ring", "storage_mode": "ours", "upload_mode": "proxy",
"retention_days": 30, "ingest_paused": false,
"daily_byte_budget": "1073741824", "daily_session_budget": 500,
"keys": [{ "public_id": "pk_live_…", "revoked_at": null, "last_seen_at": "" }],
"origins": ["https://app.angiru.ar"] }
] }

Los campos BigInt (daily_byte_budget, bytes_total de sesión) viajan como stringsJSON.stringify lanza en un BigInt crudo, y convertir a number pierde precisión por encima de 2^53.

Crea un proyecto en el tenant del caller. Requiere sesión de usuario — la clave de admin se rechaza acá, con 403: un proyecto necesita un tenant al que pertenecer, y la clave de admin no tiene ninguno.

{ "name": "Angirú" }

El servidor deriva y valida el slug (solo [0-9a-z-] — termina dentro del path de objeto del bucket, y bundlePrefix en @espejo/core rechaza cualquier otra cosa). Una colisión recibe un sufijo; nunca falla ni sobreescribe.

La primera clave de ingest se emite al momento de la creación, así que la respuesta ya tiene todo lo que necesita un snippet:

{ "success": true, "data": { "id": "uuid", "slug": "angiru", "name": "Angirú",
"keys": [{ "public_id": "pk_live_…" }] } }

Emite otra clave (rotación). Devuelve la nueva.

Revoca una — establece revoked_at; la fila permanece, para que lo que llegó bajo ella pueda seguir siendo auditado.

:keyId es el public_id (pk_live_…), lo único que esta API expone sobre una clave. El uuid interno también se acepta, por si alguna vez se expone. Esto fue ambiguo en algún momento y costó un bug: la API buscaba por uuid únicamente mientras la consola enviaba el public_id, así que revocar siempre devolvía 404.

Reemplaza la lista de orígenes permitidos: { "origins": ["https://app.angiru.ar"] }. Una lista vacía acepta cualquier origen — el valor por defecto, para que la primera integración no quede bloqueada antes de arrancar.

Consumo por día, para la pantalla de uso/límites.

{ "success": true, "data": {
"budget": { "daily_bytes": "1073741824", "daily_sessions": 500 },
"today": { "sessions": 12, "bytes": "48213311" },
"days": [{ "day": "2026-08-14", "sessions": 12, "bytes": "48213311" }],
"stored": { "sessions": 431, "bytes": "2311444992" }
} }

GET /v1/sessions?project=<slug>&limit=50&cursor=<id>&filter=all|errors|video&q=<text>

Sección titulada «GET /v1/sessions?project=<slug>&limit=50&cursor=<id>&filter=all|errors|video&q=<text>»

Las más recientes primero (started_at desc).

{ "success": true, "data": {
"items": [{
"id": "32 hex", "project_slug": "angiru",
"started_at": "", "duration_ms": 41200, "bytes_total": "1244133",
"description": "Can't save the weighing",
"page_host": "app.angiru.ar", "app_release": null, "app_env": null,
"user_ref": null, "user_agent": "",
"capture": "dom", "trigger": "manual",
"has_dom": true, "has_video": false,
"counts": { "requests": 84, "errors_4xx": 2, "errors_5xx": 1,
"console_errors": 3, "interactions": 17 }
}],
"next_cursor": null
} }

Misma forma que un ítem de lista, más objects (kind, key, bytes).

El events.json del bucket, parseado, envuelto en el envelope habitual { success, data }. data contiene el contrato de @espejo/coresummary, network, console, interactions, navigations.

Las únicas dos rutas sin envelope son /dom y /video, porque hacen streaming: envolver un stream implicaría bufferizarlo entero primero, que es exactamente lo que el streaming evita.

Los eventos de replay de rrweb, en streaming, content-type: application/json. Puede ser varios MB.

Proxea los bytes de video desde el bucket con accept-ranges para que un tag <video> pueda hacer seek. Nunca una URL pública del bucket — si el objeto pudiera pedirse sin pasar por acá, la clave dejaría de importar.

Elimina los objetos del bucket y la fila. Idempotente.

Solo { "description": "…" } — la acción de “renombrar grabación.”

GET /v1/mcp/grants · DELETE /v1/mcp/grants/:id

Sección titulada «GET /v1/mcp/grants · DELETE /v1/mcp/grants/:id»

Las conexiones MCP activas del tenant, y cómo cortarlas. Envueltas, como todo lo demás. Protegidas con JwtAuthGuard y no aceptan la clave de admin — aprobar o revocar una integración es un acto que una persona realiza sobre su propio espacio, y la clave de admin no tiene tenant. Contrato completo en servidor MCP.

{ "success": true, "data": [
{ "id": "uuid", "app_name": "Claude", "scopes": ["mcp:read"],
"project_mode": "all", "project_ids": [],
"created_at": "", "last_used_at": "" }
] }
  • Nunca habla directamente con el bucket. Todo pasa por esta API.
  • Nunca construye un path de objeto. Las claves son asunto del servidor.