The proxy serves a cluster console over HTTP, and it is the one part of the control plane a
tenant drives itself. Eight routes on four paths hang off /api/console, all of them scoped
to a single cluster, all of them refusing anything that is not a logged-in human.
What it deliberately does not do is provision. No route here creates a tenant, a cluster, a user, a plan or an operator: those remain the SQL functions in tenants, clusters and cells. The console operates a cluster that already exists.
The routes
| Route | Role needed | What it does |
|---|---|---|
GET /api/console/overview |
any member | Plan, status, live quota flags, month-to-date usage |
GET /api/console/usage |
any member | Per-minute metered usage, ?hours= window |
GET /api/console/keys |
admin |
Lists the cluster’s API keys, hashes never selected |
POST /api/console/keys |
admin |
Issues one key, returning the plaintext once |
DELETE /api/console/keys/:id |
admin |
Revokes one key of this cluster |
GET /api/console/members |
admin |
Lists (user, role) grants on this cluster |
POST /api/console/members |
admin |
Grants or changes one member’s role |
DELETE /api/console/members |
admin |
Removes one member’s grant |
Everything under /keys and /members is admin-only, reads included. Only /overview
and /usage answer a producer, consumer or viewer, which is what lets the console hide
the tabs it would otherwise offer and then refuse.
The SPA that consumes these routes is served at /console, /console/ and /console/* from
a build embedded in the binary. That bundle is static and is handed to anyone who asks for it;
the gate is on the API, not on the assets.
Human sessions only
Every route runs the same four checks before its own logic, in this order.
-
The
Hostheader resolves to a cluster, or the request is421{"code":"cluster_unknown"}. The console resolves the cluster fromHostalone: unlike the data plane it does not consultx-queen-act-cluster, so one browser session on one hostname acts on exactly one cluster. -
pxdb is configured, or the request is
503{"code":"not_configured"}. There are no user or key tables to serve in the dev-static mode described in proxy configuration, so the console is absent there rather than empty. -
The credential verifies, through the same
authenticatecall the data plane makes: signature, expiry, thejtideny-list, then thecluster_rolesrow. A missing credential is401, and a valid session whose user has no role on this cluster is403. The one case that is401rather than403is a session whose user no longer exists, so the SPA bounces to login instead of sitting on a dead end. An operator resolves toadminon every cluster, so the operator capability is also a console admin (the operator capability). -
The principal is a human. A cluster API key authenticates successfully and is then refused with
403andapi keys cannot access the console (human session required). Aqk_credential can produce, consume and administer queues, and it can never read the console or issue another key.
What overview tells a tenant about its own blocked state
GET /overview exists so a tenant can never be told it is fine while its pushes are being
rejected. It reads the same live flags the gateway turns into a 403, not a second opinion
computed for the page.
{
"slug": "acme-prod",
"status": "active",
"role": "admin",
"plan": { "code": "growth", "monthly_msgs_quota": 500000000 },
"usage": { "month": "2026-08", "msgs": 41230991 },
"push_block": null,
"storage": { "max_retained_bytes": 107374182400, "over_quota": false },
"limits": { "max_req_per_sec": 2000, "max_queues": 500 },
"features": { "streams": true, "traces": false }
}Three fields carry the blocked state, and they answer different questions:
| Field | Values | What it means |
|---|---|---|
status |
active, push_blocked, suspended, deleting |
The lifecycle status stored on the cluster or inherited from the tenant. It never says why it was set |
push_block |
null, storage, monthly_quota |
The live in-memory flag. Set means pushes are being refused right now |
storage.over_quota |
true, false |
push_block narrowed to the storage cause, next to the max_retained_bytes it is measured against |
push_block names the same two causes the rejected push names, in shorter words: storage
is the 403 {"code":"storage_quota_exceeded"} and monthly_quota is the 403
{"code":"quota_exceeded"}. Both are hard gates, enforced even on a cell running in shadow
mode, which is exactly the case where a tenant would otherwise have no idea why produce
stopped (quotas and rate limits).
The rest of the object is plan identity and headroom. usage.msgs is month-to-date for the
UTC calendar month named in usage.month, computed as the rolled-up days plus the
not-yet-rolled-up minutes, so it never under-counts today. limits carries all eleven plan
limit columns and the sample above is abridged; each entry is either a number or null, and
null means unlimited, not zero. role is the caller’s own role on this cluster.
GET /usage?hours=N is the graph behind that number: one row per (minute, op class) from
usage_minutes, with reqs, msgs, bytes_in and bytes_out. hours defaults to 24 and
is clamped to the range 1–168, so a caller cannot ask for an unbounded window or a backwards one
(usage metering).
Keys
POST /keys takes {"name": "...", "scopes": [...]}. The name is trimmed and must not be
empty; the scopes must be a non-empty subset of produce, consume, admin, read. The
response is {"id": "...", "key": "qk_live_..."} and that is the only time the plaintext
exists: only its sha256 hash is stored, and GET /keys does not select the hash column at
all, let alone return it.
DELETE /keys/:id refuses a malformed uuid with 400, then checks that the id is an
unrevoked key of this cluster before revoking it. That check lives in the handler on
purpose: the underlying queen_proxy.revoke_api_key(uuid) looks a key up globally by id and
takes no cluster argument, so without it a console admin on one cluster could revoke another
cluster’s key by guessing its uuid. A key that is not this cluster’s answers 404, the same
answer an id that never existed gets.
Members
cluster_roles is the table every authorization gate reads, and the two write routes are what
made it editable without a psql session. Both go through
queen_proxy.grant_cluster_role and revoke_cluster_role rather than direct DML, so the
same-tenant check, the audit row and the cache invalidation stay in the one place that
implements them.
Members are addressed by email, carried in the JSON body on both write verbs, DELETE
included, so an address never has to survive percent-encoding in a path. The cluster is never
read from the request: it is bound from the session’s resolved cluster, for the same reason
the key revoke checks ownership.
The target user must already exist in this cluster’s tenant. POST /members resolves the
address inside the tenant first, and an address that belongs to nobody and an address that
belongs to another tenant get the identical 404, so the route cannot be used to probe for
other tenants’ users. Creating the user is still a control-plane operation.
The last-admin guard
A grant that would demote the last admin, and a revoke that would remove them, are both
refused with 400 and cannot demote the last admin of this cluster or cannot remove the last admin of this cluster.
This is a refusal rather than a warning because it is not recoverable through this API: no
console route can create the first admin back, so a cluster with no admin can never be
administered from the console again and needs grant_cluster_role run by hand on pxdb. The
guard counts seats rather than identities, so it catches self-demotion and self-revocation
too: a sole admin removing themselves is the same admin_count == 1.
Two audit rows, not one
Every mutating control-plane function already writes one append-only queen_proxy.operations
row through record_operation. Three console routes write a second row on top of it,
attributed to the human:
| Route | Row the SQL function writes | Row the console adds |
|---|---|---|
POST /keys |
issue_api_key, actor control_plane |
console_api_key_issued, actor user, target the key id |
POST /members |
grant_cluster_role, actor control_plane, actor id the grantee |
console_member_granted, actor user, target the grantee |
DELETE /members |
revoke_cluster_role, actor control_plane |
console_member_revoked, actor user, target the removed member |
The pairing exists because the function’s own row records what changed and the console’s row
records who asked, and the function cannot know the second thing: it takes a cluster and an
email, not a caller. DELETE /keys/:id is the exception in this table and writes only
revoke_api_key’s own row.
The second row is best-effort. If the insert fails it logs a warning and the request still
succeeds, because the key or the grant already exists either way. No console route reads
operations back, so the audit trail is queried on pxdb, not through the API.
Failure reference
| Status | Code | When |
|---|---|---|
400 |
invalid_request |
Empty name, empty or unknown scope, unknown role, malformed email, malformed key uuid, or the last-admin guard |
401 |
unauthorized |
No credential, a session that fails signature, expiry or the deny-list, or one whose user no longer exists |
403 |
forbidden |
An API key, a session with no role on this cluster, or a non-admin on a /keys or /members route |
404 |
not_found |
A key that is not this cluster’s, a member with no grant here, or an email outside this tenant |
421 |
cluster_unknown |
The Host header resolves to no cluster |
502 |
bad_gateway |
pxdb accepted the connection and then failed the query |
503 |
not_configured |
No pxdb is configured at all. This body is the bare {"code":"not_configured"}, with no error key |
Related
- Tenants, clusters and cells: the SQL functions behind everything the console cannot do.
- Credentials and authorization: how a session is minted, bound to a cluster and revoked.
- Endpoints a tenant can reach: the broker routes the proxy forwards, which is a separate surface from this one.