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

@@ -169,6 +169,7 @@ export class BrokerClient {
// ── API keys (v0.2.0) ──
private apiKeyCreatedResolvers = new Map<string, { resolve: (r: { id: string; secret: string; label: string; prefix: string; capabilities: Array<"send" | "read" | "state_write" | "admin">; topicScopes: string[] | null; createdAt: string } | null) => void; timer: NodeJS.Timeout }>();
private apiKeyListResolvers = new Map<string, { resolve: (keys: Array<{ id: string; label: string; prefix: string; capabilities: Array<"send" | "read" | "state_write" | "admin">; topicScopes: string[] | null; createdAt: string; lastUsedAt: string | null; revokedAt: string | null; expiresAt: string | null }>) => void; timer: NodeJS.Timeout }>();
private apiKeyRevokeResolvers = new Map<string, { resolve: (r: { ok: true; id: string } | { ok: false; code: string; message: string }) => void; timer: NodeJS.Timeout }>();
/** Directories from which this peer serves files. Default: [process.cwd()]. */
private sharedDirs: string[] = [process.cwd()];
private _serviceCatalog: Array<{ name: string; description: string; status: string; tools: Array<{ name: string; description: string; inputSchema: object }>; deployed_by: string }> = [];
@@ -699,9 +700,22 @@ export class BrokerClient {
});
}
async apiKeyRevoke(id: string): Promise<void> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
this.ws.send(JSON.stringify({ type: "apikey_revoke", id }));
async apiKeyRevoke(id: string): Promise<{ ok: true; id: string } | { ok: false; code: string; message: string }> {
if (!this.ws || this.ws.readyState !== this.ws.OPEN) {
return { ok: false, code: "not_connected", message: "broker not connected" };
}
return new Promise((resolve) => {
const reqId = this.makeReqId();
this.apiKeyRevokeResolvers.set(reqId, {
resolve,
timer: setTimeout(() => {
if (this.apiKeyRevokeResolvers.delete(reqId)) {
resolve({ ok: false, code: "timeout", message: "broker did not respond within 5s" });
}
}, 5_000),
});
this.ws!.send(JSON.stringify({ type: "apikey_revoke", id, _reqId: reqId }));
});
}
// --- State ---
@@ -1909,6 +1923,28 @@ export class BrokerClient {
this.resolveFromMap(this.apiKeyListResolvers, msgReqId, (msg.keys as any[]) ?? []);
return;
}
if (msg.type === "apikey_revoke_response") {
const status = String(msg.status ?? "");
if (status === "revoked") {
this.resolveFromMap(this.apiKeyRevokeResolvers, msgReqId, {
ok: true as const,
id: String(msg.id ?? ""),
});
} else if (status === "not_found") {
this.resolveFromMap(this.apiKeyRevokeResolvers, msgReqId, {
ok: false as const,
code: "not_found",
message: "no api key matches that id in this mesh",
});
} else if (status === "not_unique") {
this.resolveFromMap(this.apiKeyRevokeResolvers, msgReqId, {
ok: false as const,
code: "not_unique",
message: `prefix matches ${Number(msg.matches ?? 0)} keys; use the full id`,
});
}
return;
}
if (msg.type === "push") {
this._statsCounters.messagesIn++;
const nonce = String(msg.nonce ?? "");