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.
Autenticación
Sección titulada «Autenticación»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_KEYno está configurada, cada endpoint responde401. 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).
Forma de la respuesta
Sección titulada «Forma de la respuesta»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.
Proyectos
Sección titulada «Proyectos»GET /v1/projects
Sección titulada «GET /v1/projects»{ "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 strings — JSON.stringify lanza en un BigInt crudo, y convertir
a number pierde precisión por encima de 2^53.
POST /v1/projects
Sección titulada «POST /v1/projects»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_…" }] } }POST /v1/projects/:id/keys
Sección titulada «POST /v1/projects/:id/keys»Emite otra clave (rotación). Devuelve la nueva.
DELETE /v1/projects/:id/keys/:keyId
Sección titulada «DELETE /v1/projects/:id/keys/:keyId»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.
PUT /v1/projects/:id/origins
Sección titulada «PUT /v1/projects/:id/origins»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.
GET /v1/projects/:id/usage?days=30
Sección titulada «GET /v1/projects/:id/usage?days=30»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" }} }Sesiones
Sección titulada «Sesiones»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} }GET /v1/sessions/:id
Sección titulada «GET /v1/sessions/:id»Misma forma que un ítem de lista, más objects (kind, key, bytes).
GET /v1/sessions/:id/events
Sección titulada «GET /v1/sessions/:id/events»El events.json del bucket, parseado, envuelto en el envelope habitual
{ success, data }. data contiene el contrato de @espejo/core —
summary, 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.
GET /v1/sessions/:id/dom
Sección titulada «GET /v1/sessions/:id/dom»Los eventos de replay de rrweb, en streaming, content-type: application/json.
Puede ser varios MB.
GET /v1/sessions/:id/video
Sección titulada «GET /v1/sessions/:id/video»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.
DELETE /v1/sessions/:id
Sección titulada «DELETE /v1/sessions/:id»Elimina los objetos del bucket y la fila. Idempotente.
PATCH /v1/sessions/:id
Sección titulada «PATCH /v1/sessions/:id»Solo { "description": "…" } — la acción de “renombrar grabación.”
Conexiones MCP
Sección titulada «Conexiones MCP»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": "…" }] }Lo que la consola nunca hace
Sección titulada «Lo que la consola nunca hace»- Nunca habla directamente con el bucket. Todo pasa por esta API.
- Nunca construye un path de objeto. Las claves son asunto del servidor.