api keys: revoked ones stop filling the screen, and can be forgotten

Five revoked keys, no live ones, and no way to remove any of them — which is what the screen looks
like after a few rounds of testing. `listApiKeys` returns every row and nothing filtered them, so a
list whose job is "which of my keys still work" answered with a wall of dead ones.

Nobody decided that. Revoke was implemented and the list was never taught the difference.

Dead keys — revoked or past expiry — are now hidden behind a "Show 5 revoked or expired keys"
toggle rather than merely dimmed, because on any account in use for a while they outnumber the live
ones. The empty state distinguishes "No keys yet" from "No keys in use": the first is false on an
account whose keys are all revoked, and it is exactly the sentence that sends somebody hunting for a
bug in the list.

DELETE /:id/record removes the row for good, and only after it is revoked. The `revokedAt is not
null` clause is in the statement rather than checked before it, so there is no ordering in which a
live key is deleted by a request that raced a revoke. Nothing is weakened by allowing it: the key
cannot be honoured again either way, since authentication matches a hash and a deleted row has none.
What goes is the history — `lastUsedAt`, the only evidence of whether a leaked key was used before it
was killed — which is why it stays a second, deliberate step rather than what revoke does.

Also corrected the copy, which claimed keys are per-app. They are not scoped to anything: one key
works everywhere, and per-device is a revocation convenience, not a requirement. The owner had to ask.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 23:22:29 +00:00
co-authored by Claude Opus 5
parent c4042886fc
commit 43aeffd9d3
5 changed files with 108 additions and 10 deletions
+24 -1
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { createApiKey, listApiKeys, revokeApiKey } from 'officerdb';
import { createApiKey, listApiKeys, revokeApiKey, deleteApiKey } from 'officerdb';
import { mintApiKey } from '../../auth-token';
import * as errors from '../../custom-errors';
@@ -60,3 +60,26 @@ apiKeysRouter.delete('/:id', async (ctx) => {
if (!(await revokeApiKey(id, user.id))) throw errors.NOT_FOUND('no such api key');
return ctx.json({ ok: true });
});
// ── Forgetting a key, which is not the same as revoking it ──
//
// DELETE /:id revokes and keeps the row, because the row is the record that the key existed and
// `lastUsedAt` is the only evidence of whether a leaked one was used before it was killed. This removes
// that record, and it is deliberately a SECOND request after the first: `deleteApiKey` refuses a key that
// is not already revoked, in the statement rather than before it, so there is no ordering in which a live
// key is deleted by something that raced a revoke.
//
// Nothing is weakened by allowing it. The key cannot be honoured again either way — authentication
// matches a hash, and a deleted row has none. What goes is the history, not the protection.
apiKeysRouter.delete('/:id/record', async (ctx) => {
const user = ctx.get('user');
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) throw errors.BAD_REQUEST('invalid id');
if (!(await deleteApiKey(id, user.id))) {
// One message for both "not yours / never existed" and "still live", on purpose: the first must not
// be distinguishable from the second by someone probing ids that are not theirs.
throw errors.NOT_FOUND('no such revoked api key');
}
return ctx.json({ ok: true });
});