fix(apikey): revoke must verify a row was actually updated
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

claudemesh apikey revoke <id> reported success even when the input
didn't match any row in mesh.api_key. The CLI's `apikey list` shows
truncated 8-char prefixes; users naturally paste those; broker did
exact-id match against meshApiKey.id; UPDATE affected 0 rows; old
revokeApiKey returned void so the CLI couldn't tell. Discovered via
end-to-end CLI smoke test against prod (roadmap validation pass).

Three-part fix:

- broker.revokeApiKey now returns
  { status: "revoked"|"not_found"|"not_unique"; id?, matches? } and
  accepts either the full id or a unique prefix (>=6 chars). Prefix
  matching is bounded to the caller's mesh and only succeeds if
  exactly one row matches; ambiguous prefixes return not_unique so
  we never silently revoke the wrong key.

- New WSApiKeyRevokeResponseMessage carries the structured status
  back to the CLI. Old apikey_revoke_ok type removed before being
  released — never shipped to users. The error path is no longer
  used for not_found/not_unique cases; the unified response carries
  both outcomes.

- CLI's apiKeyRevoke now resolves with { ok, id } | { ok: false,
  code, message }. runApiKeyRevoke surfaces the code/message and
  exits non-zero on failure (NOT_FOUND for missing, INVALID_ARGS
  for ambiguous prefix).

Net effect: pasting `claudemesh apikey revoke vq0fwjdX` now actually
revokes the key whose id starts with vq0fwjdX (or fails loud if 0
or >1 keys match). Verified against prod via the new branch's CLI
binary before commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 18:39:25 +01:00
parent 7d1538d743
commit 0f32529370
5 changed files with 121 additions and 15 deletions

View File

@@ -901,12 +901,51 @@ export async function listApiKeys(meshId: string): Promise<
}));
}
/** Revoke an API key. Idempotent. */
export async function revokeApiKey(args: { meshId: string; id: string }): Promise<void> {
await db
.update(meshApiKey)
.set({ revokedAt: new Date() })
.where(and(eq(meshApiKey.meshId, args.meshId), eq(meshApiKey.id, args.id)));
/**
* Revoke an API key. Returns "revoked" with the matched id, or a
* structured error.
*
* Accepts either the full id or a unique prefix (length >= 6) — the
* CLI's `apikey list` truncates ids to 8 chars for display, so users
* naturally paste the truncated form. Prefix matching is bounded to
* the caller's mesh and only succeeds if exactly one key matches;
* ambiguous prefixes return `not_unique` so we never silently revoke
* the wrong key.
*
* Idempotent for already-revoked keys (returns "revoked" with the
* prior revoked_at).
*/
export async function revokeApiKey(args: {
meshId: string;
id: string;
}): Promise<
| { status: "revoked"; id: string }
| { status: "not_found" }
| { status: "not_unique"; matches: number }
> {
const candidates = await db
.select({ id: meshApiKey.id, revokedAt: meshApiKey.revokedAt })
.from(meshApiKey)
.where(
and(
eq(meshApiKey.meshId, args.meshId),
// Try exact match first; fall back to prefix.
sql`(${meshApiKey.id} = ${args.id} OR ${meshApiKey.id} LIKE ${args.id + "%"})`,
),
)
.limit(2);
if (candidates.length === 0) return { status: "not_found" };
if (candidates.length > 1) {
return { status: "not_unique", matches: candidates.length };
}
const matched = candidates[0]!;
if (!matched.revokedAt) {
await db
.update(meshApiKey)
.set({ revokedAt: new Date() })
.where(eq(meshApiKey.id, matched.id));
}
return { status: "revoked", id: matched.id };
}
/**

View File

@@ -2523,9 +2523,17 @@ function handleConnection(ws: WebSocket): void {
case "apikey_revoke": {
const ar = msg as Extract<WSClientMessage, { type: "apikey_revoke" }>;
if (!ar.id) { sendError(ws, "invalid_args", "id required", _reqId); break; }
await revokeApiKey({ meshId: conn.meshId, id: ar.id });
log.info("ws apikey_revoke", { presence_id: presenceId, key_id: ar.id });
if (!ar.id) { sendError(ws, "invalid_args", "id required", undefined, _reqId); break; }
const result = await revokeApiKey({ meshId: conn.meshId, id: ar.id });
log.info("ws apikey_revoke", { presence_id: presenceId, key_id: ar.id, status: result.status });
const resp: WSServerMessage = {
type: "apikey_revoke_response",
status: result.status,
...(result.status === "revoked" ? { id: result.id } : {}),
...(result.status === "not_unique" ? { matches: result.matches } : {}),
...(_reqId ? { _reqId } : {}),
};
conn.ws.send(JSON.stringify(resp));
break;
}

View File

@@ -233,6 +233,16 @@ export interface WSApiKeyListResponseMessage {
_reqId?: string;
}
export interface WSApiKeyRevokeResponseMessage {
type: "apikey_revoke_response";
status: "revoked" | "not_found" | "not_unique";
/** Full id of the revoked key on success (may differ from input if a prefix was sent). */
id?: string;
/** How many keys matched on not_unique. */
matches?: number;
_reqId?: string;
}
// ── Topics (v0.2.0) ─────────────────────────────────────────────────
// Topics complement groups: groups are identity tags, topics are
// conversation scopes. targetSpec for topic-tagged messages is
@@ -1484,6 +1494,7 @@ export type WSServerMessage =
| WSTopicHistoryResponseMessage
| WSApiKeyCreatedMessage
| WSApiKeyListResponseMessage
| WSApiKeyRevokeResponseMessage
| WSStateChangeMessage
| WSStateResultMessage
| WSStateListMessage