Authentication and devices
StableGenerated 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.
GET /v1/auth/devices
Section titled “GET /v1/auth/devices”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.
| Status | Body | Description |
|---|---|---|
200 | DevicesResponse application/json | OK. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/devices/directory
Section titled “POST /v1/auth/devices/directory”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.
| Parameter | In | Type | Description |
|---|---|---|---|
X-Capsule-Identity-Key | header | string | null | The 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
| Status | Body | Description |
|---|---|---|
200 | PublishDirectoryResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
409 | DirectoryConflictProblem application/problem+json | Directory version conflict. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported media type. |
500 | CodedProblem application/problem+json | Internal server error. |
GET /v1/auth/devices/directory/{user_id}
Section titled “GET /v1/auth/devices/directory/{user_id}”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.
| Parameter | In | Type | Description |
|---|---|---|---|
user_id | path | string | Required. The account id. |
| Status | Body | Description |
|---|---|---|
200 | string application/cbor | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
404 | CodedProblem application/problem+json | Not found. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/devices/enroll
Section titled “POST /v1/auth/devices/enroll”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.
| Status | Body | Description |
|---|---|---|
200 | EnrollmentCodeResponse application/json | OK. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem 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.
| Parameter | In | Type | Description |
|---|---|---|---|
channel_id | path | string | Required. The handle a redeemed code returned. |
direction | query | string | Required. to_initiator or to_enrollee. |
| Status | Body | Description |
|---|---|---|
200 | DrainResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
404 | CodedProblem application/problem+json | Channel not found. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem 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.
| Parameter | In | Type | Description |
|---|---|---|---|
channel_id | path | string | Required. The handle a redeemed code returned. |
Request body (required, application/json): RelayRequest
| Status | Body | Description |
|---|---|---|
204 | — | the request succeeded and there is no content to send. |
400 | CodedProblem application/problem+json | Bad Request. |
404 | CodedProblem application/problem+json | Channel not found. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
500 | CodedProblem 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.
| Parameter | In | Type | Description |
|---|---|---|---|
channel_id | path | string | Required. The handle a redeemed code returned. |
| Status | Body | Description |
|---|---|---|
204 | — | the request succeeded and there is no content to send. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
404 | CodedProblem application/problem+json | Channel not found. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/devices/enroll/redeem
Section titled “POST /v1/auth/devices/enroll/redeem”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
| Status | Body | Description |
|---|---|---|
200 | ChannelResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
404 | CodedProblem application/problem+json | Code refused. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
429 | CodedProblem application/problem+json | Too many attempts. |
500 | CodedProblem application/problem+json | Internal server error. |
DELETE /v1/auth/devices/{session_id}
Section titled “DELETE /v1/auth/devices/{session_id}”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.
| Parameter | In | Type | Description |
|---|---|---|---|
session_id | path | string | Required. The session’s identifier. |
| Status | Body | Description |
|---|---|---|
204 | — | the request succeeded and there is no content to send. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
404 | CodedProblem application/problem+json | Not found. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
GET /v1/auth/escrow
Section titled “GET /v1/auth/escrow”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.
| Status | Body | Description |
|---|---|---|
200 | string application/octet-stream | OK. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
404 | CodedProblem application/problem+json | Not found. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
PUT /v1/auth/escrow
Section titled “PUT /v1/auth/escrow”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
| Status | Body | Description |
|---|---|---|
200 | StoreEscrowResponse application/json | OK. |
400 | CodedProblem application/problem+json | Malformed request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported media type. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/login
Section titled “POST /v1/auth/login”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
| Status | Body | Description |
|---|---|---|
200 | TokenResponse application/json | A session was opened; here is its token pair. |
202 | SecondFactorChallenge application/json | The password verified; a second factor is required to finish. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Invalid credentials. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
423 | CodedProblem application/problem+json | Account locked. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/login/verify-totp
Section titled “POST /v1/auth/login/verify-totp”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
| Status | Body | Description |
|---|---|---|
200 | TokenResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Challenge expired. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
429 | CodedProblem application/problem+json | Too many attempts. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/logout
Section titled “POST /v1/auth/logout”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.
| Status | Body | Description |
|---|---|---|
204 | — | the request succeeded and there is no content to send. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/logout/all
Section titled “POST /v1/auth/logout/all”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
| Status | Body | Description |
|---|---|---|
200 | RevokeAllResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Master-key proof required. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/logout/all/challenge
Section titled “POST /v1/auth/logout/all/challenge”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.
| Status | Body | Description |
|---|---|---|
200 | RevokeChallengeResponse application/json | OK. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/password
Section titled “POST /v1/auth/password”Replace the password this account’s sessions are opened with.
Every other session ends
Section titled “Every other session ends”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.
Why the order is verify, write, revoke
Section titled “Why the order is verify, write, revoke”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
| Status | Body | Description |
|---|---|---|
204 | — | the request succeeded and there is no content to send. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
404 | CodedProblem application/problem+json | Not found. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
423 | CodedProblem application/problem+json | Account locked. |
500 | CodedProblem application/problem+json | Internal server error. |
GET /v1/auth/profile
Section titled “GET /v1/auth/profile”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.
| Status | Body | Description |
|---|---|---|
200 | ProfileResponse application/json | OK. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
404 | CodedProblem application/problem+json | Not found. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
PATCH /v1/auth/profile
Section titled “PATCH /v1/auth/profile”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
| Status | Body | Description |
|---|---|---|
200 | ProfileResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
404 | CodedProblem application/problem+json | Not found. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/reauthenticate
Section titled “POST /v1/auth/reauthenticate”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.
Errors
Section titled “Errors”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
| Status | Body | Description |
|---|---|---|
200 | ReauthenticateResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
423 | CodedProblem application/problem+json | Account locked. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/refresh
Section titled “POST /v1/auth/refresh”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
| Status | Body | Description |
|---|---|---|
200 | TokenResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Session expired. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/register
Section titled “POST /v1/auth/register”Create an account, and open its first session.
Why it signs you in
Section titled “Why it signs you in”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.
What it does not do
Section titled “What it does not do”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.
200, where Salvo answered 201
Section titled “200, where Salvo answered 201”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
| Status | Body | Description |
|---|---|---|
200 | TokenResponse application/json | OK. |
400 | CodedProblem application/problem+json | Bad Request. |
409 | CodedProblem application/problem+json | Account already exists. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/totp/disable
Section titled “POST /v1/auth/totp/disable”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
| Status | Body | Description |
|---|---|---|
204 | — | the request succeeded and there is no content to send. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
409 | CodedProblem application/problem+json | Not enrolled. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/totp/enroll
Section titled “POST /v1/auth/totp/enroll”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.
| Status | Body | Description |
|---|---|---|
200 | EnrollmentResponse application/json | OK. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
409 | CodedProblem application/problem+json | Already active. |
413 | — | the request body exceeds the configured limit. |
500 | CodedProblem application/problem+json | Internal server error. |
POST /v1/auth/totp/verify-enrollment
Section titled “POST /v1/auth/totp/verify-enrollment”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
| Status | Body | Description |
|---|---|---|
204 | — | the request succeeded and there is no content to send. |
400 | CodedProblem application/problem+json | Bad Request. |
401 | CodedProblem application/problem+json | Unauthorized. Headers: WWW-Authenticate. |
403 | CodedProblem application/problem+json | Forbidden. |
409 | CodedProblem application/problem+json | Nothing pending. |
413 | — | the request body exceeds the configured limit. |
415 | CodedProblem application/problem+json | Unsupported Media Type. |
422 | CodedProblem application/problem+json | Unprocessable Entity. |
500 | CodedProblem application/problem+json | Internal server error. |
Schemas
Section titled “Schemas”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.
ChangePasswordRequest
Section titled “ChangePasswordRequest”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.
| Field | Type | Description |
|---|---|---|
current_password | string | Required. 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_password | string | Required. The password to replace it with. |
ChannelResponse
Section titled “ChannelResponse”The channel a redeemed code opens.
| Field | Type | Description |
|---|---|---|
channel_id | string | Required. The handle both devices relay through. Possession of it is the capability. |
expires_at | string | Required. When the channel closes on its own, RFC 3339. |
CodeRequest
Section titled “CodeRequest”A six-digit code, and nothing else.
| Field | Type | Description |
|---|---|---|
totp_code | string | Required. The code the authenticator app is showing. |
CodedProblem
Section titled “CodedProblem”An RFC 9457 problem detail.
| Field | Type | Description |
|---|---|---|
type | string | Required. |
title | string | — |
status | integer | Required. |
detail | string | — |
instance | string | — |
code | string | Required. The stable error.* catalog code. The client localizes this; detail stays English. Present on every problem this server renders. |
CohortView
Section titled “CohortView”One cohort this account has been seen under.
| Field | Type | Description |
|---|---|---|
cohort_hash | string | Required. The advisory hash. |
first_seen | string | Required. 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_seen | string | Required. The most recent time, RFC 3339. |
DevicesResponse
Section titled “DevicesResponse”The session ledger.
| Field | Type | Description |
|---|---|---|
sessions | SessionView[] | Required. Every live session, oldest first. |
cohorts | CohortView[] | 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”. |
DirectoryConflictProblem
Section titled “DirectoryConflictProblem”An RFC 9457 problem detail.
| Field | Type | Description |
|---|---|---|
type | string | Required. |
title | string | — |
status | integer | Required. |
detail | string | — |
instance | string | — |
code | string | Required. The stable error.* catalog code. The client localizes this; detail stays English. Present on every problem this server renders. |
submitted | integer | The directory version the request carried. |
stored | integer | The version the server holds. A client re-signs above this one. |
DrainResponse
Section titled “DrainResponse”Everything pending in one mailbox.
| Field | Type | Description |
|---|---|---|
payloads | string[] | Required. The payloads in arrival order, removed by this call. Possibly empty. |
EnrollmentCodeResponse
Section titled “EnrollmentCodeResponse”A freshly issued enrollment code.
| Field | Type | Description |
|---|---|---|
code | string | Required. The full-entropy code the QR payload carries. |
text_fallback | string | Required. 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_at | string | Required. When both spellings stop being redeemable, RFC 3339. |
EnrollmentResponse
Section titled “EnrollmentResponse”A freshly issued, unconfirmed enrollment.
| Field | Type | Description |
|---|---|---|
provisioning_uri | string | Required. 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. |
LoginRequest
Section titled “LoginRequest”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.
| Field | Type | Description |
|---|---|---|
email | string | Required. The account’s email address. |
password | string | Required. The account’s password. Verified by the account directory and never retained, logged, or echoed. |
cohort_hash | string | null | An 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_id | string | null | The 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. |
ProfileResponse
Section titled “ProfileResponse”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.
| Field | Type | Description |
|---|---|---|
user_id | string | Required. The account identifier every manifest and every session names. |
email | string | Required. 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_name | string | null | The 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_at | string | Required. When the account was created, RFC 3339. |
PublishDirectoryResponse
Section titled “PublishDirectoryResponse”The accepted version, echoed so a client knows what is now in force.
| Field | Type | Description |
|---|---|---|
directory_version | integer | Required. The version now stored, which equals the submitted one. |
ReauthenticateRequest
Section titled “ReauthenticateRequest”A password, re-presented on a session that already exists.
| Field | Type | Description |
|---|---|---|
password | string | Required. The account’s password. |
ReauthenticateResponse
Section titled “ReauthenticateResponse”When the re-authenticated session’s freshness window last opened.
| Field | Type | Description |
|---|---|---|
authenticated_at | string | Required. 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. |
RedeemRequest
Section titled “RedeemRequest”The code a device presents.
| Field | Type | Description |
|---|---|---|
code | string | Required. Either spelling of the issued code. |
RefreshRequest
Section titled “RefreshRequest”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.
| Field | Type | Description |
|---|---|---|
refresh_token | string | Required. 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. |
RegisterRequest
Section titled “RegisterRequest”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.
| Field | Type | Description |
|---|---|---|
email | string | Required. The address the account is identified by. |
password | string | Required. 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. |
RelayRequest
Section titled “RelayRequest”One relayed payload.
| Field | Type | Description |
|---|---|---|
direction | string | Required. Which mailbox to append to: to_initiator or to_enrollee. |
payload | string | Required. The opaque payload. The server never inspects it. |
RevokeAllRequest
Section titled “RevokeAllRequest”A master-key proof over an issued challenge.
| Field | Type | Description |
|---|---|---|
challenge | string | Required. The challenge that was issued. |
proof | string | Required. The account identity key’s hybrid signature over revoke_all_signing_bytes, canonical CBOR, base64. |
RevokeAllResponse
Section titled “RevokeAllResponse”What a global sign-out closed.
| Field | Type | Description |
|---|---|---|
revoked | integer | Required. 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. |
RevokeChallengeResponse
Section titled “RevokeChallengeResponse”The challenge a global sign-out is signed over.
| Field | Type | Description |
|---|---|---|
challenge | string | Required. The single-use token. Burned on the first attempt, successful or not. |
expires_at | string | Required. When it stops being redeemable, RFC 3339. |
SecondFactorChallenge
Section titled “SecondFactorChallenge”A half-finished sign-in.
Debug is hand-written: the token is a credential, even though it authenticates nothing on
its own.
| Field | Type | Description |
|---|---|---|
mfa_token | string | Required. The token to present alongside the code. |
expires_by | integer | Required. 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. |
SessionView
Section titled “SessionView”One live session.
| Field | Type | Description |
|---|---|---|
session_id | string | Required. The session’s identifier — what a revoke names. |
created_at | string | Required. 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_at | string | Required. 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_at | string | Required. 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_agent | string | null | The User-Agent the opening ceremony carried, if any. |
ip_address | string | null | The address the opening ceremony came from, if any. |
cohort_hash | string | null | The advisory cohort this session asserted, if any. Grouping only. |
device_id | string | null | The 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. |
current | boolean | Required. 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. |
StoreEscrowResponse
Section titled “StoreEscrowResponse”What storing an escrow did.
| Field | Type | Description |
|---|---|---|
stored_at | string | Required. 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. |
replaced | boolean | Required. 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. |
TokenResponse
Section titled “TokenResponse”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.
| Field | Type | Description |
|---|---|---|
access_token | string | Required. The short-lived credential for ordinary requests. |
refresh_token | string | Required. The long-lived credential that buys new pairs from POST /v1/auth/refresh. |
token_type | string | Required. Always Bearer. |
expires_by | integer | Required. 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. |
UpdateProfileRequest
Section titled “UpdateProfileRequest”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.
| Field | Type | Description |
|---|---|---|
display_name | string | null | The display name to set, clear (null), or leave alone (absent). |
VerifyLoginRequest
Section titled “VerifyLoginRequest”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).
| Field | Type | Description |
|---|---|---|
mfa_token | string | Required. The challenge issued by POST /v1/auth/login. |
totp_code | string | Required. The code the authenticator app is showing. |
cohort_hash | string | null | An advisory device-cohort hash grouping one physical device’s re-enrollments (S-C13). |
device_id | string | null | The directory device the client claims to be (S-N3), as a UUID. |