The operator surface

/v1/operator/* is the cross-tenant read surface — the estate as one dataset, spanning every workspace of the app. It backs the Axis dashboard's inbox section.

It is defined by the absence of X-Axis-Tenant. There is no "operator mode" flag and no alternate base URL. TenantGuard skips resolution for @Operator() routes, so no tenantContext is ever set, and every store query runs unscoped. That single fact explains most of the rest of this page.

What it takes to get in

The guard runs after TenantGuard and before ScopeGuard, and fails closed.

Check Applies to On failure
An auth context exists Everyone insufficient_scope
scopes includes stats Everyone insufficient_scope
user.isOperator Session callers only insufficient_scope
scopes includes operator:connections:write @OperatorWrite() routes insufficient_scope

admin grants nothing here

This is the asymmetry that trips people. ScopeGuard treats admin as satisfying every @RequireScopes on tenant routes. OperatorGuard does not consult it at all. Cross-tenant access is a separate, explicit grant, and an admin key confined to a single workspace must never be able to read the whole estate's figures.

POST /v1/admin/tenants/merge is the only route that needs both paths — it is @Operator() (so it is cross-tenant and gated on stats) and @RequireScopes('admin').

A human additionally needs isOperator

For a session caller the scope check alone is not enough, and the reason is worth understanding because it was a real leak.

stats is granted to every user session, unconditionally, because the tenant-scoped /v1/reports/* routes require it and reading your own workspace's aggregates is an ordinary member action. Withholding it would break workspace reporting for every normal user. So one scope guards two trust levels — and before the isOperator check existed, any authenticated workspace user could call /v1/operator/overview and read the entire estate.

isOperator closes it:

const isOperator =
  !!this.operatorAppSlug &&
  apps.some((a) => a.slug === this.operatorAppSlug);

The caller's Accounts app grants are already fetched during session resolution, so this costs no extra round trip. Matching is by slug, never by id, because Accounts app ids are environment-specific.

INBOX_OPERATOR_APP_SLUG unset means no human has operator access. The expression short-circuits on !!this.operatorAppSlug, so an unset or mistyped variable makes isOperator false for everyone, silently. Machine sk_ keys are unaffected — they carry no user, and their stats grant is explicit in Accounts — so the dashboard's machine-key paths keep working while every human is locked out. That combination is exactly what makes the misconfiguration hard to spot.

Routes

Controller Routes
OperatorController GET overview, GET tenants, GET usage
OperatorStatsController GET tenants/:tenantId/summary, GET stats/:domain
OperatorConnectionsController GET connections, GET connections/:id, GET tenants/:tenantId/connections
OperatorThreadsController GET threads, GET threads/:id, GET threads/:id/entries, GET tickets, GET tickets/:id, GET tenants/:tenantId/threads, GET tenants/:tenantId/tickets
OperatorContactsController GET contacts, GET contacts/:id, GET segments, GET tenants/:tenantId/contacts, GET tenants/:tenantId/segments
OperatorCampaignsController GET campaigns, GET campaigns/:id, GET templates, GET tenants/:tenantId/campaigns, GET tenants/:tenantId/templates
OperatorFlowsController GET flows, GET reviews, GET tenants/:tenantId/flows, GET tenants/:tenantId/reviews
OperatorPlatformController GET webhooks, GET webhooks/deliveries, GET imports, GET tenants/:tenantId/imports
ReportsOperatorController GET reports/catalog, GET reports/presets, POST reports/query
OperatorConnectionRequestController GET connection-requests plus the @OperatorWrite() actions: approve, reject, whatsapp/connect, email/connect, test
TenantMergeController POST /v1/admin/tenants/merge

All prefixed /v1/operator except the last. Every list route pages by offset — { page, limit, total, hasMore }, default 50, max 200 — except threads/:id/entries, which is cursor-paged and newest-first. GET /v1/operator/tenants is the one route allowing up to 1000 (MAX_TENANT_LIMIT), and it still defaults to 50. See The operator SDK for the typed client.

What is redacted

The cross-tenant reads expose real customer content to a caller who is by definition not a member of the workspace that owns it. Credentials, message payloads and raw webhook bodies are redacted service-side. Contact identifiers are masked in lists and unmasked only by GET /v1/operator/contacts/:id.

Access is logged

GET /v1/operator/threads/:id/entries is the one route that serves message bodies, and GET /v1/operator/contacts/:id the one that serves unmasked identifiers. Both emit a structured operator.read log line carrying actorUserId, actorSessionId, actorKeyId, appId, tenantId, recordId and count.

It is a log line rather than an audit table deliberately: it must never be able to fail the request it describes, and a dropped audit row would be worse than a log the platform already ships. actorUserId is null for machine keys, which are attributable by actorKeyId instead.

This is also the argument for pointing a human-facing dashboard at session rather than a shared machine key — a session names the person who looked.

Operator writes are not idempotency-protected

if (!req.authContext || !req.tenantContext) return next.handle();

The idempotency interceptor bails when there is no tenant context, and an operator route never has one. So every @OperatorWrite() route falls straight through it. Approving a connection request, entering provider credentials, running a test — none of these are replay-protected. Retrying one re-executes it. Send an Idempotency-Key if you like; nothing reads it.

The cause is structural rather than an oversight: the reservation scope is (appId, tenantId, key) and an operator route genuinely has no tenant to scope against. If your operator UI retries on timeout, make the underlying action tolerant of running twice, or do not retry it.