Console API
This is the API that apps/console calls (and that a script or an operator
can call directly). Source of truth in the repo: docs/console-api.md,
apps/api/src/projects/projects.controller.ts and
apps/api/src/sessions/sessions.controller.ts.
Authentication
Section titled “Authentication”Two paths, both accepted by SessionOrAdminKeyGuard on every endpoint below
except where noted:
User session (the normal path):
Authorization: Bearer <jwt>Issued by POST /v1/auth/login. Everything scopes automatically to the
signed-in user’s tenant.
Admin key — for operating and diagnosing without an account:
x-espejo-admin-key: <ESPEJO_ADMIN_KEY>Rules, mirrored from a guard already battle-tested elsewhere:
- Minimum 32 characters — otherwise an empty or unset env var turns
""into a master key. - Compared in constant time (
timingSafeEqual), never===. - Fails closed: if
ESPEJO_ADMIN_KEYisn’t configured, every endpoint answers401. Never “no key configured, so anything goes.” - This key does not grant ingest. Writing sessions needs a project’s ingest key instead.
- It sees every tenant — no filter is applied. That’s why it’s refused
for anything that grants access on a person’s behalf: creating a project,
and everything under
/v1/mcp/*(see MCP server).
Response shape
Section titled “Response shape”Every success response: { "success": true, "data": … }. Errors use Nest’s
shape: { "statusCode", "message", "error" }.
An id outside the caller’s scope returns 404, never 403. A 403
already confirms the id exists — half of what someone probing ids is looking
for.
Projects
Section titled “Projects”GET /v1/projects
Section titled “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"] }] }BigInt fields (daily_byte_budget, session bytes_total) travel as
strings — JSON.stringify throws on a raw BigInt, and converting to
number loses precision above 2^53.
POST /v1/projects
Section titled “POST /v1/projects”Creates a project in the caller’s tenant. Requires a user session — the
admin key is refused here, with 403: a project needs a tenant to belong
to, and the admin key has none.
{ "name": "Angirú" }The server derives and validates the slug ([0-9a-z-] only — it ends up
inside the bucket object path, and bundlePrefix in @espejo/core rejects
anything else). A collision gets a suffix; it never fails or overwrites.
The first ingest key is issued at creation, so the response already has everything a snippet needs:
{ "success": true, "data": { "id": "uuid", "slug": "angiru", "name": "Angirú", "keys": [{ "public_id": "pk_live_…" }] } }POST /v1/projects/:id/keys
Section titled “POST /v1/projects/:id/keys”Issues another key (rotation). Returns the new one.
DELETE /v1/projects/:id/keys/:keyId
Section titled “DELETE /v1/projects/:id/keys/:keyId”Revokes one — sets revoked_at; the row stays, so what came in under it can
still be audited.
:keyId is the public_id (pk_live_…), the only thing this API
exposes about a key. The internal uuid is also accepted, in case it’s ever
surfaced. This was ambiguous once and cost a bug: the API looked up by uuid
only while the console sent the public_id, so revoking always 404’d.
PUT /v1/projects/:id/origins
Section titled “PUT /v1/projects/:id/origins”Replaces the allowed-origins list: { "origins": ["https://app.angiru.ar"] }.
An empty list accepts any origin — the default, so the first integration
isn’t blocked before it starts.
GET /v1/projects/:id/usage?days=30
Section titled “GET /v1/projects/:id/usage?days=30”Per-day consumption, for the usage/limits screen.
{ "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" }} }Sessions
Section titled “Sessions”GET /v1/sessions?project=<slug>&limit=50&cursor=<id>&filter=all|errors|video&q=<text>
Section titled “GET /v1/sessions?project=<slug>&limit=50&cursor=<id>&filter=all|errors|video&q=<text>”Newest first (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
Section titled “GET /v1/sessions/:id”Same shape as a list item, plus objects (kind, key, bytes).
GET /v1/sessions/:id/events
Section titled “GET /v1/sessions/:id/events”The bucket’s events.json, parsed, wrapped in the usual { success, data }
envelope. data holds the @espejo/core contract — summary, network,
console, interactions, navigations.
The only two un-enveloped routes are /dom and /video, because they
stream: wrapping a stream would mean buffering it whole first, exactly what
streaming avoids.
GET /v1/sessions/:id/dom
Section titled “GET /v1/sessions/:id/dom”The rrweb replay events, streamed, content-type: application/json. Can be
several MB.
GET /v1/sessions/:id/video
Section titled “GET /v1/sessions/:id/video”Proxies the video bytes from the bucket with accept-ranges so a <video>
tag can seek. Never a public bucket URL — if the object could be
requested without going through here, the key would stop mattering.
DELETE /v1/sessions/:id
Section titled “DELETE /v1/sessions/:id”Deletes the bucket objects and the row. Idempotent.
PATCH /v1/sessions/:id
Section titled “PATCH /v1/sessions/:id”Only { "description": "…" } — the “rename recording” action.
MCP connections
Section titled “MCP connections”GET /v1/mcp/grants · DELETE /v1/mcp/grants/:id
Section titled “GET /v1/mcp/grants · DELETE /v1/mcp/grants/:id”The tenant’s live MCP connections, and how to cut them. Enveloped, like
everything else. Guarded with JwtAuthGuard and does not accept the
admin key — approving or revoking an integration is an act a person takes
over their own space, and the admin key has no tenant. Full contract in
MCP server.
{ "success": true, "data": [ { "id": "uuid", "app_name": "Claude", "scopes": ["mcp:read"], "project_mode": "all", "project_ids": [], "created_at": "…", "last_used_at": "…" }] }What the console never does
Section titled “What the console never does”- It never talks to the bucket directly. Everything goes through this API.
- It never builds an object path. Keys are the server’s to know.