Authentication

One header carries every credential this server takes: Authorization: Bearer. What follows it is either an API key, which starts with dk-, or the access token a signed-in session holds.

There is no other scheme. A key is recognised by its prefix and looked up as a key; anything else is looked up as a session token.

API keys

A key is minted from the developer console, or by POST /api/keys on a signed-in session with {"name": "...", "scopes": ["chat"]}. Both fields are optional: an unnamed key is called default, and an omitted scope list means every scope.

{ "key": "dk-...", "id": "...", "name": "editor", "preview": "dk-AbC1dE", "scopes": ["chat"] }

key is shown exactly once. Only a hash of it is stored, so a key that is lost is replaced rather than recovered. preview is the first nine characters and is all that is ever shown again: enough to tell two keys apart in a list, never enough to use one.

RouteWhat it does
POST /api/keysMint a key. Returned in full once.
GET /api/keysThe keys on this account: id, name, preview, scopes, created_at, last_used_at.
DELETE /api/keys/{id}Revoke a key. It stops working immediately.

last_used_at is stamped by the same statement that authenticates the key, so it is the truth about a key you are deciding whether to revoke.

The API surface is on the Max plan. That is checked when a key is created and again on every call it makes, because a subscription can end after a key is minted; the answer is plan_required rather than unauthorized, so a working key on a lapsed plan says what it is.

Scopes

There are two, and the split is the cash boundary rather than an access-control model invented for the sake of one.

ScopeReaches
chatPOST /v1/chat/completions and GET /v1/models, which run on a GPU this product owns.
imagesPOST /v1/images/generations, which is cash at a provider, per call.

The check is on the route that spends money: POST /v1/images/generations refuses a key without images, and no other route asks. Two scopes and no more, because a permission model with a row per route is a thing to maintain and to get wrong, and every distinction past this one is free.

A key lives in an editor's configuration, on a laptop, in a dotfile somebody commits by accident. An editor never generates an image, so a key scoped to chat costs capacity we already own if it leaks, and nothing else. Keys created before scopes existed reach everything, because silently breaking a key already pasted into an editor is worse than the leak this defends against.

Sessions

The product's own surfaces authenticate with a session rather than a key: the same Authorization: Bearer header, carrying an access token.

RouteBodyWhat it answers
POST /api/auth/signuphandle, passwordaccess_token, expires_at, code
POST /api/auth/signincodeaccess_token, expires_at
POST /api/auth/refreshnone, reads the cookieA rotated pair
POST /api/auth/logoutnone, reads the cookieNothing. The session is gone.
POST /api/auth/recoverhandle, passwordA fresh code, and no session
POST /api/auth/guestnoneA session with no credentials
POST /api/auth/claimhandle, passwordcode, on the guest session that is already open

Signing up and claiming are charged a toll every time, and signing in and recovering once an address has missed enough attempts to look like it is guessing. The refusal is 428 with a challenge beside the code: solve it and send toll in the body of the same request. See errors.

  • An access token lasts 30 minutes. The refresh half is an HttpOnly cookie, drael_refresh, scoped to /api/auth and good for 30 days.
  • Refreshing rotates both halves, so a stolen refresh token that has already been used fails. Refresh once at a time: two concurrent refreshes sign the account out.
  • A handle is 3 to 32 characters of letters, digits, dot, dash or underscore. It is not unique: two accounts may be called the same thing, and nothing here answers whether a name is taken, because that question is an existence oracle (ADR 0022). A password is at least 10 characters and there are no composition rules: length is the only property that measures anything.
  • What has to be unique is the pair. A name and a password that together already open an account answer pair_taken, and the second person to choose both is asked for a different password.
  • Signing in answers unauthorized for anything that is not a live code, and spends the same work on all of them. Recovering answers unauthorized for a wrong password and for a name nobody holds alike, byte for byte, so neither endpoint says which accounts exist.

The code, and the way back to it

One string opens an account and there is nothing else to type (ADR 0022). The name and the password do not sign anybody in: they buy a new code (ADR 0023).

DRL-XXXX-XXXX-XXXX-XXXX
  • Shown once, at signup, at a claim, or at a recovery, and stored only as a salted hash. There is no email address on file, so there is no address to resend it to.
  • The alphabet leaves out the glyphs a person confuses when copying one off a screen, and the code is grouped for the same reason. Typing it back in any reasonable shape verifies: case, spaces and dashes are normalised away.
  • POST /api/auth/recover takes the name and the password and answers with a new code. It cannot answer with the old one: that is stored as a salted hash and nothing can read it back. The old code stops working in the same statement.
  • It answers a code and never a session, deliberately. Signing somebody in on a name and a password would make the account worth the password's entropy rather than the code's, and would make a compromise silent. Minting instead retires the code the real holder has, so they find out.
  • POST /api/account/code mints a new code while signed in. The old one stops working the moment it is called.

The pair is the backup for the code. Lose both and the account is gone, and there is no address to appeal to.

Guest sessions

POST /api/auth/guest opens a session with no credentials, so a visitor can ask a question without signing up. It provisions a real account on a real plan: there is no demo path, and therefore no second implementation to drift.

The client calls it as the first message is sent, never on a page load, so a crawler reading the site creates nothing.

  • Three free sessions per address per 24 hours, counted from the first one rather than from midnight, plus a ceiling on how many the product hands out in a day. Past either, the answer is rate_limited.
  • A limiter that cannot answer refuses with unavailable. Failing open here hands the GPU to whoever notices the counter is down.
  • POST /api/auth/claim gives that session a handle and a password. The account and its history do not move: what the visitor did as a guest stays theirs, and the recovery code is shown at that moment.
  • Image generation is the one thing a guest cannot do, and it answers account_required.
  • A guest account nobody claims is deleted after 30 days.

Errors

CodeHTTPMeaning
unauthorized401Missing or invalid credential.
plan_required403The API is on the Max plan.
scope_required403The key is scoped to chat and the route spends cash.
pair_taken409That name and password together already open an account.
challenge_required428The request has to carry a solved challenge.
not_a_guest409The session already has credentials, so there is nothing to claim.
key_not_found404No such key.
rate_limited429Too many free sessions from here today.
unavailable503A limit could not be checked, so the request was refused.