Skip to content

Contract: the web API

Status: Accepted

The interface between lemonfiber, which serves it, and lemonfiber-web, which draws it.

Satisfies: G1-R1, G1-R2, G1-R7, G1-R12, C6-R9, C6-R10, C6-R11, C6-R12


ADR-0011 put the web surface in its own repo, which means the boundary between it and the core is a published shape rather than a compiler check. That is the whole point: G1-R2 says no surface may implement behaviour independently, and a client that can only ask and draw cannot break it by construction.

The risk a contract carries is drift, so this one is built to make drift structurally hard rather than merely discouraged.

Machine-readable output is already a stable interface (ARCH-R9, versioning):

{ "api_version": 1, "kind": "status", "data": { } }

The web API does not introduce a second shape. Every endpoint answers with the identical envelope the equivalent command emits under --json, byte for byte. A script piping lemonfiber status --json and the web app fetching /api/status receive the same document.

This is the load-bearing decision in the contract, and it is worth being explicit about why. A separate “web shape” would be a second serialisation of the same domain — two places to change when a field is added, two sets of tests, and eventually two answers to the same question, which is exactly what G1-R2 exists to prevent. One shape means the surface adds no vocabulary of its own.

Each command that supports --json has one endpoint, named for the command, returning that command’s envelope:

GET /api/forms GET /api/status GET /api/services
GET /api/checks GET /api/storage GET /api/logs?…
GET /api/requests GET /api/trace?… GET /api/stuck
GET /api/version GET /api/config?… GET /api/quality
GET /api/explain?… GET /api/backups GET /api/bundle/{name}
GET /api/front-door
GET /api/outbound GET /api/stored GET /api/clients
GET /api/credentials GET /api/alerts
GET /api/space GET /api/bandwidth
GET /api/hosting GET /api/uninstall
GET /api/migration
GET /api/history
GET /api/update?…

Query parameters mirror what the command takes, flag or argument. A command that gains one gains a parameter; one that gains an endpoint gained a command first.

/api/clients takes none either, and for a different reason: what to watch on is the same answer on every machine, because the client landscape belongs to the platforms rather than to a stack. It reads nothing and asks nothing of the engine, so it answers where nothing is set up yet — which is when somebody deciding what to tell the house is most likely to ask. Naming a device would let a surface show one row and call it the answer, and the row most worth reading is the one saying a device is poorly served and what to do instead.

/api/credentials takes none, and it is half a word on purpose. What it answers with is every credential the stack holds — what each is, what authenticates with it, where the value lives and where it stands — and no value, because the shape it is built from has no field one could go in. The two things that can be asked of a line of it are not offered here at all: printing a credential over this door would put it through a browser’s cache, whatever proxy is between and the log each of them keeps, and replacing one is a write a request could be forged into making against the credential the stack is currently working on. Both are the terminal’s, in front of the person who typed the confirmation. A parameter narrowing the list would be a way to ask about one credential rather than the set, and the answer worth reading is the set — the credential worth knowing about is usually the one somebody had forgotten was there.

/api/space takes none, and the absence is the point. What it answers with is the account of where the disk went and an offer of what could be got back; there is no parameter choosing what to reclaim, because that choice is never a caller’s to make. A torrent still seeding is named with what removing it costs and left with the operator, and something the operator has already asked to be left alone is not on offer at any level of fullness — so a narrowing parameter would be a way to ask for exactly the two things nothing here will take.

/api/bandwidth takes none, and the absence draws the same line the disk’s does. What it answers with is the account of the line: what it was measured to carry, what the stack is being held to, which side of the household’s day each download client says it is on, and whether each is actually keeping to what it was given. Declaring a limit is the action of the same name, at the door changes are asked for — a read that could set one would be a read that changes what everybody in the house experiences, which is the distinction the two doors exist to draw.

/api/uninstall takes one, and it is required in spirit rather than in shape: which of the four removals is being read. Naming none reads the one that removes nothing, because that is the safe reading and the one a browser opening the page has not chosen anything by; a word that names none of the four is refused rather than read as whichever the shape would default to, since on this subject the default that would hurt is the one that reaches the library. It is a read and never a removal — what it answers with is the listing every container, image and path a removal would take, and the action of the same name is where an answer to that listing goes. The two are one command read the same way twice, so what a browser agrees to is what it was shown.

/api/hosting takes none, because what is hosted is a property of the machine rather than of the caller. It answers with every long-running command that could be handed to this machine’s service manager and what became of each — installed or not, running or not, and where the words of a command with no terminal are being written. Naming one would let a surface show a single row and call it the answer, and the row worth reading is the one saying a guarantee the operator believes is in force is not.

/api/front-door takes none, because the question takes none: which one address the household is given is derived from what the stack runs rather than asked for, so a parameter here would be a way for one surface to be told a different door from another.

/api/bundle/{name} is the one read that does not answer with an envelope. It answers with the bundle itself, because a browser has no path on the host to be told and handing the file over is the only form --out can take on a screen. The name is resolved beneath the bundles directory rather than followed, so one carrying a path, or climbing out of that directory, is refused by name.

The body of a refusal is the error envelope, the same one --json renders, so the status is the only thing that tells one refusal from another — and they are worth telling apart:

Refused because Status
What the request named, this product does not have 404
The request could not be answered as it was asked 400
Nothing about the request was wrong; the machine could not answer it 500

The line between the first two is what the request was for. A word this product does not explain is absent — the word is the whole of what /api/explain was asked for, and there is no entry — while a parameter left out, given twice, or given a value the surface does not offer is a request that could never have been answered as it stands. It is the same line the write surface already draws between an action name nothing answers to and an argument it cannot take, and the same reading ARCH-R72 makes of a job name this run never issued.

500 is reserved rather than incidental. A client told the machine failed will retry, and a client told that about a word with no entry retries forever; worse, one that cannot tell the two apart has to word a single message that is true of both, which is how “no entry” and “no answer” reach an operator as the same sentence.

GET /api/events text/event-stream

The SSE event name is the envelope’s kind, and the payload is the envelope. Three properties are required rather than incidental:

It is the same gather. G1-R12 requires concurrent surfaces to agree, and two independent gathers are two chances to disagree — so the stream is fed by the gather that already serves the dashboard, not a second one built for the web.

A silent stream is distinguishable from a broken one. Without a heartbeat, “nothing has changed” and “the connection died twenty minutes ago” look identical to a client, which is the same confusion Reading::Stale exists to prevent one layer down.

A resumed stream does not lie about what it missed. On reconnect a client may hold values gathered before the gap. Those are Stale by definition, and must be presented as such rather than as current.

POST /api/session

The per-run token answers one question — is this the machine’s own operator — and it answers it by having been printed on the terminal that started the process. That is the population loopback already answers for, so while the surface is loopback-bound it is the whole of what admission needs to be. It is no use at all to somebody holding a phone, and being reachable from a phone is the entire case for binding beyond loopback (C6).

So there is a second way in, and exactly one: the operator’s own password, exchanged once for a session. The password is not sent again — verifying it is deliberately expensive, and a credential re-sent on every request is a credential with more chances to leak.

The request body is { "password": … }. The reply is the envelope, kind: "admission", carrying the session’s own secret and the moment it stops being one. That secret travels in X-Lemonfiber-Token, exactly as the per-run token does, so the surface has one credential header to read and a client has one thing to hold rather than two.

Refused because Status
The password was wrong, or none is configured 401
Too many wrong answers lately 429, saying how long is left

401 here rather than the 403 every other refusal answers with, and the difference is the whole reason to distinguish them: 403 means nothing you can send would help, which is true of a missing token and false of a wrong password. A client that cannot tell the two apart cannot know whether offering a login is worth anything.

A session expires, on an absolute clock rather than on use: a window left open all week is not evidence that whoever opened it is still there. It is also void the moment the password changes — which is what makes changing the password a way to end a session somebody else is holding, rather than only a way to stop the next one.

Neither is a rule a client may keep its own version of. The server refuses an expired or voided session exactly as it refuses a wrong one, and a client that cached the verdict would be a second opinion about who is admitted.

Failed answers are rate-limited, and the limit is on the surface rather than on the caller’s address: there is one password, and choosing a new source address per attempt is the ordinary shape of the attack. The refusal says how long is left, so a client waits rather than retrying into the limit and extending it.

POST /api/actions/<name>

Arguments mirror the command’s. G1-R1 requires every action to be available from every surface, and this contract adds the converse: the web API exposes nothing the CLI cannot do. An action that exists only here would be a behaviour implemented by a surface.

Long-running actions return a job identifier and report progress on the event stream, so a browser tab that closes mid-repair does not orphan the work. The identifier is redeemable — see a job’s outcome — because a name that cannot be turned back into an outcome makes the reply an acknowledgement rather than an answer.

GET /api/setup POST /api/setup/answer POST /api/setup/next
POST /api/setup/back POST /api/setup/apply POST /api/setup/recover

Named for the walk rather than for a command, because a wizard is one request read many times: what it is standing on, an answer to that, and the movement between. apply is where it stops asking and writes, and recover is what an apply that stopped part-way is answered with — offered after the report has named what that apply had already written, so the choice is made by somebody who has seen it.

These are writes and sit behind the same guard as the actions above. They are named here because the first-run walk is where credentials are entered, and an endpoint that takes one and is written down nowhere is the one nobody audits.

A writable API on loopback is reachable from any page the operator happens to visit — a page cannot read a cross-origin response, but it can send a request the server acts on, and DNS rebinding defeats a naive origin check. The CLI never had this exposure; nothing a web page does reaches argv.

Guard Requirement
Bound to loopback, never all interfaces C6-R1, C6-R3
A per-run token, printed by the CLI at start, on every request ARCH-R52
Origin and Host checked against the bound address ARCH-R53
No proxying to an admin service C6-R12
Says plainly that it is unencrypted HTTP C6-R6

The token travels in a header. Never a query parameter: URLs reach logs, history and referrers, and a credential that leaks into any of those has leaked.

The client declares the api_version it speaks. Because the built app is embedded from a pinned submodule (ADR-0012), build.rs validates that declaration against the binary at compile time — the same protection ARCH-R6 already gives the embedded stack’s schema_version. A mismatched pair cannot be released.

The runtime check remains, because a browser may hold a cached older app. A mismatch there refuses plainly and says which versions are involved, rather than rendering a page whose fields have quietly changed meaning.

A requirement that states an obligation without stating its mechanism gets two implementations that both satisfy it and cannot talk to the same server. These are the particulars, fixed so that no client has to invent them.

The header is X-Lemonfiber-Token. The binary prints the token when it starts serving, and a client is given it by its caller — there is no discovery, no file to read, and no default.

There is no default port. The binary chooses a free one unless told otherwise and prints the whole address; a client is configured with that address rather than assembling one.

A host name is accepted only if it resolves to a loopback address. Refusing the word localhost outright is the wrong trade: it is what an operator types and what a printed address may contain. Refusing a name that resolves off loopback is the protection that matters, and resolving before connecting is what provides it.

The server emits a comment line at least every 15 seconds when nothing else has been sent. A client treats the stream as broken once twice that has passed in silence, which tolerates one missed beat without pretending a dead connection is a quiet one.

Every event carries an id. A client resuming sends the last one it saw as Last-Event-ID, and the server replays from after it where it can, or restarts the stream where it cannot. Either way everything the client still holds from before the gap is stale until replaced — the resumption mechanism does not change what is current, only what is retransmitted.

GET /api/jobs/<job>
DELETE /api/jobs/<job>

The other end of the accepting reply. It is not named for a command, because it answers no request the command line has: being answered with a name instead of an outcome is the web’s own arrangement, and this is the half that makes it an answer.

DELETE releases the name. For work with no ending of its own — a guard that holds until the data location is lost — a browser has no interruption to send, so letting the name go is its Ctrl-C, and what the container engine was already asked to do goes on exactly as it does when a terminal is closed.

The standing is carried by the status, and the body is a document the client already parses either way:

Standing Status Body
Still in flight 202 the identical envelope the accepting reply carried
Finished 200 the equivalent command’s machine-readable output
Stopped 500 the error envelope the failure renders
Not a name this run issued 404 a refusal, not the name repeated back

Status rather than a field, because the alternative is a shape only this endpoint has — and a second serialisation of an outcome the contract already describes is the drift ARCH-R47 exists to prevent.

The stream is not the mechanism. A client that reconnects sends the last id it saw, and a client connecting for the first time has none to send, so an event announcing the end reaches only a client that was already listening when it happened — which is not the client the accepting reply exists to serve. A tab closed mid-repair and reopened holds a name and nothing else, and this is what it does with it.

Names do not outlive the run that issued them. Work in flight does not survive the process doing it, so a record that outlived the run would describe jobs nothing is running; a name from an earlier run is therefore one this run never issued.

data differs by kind, so a client exposes it typed by its kind rather than as an untyped value. Generated types make this ordinary rather than laborious: the kind is the discriminator, and an untyped payload on the public surface means the generation has not been used.

ID Requirement
ARCH-R46 The web API MUST carry the same api_version envelope as machine-readable command output.
ARCH-R47 A web API response MUST be identical to the equivalent command’s machine-readable output.
ARCH-R48 The web API MUST NOT expose an action that is unavailable from the command line.
ARCH-R49 Live state MUST be served from the same gather that serves the other surfaces.
ARCH-R50 The event stream MUST emit a heartbeat, so a silent stream is distinguishable from a broken one.
ARCH-R51 A resumed event stream MUST NOT present values gathered before the gap as current.
ARCH-R52 Every request MUST carry a per-run token, delivered in a header and never in a URL; a request without it MUST be refused.
ARCH-R53 Origin and Host MUST be checked against the bound address, and a mismatch MUST be refused.
ARCH-R54 The client’s declared api_version MUST be validated against the binary at build time.
ARCH-R55 An api_version mismatch at run time MUST be refused plainly, naming both versions, rather than rendering a partial view.
ARCH-R56 The contract artefact MUST be generated from the types the server serialises, never hand-written.
ARCH-R57 Regenerating the contract artefact MUST produce no diff, and CI MUST fail if it does.
ARCH-R58 An SDK’s contract types MUST be generated from the artefact; hand-written response shapes MUST NOT be used.
ARCH-R59 The per-run token MUST be sent in the X-Lemonfiber-Token header.
ARCH-R60 A client MUST refuse a base address that does not resolve to a loopback address, and MUST NOT refuse a loopback address for being named rather than numeric.
ARCH-R61 The event stream MUST emit a heartbeat at least every 15 seconds, and a client MUST treat twice that in silence as a broken stream.
ARCH-R62 Every event MUST carry an id, and a resuming client MUST send the last one it saw as Last-Event-ID.
ARCH-R63 A client MUST expose a payload typed by its kind, never as an untyped value.
ARCH-R64 The contract artefact MUST be published with every release, and an SDK MUST vendor it from an exact revision recorded beside the copy.
ARCH-R65 Generating an SDK’s contract types MUST read the vendored artefact, and MUST NOT reach the network.
ARCH-R66 Regenerating an SDK’s contract types MUST produce no diff, and CI MUST fail if it does.
ARCH-R67 Generation MUST refuse an artefact whose api_version the SDK does not implement, naming both versions, and MUST write nothing when it refuses.
ARCH-R70 A job identifier returned by a long-running action MUST be redeemable: a client MUST be able to learn from the server that the work ended and how it went, without having held a connection open since it started.
ARCH-R71 Redeeming a job identifier MUST answer finished work with the equivalent command’s machine-readable output and work still in flight with the identical document the accepting reply carried; the two MUST be distinguished by status, never by a shape only this endpoint has.
ARCH-R72 A job identifier the run did not issue MUST be refused as absent, and MUST NOT be reported as still in flight.
ARCH-R73 The contract artefact MUST NOT describe a schema in a form whose meaning depends on which JSON Schema draft the reader applies, and SDK generation MUST refuse such an artefact, naming where it occurs, rather than generate from the half of it that it reads.
ARCH-R74 A refused read MUST carry a status that distinguishes what the request named and this product does not have, from a request that could not be answered as it was asked, from a failure of the machine; the body MUST be the error envelope in every case, and the status of a failure of the machine MUST NOT be given to either of the others.
ARCH-R75 The operator’s password MUST be exchanged for a session by one request and MUST NOT be required by any other.
ARCH-R76 A session’s secret MUST travel in the same header the per-run token does, so the surface reads one credential header.
ARCH-R77 A refusal a caller could correct by sending something else MUST be distinguished by status from one nothing they could send would satisfy, and a refusal for too many failed attempts MUST say how long is left.

Two SDKs hand-writing this contract would be two sources of truth for it, and a third would be a third. So the shapes — fields, types, optionality, permitted enum values — are generated from the server’s own serde types into one artefact that every SDK consumes (ADR-0014).

Everything above that a schema cannot express stays here, in prose, and every SDK implements it and tests it: the heartbeat, resumption that does not present pre-gap values as current, the token’s placement, and the refusal on mismatch. This document is normative for what the surface means; the artefact is normative for what it looks like. Neither restates the other.

An SDK does not ask the server for the contract while it builds. It carries a copy. A build that fetched would depend on a host being reachable, and two builds of the same commit could produce different types.

So the artefact travels as a vendored file pinned to an exact revision. lemonfiber publishes it with every release; an SDK fetches it once, records the revision it came from beside the copy, and every build after that reads only what is on disk. Taking a contract change then becomes a deliberate act that arrives as a diff somebody reads, rather than something that happens to a build nobody was watching.

The pin is a revision rather than a version number because a revision names exactly one artefact: the vendored bytes can always be checked against what that revision served, which is what makes the copy verifiable rather than merely present.

Three guards sit either side of the copy. Regenerating from it must produce no diff, so a stale generated tree fails CI rather than shipping. Generation refuses an artefact whose api_version it does not implement, naming both versions and writing nothing — types that compile and lie are worse than a build that stops, and a refusal that does not say which two versions disagreed sends somebody looking for what it already knew.

The third guard is about the artefact’s own legibility, and it is the one nothing suggested until it was needed. An artefact can be valid, generated, pinned, regenerated without a diff, and still not be read the same way twice — which is worse than being unreadable, because nothing stops. A $ref beside a constraint is that shape: a draft-07 reader discards what accompanies a reference, a 2020-12 reader applies both, and the draft a schema declares says nothing about which of the two a generator happens to be.

It reached both SDKs once. A verdict carrying a diagnosis was described as a reference to the diagnosis sitting beside the property naming the verdict. The TypeScript generator kept the property and dropped the reference, so both such verdicts became a type holding the verdict’s name and none of the diagnosis — no summary, no meaning, no remedies. The PHP generator kept the reference and dropped the property, so both became the diagnosis with nothing to say which verdict it belonged to: two verdicts collapsed into one shape, and five arrived as four. Each discarded exactly what the other kept, and both produced output that compiled and analysed clean, which is why neither side said so.

So the artefact is held to one reading, and a generator meeting a shape that has two refuses it rather than choosing. An annotation is not a constraint — a described reference means one thing to every reader, and stays ordinary company.

  • sdk-ts — the TypeScript client implementing this contract
  • versioning.mdapi_version and the envelope it belongs to
  • design-tokens.md — the other contract lemonfiber-web consumes
  • ADR-0011 — why the boundary is a contract
  • ADR-0012 — how the built client reaches a machine
  • C6 — the security policy this sits inside

This page lives in another repository Rendered from lemonfiber/spec at 1d10402, 2026-09-09. Read the source of this page