koala73/worldmonitor · warning · ConvexError

ALREADY_REVOKED

ALREADY_REVOKED

Error message

ALREADY_REVOKED

What it means

Thrown by `revokeProMcpToken` when the token row exists, is owned by the caller, but already has `revokedAt` set. This guards against double-revoke producing confusing audit trails. Plain-string ConvexError; `err.data === "ALREADY_REVOKED"`. Distinct from NOT_FOUND (row missing/foreign) — here the row is found and owned but already in the revoked state.

Source

Thrown at convex/mcpProTokens.ts:240

/**
 * Revoke a Pro MCP token row owned by the current user.
 *
 * Tenancy gate: the caller must own the row. Non-owner attempts surface
 * as `NOT_FOUND` (don't leak existence of other users' tokens). Mirrors
 * `apiKeys.revokeApiKey`.
 */
export const revokeProMcpToken = mutation({
  args: { tokenId: v.id("mcpProTokens") },
  handler: async (ctx, args) => {
    const userId = await requireUserId(ctx);
    const row = await ctx.db.get(args.tokenId);

    if (!row || row.userId !== userId) {
      throw new ConvexError("NOT_FOUND");
    }
    if (row.revokedAt) {
      throw new ConvexError("ALREADY_REVOKED");
    }

    await ctx.db.patch(args.tokenId, { revokedAt: Date.now() });
    return { ok: true };
  },
});

View on GitHub (pinned to ffec79ac33)

Solutions

  1. Disable the revoke button immediately on click (optimistic UI) and refresh the list after.
  2. On `err.data === "ALREADY_REVOKED"`, treat as idempotent success — the desired state (revoked) is achieved.
  3. Use a request-in-flight guard so a double-click only fires one mutation.
  4. After revoke, optimistically mark the row revoked in local state before the server confirms.

Example fix

// before
const revoke = (id) => convex.mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: id });

// after — idempotent handling + optimistic UI
const revoke = async (id) => {
  setTokens((t) => t.map((x) => x.id === id ? { ...x, revokedAt: Date.now() } : x));
  try {
    await convex.mutation(api.mcpProTokens.revokeProMcpToken, { tokenId: id });
  } catch (err) {
    if (err.data === "ALREADY_REVOKED" || err.data === "NOT_FOUND") return; // desired state
    throw err;
  }
};
Defensive patterns

Strategy: try-catch

Validate before calling

// Optimistically mark revoked before the call to prevent double-revoke
setTokens((t) => t.map((x) => x._id === id ? { ...x, revokedAt: Date.now() } : x));

Type guard

function isAlreadyRevoked(row: { revokedAt?: number | null }): boolean {
  return !!row.revokedAt;
}

Try / catch

try {
  await convex.mutation(api.mcpProTokens.revokeProMcpToken, { tokenId });
} catch (err) {
  if (err.data === "ALREADY_REVOKED" || err.data === "NOT_FOUND") return; // desired state
  throw err;
}

Prevention

When it happens

Trigger: Calling revoke on a token that was already revoked: a double-click on the revoke button; a retry after a network timeout where the first call actually succeeded; the silent oldest-rotation in `issueProMcpToken` revoked it, and the user then manually revokes the same row.

Common situations: User clicks revoke twice before the UI updates; a client-side retry library re-fires the mutation after the first succeeded; a token was auto-rotated by the cap logic and the stale UI still shows it as active.

Related errors


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/6b0d505ad4327ee9. Report an issue: GitHub.