Skip to content

Authentication and devices

Stable

Generated from capsule-server/openapi.json, the OpenAPI 3.2 document capsule-server emits and mise run openapi-check-kynos keeps current. To change a description on this page, change the annotation on the handler or model it comes from and regenerate — this file is build output. The auth model, error contract, and conventions common to every endpoint are on the REST API overview.

List the caller’s live sessions and the cohorts they group under.

Scoped by credential with no path parameter, for the same reason the escrow is: the only account entitled to a session ledger is its own, and making that structural beats enforcing it.

Authentication: required — bearer.

StatusBodyDescription
200DevicesResponse
application/json
OK.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Publish the caller’s signed device directory.

The bytes are stored verbatim; the server decodes them to read directory_version and nothing else. The monotonicity comparison is the store’s, not this handler’s — see [crate::directory] for why a read-compare-write here would be a rollback window.

Authentication: required — bearer.

ParameterInTypeDescription
X-Capsule-Identity-Keyheaderstring | nullThe account’s identity public key, standard base64 over the hybrid classical ‖ ml layout. Required: invariant 23’s second clause is undefined without it.

Request body (required, application/cbor): string

StatusBodyDescription
200PublishDirectoryResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
409DirectoryConflictProblem
application/problem+json
Directory version conflict.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported media type.
500CodedProblem
application/problem+json
Internal server error.

Fetch a user’s signed device directory, verbatim.

The response body is the exact bytes the owner signed. Re-encoding them would detach the document from its signature, and the failure would look like the publisher’s bug.

Authentication: required — bearer.

ParameterInTypeDescription
user_idpathstringRequired. The account id.
StatusBodyDescription
200string
application/cbor
OK.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
404CodedProblem
application/problem+json
Not found.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Issue a one-time enrollment code for the caller’s account.

Gated on a recent credential presentation, not merely on a valid session — a stolen token must not be able to enroll a rogue device. See [crate::enrollment] for exactly how much that gate can mean.

Authentication: required — bearer.

StatusBodyDescription
200EnrollmentCodeResponse
application/json
OK.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

GET /v1/auth/devices/enroll/channel/{channel_id}

Section titled “GET /v1/auth/devices/enroll/channel/{channel_id}”

Take everything pending in one of a channel’s mailboxes.

Destructive: a relayed payload is delivered once. Draining one direction leaves the other untouched, so the two devices do not consume each other’s mail.

Authentication: none.

ParameterInTypeDescription
channel_idpathstringRequired. The handle a redeemed code returned.
directionquerystringRequired. to_initiator or to_enrollee.
StatusBodyDescription
200DrainResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
404CodedProblem
application/problem+json
Channel not found.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

POST /v1/auth/devices/enroll/channel/{channel_id}

Section titled “POST /v1/auth/devices/enroll/channel/{channel_id}”

Append a payload to one of a channel’s two mailboxes.

Unauthenticated and gated by the handle alone. The relay is a dumb pipe by design — see [crate::enrollment] — and the safety-code check is what defends the ceremony.

Authentication: none.

ParameterInTypeDescription
channel_idpathstringRequired. The handle a redeemed code returned.

Request body (required, application/json): RelayRequest

StatusBodyDescription
204the request succeeded and there is no content to send.
400CodedProblem
application/problem+json
Bad Request.
404CodedProblem
application/problem+json
Channel not found.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
500CodedProblem
application/problem+json
Internal server error.

DELETE /v1/auth/devices/enroll/channel/{channel_id}

Section titled “DELETE /v1/auth/devices/enroll/channel/{channel_id}”

Close a channel and drop both mailboxes with it.

The initiator’s, and authenticated. A close is the one relay operation that is not idempotent from the other device’s point of view — it ends the ceremony — so leaving it on the handle alone would make an abandoned QR code a denial of service. The account is checked against the channel’s recorded initiator, and a channel belonging to another account answers exactly as an unknown one does.

Authentication: required — bearer.

ParameterInTypeDescription
channel_idpathstringRequired. The handle a redeemed code returned.
StatusBodyDescription
204the request succeeded and there is no content to send.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
404CodedProblem
application/problem+json
Channel not found.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Redeem a code for a relay channel.

Unauthenticated, necessarily. Device B has no account, no session and no key material — it is a phone that has just scanned a QR code. The code is the only thing it holds, so the code is the credential.

Authentication: none.

Request body (required, application/json): RedeemRequest

StatusBodyDescription
200ChannelResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
404CodedProblem
application/problem+json
Code refused.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
429CodedProblem
application/problem+json
Too many attempts.
500CodedProblem
application/problem+json
Internal server error.

Revoke one of the caller’s sessions.

Any live token may do this, including for the session making the request — signing this device out is a legitimate thing to ask for, and refusing it would only push a client into calling logout and hoping the two behave the same.

Only the caller’s own sessions. The ownership check is against the record the store returns rather than against a separate lookup, so there is no window between checking and closing, and a session id belonging to another account answers exactly as an unknown one does.

Authentication: required — bearer.

ParameterInTypeDescription
session_idpathstringRequired. The session’s identifier.
StatusBodyDescription
204the request succeeded and there is no content to send.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
404CodedProblem
application/problem+json
Not found.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Fetch the caller’s wrapped master key, verbatim.

The bytes are what a client runs its KDF against, so they come back exactly as they went in. The server never derives, unwraps or re-encodes: a re-encoded wrap is a wrap that no longer opens, and the failure would look like a lost master key.

Authentication: required — bearer.

StatusBodyDescription
200string
application/octet-stream
OK.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
404CodedProblem
application/problem+json
Not found.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Store the caller’s wrapped master key, replacing whatever they had.

PUT, because there is exactly one escrow per account and this is its address. Storing over an existing escrow is the guided re-wrap, and it deletes the old blob in the same operation — the lost recovery secret must stop working, which is the entire point of rotating.

Authentication: required — bearer.

Request body (required, application/octet-stream): string

StatusBodyDescription
200StoreEscrowResponse
application/json
OK.
400CodedProblem
application/problem+json
Malformed request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported media type.
500CodedProblem
application/problem+json
Internal server error.

Exchange an email and password for a session — or for a second-factor challenge.

The two advisory identifiers a client may send — cohort_hash and device_id — are recorded on the session for the devices listing and gate nothing; an unusable one is dropped rather than refused.

Two statuses, because there are two outcomes

Section titled “Two statuses, because there are two outcomes”

An account with a confirmed second factor (S-C55) gets 202 and a short-lived challenge: the credentials were accepted and the request is not complete. No session is opened, no cohort is recorded and no refresh token is minted, because none of those may exist for an authentication that has not finished — and the client’s advisory identifiers ride the completing request instead, since that is what creates the session they describe.

The retired surface got this wrong in the most consequential way available: it had all four TOTP operations and its login never issued a challenge, so a confirmed second factor gated nothing at all.

Authentication: none.

Request body (required, application/json): LoginRequest

StatusBodyDescription
200TokenResponse
application/json
A session was opened; here is its token pair.
202SecondFactorChallenge
application/json
The password verified; a second factor is required to finish.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Invalid credentials.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
423CodedProblem
application/problem+json
Account locked.
500CodedProblem
application/problem+json
Internal server error.

Complete a sign-in with a code.

This is where the session is opened — not POST /v1/auth/login, which for an account with a second factor opens nothing. The advisory cohort_hash and device_id ride this request for the same reason: the session they describe is created here.

Authentication: none.

Request body (required, application/json): VerifyLoginRequest

StatusBodyDescription
200TokenResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Challenge expired.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
429CodedProblem
application/problem+json
Too many attempts.
500CodedProblem
application/problem+json
Internal server error.

End the session the presented access token was issued against.

Idempotent: a session that is already closed, expired, or was never opened produces the same answer, because “there is no longer a session” is what the caller asked for.

Authentication: required — bearer.

StatusBodyDescription
204the request succeeded and there is no content to send.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Close every session for the account the proof establishes.

No Auth, deliberately. design/authentication.md gates this on proof of master-key possession instead of a session token, and the reason is the damage scenario: an attacker holding a stolen token could otherwise invoke “log out of all devices” and lock the legitimate user out of every device they own. Requiring the identity key means a stolen token can revoke only itself. The account is established by the burned challenge, so there is no account field for a caller to aim at either.

The caller’s own session goes with the rest. That is the ceremony, not an oversight.

Authentication: none.

Request body (required, application/json): RevokeAllRequest

StatusBodyDescription
200RevokeAllResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Master-key proof required.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
500CodedProblem
application/problem+json
Internal server error.

Issue a single-use challenge for a global sign-out.

Authenticated by a session token, unlike the revoke itself. That is not a contradiction of the ceremony’s asymmetry: a challenge is worthless without the identity key, so handing one to a stolen token costs nothing — while issuing them unauthenticated would make this an oracle for whether an account exists. The account comes from the credential and never from a request field, so a caller cannot ask for somebody else’s challenge.

Authentication: required — bearer.

StatusBodyDescription
200RevokeChallengeResponse
application/json
OK.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Replace the password this account’s sessions are opened with.

A password change whose point is that a credential has leaked would be worthless if the sessions opened with the leaked credential kept working. So the change closes every session of the account — and then re-opens the caller’s own, under its own session id, so the person doing the rotation is not signed out of the device they are doing it on while everybody else is.

Re-opening the same id rather than minting a new one is what lets this answer 204 with no body: the caller’s existing token pair keeps working, because the session it names is still there. Returning a fresh pair was considered and rejected — it would make this a second token mint with none of POST /v1/auth/refresh’s rotation discipline, for no gain.

The re-opened record’s authenticated_at is now, and that is not bookkeeping: presenting the current password is a credential presentation, so a freshness gate (S-C7) measuring from anything earlier would be measuring from the wrong moment.

Verification first, because a wrong current password must change nothing. The write next, because a revocation that ran before it would sign everybody out and then fail. The revocation last, and its failure is logged and not returned: the password is already changed, so answering 500 would tell the caller the rotation did not happen when it did, and they would try again with a current password that is no longer current.

Authentication: required — bearer.

Request body (required, application/json): ChangePasswordRequest

StatusBodyDescription
204the request succeeded and there is no content to send.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
404CodedProblem
application/problem+json
Not found.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
423CodedProblem
application/problem+json
Account locked.
500CodedProblem
application/problem+json
Internal server error.

The caller’s own profile.

There is no {user_id} segment, for the reason the escrow surface has none: the account comes from the credential, so reading somebody else’s profile is not a forbidden request but an unrepresentable one. A directory of other people’s public facts already exists and is a different surface — GET /v1/auth/devices/directory/{user_id} — which publishes keys and nothing else.

Authentication: required — bearer.

StatusBodyDescription
200ProfileResponse
application/json
OK.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
404CodedProblem
application/problem+json
Not found.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Edit the caller’s own profile.

PATCH, because the body is a partial: what it does not mention, it does not change. An empty body is a valid request and answers 200 with the profile unchanged — a client that sent nothing asked for nothing, and refusing it would make “save” fail on a form nobody edited.

Authentication: required — bearer.

Request body (required, application/json): UpdateProfileRequest

StatusBodyDescription
200ProfileResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
404CodedProblem
application/problem+json
Not found.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
500CodedProblem
application/problem+json
Internal server error.

Prove a credential again on the current session, without opening a new one.

The only way to satisfy the freshness gate S-C7 enforces, and it exists because without it the gate is unusable: authenticated_at is deliberately not reset by a refresh, so a user signed in an hour ago would otherwise have to sign out entirely to add a device — and the session they abandoned would linger in their own devices listing.

It does not mint tokens and does not rotate the session. The caller keeps the credential they already hold; what changes is one timestamp on the record behind it.

The same refusals as a sign-in, for the same reasons: a wrong password is 401 error.auth.invalid_credentials, a locked account is 403, and the account directory failing is 500. A caller that guessed a password here learns exactly what it would learn at /v1/auth/login, and no more.

Authentication: required — bearer.

Request body (required, application/json): ReauthenticateRequest

StatusBodyDescription
200ReauthenticateResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
423CodedProblem
application/problem+json
Account locked.
500CodedProblem
application/problem+json
Internal server error.

Exchange a refresh token for a new pair, rotating the session.

The presented session is closed and a new one opened in its place, so a refresh token is good exactly once. The session’s advisory provenance — its cohort hash and device id — is carried across the rotation, or the devices listing would lose track of a device every time its tokens turned over.

Authentication: none.

Request body (required, application/json): RefreshRequest

StatusBodyDescription
200TokenResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Session expired.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
500CodedProblem
application/problem+json
Internal server error.

Create an account, and open its first session.

The alternative is 201 with no body and a client that immediately posts the same credentials to /v1/auth/login, which is one more round trip for one more chance to fail and nothing gained. It also makes the CLI’s capsule register mean what a person expects: after it, you are registered and signed in.

It does not publish a device directory, and the account is therefore unable to upload until its client publishes one. That is not an omission here: S-C20 removed the account-creation fallback for invariant 7’s floor precisely so that “was this device in the directory” has an honest answer for a brand-new account, and the honest answer is no. A client’s first action after registering is POST /v1/auth/devices/directory.

It is not rate-limited, and that is a real gap rather than an oversight — see [crate::auth::registry] for the fact the limiter is waiting on. This is the one unauthenticated write on the surface.

Kynos’s Created requires a Location — a 201 that does not say where tells a client something exists and not how to reach it, which is a defect the type refuses to let you commit. This server exposes no URL for an account: GET /v1/auth/profile is among the operations S-C53 records as unported. Inventing a location to satisfy a status would be inventing a surface, so the status moved instead. What a caller actually needs — the token pair — is in the body either way.

Authentication: none.

Request body (required, application/json): RegisterRequest

StatusBodyDescription
200TokenResponse
application/json
OK.
400CodedProblem
application/problem+json
Bad Request.
409CodedProblem
application/problem+json
Account already exists.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
500CodedProblem
application/problem+json
Internal server error.

Remove the second factor, on presentation of a live code.

A session is not enough. The whole point of the factor is that a stolen access token is insufficient, and a disable that took only a token would let the token turn off the control that makes it insufficient.

Authentication: required — bearer.

Request body (required, application/json): CodeRequest

StatusBodyDescription
204the request succeeded and there is no content to send.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
409CodedProblem
application/problem+json
Not enrolled.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
500CodedProblem
application/problem+json
Internal server error.

Start enrolling an authenticator.

Answers the otpauth:// URI the app scans. Nothing is gated yet: until a code confirms the secret, sign-in is unchanged — which is what stops a mis-scanned QR code from locking somebody out of their own account.

Authentication: required — bearer.

StatusBodyDescription
200EnrollmentResponse
application/json
OK.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
409CodedProblem
application/problem+json
Already active.
413the request body exceeds the configured limit.
500CodedProblem
application/problem+json
Internal server error.

Confirm an enrollment with a live code.

The confirming code is spent: its step goes straight into the replay ledger, so it cannot also complete a sign-in a moment later. That is the one place the ledger’s first entry comes from, and skipping it would leave the newest code in the account’s history unused.

Authentication: required — bearer.

Request body (required, application/json): CodeRequest

StatusBodyDescription
204the request succeeded and there is no content to send.
400CodedProblem
application/problem+json
Bad Request.
401CodedProblem
application/problem+json
Unauthorized. Headers: WWW-Authenticate.
403CodedProblem
application/problem+json
Forbidden.
409CodedProblem
application/problem+json
Nothing pending.
413the request body exceeds the configured limit.
415CodedProblem
application/problem+json
Unsupported Media Type.
422CodedProblem
application/problem+json
Unprocessable Entity.
500CodedProblem
application/problem+json
Internal server error.

The models these endpoints carry. A field whose type names another model links to it when this page documents that model, which it does when some path from an operation reaches it within 4 references. A model only ever reached deeper than that is named without being expanded.

The two passwords a rotation needs.

Debug is hand-written for the reason routes::auth’s bodies are: a derived one would print both credentials into any log line that formatted the request.

FieldTypeDescription
current_passwordstringRequired. The password currently in use, which authorizes the change. Verified through the same directory method a sign-in uses, so a locked account is locked here too.
new_passwordstringRequired. The password to replace it with.

The channel a redeemed code opens.

FieldTypeDescription
channel_idstringRequired. The handle both devices relay through. Possession of it is the capability.
expires_atstringRequired. When the channel closes on its own, RFC 3339.

A six-digit code, and nothing else.

FieldTypeDescription
totp_codestringRequired. The code the authenticator app is showing.

An RFC 9457 problem detail.

FieldTypeDescription
typestringRequired.
titlestring
statusintegerRequired.
detailstring
instancestring
codestringRequired. The stable error.* catalog code. The client localizes this; detail stays English. Present on every problem this server renders.

One cohort this account has been seen under.

FieldTypeDescription
cohort_hashstringRequired. The advisory hash.
first_seenstringRequired. The first time this account was seen under it, RFC 3339. What lets a client say “a device you’ve used before” about a session whose own device_id is new — which is the entire reason the map is durable.
last_seenstringRequired. The most recent time, RFC 3339.

The session ledger.

FieldTypeDescription
sessionsSessionView[]Required. Every live session, oldest first.
cohortsCohortView[]Required. Every cohort this account has ever been seen under, oldest first sighting first. Served beside the sessions rather than folded into them, because a cohort outlives the sessions that carried it: a reinstall’s new session groups with a cohort whose other sessions expired months ago, and a client that only had per-session cohorts could not say “you have used this device before”.

An RFC 9457 problem detail.

FieldTypeDescription
typestringRequired.
titlestring
statusintegerRequired.
detailstring
instancestring
codestringRequired. The stable error.* catalog code. The client localizes this; detail stays English. Present on every problem this server renders.
submittedintegerThe directory version the request carried.
storedintegerThe version the server holds. A client re-signs above this one.

Everything pending in one mailbox.

FieldTypeDescription
payloadsstring[]Required. The payloads in arrival order, removed by this call. Possibly empty.

A freshly issued enrollment code.

FieldTypeDescription
codestringRequired. The full-entropy code the QR payload carries.
text_fallbackstringRequired. The shorter transcribable numeric fallback. Deliberately weaker than the QR payload and safe because it never stands alone: redemption is single-use and expires, and channel integrity rests on the safety-code check rather than on this value.
expires_atstringRequired. When both spellings stop being redeemable, RFC 3339.

A freshly issued, unconfirmed enrollment.

FieldTypeDescription
provisioning_uristringRequired. The otpauth:// URI an authenticator app scans. It carries the shared secret, so it is a credential: served once, over the authenticated channel, and never fetchable again. Losing it before confirming means enrolling again, which is why a pending enrollment is replaceable without ceremony.

Credentials, plus the two advisory identifiers a client may volunteer.

Debug is hand-written. A derived one would print the password into any log line, panic message or tracing field that formatted the request — which is the single worst thing this file could do, and is one #[derive(Debug)] away at all times. No #[schema(min_length = ...)] on either credential, deliberately. Kynos 0.1.0 publishes a string constraint into the document but does not enforce it on the request path — an empty password reaches the handler — so declaring one would put a promise in the contract that the server does not keep, which is the exact class of drift this rebuild exists to remove. Length is a body-size concern and belongs to a limits middleware; it is recorded as owed rather than asserted here.

FieldTypeDescription
emailstringRequired. The account’s email address.
passwordstringRequired. The account’s password. Verified by the account directory and never retained, logged, or echoed.
cohort_hashstring | nullAn advisory device-cohort hash grouping one physical device’s re-enrollments (slice S-C13). Legibility metadata only: no authorization path reads it, and an unusable value is dropped rather than refused — a sign-in must not fail over a field that gates nothing.
device_idstring | nullThe directory device the client claims to be (slice S-N3), as a UUID. Client-asserted and unverified. Dropped, not refused, when it is not a usable UUID, for the same reason as cohort_hash.

An account’s profile as it is served.

Deserialize is derived so the suite reads it back through the same type the server wrote — a test pulling display_name out of a serde_json::Value would still pass if the field were renamed on the way out.

FieldTypeDescription
user_idstringRequired. The account identifier every manifest and every session names.
emailstringRequired. The address this account signs in with. Read-only on this surface. Changing it needs proof that the caller controls the new address, and this server has no way to obtain one; see [crate::auth::profile].
display_namestring | nullThe name the account chose to be shown as, if it chose one. Absent rather than null when unset, so a client’s “has a name” test is a key test.
created_atstringRequired. When the account was created, RFC 3339.

The accepted version, echoed so a client knows what is now in force.

FieldTypeDescription
directory_versionintegerRequired. The version now stored, which equals the submitted one.

A password, re-presented on a session that already exists.

FieldTypeDescription
passwordstringRequired. The account’s password.

When the re-authenticated session’s freshness window last opened.

FieldTypeDescription
authenticated_atstringRequired. The moment the credential was accepted, RFC 3339. Returned so a client can decide locally whether a gated operation will be admitted, rather than discovering it from a 403 in the middle of a ceremony.

The code a device presents.

FieldTypeDescription
codestringRequired. Either spelling of the issued code.

The refresh token being exchanged for a new pair.

Debug is hand-written, for the same reason as [LoginRequest]: this field is a live credential.

FieldTypeDescription
refresh_tokenstringRequired. The refresh token issued by a previous login or refresh. Unconstrained in the schema for the reason [LoginRequest] records: an empty one is a token that does not verify, which is a 401 the handler already answers correctly.

The POST /v1/auth/register body.

Deliberately the smallest thing that can create an account: an address and a password. No display name, no profile, no invitation code — every one of those would be a field the server stores about a person, and this server’s whole posture is that it stores as little as it can.

FieldTypeDescription
emailstringRequired. The address the account is identified by.
passwordstringRequired. The password that will authenticate this account’s sessions. Never the master key’s input: the master key does not derive from it and is never visible to the credential verifier. Hashed by the registry adapter and never retained, logged, or echoed.

One relayed payload.

FieldTypeDescription
directionstringRequired. Which mailbox to append to: to_initiator or to_enrollee.
payloadstringRequired. The opaque payload. The server never inspects it.

A master-key proof over an issued challenge.

FieldTypeDescription
challengestringRequired. The challenge that was issued.
proofstringRequired. The account identity key’s hybrid signature over revoke_all_signing_bytes, canonical CBOR, base64.

What a global sign-out closed.

FieldTypeDescription
revokedintegerRequired. How many sessions were closed — the caller’s own among them. Counted from the records the store actually removed, never from a separately maintained index. The Salvo implementation read a per-user set that revoke_session did not clean up, so this number inflated by one for every prior refresh; S-C29 made the record and its listing entry one fact, so there is nothing left to disagree.

The challenge a global sign-out is signed over.

FieldTypeDescription
challengestringRequired. The single-use token. Burned on the first attempt, successful or not.
expires_atstringRequired. When it stops being redeemable, RFC 3339.

A half-finished sign-in.

Debug is hand-written: the token is a credential, even though it authenticates nothing on its own.

FieldTypeDescription
mfa_tokenstringRequired. The token to present alongside the code.
expires_byintegerRequired. The absolute Unix-seconds instant the challenge stops being honoured. Absolute rather than a duration, matching TokenResponse::expires_by, so a client has one convention rather than two.

One live session.

FieldTypeDescription
session_idstringRequired. The session’s identifier — what a revoke names.
created_atstringRequired. When this session record was minted, RFC 3339. A refresh rotates the session, so after one this is the rotation time and not the sign-in. authenticated_at is the field that answers “when did you last sign in”.
authenticated_atstringRequired. When the user last proved a credential on this session’s lineage, RFC 3339. Carried forward across refreshes, so it is the one timestamp here that means what a user reading a devices list expects “signed in” to mean. It is also what the cross-device add’s freshness gate reads (S-C7), so a client can show why an add is about to ask for a password again.
last_active_atstringRequired. When it was last seen, RFC 3339. Equal to created_at until S-C48 puts the session ledger on the request path. A client must not label this “last used” before then.
user_agentstring | nullThe User-Agent the opening ceremony carried, if any.
ip_addressstring | nullThe address the opening ceremony came from, if any.
cohort_hashstring | nullThe advisory cohort this session asserted, if any. Grouping only.
device_idstring | nullThe directory device the client claimed to be (S-N3), if any. A different identifier space from cohort_hash: this names one directory device, the cohort groups re-enrollments of one physical device. Both are client-asserted; neither gates anything.
currentbooleanRequired. Whether this is the session making the request. So a client can label “this device” without comparing tokens it should not be handling, and so revoking the current session is a deliberate act rather than an accident.

What storing an escrow did.

FieldTypeDescription
stored_atstringRequired. When the server accepted it, RFC 3339. Echoed so a client can tell whether a cached copy is current — the stale-cache rule, which exists because a rotation from another device would otherwise manufacture false verification failures on this one.
replacedbooleanRequired. Whether this displaced an earlier escrow. A rotation and a first escrow are different events for a client: one completes account setup, and the other means the previous recovery secret has stopped working.

A freshly issued token pair.

The field names and expires_by’s meaning are a live client contract — capsule-sdk’s TokenResponseBody reads exactly these — so they are preserved verbatim from the Salvo surface. Debug is hand-written; both tokens are bearer credentials.

Deserialize is derived so the suite reads the pair back through the same type the server wrote — a test that pulled access_token out of a serde_json::Value would still pass if the field were renamed on the way out.

FieldTypeDescription
access_tokenstringRequired. The short-lived credential for ordinary requests.
refresh_tokenstringRequired. The long-lived credential that buys new pairs from POST /v1/auth/refresh.
token_typestringRequired. Always Bearer.
expires_byintegerRequired. The absolute Unix-seconds instant access_token stops being honoured. Absolute rather than a duration, which is what the field has always carried despite its name; the SDK depends on it.

A partial edit of the caller’s profile.

display_name is a doubly optional field on the wire, and the two levels mean different things: an absent key leaves the name alone, and an explicit null clears it. That is what #[serde(default, deserialize_with = …)] over an Option<Option<String>> buys, and it is the whole reason this body is not deny_unknown_fields-plus-a-flat-option: a flat one cannot tell “I did not mention the name” from “remove the name”, so every partial update would wipe a field the caller never sent.

FieldTypeDescription
display_namestring | nullThe display name to set, clear (null), or leave alone (absent).

Completing a sign-in with a second factor.

It carries the same two advisory identifiers LoginRequest does, because this is the request that opens the session: without them a TOTP sign-in would land in the devices view as an unknown, ungrouped device (S-N3).

FieldTypeDescription
mfa_tokenstringRequired. The challenge issued by POST /v1/auth/login.
totp_codestringRequired. The code the authenticator app is showing.
cohort_hashstring | nullAn advisory device-cohort hash grouping one physical device’s re-enrollments (S-C13).
device_idstring | nullThe directory device the client claims to be (S-N3), as a UUID.