fix(apikey): revoke must verify a row was actually updated
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:
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user