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:
@@ -3,6 +3,7 @@ export {
|
||||
createApiKey,
|
||||
listApiKeys,
|
||||
revokeApiKey,
|
||||
deleteApiKey,
|
||||
touchApiKey,
|
||||
type ApiKeyIdentity,
|
||||
} from './queries';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||
import { eq, and, isNull, isNotNull, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { apiKeys } from './schema';
|
||||
import { users } from '../auth/schema';
|
||||
@@ -99,6 +99,32 @@ export async function revokeApiKey(id: number, userId: number): Promise<boolean>
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a revoked key's row for good.
|
||||
*
|
||||
* Separate from revoking, and only reachable AFTER it — the `revokedAt is not null` clause is in the
|
||||
* statement rather than checked before it, so there is no window where a live key is deleted by a request
|
||||
* that raced a revoke.
|
||||
*
|
||||
* ── Why this is a second step and not what revoke does ──
|
||||
*
|
||||
* The row IS the record that the key existed. `lastUsedAt` is the only evidence of whether a leaked key
|
||||
* was used before it was killed, and on what date; deleting on revoke would throw that away at exactly
|
||||
* the moment somebody most wants it. So revoke keeps everything and this exists for afterwards, when the
|
||||
* question has been answered and the row is only clutter.
|
||||
*
|
||||
* Deleting is safe in a way it would not be for a password: the key can never be honoured again either
|
||||
* way, because `authenticate` matches on a hash that no longer has a row. Losing the row loses the
|
||||
* history, not the protection.
|
||||
*/
|
||||
export async function deleteApiKey(id: number, userId: number): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(apiKeys)
|
||||
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId), isNotNull(apiKeys.revokedAt)))
|
||||
.returning({ id: apiKeys.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** How stale last_used_at is allowed to get. A write per request is not worth this column's value. */
|
||||
const TOUCH_DEBOUNCE_MS = 60_000;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user