Files
platform/docs/mobile-api-keys.md
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

17 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.

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

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

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.tslistApiKeys / 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.tsdeviceName(), lifted out of dav.ts's deviceLabel() so both credential features name a device the same way. deviceLabel() now composes it; no caller changed.
  • useAuthsignin(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.