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>
331 lines
17 KiB
Markdown
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.
|