koala73/worldmonitor · warning · ConvexError

ALREADY_REVOKED

ALREADY_REVOKED

Error message

ALREADY_REVOKED

What it means

revokeEmbedKey throws "ALREADY_REVOKED" when the target embedKeys document exists, is owned by the caller, but already has a truthy revokedAt timestamp. Revocation is a one-way transition; re-revoking would be a no-op at best, so the mutation rejects the call explicitly instead of silently patching the same value again.

Solutions

  1. Check key.revokedAt === null via listEmbedKeys before calling revokeEmbedKey, and skip keys already revoked.
  2. Treat the ALREADY_REVOKED ConvexError as a success signal in retry/reconcile paths — the desired end state (revoked) is already true.
  3. Update local/optimistic state immediately after a successful revoke so the UI cannot re-issue the mutation.
  4. In batch scripts, collect keyIds first and filter out those already revoked in a prior run.

Example fix

// before
await client.mutation(api.embedKeys.revokeEmbedKey, { keyId }); // throws ALREADY_REVOKED on retry
// after
try {
  await client.mutation(api.embedKeys.revokeEmbedKey, { keyId });
} catch (e) {
  if (!String(e).includes('ALREADY_REVOKED')) throw e; // idempotent: already in desired state
}
Defensive patterns

Strategy: try-catch

Validate before calling

const keys = await client.query(api.embedKeys.listEmbedKeys, {});
const key = keys.find(k => k.id === keyId);
if (key && key.revokedAt !== null) return { ok: true }; // already revoked, nothing to do

Type guard

function isActiveKey(key: { revokedAt: number | null }): boolean {
  return key.revokedAt === null;
}

Try / catch

try {
  await client.mutation(api.embedKeys.revokeEmbedKey, { keyId });
} catch (e) {
  if (String(e).includes('ALREADY_REVOKED')) return { ok: true }; // desired state already reached
  throw e;
}

Prevention

When it happens

Trigger: Calling revokeEmbedKey a second time on the same keyId (double-click on the revoke button, a retried mutation after a network timeout in which the first attempt actually committed, or two UI components racing to revoke the same key).

Common situations: Optimistic UI not marking the key as revoked before the confirm dialog closes; a retry wrapper treating a lost response as failure and re-sending; cron/cleanup scripts revoking a list of keys where some were already revoked in an earlier run.

Understand the failure class

Background: "Invalid state transition" errors: "status must be X, actually Y", "already rejected/charging/uninstalled", "cannot ... while running" — what they mean when a library rejects your call — this error's family across 31 libraries.

Related errors


AI-assisted analysis of koala73/worldmonitor@7d06c8633d (2026-09-15). Data as JSON: /api/errors/32bdd25c16671a26. Report an issue: GitHub.

Appendix: source

Thrown at convex/embedKeys.ts:167

      revokedAt: k.revokedAt,
      supersededAt: k.supersededAt,
      allowedOrigins: k.allowedOrigins,
    }));
  },
});

/** Revoke an embed key owned by the current user. */
export const revokeEmbedKey = mutation({
  args: { keyId: v.id("embedKeys") },
  handler: async (ctx, args) => {
    const userId = await requireUserId(ctx);
    const key = await ctx.db.get(args.keyId);

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

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

// ---------------------------------------------------------------------------
// Internal (service-to-service) — called from HTTP actions / middleware
// ---------------------------------------------------------------------------

/**
 * Look up an embed key by its SHA-256 hash.
 * Returns the key row (with userId) if found and not revoked, else null.
 * Used by the embed edge handler to resolve the embedding account.
 */
export const validateKeyByHash = internalQuery({
  args: { keyHash: v.string() },

View on GitHub (pinned to 7d06c8633d)