Files
platform/docs/mobile-api-keys.md
T
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00

331 lines
17 KiB
Markdown

# 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 permission 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` permissions — 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 permissions. 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.