Files
platform/docs/mobile-api-keys.md
T
pastilhasandClaude Opus 5 68f2c55ecf one directory per feature: schema.ts and queries.ts together
src/databases/officer_db/src/<feature>/{schema.ts,queries.ts}, replacing the
parallel schema/ and queries/ trees. 24 feature directories, 46 files moved with
git mv so history follows.

The parallel trees had drifted, which is what the restructure is really fixing:

  four features were named differently on each side — app-store/sidecar-installs,
  email/email-accounts, server/server-config

  operations had a schema and NO query file: its task_logs is reached directly
  from src/servers/api/task-logger.ts, bypassing this package's own boundary

  integrations had queries and NO schema, because it spans two features'
  tables — server_integrations and user_integrations

Both lopsided cases survive as directories holding one file, which states the
problem instead of hiding it across two trees.

Nothing outside the package changed how it imports. `officerdb`, `officerdb/types`
and `officerdb/db` resolve exactly as before; index.ts absorbed the path changes.
Added `"./*": "./src/*"` so the new layout is reachable — `officerdb/soulseek/schema`
— which one script needed, because soulseek is a plugin and therefore commented
out of the aggregator.

schema/index.ts became src/schema.ts, keeping the core/plugin split from earlier
tonight. drizzle.config.ts and the package's "./schema" export follow it.

Verified rather than assumed: all 52 files in the package parse, every relative
import resolves against the new layout (checked by walking each specifier to a
real file, since parsing does not check paths), and everything in the tree
importing officerdb still parses. Not typechecked — empty node_modules, frozen
installs.

One rewrite bug worth recording: the rule mapping a query module's sibling import
also matched the './schema' this pass had just written, turning it into
'../schema/queries' in 22 files. Caught by the resolver check, not by parsing —
both spellings parse fine.

Also corrects every path reference the move invalidated: src/databases/CLAUDE.md's
layout diagram, the root CLAUDE.md data section, three docs, and seven sidecar
comments naming queries/<x>.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:34:04 +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 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.