API request boundaries

An API listener bounds every request before it reads it. A deployment may declare the bounds in dashboard.request_limits: positive whole-byte head_bytes, body_bytes and registry_import_bytes values, plus the operator_console and fleet_join bounds:

stado config set dashboard.request_limits \
  "{\"head_bytes\":$HEAD_BYTES,\"body_bytes\":$BODY_BYTES,\"registry_import_bytes\":$IMPORT_BYTES,\"operator_console\":{\"argument_count\":$ARGUMENT_COUNT,\"argument_bytes\":$ARGUMENT_BYTES,\"input_bytes\":$INPUT_BYTES,\"request_bytes\":$REQUEST_BYTES},\"fleet_join\":{\"request_bytes\":$JOIN_REQUEST_BYTES,\"field_bytes\":$JOIN_FIELD_BYTES}}"
stado config get dashboard.request_limits
stado config validate

WC_DASHBOARD_REQUEST_LIMITS overrides the document with the same JSON shape. Invalid JSON, missing fields, unknown fields, zero, negative and fractional bounds refuse startup instead of selecting a replacement value. config validate checks the stored document; startup also checks the environment override. Desktop's existing configuration operation accepts the same dotted key and JSON value; host configuration operations can set that key on the selected host. No separate transport policy belongs to the client.

A deployment that declares neither binds the listener all the same and bounds every byte value, and the console's argument count, by the memory the host can give a request when the listener starts: the available bytes its kernel reports (MemAvailable on Linux, the kern.memorystatus_level share of physical memory on macOS). A request larger than that could not be held whatever was declared. Requiring the declaration instead stopped the vault owner's object API when a release that required it reached a host without one, and every Stado command in the fleet answered 502. A host whose memory cannot be read refuses to start the listener, naming both keys and the reading.

A Stado installation checks that the new image can run this host's API before it moves the host onto it. stado release local converge, the step every Stado install runs after placing its binary, refuses with this host's configuration cannot run the installed Stado's API, so no unit is recycled onto it and the running units keep the image they started with: … when it cannot. No unit is recycled and no release-version marker is written, so the host's running stado serve keeps serving; the installation reports failed.

Converge acts on this machine only, so it reads which units and service-local readers are this machine's from this machine's own registry record: the store when it is a directory on this disk, otherwise the last-known-good copy, never the registry authority on another host. It prints the copy's age and generation it read. An installation is how the services that serve the authority's snapshot are repaired, so it must not wait on that snapshot; a machine that holds no record is refused naming where the copy was looked for.

Every other registry read that asks the authority over SSH is abandoned once it stands longer than the refresh interval this machine's resolver publishes, or, when no resolver publishes one (none runs, or the state it left names no interval), the interval this machine's registry record declares for its resolver, or, when this machine's record declares none, the interval the authority's own resolver is declared with in that last-known-good copy: the authority serves its snapshot at that interval, so a read outlasting it is not being served. The refusal names the authority, the interval and stado service logs com.wisent.stado --host <authority>, and the reader goes on to its last-known-good copy. Only a read no record declares an interval for waits.

A read given up that way is kept in ~/.stado/authority-unanswered.json. For one interval after it, any later command's read of that authority is refused at once, the registry authority <host> is not asked again before <time>: a read of it was given up at <time> after <N>s unanswered (<that read's sentence>), and goes on to the last-known-good copy, so only one command per interval waits. After the interval the authority is asked again, and an answer removes the record.

The API listener counts the request line, header fields and terminating blank line against head_bytes. Finding the terminator does not bypass the limit. A head ending exactly at the limit is accepted; body bytes or the next request received with it do not count against that head.

A complete oversized head, or a head that fills the budget without its terminator, receives HTTP 431 Request Header Fields Too Large with HTTP request head too large: … the <N>-byte head bound (dashboard.request_limits.head_bytes, or this host's available memory when none is declared). The listener closes that connection and records the refusal and its status in its service log. This parsing rule applies before routing or authentication, including the enrollment-only listener used by qualification. Ordinary and integration bodies use body_bytes; POST /api/registry/import uses registry_import_bytes. Integration handlers apply no second body-size cap. An oversized body receives HTTP 413 Content Too Large naming its declared size and allowed limit before a handler reads it. Object PUT and compose continue to carry object-sized content.

Every other refusal of the request itself also arrives before routing, closes the connection and states what was wrong:

Request Status Body
request line not <method> <target> HTTP/<version> 400 HTTP request line "<line>" is not <method> <target> HTTP/<version>
head bytes that are not UTF-8 400 HTTP request head is not UTF-8: <where>
header line without a colon 400 HTTP header line "<line>" has no colon between a name and a value
client closes before the blank line ending the head 400 incomplete HTTP request head: the client closed after <N> bytes …
two Content-Length fields, or one that is not a whole number 400 duplicate Content-Length / invalid Content-Length "<value>": <cause>
object PUT, compose or registry import without Content-Length 411 mutating object and registry import requests require Content-Length
any Transfer-Encoding 501 Transfer-Encoding is unsupported: this listener frames a body by Content-Length only

A client that closes before sending the whole body its Content-Length declares is not answered; the service log names the route and how many of the declared bytes arrived.

A route reads its query string as application/x-www-form-urlencoded fields (+ is a space, %HH one byte). A field it cannot decode is answered 400 with {"error": "the query field \"<field>\" cannot be read: <reason>"}, the reason being a % not followed by two hexadecimal digits, or decoded bytes that are not UTF-8; the route never reads such a field as other text. For example GET /api/release/object?uri=%ZZ answers that the escape %ZZ is not two hexadecimal digits, before any object is looked up.

The operator_console object declares positive integers for argument_count, argument_bytes, input_bytes and request_bytes. POST /api/operator/run uses request_bytes for its complete JSON body, not body_bytes. Validation checks the number of arguments, the UTF-8 byte length of each argument, and each optional input and stdin field before starting a child. Empty or NUL-containing arguments are refused. A value above its bound returns HTTP 400 naming the observed size and declaration; an oversized HTTP body returns HTTP 413. A mutating command still requires RUN_MUTATION.

/api/operator/workload/attach reads the same declaration. It checks the expanded command arguments and bounds each incoming WebSocket frame and complete message by request_bytes. Setup refusals are error frames and do not start a workload. input_bytes bounds the HTTP input fields; streamed WebSocket input is bounded per frame and message rather than by total attachment duration.

The fleet_join object declares positive whole-byte request_bytes and field_bytes. POST /api/fleet/join uses that body bound before reading the body, including when the URL has a query string. Each trimmed reported string uses the field bound, measured in UTF-8 bytes. Required fields must be nonempty; an unavailable installed-key fingerprint may remain empty. An invalid or oversized field returns HTTP 400 naming the field, observed bytes and dashboard.request_limits.fleet_join.field_bytes; an oversized body returns HTTP 413. Hostname traversal checks and invite-token authorization are unchanged. A report within all bounds still needs a usable token and cannot write a pending enrollment without one.

Desktop's first-run import and Settings → Existing registry send the selected document to the API without a second file-size policy. A refused import displays the API's plain-text reason, including the declared and allowed byte counts, rather than discarding it because it was not JSON.

Run the real parser journey from the Stado checkout:

STADO_BIN="$CANDIDATE" STADO_TEST_HEAD_BYTES="$HEAD_BYTES" \
  STADO_TEST_PORT=0 node tests/http/header-boundary.mjs

HEAD_BYTES is the fixture's positive head-budget declaration. The journey derives distinct body budgets from that input and console and enrollment bounds from its actual command, reported fields and request bytes, then supplies the complete document. Port zero asks the operating system for an available port. The journey starts isolated enrollment and operator API listeners with their own home and local storage under the checkout's ignored build directory, and removes that state when it exits. It checks missing and invalid startup declarations, a boundary-sized head with a body, a complete oversized head, a budget exhausted before the terminator, both body limits, console arguments and input fields, confirmation refusal, and real command execution at the request boundary. Enrollment cases check empty and missing fields, ASCII and multibyte byte bounds, hostname traversal, request-size checks with and without a query, and a boundary-sized report reaching token refusal. They also check that no enrollment was written. Integration cases check that an exact-boundary body still requires a bearer and that an oversized body is refused before authentication. An unavailable integration verifier is blocked, not passed. These requests carry no bearer, so they cannot dispatch an owner command to a host. Reports under build/http-header-boundary/run-*/report.json retain source revision, checkout changes, executable version and hash, test hash, declared limits, commands, exit statuses and actual HTTP responses. An unavailable listener is blocked, not passed. The existing Desktop API and host-health test fixtures take STADO_TEST_REQUEST_LIMITS as that same JSON document and persist it through stado config set before starting their API.

The real attachment journey uses that declaration for its listener bounds and measures console bounds from the request under test:

STADO_TEST_REQUEST_LIMITS="$REQUEST_LIMITS_JSON" \
  cargo test --locked --manifest-path stado-rs/Cargo.toml \
  --test service-journeys journey::console_limits:: -- --nocapture

It executes workload attach --help through an exact-boundary WebSocket message without starting a fleet workload, then checks expanded argument counts, ASCII and multibyte argument sizes, and an oversized frame. Reports under .build/desktop-api/ retain source and binary identities, the handshake, response frames and the persisted configuration after each case.

Source: this website