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