The ops endpoint#

health() answers may I take traffic? in a status code. ops() answers what is going on in here? in a body — which is why it is authenticated and that one is not.

TypeScript
import { ops } from '@sigx/actors/host';
import { clusterStats } from '@sigx/actors/cluster';

export const app = defineActorApp({ actors, storage })
    .use(metrics())
    .use(health())
    .use(ops({
        secret: process.env.SIGX_OPS_SECRET,
        cluster: (signal) => clusterStats(placement, { signal }),
    }));
RouteReturns
GET /_sigx/opsOpsSnapshot{ v, at, uptimeMs, stats, health, ops }
GET /_sigx/ops/clustera clusterStats() fan-out; 404 when unwired

The secret is mandatory#

ops() throws at construction without one outside __DEV__.

An ops endpoint that is unauthenticated by omission publishes your deployment, and nothing in the response says so. Actor type names, cluster topology and — if you ask for them — actor keys.

SIGX_OPS_SECRET is the conventional way to supply it, and it is what the CLI reads.

Why the cluster fan-out is a thunk#

ops() lives in @sigx/actors/host and clusterStats in @sigx/actors/cluster. Passing the call as a thunk keeps the cluster bundle out of a single-node host that will never use it:

TypeScript
ops({ secret, cluster: (signal) => clusterStats(placement, { signal }) });

ops({ cluster }) also takes an optional second argument — the parsed query — so GET /_sigx/ops/cluster?detail=1&activations=20&host=<id> reaches through. The one-argument form still works.

Contributing a section#

TypeScript
registry.reportOps('queue', () => ({ depth: queue.length, oldestMs: queue.oldestAge() }));
registry.ops();   // the aggregate

Providers run per read and must stay synchronous. Names must be unique. A throwing provider costs only its own section, which comes back as { error } — one broken provider never takes down the endpoint.

reportDigest(name, (options?) => …) is the mergeable sibling, used by cluster stats to fold metrics across hosts.

Actor keys are off by default#

The activation list is not in the default response, and requested limits are clamped by the responder rather than trusted from the query.

Actor keys can be personal data — a user id, an email, an order number. Turning the list on is a deliberate act, over an authenticated channel, and it is worth knowing what your keys contain before you do.

Reaching ops() from a browser#

Two facts drive everything here:

  • ops() sets no CORS headers. A browser cannot call it cross-origin at all, however the fetch is configured.
  • It refuses to construct without a bearer secret outside __DEV__, because it reports actor type names, traffic shape and cluster topology — and the activation list carries actor keys, which are user data.

The conclusion follows: the browser calls a same-origin route of your own app, which authenticates the operator however the app already does and forwards to the host with the bearer attached server-side. The secret never reaches the client.

Work around the CORS block by putting the secret in browser code and you have published your cluster topology to every visitor — and nothing in the browser will tell you.

The whole server half:

JavaScript
// GET /admin/ops         → the host snapshot
// GET /admin/ops/cluster → the fan-out
if (url.pathname === '/admin/ops' || url.pathname.startsWith('/admin/ops/')) {
    if (!(await isOperator(request))) return new Response('no', { status: 403 });
    return fetch(HOST_ORIGIN + url.pathname.replace('/admin/ops', '/_sigx/ops') + url.search, {
        headers: { authorization: `Bearer ${process.env.OPS_SECRET}` }
    });
}

and the browser half is httpSource({ url: location.origin, base: '/admin/ops' }) from @sigx/actors-monitor — with no secret — or <ActorsDashboard source={…} /> from @sigx/actors-dashboard over it.

Two details fail silently and are worth getting right the first time:

  • Forward the sub-path and the query verbatim. httpSource appends /cluster and ?detail=1&host=… itself. A proxy that drops the path turns the cluster view into a 404; one that drops the query leaves every per-host drill-down waiting for a detail poll forever, with no error anywhere.
  • Match the mount exactly, or a / or ? after it. startsWith('/ops') also matches /opsummary, and forwards your bearer to a URL nobody meant to build.

Forward the method too — ops() serves GET and HEAD and answers 405 to anything else — and pass through www-authenticate and allow, which carry the diagnosis when a poll fails: a 401 from the host means the proxy's secret is stale, not that the host is down.

In a cluster, the proxy needs any one surviving host — the fan-out reaches the rest — so a list of candidate origins tried in order is more robust than a single address. The examples/dashboard directory of the actors repo has the production-shaped version, with its tests.

Next steps#