Files
platform/docs/mobile-api-keys.md
T
2026-08-08 10:44:54 +00:00

13 KiB

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.


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

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

// 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

{
  "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 today. ALLOW_ANY_ORIGIN defaults to on, so origin checking is off 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

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.


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/schema/api-keys.ts — the table, and why it stores what it stores.