Contract: getting in to the web API
Rendered from spec at76d46f3 (2026-10-25) · its source · Edit this page
Status: Accepted
Part of the web API contract: how a caller is admitted, by the per-run token, a session or an integration key, and the limit wrong answers meet. The requirements it describes are listed with the rest of the contract’s, under Requirements.
Getting in
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 |
An invitation is claimed at the same door. The body
{ "name": …, "password": …, "claim": … } is a claim
(D6-R25): claim is the
token the invitation’s join link carries, and password is the one the person chose, which
the core sets on their own account at the media server before it opens their session. The
reply is the same admission envelope. A claim is counted against the same attempts as a
sign-in. An invitation that is not open, whether its token is unknown, spent, lapsed or
declined, is refused 401 under one code, so a guess learns nothing about which; a password
shorter than the minimum is refused under a code of its own naming it. The token travels in
the body, never in a URL the core receives (ARCH-R219).
The invitation envelope carries join, the link the companion opens, and where it carries
none for a reason other than a rehearsal, unjoinable, saying why in words every surface can
show (ARCH-R218). No envelope carries a drawn code: a surface draws a QR code from the
address or link it was given, exactly as given (ARCH-R220,
G1-R15).
401 is the door’s alone. A wrong password is answered where it was offered, so a client
reading 401 knows that the password it just sent is the thing to change. Every other
refusal, including a session this run no longer admits, answers 403, and what tells a
client that signing in again would help is the refusal’s code, not its status.
An integration key
A program that runs beside the stack for months, such as Home Assistant, holds neither the
per-run token nor a session. It holds a key the operator minted for it under a name and
with one scope, read, act or member:<account>
(C10,
ADR-0037).
A key travels in X-Lemonfiber-Token, so the surface still reads one credential header. It
survives a restart, is refused exactly as a wrong token is once revoked, and is accepted
from another machine only over the TLS its pin verifies. The actions an act key may call
are published in the contract, and every other action is refused to a key, naming its
scope.
Minting and revoking are the one credential write the web API takes. Only an operator session may make them, minting asks for the password in the same request, and the secret appears once, in the mint reply, beside the stack’s address and certificate pin.
GET /api/keys
POST /api/keys
DELETE /api/keys/{name}
POST /api/keys takes { "name": …, "scope": …, "purpose": …, "password": … }. The reply
is the envelope under its own kind, carrying the secret, the pin, and the address where the
stack has been served encrypted on the network. It is sent with Cache-Control: no-store,
as every reply here is. GET /api/keys lists the keys without their secrets, and
DELETE /api/keys/{name} revokes one. A key is refused at all three, whatever its scope.
A key may be minted as a claim instead, for a program on a device with a camera.
POST /api/keys with "claim": true mints the key without handing over its secret: the
reply carries link, a key link with the stack’s address, its certificate fingerprint,
the stack, when the claim expires and the claim itself, and the listing shows the key
as waiting to be collected (C10-R18).
The program collects it once, within ten minutes, at
POST /api/keys/claim
with { "claim": … }, over the TLS the fingerprint verifies. The reply is the key envelope
carrying the secret, its name, its scope and the pin, sent with Cache-Control: no-store.
The claim travels in the body, never in a URL the core receives. A claim that is unknown,
spent or lapsed is refused 401 under one code, so a guess learns nothing about which, and
it counts against the limit wrong passwords meet. Collecting a
key alerts the operator with the key, its purpose and where it was collected from; a key
nobody collects is revoked when its claim lapses. A surface draws the link as a QR code
(ARCH-R220) and shows it only until its screen is left, so a photograph of it is worth one
collection inside ten minutes, which the operator is told about.
A phone may prove the operator with a device key instead of the password
(ADR-0044,
ARCH-R258 to ARCH-R261). An operator session registers the public half of a P-256 key
pair the phone holds in its hardware, and lists and revokes the ones registered:
GET /api/device-keys
POST /api/device-keys
DELETE /api/device-keys/{name}
POST /api/device-keys/{name}/challenge
POST /api/device-keys takes { "name": …, "public_key": …, "attestation": …, "password": … }:
the public half as its SubjectPublicKeyInfo in base64url, and the platform’s attestation of
where the key lives, an object tagged by platform:
platform |
Beside it | What it carries |
|---|---|---|
android |
chain |
The key attestation chain, each certificate’s DER in base64url, the key’s own certificate first |
ios |
object |
The App Attest attestation object’s CBOR in base64url |
On iOS the phone attests with the SHA-256 of exactly the bytes public_key decodes to as
the client data hash, hashed once, so the attestation covers that public half and no other. The core verifies the attestation
against the platform roots in its build, sending nothing out to do it, and only for an app
it trusts: the official NightWorksIO companion, or a build named in the setting
LEMONFIBER_DEVICE_KEY_APPS (ARCH-R262). It answers the device-key envelope:
the name, the fingerprint of the public half, its backing, strongbox, enclave or
tee, and when it was registered. On iOS enclave means the genuine app placed the key in
the Secure Enclave: App Attest vouches for the app, not for the key. A missing attestation,
one that does not verify, one placing the key in software and one from an app the core does
not trust are each refused under a code of their own. GET /api/device-keys
answers device-keys, each with its backing and last use, and DELETE /api/device-keys/{name} revokes
one. POST /api/device-keys/{name}/challenge answers device-challenge: a fresh challenge
and when it expires, one minute on, good for one mint from that session with that device
key.
A mint then carries { "device": …, "challenge": …, "signature": … } in place of
password. The signature is ECDSA over SHA-256 of the UTF-8 lines
lemonfiber-mint/1, the challenge, the name, the scope, the purpose and claim or key,
each ended by a line feed, in base64url, so a challenge signed for one mint proves no other.
A device key not registered, a challenge that does not stand and a signature that does not
verify are each refused under a code of their own; the last counts against
the limit wrong passwords meet. A key minted this way is listed
with proved_by, the device key that proved it (ARCH-R293), and revoking that device key
leaves the key standing. None of these routes is open to
a member session or a key, and a signature admits nothing but the mint it was made for.
A household member reaches the same three routes for keys of their own. Their mint names
member: and themselves as the scope and gives their own password, which the media server
checks as it checks their sign-in, counted against the same limits; it is refused with a
code of its own while the operator has not turned LEMONFIBER_MEMBER_KEYS on. Their listing
holds only the keys scoped to them, they revoke only those, and a key that is not theirs is
answered as one that does not exist. Turning the setting off stops members minting, not
revoking.
A key’s secret begins with lfk_, so the guard can tell a value shaped like a key from a
token or a session without looking it up. That is what it counts: a value shaped like a key
that matches no key counts against the limit wrong passwords meet.
A revoked or orphaned key is refused as a wrong token is and not counted. While the limit
holds, a request carrying a value shaped like a key is answered 429 before the value is
checked.
A key presented from another machine over plain HTTP, or over a connection whose origin cannot be placed, is refused before it is checked, with a code of its own: the remedy is to connect over the TLS the pin verifies, not to send a different key.
The actions a key may call are published in the artefact as key_callable. Each entry
names the action, says whether it disturbs the running system, says whether it takes
dry_run, so a client can rehearse it first and offer the real call after, and says whether
calling it again with the same arguments leaves the stack as one call did, so a client can
tell a person whether repeating it is safe (ARCH-R160). That is read by where the stack ends
up, not by whether the work is done again: a second restart takes the services down a second
time and leaves them running, as the first did, so a restart is idempotent; an update moves to
whatever is newest by the time it is called, so it is not. Whether it takes
dry_run is read from the core’s own account of each command rather than written beside the
list. The list starts as:
POST /api/actions/restart POST /api/actions/diagnose
POST /api/actions/update POST /api/actions/downloads-pause
POST /api/actions/downloads-resume
A rehearsal of one of these answers an offer, and the real call may carry it back. The
rehearsal of each action that takes dry_run answers, beside what it would do, an offer built
from what it read, as the plugin writes’ rehearsals do. A call carrying that offer builds it
again from what is there now and is refused where the two differ, naming what moved, with a
problem code the artefact lists among its refusals: a restart rehearsed against one set of
services is not carried out against another, and an update rehearsed against one release is not
carried out against the next. A call carrying no offer acts as it does without one, because
these are calls a program makes on a schedule as well as after asking somebody, and none of them
installs anything a rehearsal would have to be read for. key_callable says, entry by entry,
whether an action answers an offer, so a client knows which of its calls can carry one
(ARCH-R164).
An act key asking for any other action is refused, naming its scope, and a read key is
refused every action.
The session
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, with the same status, sentence and code. A client that cached the verdict would be a second opinion about who is admitted. Sessions live only as long as the process that opened them, so a session from an earlier run is refused the same way too. A client holding one learns only that what it carries is no longer admitted, and the remedy is to sign in again. Naming which secret failed would tell somebody guessing which one to keep guessing at.
Wrong answers are counted
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.