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.
| Route | What it does |
POST /api/keys | Mint a key. Returned in full once. |
GET /api/keys | The 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.
| Scope | Reaches |
chat | POST /v1/chat/completions and GET /v1/models, which run on a GPU this product owns. |
images | POST /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.
| Route | Body | What it answers |
POST /api/auth/signup | handle, password | access_token, expires_at, code |
POST /api/auth/signin | code | access_token, expires_at |
POST /api/auth/refresh | none, reads the cookie | A rotated pair |
POST /api/auth/logout | none, reads the cookie | Nothing. The session is gone. |
POST /api/auth/recover | handle, password | A fresh code, and no session |
POST /api/auth/guest | none | A session with no credentials |
POST /api/auth/claim | handle, password | code, 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
HttpOnlycookie,drael_refresh, scoped to/api/authand 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
unauthorizedfor anything that is not a live code, and spends the same work on all of them. Recovering answersunauthorizedfor 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/recovertakes 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/codemints 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/claimgives 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
| Code | HTTP | Meaning |
unauthorized | 401 | Missing or invalid credential. |
plan_required | 403 | The API is on the Max plan. |
scope_required | 403 | The key is scoped to chat and the route spends cash. |
pair_taken | 409 | That name and password together already open an account. |
challenge_required | 428 | The request has to carry a solved challenge. |
not_a_guest | 409 | The session already has credentials, so there is nothing to claim. |
key_not_found | 404 | No such key. |
rate_limited | 429 | Too many free sessions from here today. |
unavailable | 503 | A limit could not be checked, so the request was refused. |