mobile-api-keys: record what the client actually does now

The client half exists in monorepo-mobile as of f29774a, with OffChat as the
proof of concept, so this document is no longer purely forward-looking. Adds a
section covering what shipped, the two places the advice here was wrong about
the client — one key per app is not possible on mobile (shared-session puts one
credential in front of all nineteen apps), and signout was never the problem,
distress signout was — and two decisions worth a second opinion: clearing the
credential on 401 only, and leaving the traded-in JWT to expire rather than
blacklisting it, because that handler clears vault tokens keyed on the user.
This commit is contained in:
2026-08-08 16:26:06 +00:00
parent b953a6ba8c
commit 9974736587
+64
View File
@@ -6,6 +6,15 @@ 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 **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. 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 ## The short version
@@ -256,6 +265,61 @@ document was written, along with a WebSocket upgrade on `/api/cliamp/ws?token=<k
--- ---
## 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 ## Where this lives on the server
- `src/servers/auth-token.ts` — the key format and `resolveAuthToken`, the single function that turns a - `src/servers/auth-token.ts` — the key format and `resolveAuthToken`, the single function that turns a