# API keys — implementing the client side

Written 2026-08-08, for the mobile developer. Server side is live and verified; nothing below is
planned-but-missing unless it says so.

**The mobile apps are not built here.** This document is the boundary: what the server now accepts, what
changes in `monorepo-mobile`, and what does not.

> **Update, later on 2026-08-08 — the client side now exists, in `monorepo-mobile`.** `@officer/core`
> gained `services/api-keys.ts` and `useAuth` learned that a key is a session; **OffChat (`apps/chat`) is
> the proof of concept** and is the only app wired up. The other eighteen are untouched and behave
> exactly as before.
>
> **None of it has been compiled or run** — it was written on the Linux box, which has no `node_modules`
> for that repo and no Mac. Read §"What the client actually does now" for what shipped, what it changed
> about the advice below, and the two things this document got wrong about the client.

---

## The short version

**An API key goes exactly where the JWT goes.** Same `Authorization: Bearer …` header, same `?token=`
query fallback for media, same `?token=` on the WebSocket. Every door was taught the new format at once,
so there is no endpoint where a key works and another where it doesn't.

That is the whole point of the design, and it means the client change is small:

- `packages/core/src/services/api.ts` needs **no change at all** — it already sends whatever
  `getToken()` returns as a bearer token.
- `packages/core/src/state/useAuth.ts` gains a second way to obtain that value.
- Multi-server storage already exists (`tokenKeyFor(activeServerId())` in `services/servers.ts`), so a
  key per server slots into the same SecureStore entry the JWT uses today.

The problem being solved is **multiple logins across the apps**. Today every app signs in with the
password and gets its own 30-day JWT, and there is no way to cut one device off without a password
change that kills all of them. A key is revocable on its own, from the web UI, in one click.

---

## What a key looks like

```
ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0
```

`ofk_` prefix, then 32 CSPRNG bytes base64url-encoded. **Treat it as opaque.** Do not parse it, do not
validate its length, do not assume 47 characters — the only guarantee is the `ofk_` prefix and that it is
URL-safe and header-safe.

- **It does not expire** unless one was asked for at creation. Assume no expiry.
- **It is shown exactly once**, by the response that creates it. The server stores a SHA-256; there is no
  endpoint that reads a key back and there never will be. If it is lost, revoke and mint another.
- **It carries the account's full authority** — the same as the password, no more. A member's key is
  still a member.

---

## Two ways for the app to get one

### (a) Mint it in-app — recommended

Sign in with the password once, immediately trade the JWT for a key, store the key, throw the JWT away.
No copy-paste, no typing 47 characters on a phone keyboard.

```
POST /api/auth/signin          {email, password}     → {token, user}
POST /api/api-keys             {name: "iPhone 15"}   → {entry, key}   (Authorization: Bearer <token>)
    store `key`, discard `token`
```

Name it after the device, not the app — the owner reads that string in the web UI when deciding what to
revoke, and "iPhone 15" is a decision they can make while "music" is not.

If you want one key per app rather than per device, name it `"<device> — <app>"`. Either is fine; be
consistent so the list stays readable.

### (b) Paste a key the owner made in the web UI

**Settings → Integrations → Personal → API keys.** Useful for a device that cannot show a sign-in form,
and as the recovery path when (a) fails. A paste field that accepts the key and skips signin entirely.

Validate only that it is non-empty and starts with `ofk_`, then make a real request and let the server
answer.

---

## Using it

Identical to the JWT in all three places:

| Where                                          | How                               |
| ---------------------------------------------- | --------------------------------- |
| Normal requests                                | `Authorization: Bearer ofk_…`     |
| Media URLs (`<Image>`, `<Video>`, file `/raw`) | `?token=ofk_…` — URL-encode it    |
| WebSockets                                     | `?token=ofk_…` on the upgrade URL |

The `?token=` fallback is accepted on **every** protected `/api` route, not just media. That is
pre-existing behaviour and not something to rely on: it puts the credential in URLs, which reach proxy
logs. Use the header wherever a header is possible.

---

## What changes in the client

**1. `useAuth`: a key is a session.** The current `signin` throws when the response has no token
(`'Sign-in did not return a token'`). A key-based login never calls `/api/auth/signin` at all — it calls
`GET /api/auth/me` with the key to confirm it works and to learn who it belongs to, then persists it and
sets the signed-in state.

**2. Do not call `POST /api/auth/signout` when signed in with a key.** It is harmless — the server skips
the blacklist step for a key and returns `{ok: true}` — but it does nothing useful either. "Sign out"
with a key means: clear it locally. If the user wants it dead server-side, that is **revoke**, and it
belongs in the app's settings screen, not on the sign-out button. Wording matters here: signing out of
one phone should not silently disable a key another app is using.

**3. Store it exactly where the token goes.** `tokenKeyFor(activeServerId())` in SecureStore. Nothing
else needs to know which kind of credential it holds — that is what makes this change small.

**4. Optionally: let the app revoke its own key.** `DELETE /api/api-keys/:id` works when authenticated
with that same key. Keep `entry.id` from the create response if you want a "disconnect this device"
button.

---

## Error semantics — the part worth getting right

**Error bodies are plain text, not JSON.** `Unauthorized`, `Forbidden`, `Not Found`, `Invalid request
body`. There is no `{error}` or `{message}` envelope anywhere. `packages/core/src/services/api.ts`
already handles this correctly (it tries JSON and falls back to raw text) — do not "fix" it.

The one exception: **429** returns `{"retryAfter": <seconds>}` as JSON, with **no `Retry-After`
header**. Rate limiting applies to `/api/auth/*` only, so a key-based client that never calls signin
will not meet it.

**401 and 403 mean different things and must be handled differently.**

| Status  | Meaning                                                              | What the app should do                                                                     |
| ------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| **401** | The credential is dead — revoked, expired, or never valid.           | Clear it, send the user to the login screen.                                               |
| **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. |

Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a
logout loop they cannot escape, because signing in again produces a credential with the same 403.

A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached.

---

## What a key can and cannot reach

Authorization is unchanged by how you authenticated. A key resolves to a user, and that user's role
decides everything after.

- **The owner** (user 1) reaches everything.
- **Any other account** reaches only what its role has been granted, and **can never** reach the
  `execution` capabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
  user in the owner's home; they are refused structurally, not by policy.

Verified: a member's key returns the same status as that member's JWT on every route tried, 403s
included. If you see a key behave differently from a password login for the same account, that is a bug —
report it, don't work around it.

For sockets specifically: `cliamp` and `cliamp-audio` (music playback) are grantable. `chat`,
`terminal`, `task-runner`, `pipeline` and `desktop` are owner-only. `useChatSocket` therefore works for
the owner and will always 403 for a member — that is not new, and not caused by keys.

---

## Endpoint reference

All three require an authenticated caller and act only on that caller's own keys. Nothing accepts a user
id; there is no request shape that reaches another account's keys.

### `POST /api/api-keys`

```jsonc
// request
{ "name": "iPhone 15", "expiresInDays": 90 }   // expiresInDays optional; omit for no expiry

// 200
{
  "entry": {
    "id": 1, "userId": 1, "name": "iPhone 15",
    "prefix": "ofk_eRgp_F",        // display only — first 10 chars, never enough to use
    "lastUsedAt": null, "expiresAt": null, "revokedAt": null,
    "createdAt": "2026-08-08T10:08:58.687Z"
  },
  "key": "ofk_eRgp_Fgqr5hKAyydvxfvu-HL12s-HYwSlTS7wYg-Ua0"   // the only time this appears
}
```

`400` on a missing/blank name, a name over 100 characters, or a non-positive `expiresInDays`.

### `GET /api/api-keys`

```jsonc
{
  "keys": [
    /* entry objects as above, newest first — never the key itself */
  ],
}
```

`lastUsedAt` is debounced to at most one write a minute, so it can lag by up to 60 seconds. It is a
"which of these is still in use" signal, not an audit trail.

### `DELETE /api/api-keys/:id`

`{"ok": true}`, or `404` if the id is not yours or is already revoked — deliberately the same answer for
both, so the endpoint cannot be used to discover whether an id exists.

---

## Things that will surprise you

- **No `Origin` header is needed.** Origin checking was removed entirely on 2026-08-13; before that it
  was off by default
  and the apps work sending none — which is what they do. Nothing here changes that. If it is ever
  switched off, every app breaks at once and will need its `OFFICER_<APP>_ORIGIN` value compiled in; that
  is a separate conversation, not part of this work.
- **`/api/auth/signin` can return 200 with no token.** Pre-existing: it happens when the account has a
  passkey registered against the caller's origin, and it means "now do WebAuthn". Mobile sends no
  `Origin`, so it never triggers today. Minting a key at first sign-in and never signing in again makes
  the app immune to it permanently — a real reason to prefer path (a).
- **`user` in the signin response has no `role`.** It is `{id, email, name, username, passkeys}` where
  `passkeys` is a count. Role comes from `GET /api/auth/me`. The mobile `AuthUser` type currently
  declares `role` as required, which is wrong for signin — worth fixing while you are in there.
- **A key can mint another key.** There is no parent/child link, so revoking a key does not revoke ones
  created with it. This is a consequence of a key carrying full account authority and is accepted for
  now; it is the strongest single argument for scoped keys.

---

## Not built

- **Scopes.** A key cannot be narrowed to a subset of its holder's capabilities. The column and the check
  are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is
  there today. Design as if every key is full-authority, because it is.
- **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave —
  revocation from a phone that has been lost is not a thing you can do from the phone.
- **Server-side "sign out everywhere".** Password change invalidates JWTs but deliberately **not** API
  keys, since rotating them independently is the reason they exist. If the owner wants everything dead,
  they revoke each key.

---

## Verifying against a real server

```bash
BASE=https://officer.pastilhas.dev

TOKEN=$(curl -s -X POST $BASE/api/auth/signin -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","password":"…"}' | jq -r .token)

KEY=$(curl -s -X POST $BASE/api/api-keys -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' -d '{"name":"curl test"}' | jq -r .key)

curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY"   # 200
ID=$(curl -s $BASE/api/api-keys -H "Authorization: Bearer $KEY" | jq -r '.keys[0].id')
curl -s -X DELETE $BASE/api/api-keys/$ID -H "Authorization: Bearer $KEY"                     # {"ok":true}
curl -s -o /dev/null -w '%{http_code}\n' $BASE/api/auth/me -H "Authorization: Bearer $KEY"   # 401
```

That sequence — create, use, revoke, 401 — is the one that was run against the live server before this
document was written, along with a WebSocket upgrade on `/api/cliamp/ws?token=<key>` returning 101.

---

## What the client actually does now

Built in `monorepo-mobile` on 2026-08-08, against the server described above. **Written, never compiled**
— no `node_modules` in that tree on this machine and no Mac, so not even `tsc` has seen it.

### New in `@officer/core`

- **`src/services/api-keys.ts`** — `listApiKeys` / `createApiKey` / `revokeApiKey`, `isApiKey`,
  `defaultApiKeyName`, and `revokeApiKeyByCredential` (below). Shaped to mirror `dav.ts`'s app
  passwords, which is the same mint-once-hash-forever contract.
- **`src/services/device-name.ts`** — `deviceName()`, lifted out of `dav.ts`'s `deviceLabel()` so both
  credential features name a device the same way. `deviceLabel()` now composes it; no caller changed.
- **`useAuth`** — `signin(email, password, { deviceKeyName })` opts into path (a); `signInWithApiKey(key)`
  is path (b).

### Where this document was wrong about the client

**1. There is no such thing as "one key per app" on mobile.** §(a) offers per-device or per-app as a free
choice. It is not: `packages/core/src/services/shared-session.ts` holds **one token per server, shared
across the whole suite** through an iOS keychain access group and an Android signature-permission
ContentProvider. Whichever app writes last wins, for all of them. So a key named after an app actively
misleads the owner about what revoking it disconnects — the answer is always "the device". The default
name is `"<device> — <app>"`, where the app half records only who did the minting.

**2. "Do not call signout with a key" was not the whole story — `distressSignout` was the real problem.**
Under duress the app clears the credential locally _first_, then best-effort revokes server-side via
`POST /api/auth/revoke`. That endpoint blacklists a JWT and does nothing whatever for a key, so the
moment a phone starts holding keys, distress sign-out silently stops killing the credential. **And it
matters more here than it ever did for a JWT: an unrevoked session token still expires in thirty days, an
unrevoked key never does.**

`revokeApiKeyByCredential(baseUrl, key)` is the fix — raw `fetch` (the credential is already out of
storage by then, so it cannot go through `request()`), `GET /api/api-keys`, match on the `prefix` the
server kept in the clear, `DELETE /api/api-keys/:id`.

### Two client-side decisions worth a second opinion

- **A 401 is now the only thing that clears the credential.** `useAuth`'s `/api/auth/me` query used to
  clear on _any_ throw. A network error is `ApiError(0)` and a timeout is `ApiError(408)`, so launching
  with no connectivity destroyed a working session and dropped the user at a login screen that needs the
  network to be useful — and a 403 produced the logout loop this document warns about. Pre-existing, not
  caused by keys, fixed while in there.
- **The traded-in JWT is left to expire, not blacklisted.** Minting a key and keeping it drops a live
  thirty-day token on the floor, and `POST /api/auth/signout` is the obvious tidy-up — but that handler
  also calls `clearVaultTokens(user.id)`, keyed on the **user**, not the session. Blacklisting the
  discarded token would therefore drop a vault session brokered on another of the owner's devices, as a
  side effect of signing in. Not worth it for a token nothing holds and nothing will send again.

### Still not built on the client

No key-management screen in any app — listing and revoking remain web-only, per "Not built" above. The
service verbs exist (`listApiKeys`, `revokeApiKey`) if that changes.

---

## Where this lives on the server

- `src/servers/auth-token.ts` — the key format and `resolveAuthToken`, the single function that turns a
  bearer string into a caller. All four doors call it: `userMiddleware`, `originScopeMiddleware`, the
  WebSocket upgrade in `server.tsx`, and the vault socket.
- `src/servers/api/api-keys/router.ts` — the three endpoints.
- `src/databases/officer_db/src/api-keys/schema.ts` — the table, and why it stores what it stores.
