operator-api is the admin plane: plain HTTP with an OpenAPI description,
replacing Ice. It creates accounts, rewrites ACLs, bans, moderates live
sessions, reads settings and carries a live event channel.
This document is the surface. Rationale is
ARCHITECTURE.md §3 and TOML is
CONFIGURATION.md "The admin plane".
Off by default, and bound to loopback when on:
[services.operator-api]
enabled = true
listen = "127.0.0.1:8081"docker compose --profile admin up -d --wait
curl -sX POST -H "Authorization: Bearer $STARLING_ADMIN_TOKEN" \
localhost:8081/v1/whoamiTwo routes take no credential:
| Route | Answer |
|---|---|
GET /healthz |
ok\n. Liveness only; server health is GET /v1/health |
GET /openapi.json |
The OpenAPI 3.1 description, path by path, with the scope each operation needs |
Everything under /v1 is authenticated, /v1/health included: readiness names
internal services and the caches they wait on, which is a map of the deployment.
The credential arrives in Authorization, whatever the mode.
| Mode | Header | Verified by |
|---|---|---|
token |
Bearer <value> |
this process, against the configured token set |
mtls |
the certificate subject verbatim, no Bearer prefix (Authorization: CN=admin-console) |
the TLS terminator in front, which is the only thing that has seen the chain |
oidc |
Bearer <jwt> |
nothing yet |
jwt |
Bearer <jwt> |
nothing yet |
oidc and jwt refuse every request. The JWKS client is not built, and a
surface that accepted unverified tokens would be worse than one that is closed.
Both log the refusal at warn. Use token or mtls until it lands.
A token identity is named after the environment variable, never the value:
token:STARLING_ADMIN_TOKEN. An audit record must not contain the credential
whose use it records.
The strategy is reloaded when [services.operator-api.auth] changes; a leaked
token stops working without a restart. A configuration the factory refuses
leaves the previous strategy in force and is logged.
An identity holds a list. * is everything, and a trailing * is a prefix
match, so userdata:* covers userdata:read.
| Scope | Unlocks |
|---|---|
userdata:read |
read accounts, avatars, comments |
userdata:write |
register, edit, delete accounts; set avatars and comments |
metadata:read |
the channel tree |
metadata:write |
create, edit, remove channels |
permissions:read |
channel ACLs, effective permissions |
permissions:write |
replace an ACL, grant temporary group membership |
moderation:read |
list bans |
moderation:write |
ban, unban, kick |
plugins:read |
list server plugins |
plugins:write |
install, enable, disable, uninstall server plugins |
session-view:read |
list sessions, subscribe to /v1/events |
session:write |
move, mute, deafen, suppress, promote a live session |
server-config:read |
read settings, read /v1/health |
server-config:write |
change settings |
text:write |
send a server message |
audit:read |
query the audit service's log |
POST /v1/whoami demands the literal scope *; a narrowly scoped identity gets
403 from it. It is a check that a * credential works, not a general
introspection route.
A second, always-on path alongside whichever mode above is configured. A
client already connected over the mumble control channel can ask
server-config for a short-lived ticket instead of an operator typing a
credential meant for an out-of-band console:
ServerConfigEnvelope{ OperatorTicketRequest{ scopes: ["server-config:write"] } }
-> ServerConfigEnvelope{ OperatorTicketReply{ token, granted_scopes, expires_at_ms, base_url } }
server-config grants a scope only when the session's live permission
already covers the equivalent control-channel action -- server-config:write
needs Write on the root channel, the same check a livery write over the
channel makes; see starling_runtime::operator_scope for the full table. A
ticket never grants more than that session could already do some other way,
and it may grant fewer scopes than were requested.
base_url is [services.operator-api].public_url, empty when unset, in
which case a client has nowhere to present the ticket and falls back to
asking an operator for one directly. The token itself is a 256-bit random
value, valid for at most five minutes, held by server-config as a SHA-256
digest and never persisted; a restart, or routing to a different
server-config replica, invalidates every outstanding ticket the same way
expiry would. operator-api accepts one wherever the configured
authenticator refuses a bearer, by asking server-config's VerifyTicket
RPC -- an extra round trip paid only by a request that would otherwise be a
bare refusal.
Source:
diagrams/operator-api-request.puml. The
record is written before the action, because one written afterwards can be
missing for an action that happened, and it reaches the disk before the call
returns.
| Status | Means |
|---|---|
200 |
a body follows |
204 |
applied, nothing to return |
400 |
the request is malformed: nothing to change, a bad hex hash, an empty password, a ban matching nothing |
401 |
no credential, or one that is not the shape this mode expects |
403 |
a credential this server does not accept, one carrying no mapped scope, or one lacking the scope this route needs |
404 |
no such account, session or ban |
409 |
the target exists and the change was refused: a name already taken, an ACL rejected, a ban that did not apply |
502 |
the service behind this route is unreachable, or answered with a transport error |
503 |
the action could not be recorded, so it did not happen (audit.fail_closed) |
Errors carry {"error": "..."}. 401 and 403 stay distinguishable on purpose:
401 is "who are you", 403 is "not you", and they send an operator to different
files.
Every route addresses server instance 1. Multi-tenancy is a deployment shape
Starling supports and this API has never exposed; PUT /v1/accounts/{id} alone
takes an instance field.
| Route | Scope | Answer |
|---|---|---|
GET /v1/accounts |
userdata:read |
[{id, name, email, cert_hash}] |
POST /v1/accounts |
userdata:write |
the created account |
PUT /v1/accounts/{id} |
userdata:write |
the updated account |
DELETE /v1/accounts/{id} |
userdata:write |
204, idempotent: an id that is not there is not 404 |
?name= is an exact lookup and ?prefix= a scan; they are different questions,
and resolving a known username must not depend on no other account sharing its
opening characters. A ?name= miss is [], not 404: this is a collection, and
"no account is called that" is an answer about its contents — as is a ?name=
against a userdata that is down, which also reads [] rather than 502. A
bare list returns at most 200 accounts and takes no cursor.
cert_hash is SHA-1 as lowercase hex, the form murmur prints and a Mumble
client's certificate dialog shows. Empty when none is registered.
curl -sX POST localhost:8081/v1/accounts -H "$AUTH" \
-d '{"name":"ada","email":"ada@example.org","password":"..."}'A PUT writes only the fields present, so two operators changing different
settings do not overwrite each other:
| Field | Notes |
|---|---|
password |
absent leaves it alone; present-but-empty is 400, not a login any password opens |
name, email |
replaced as given |
cert_hash |
hex, even length, or 400. "" clears it. Registering it ahead of time is how a user is issued a certificate to import rather than having to connect with one first |
instance |
which server instance; defaults to 1 |
Sending no field at all is 400. A missing id is 404, checked before the
write, because userdata reports both a missing account and a refused change as
permission_denied and an operator cannot act on "denied".
The SuperUser is account 0, so setting its password is
PUT /v1/accounts/0 {"password":"..."}. There is deliberately no separate
route; one would be this endpoint with a hard-coded id and would drift. The
account has to exist: userdata creates it on first boot, but a database
restored from before that needs starling set-superuser-password.
Content-addressed blobs behind an account, not fields on it, and bytes do not belong in a JSON field.
| Route | Scope | Body |
|---|---|---|
GET /v1/accounts/{id}/texture |
userdata:read |
application/octet-stream, 404 when unset |
PUT /v1/accounts/{id}/texture |
userdata:write |
raw bytes; empty clears |
DELETE /v1/accounts/{id}/texture |
userdata:write |
204 |
GET /v1/accounts/{id}/comment |
userdata:read |
text/plain; absent reads as "" |
PUT /v1/accounts/{id}/comment |
userdata:write |
raw text |
A texture is served as octet-stream, not image/png: the bytes are whatever was
uploaded, and murmur's legacy texture format is zlib-compressed BGRA rather than
an image at all. An absent comment is an empty one, because no caller could do
anything different on 404 except write the same branch back to "".
The blob is stored before the account points at it, so a failure between the two leaves an unreferenced blob rather than an account pointing at bytes that were never written.
| Route | Scope | Answer |
|---|---|---|
GET /v1/channels |
metadata:read |
{version, channels:[...]} |
POST /v1/channels |
metadata:write |
{version, channel} |
PUT /v1/channels/{id} |
metadata:write |
{version, channel} |
DELETE /v1/channels/{id} |
metadata:write |
204 |
A channel reads as:
{"id":3,"parent":0,"name":"General","description":"","position":0,
"max_users":0,"links":[],"hidden":false,"temporary":false,
"detached":false,"created_at_ms":1750000000000}hidden, temporary and detached are unpacked from the wire bitfield so a
client need not know the layout. detached matters because parent is 0 for
both a detached channel and one truly at the root, and a caller building a tree
has no other way to tell a meeting room from the server's root.
The tree is not visibility-filtered, the same property Ice's getChannels
had. This plane answers for the server, not for a session, so there is no viewer
whose permissions could filter it. Filter by a user's SeeChannel yourself; the
hidden flag is surfaced for exactly that.
POST takes name, and optionally parent (0, the root, when omitted),
description, position, max_users, temporary and invitee_user_ids.
Naming invitees makes it a private room: @all is denied see, enter and
traverse, and each listed account is granted them. Creating a channel whose name
is taken is 409, never a silent hand-back of somebody else's room.
PUT writes only the fields named, out of name, description, parent,
position, max_users. None named is 400.
| Route | Scope | Answer |
|---|---|---|
GET /v1/channels/{id}/acl |
permissions:read |
{inherit, acls:[...], groups:[...]} |
PUT /v1/channels/{id}/acl |
permissions:write |
204 |
POST /v1/channels/{id}/groups/{group}/members |
permissions:write |
204 |
DELETE /v1/channels/{id}/groups/{group}/members |
permissions:write |
204 |
An ACL is read and written whole, unlike the field-list updates elsewhere: the entries are ordered and evaluated as a sequence, so "change entry 3" is not a well-defined request. Read it, edit it, write it back.
grant and deny stay numeric Perm bit sets. Names would put a translation
table in three places, and the numbers are what murmur's ACL editor, the wire
protocol and the database already use.
Entries carrying inherited: true are shown on a read so an operator sees the
effective set, and dropped on a write: writing one back would copy an
ancestor's rule into this channel, where it would then stop tracking the
ancestor.
A group membership request names exactly one of account or session; both, or
neither, is 400. The distinction is the feature: an account grant lasts
until removed, and a session grant is the only way to put an unregistered
user in a named group, since permanent membership is keyed by account id and a
guest has none. Neither is durable — upstream says so of its own, and a
session-scoped grant surviving a restart would attach to whoever connects next
under that session id.
| Route | Scope | Answer |
|---|---|---|
GET /v1/sessions |
session-view:read |
{version, users:[...]} |
PATCH /v1/sessions/{session} |
session:write |
the applied state |
POST /v1/sessions/{session}/kick |
moderation:write |
204 |
GET /v1/sessions/{session}/permissions |
permissions:read |
effective permissions |
A user reads as {session, name, channel, user_id, mute, deaf, self_mute, self_deaf, suppress, priority_speaker, connected_at_ms}. user_id is null
for an unregistered guest, never 0: account 0 is the SuperUser, and a guest
written as 0 would read as the administrator.
PATCH is the only route here that reaches a live connection. It is a partial
update — omitted means "leave it alone" — because a PUT could not express that
without the caller restating the user's whole state and racing whoever else is
changing it. Fields: channel, mute, deaf, suppress, priority_speaker.
curl -sX PATCH localhost:8081/v1/sessions/42 -H "$AUTH" \
-d '{"mute":true,"channel":3}'The answer is the applied state, which is not always what was asked:
deafening also mutes. A vanished session is 404 and a refused change is 409,
because the recourse differs — retry elsewhere, or fix the ACL.
kick takes an optional {"reason":"..."}; murmur allows a bare kick.
permissions is murmur's effectivePermissions, and its hasPermission when
?permission= names one bit. One route, because the second is the first plus a
mask test and two routes could disagree. ?channel= selects the channel,
default 0.
{"session":42,"channel":3,"granted":903,"groups":["all","auth"],
"permission":4,"allowed":true}The session is resolved through session-view and nowhere else. Letting a
caller assert an identity would make this a way to ask "what could this other
user do" and get an answer shaped by the lie.
| Route | Scope | Answer |
|---|---|---|
GET /v1/bans |
moderation:read |
[{id, name, reason, start_ms, duration_s}] |
POST /v1/bans |
moderation:write |
204 |
DELETE /v1/bans/{id} |
moderation:write |
204 |
A ban matches on any of three things, so all three are optional individually:
| Field | Notes |
|---|---|
address |
dotted-quad or IPv6 literal; anything else is 400 |
prefix_len |
how much of address to match; defaults to all of it |
cert_hash |
hex |
name |
matched as stored |
reason |
free text |
duration_s |
absent or 0 is permanent, as in murmur's BanTable |
session |
a connected session to drop as part of the ban |
A ban naming none of address, cert_hash or name is 400, never stored: it
would sit in the list looking like protection and catch nobody. Lifting a ban
that is not there is 404 — the state asked for, but saying so is what tells an
operator their id was wrong.
The list omits address, prefix_len and cert_hash; a ban's match criteria
cannot currently be read back through this API.
| Route | Scope | Answer |
|---|---|---|
GET /v1/plugins |
plugins:read |
{plugins: [{id, name, version, enabled, wasm, path, info_json, source, installed_at_ms, builtin, load_error}], plugins_dir, host_abi_version} |
POST /v1/plugins |
plugins:write |
{plugin} |
PATCH /v1/plugins/{id} |
plugins:write |
204; body {enabled} |
DELETE /v1/plugins/{id} |
plugins:write |
204 |
An install takes {marketplace_id, manifest_url, version?, expected_sha256?}.
The server fetches the manifest, refuses it if it does not hash to
expected_sha256 or names another plugin, version or plugin ABI, then fetches
the build for its own platform (a WASM build when there is no native one),
checks it against the manifest's digest and loads it disabled; enabling it
is a separate PATCH. It needs plugins_dir set. A refusal is 409 with the
reason.
Both scopes need Write on the root channel when minted as a session ticket,
the check the C++ server made for plugin administration. Installing is loading
code into the server.
POST /v1/messages, scope text:write. The server addressing connected users,
which is the one thing an external system cannot do by holding a client
connection of its own.
{"sessions":[42], "channels":[3], "tree":false, "body":"...", "store":false}tree extends channels to their subtrees; store puts the message in
history. An empty or unaddressed message is 400. Nobody connected is
reported, not refused — {"delivered":false,"reason":"..."} — because a
notice to an offline user is a normal outcome and the caller may want to fall
back to email.
| Route | Scope | Answer |
|---|---|---|
GET /v1/config |
server-config:read |
the settings snapshot |
POST /v1/config |
server-config:write |
204 |
Settable: welcome_text, password, max_users, max_bandwidth,
text_message_length, image_message_length, channel_nesting_limit,
channel_count_limit, listeners_per_channel, listeners_per_user,
log_days, profile_history, allow_voice_messages,
voice_message_max_seconds, voice_message_max_bytes, users_per_channel, default_channel, remember_channel,
remember_channel_duration, message_limit, message_burst,
plugin_message_limit, plugin_message_burst, allow_html,
allow_recording, broadcast_listener_volume_adjustments, cert_required,
obfuscate_ips, allow_ping, channel_name_regex, user_name_regex,
registry_name, registry_password, registry_url, registry_hostname,
registry_location, invites, invite_max_hours, invite_max_uses,
invite_skips_password, invite_address.
The mapping lives in starling_runtime::settings beside the defaults and the
field-wise merge, so a setting added there is settable here without a second
edit and the two cannot disagree about a name.
Three asymmetries to know:
- Neither password is readable.
passwordandregistry_passwordare settable and absent fromGET: a credential read back is a credential in a log, a browser cache and whatever proxy sits between. - Six more are write-only —
users_per_channel,default_channel,remember_channel,remember_channel_duration,channel_name_regex,user_name_regex.GETdoes not return them. - An unrecognised key is not an error. It is carried into the snapshot's
extramap, which is how a service adds an operator-facing knob without a proto release — and also how a typo is accepted silently. A known key with the wrong JSON type is ignored and logged at warn; a body in which every key was ignored is400.
Changes are live. A setting an operator changes here outranks the file, and
server-config publishes it to every subscriber rather than waiting for a
restart.
| Route | Scope | Answer |
|---|---|---|
GET /v1/invites |
server-config:read |
every invite that has not expired, newest first |
POST /v1/invites |
server-config:write |
201 and the invite |
DELETE /v1/invites/{code} |
server-config:write |
204, or 404 for no such invite |
POST takes {"channel", "max_age_s", "max_uses", "creator"}, and every field
may be left out: no channel, the longest lifetime and the most uses the
invite_max_hours and invite_max_uses settings allow, and "operator" as
the creator. Longer or larger than those ceilings is clamped, as it is for a
client. An operator who needs a permanent invite sets invite_max_hours to 0
first, which the audit log records. With invites set to off it answers 409:
an invite that could never admit anybody is a link that fails silently in
somebody else's hands.
{"code":"k3m9x2p7qr4t","channel":12,"created_ms":1750000000000,
"expires_ms":1750604800000,"max_uses":0,"uses":3,"creator":"alice",
"creator_account":7}expires_ms and max_uses are 0 for "never" and "unlimited". uses counts
people, not logins. The link a client builds from a code is
fancy://invite/<code>?server=<host:port>&name=<server name>; a stock client
redeems a code by adding invite:<code> to its access tokens. Revoking an
invite leaves everybody it already admitted connected, and admits nobody after.
GET /v1/health, scope server-config:read. The route a dashboard polls.
It is a read of a snapshot, not a sweep: the health collector polls every
service once every few seconds for everybody, so ten viewers cost what one does.
observed_at_ms comes with the answer, so a stale picture is visibly stale.
{"state":"ready","observed_at_ms":1750000000000,"interval_ms":5000,
"history":[{"observed_at_ms":...,"state":"ready","ready":21,"warming":0,
"warning":0,"unreachable":0,"worst_latency_us":812,
"slowest":"metadata","busiest_percent":4,"busiest":"text",
"rejected":0}],
"disabled":[],
"services":[{"service":"userdata","state":"ready","latency_us":140,"error":"",
"gates":[{"name":"schema","state":"ready"}],
"load":[{"name":"inflight","used":0,"peak":3,
"capacity":64,"rejected":0}]}]}state is one of ready, warming, warning, unreachable, unknown — the
name, not the wire number. History arrives in the same round trip because a
dashboard needs both the detail of now and the shape of the last hour, and two
polls would double the traffic to draw one page. It is best effort: a collector
that answers Get but not History yields an empty series rather than failing
the page.
worst_latency_us and latency_us are microseconds, unrounded, because
rounding at the source destroys the difference between "fast" and "not
measured". capacity: 0 means nothing declares a limit, so show used and
peak as counts and invent no percentage.
GET /v1/log, scope audit:read. murmur's getLog, served from the audit
service's store.
| Query | Meaning |
|---|---|
since_ms, until_ms |
the window |
category |
one log category |
account |
the target account |
limit |
how many entries |
[{"id":"0197...","at_ms":1750000000000,"category":"moderation",
"action":"ban","actor_name":"ada"}]This is not the operator audit file (§5). This route reads what the audit
service recorded about the server; the file records what operators did to it.
getLogLen has no counterpart: it exists upstream so a client can page by
offset, and paging by offset over an append-only log re-reads rows that shifted
underneath it. The service takes a before cursor, but this route does not yet
expose it — page by narrowing until_ms.
Every operator action is recorded before it happens, and a request is refused if
it cannot be recorded. operator-api writes this file itself rather than
calling the audit service, because audit is optional and the
highest-privilege plane must not depend on a service the operator may not be
running.
One JSON object per line, flushed per record — an action still in a page cache when the process dies was not recorded, and fail-closed would be a lie:
{"id":"0197...","at_ms":1750000000000,"subject":"token:STARLING_ADMIN_TOKEN",
"action":"PUT /v1/accounts/0","outcome":"accepted"}outcome is accepted, meaning the request was admitted, not that it
succeeded; the record precedes the work. What the service made of it is in the
service's own log, which is why every write also carries the operator identity
as its Actor — an operator action appearing in the audit file and nowhere else
is only half recorded.
fail_closed = false trades the refusal for a logged error and is the
operator's decision, not the default. It is read per request, so a 503 from an
unwritable log is the policy working. Changing the path or the policy needs a
restart; only the auth strategy reloads.
GET /v1/events, scope session-view:read. What changed, as it changes — one
stream instead of polling every route above.
Source: diagrams/operator-api-events.puml.
Authorisation happens before the upgrade. A socket that opens and closes on the first frame is, to most clients, indistinguishable from a network fault, and an operator debugging a bad token deserves a 401.
Each frame is one JSON object tagged with event. The names are the C++
server's ServerCallback method names: nothing here speaks Ice, but the systems
being pointed at this channel were written against those names, and a
gratuitously different vocabulary would make each of them rewrite a switch to
gain nothing.
event |
Payload | Fires when |
|---|---|---|
userConnected |
user |
a user finished connecting |
userStateChanged |
user |
they moved, were renamed, muted, deafened or suppressed |
userDisconnected |
user |
they left; the payload is their last known state, since nothing can look them up afterwards |
userTextMessage |
user, message |
a message was delivered |
channelCreated |
channel |
a channel was created |
channelStateChanged |
channel |
it was renamed, moved, or had its description or limits changed |
channelRemoved |
channel |
it and its subchannels were removed |
contextAction |
action, owner, actor_session, session, channel |
a user chose a context-menu entry registered over this channel |
started |
server_id |
the state below the bridges became readable |
stopped |
server_id |
it stopped being readable |
user is the shape GET /v1/sessions serves, and channel the shape
GET /v1/channels serves minus created_at_ms. On userTextMessage the user
is only what text knows of the sender — session, name, channel,
user_id — and the remaining flags read false; join on session for the rest.
A message is
{body, channels, sessions, tree, sent_at_ms, from_client}; from_client is
false when the server itself sent it through POST /v1/messages, which a
watcher that reacts to messages needs so it does not answer its own
announcements.
Three more arrive from the transport rather than from the server's state:
event |
Payload | Means |
|---|---|---|
lagged |
missed |
you fell more than 1024 events behind and lost the oldest. Re-read /v1/sessions and /v1/channels |
error |
reason |
a command could not be parsed or was refused |
pong |
answer to ping |
It is a notification channel, not a replay. A subscriber receives what is
published after it subscribes and nothing before. Read /v1/sessions and
/v1/channels first, then follow the stream.
started means all four bridges hold a subscription, and a subscriber that
connects while the channel is already live is sent its own started on arrival.
An earlier version set it from the session bridge alone, so started went out
while the channel bridge was still attaching and a channel created in that
window produced no event at all. stopped fires as soon as any bridge drops,
for the same reason: a subscriber cannot tell which half of the server it has
stopped hearing about.
Upserts are split back apart. session-view and metadata both publish an
upsert, which is a create and an update collapsed into one — right for a
subscriber rebuilding a view, wrong for one reacting to arrivals. This service
tracks which ids it has seen and reports userConnected against
userStateChanged accordingly, and suppresses a state event when nothing in the
projection actually changed.
Reconnects are silent. A bridge that re-attaches seeds itself from the snapshot, so everyone already connected is not reported as an arrival and every restart does not look like a join flood. Bridges retry every 2 s, and an attach that hangs is abandoned after 5 s so the retry is actually reached.
The channel is bidirectional, because registering a context-menu entry has to end when the connection servicing it does: an entry whose owner has gone is a menu item that does nothing. A disconnect withdraws everything that connection registered, on either transport.
command |
Fields | Reply |
|---|---|---|
addContext |
action, text, context |
contextAdded |
removeContext |
action |
contextRemoved |
ping |
pong |
{"command":"addContext","action":"kick","text":"Kick","context":4}context is a bitwise OR of the upstream bits: 1 server, 2 channel, 4
user. The tag is command, not action, because addContext carries a field
called action already. An unparseable command is answered with error rather
than dropped — a command silently ignored looks exactly like one the server
chose not to honour.
Entries are owned by operator-api, not by the operator who added them:
operators come and go, and the menu entry outlives the request.
The same channel over HTTP/3, at the same path, off unless configured:
[services.operator-api.webtransport]
enabled = true
listen = "0.0.0.0:8443"Identical JSON, so a consumer written against one transport reads the other unchanged. Differences are mechanical:
| WebSocket | WebTransport | |
|---|---|---|
| Framing | one message per event | newline-delimited JSON |
| Direction | one socket | server-opened uni stream out, client-opened uni stream in |
| Credential | Authorization on the upgrade |
Authorization on the CONNECT |
| Reachability | any reverse proxy | needs the deployment to terminate QUIC |
Two unidirectional streams rather than one bidirectional: a BidiStream sends
and receives on one object, so reading and writing it concurrently means a lock,
and a lock held across a write would stall event delivery whenever a command
arrived. Replies still go out on the event stream, so the consumer sees one
ordered sequence either way.
A UDP port that will not bind is logged, not fatal. It must not take down the HTTP surface, which serves the same channel and is what a proxied deployment uses anyway.
A refused credential here closes the session with no status line: the WebSocket
answers 401, WebTransport logs and hangs up. Check a token against
POST /v1/whoami before blaming QUIC.
oidc, jwt |
refuse every request; no JWKS client |
POST /v1/whoami |
requires the literal * scope |
GET /v1/accounts |
200 accounts, no cursor; ?name= swallows a transport failure as [] |
GET /v1/bans |
omits address, prefix_len, cert_hash |
GET /v1/log |
no before cursor |
GET /v1/config |
six settable keys are not readable |
POST /v1/config |
an unknown key is accepted into extra, not rejected |
| Everything | server instance 1, except PUT /v1/accounts/{id} |