{"record":{"id":"29e9d1f0ed074b0f","repo":"koala73/worldmonitor","slug":"already-revoked","errorCode":"ALREADY_REVOKED","errorMessage":"ALREADY_REVOKED","messagePattern":"ALREADY_REVOKED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"warning","filePath":"convex/apiKeys.ts","lineNumber":183,"sourceCode":"      revokedAt: k.revokedAt,\n      scopes: k.scopes,\n      companyMonitoringAccountId: k.companyMonitoringAccountId,\n    }));\n  },\n});\n\n/** Revoke a key owned by the current user. */\nexport const revokeApiKey = mutation({\n  args: { keyId: v.id(\"userApiKeys\") },\n  handler: async (ctx, args) => {\n    const userId = await requireUserId(ctx);\n    const key = await ctx.db.get(args.keyId);\n\n    if (!key || key.userId !== userId) {\n      throw new ConvexError(\"NOT_FOUND\");\n    }\n    if (key.revokedAt) {\n      throw new ConvexError(\"ALREADY_REVOKED\");\n    }\n\n    await ctx.db.patch(args.keyId, { revokedAt: Date.now() });\n    return { ok: true, keyHash: key.keyHash };\n  },\n});\n\n// ---------------------------------------------------------------------------\n// Internal (service-to-service) — called from HTTP actions / middleware\n// ---------------------------------------------------------------------------\n\n/**\n * Look up an API key by its SHA-256 hash.\n * Returns the key row (with userId) if found and not revoked, else null.\n * Used by the edge gateway to validate incoming API keys.\n */\nexport const validateKeyByHash = internalQuery({\n  args: { keyHash: v.string() },","sourceCodeStart":165,"sourceCodeEnd":201,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/apiKeys.ts#L165-L201","documentation":"Thrown by revokeApiKey when the key row exists and is owned by the user, but its revokedAt field is already set (non-null). This is an idempotency guard preventing double-revocation; the key is already in the desired terminal state.","triggerScenarios":"Calling revokeApiKey twice on the same key; calling revoke after the key was auto-revoked by the KEY_LIMIT overflow-convergence path; a UI double-click firing two revoke requests.","commonSituations":"User clicked revoke twice; the create-key overflow handler revoked this key and the user then tries to manually revoke it; a retry of a previously-successful revoke.","solutions":["Treat ALREADY_REVOKED as the desired terminal state — the key is revoked, no further action needed.","Guard the UI against double-submission (disable the button after first click).","Check key.revokedAt from listApiKeys before calling revokeApiKey."],"exampleFix":"// before\nawait revokeApiKey(ctx, { keyId }); // ALREADY_REVOKED on second click\n// after — idempotent wrapper\ntry {\n  await revokeApiKey(ctx, { keyId });\n} catch (e) {\n  if (e.message !== \"ALREADY_REVOKED\") throw e;\n  // already revoked — treat as success\n}","handlingStrategy":"try-catch","validationCode":"const keys = await listApiKeys(ctx, {});\nconst target = keys.find(k => k.id === keyId);\nif (!target || target.revokedAt) return { ok: true };\nawait revokeApiKey(ctx, { keyId });","typeGuard":null,"tryCatchPattern":"try {\n  await revokeApiKey(ctx, { keyId });\n} catch (e) {\n  if (e instanceof ConvexError && e.message === \"ALREADY_REVOKED\") return { ok: true };\n  throw e;\n}","preventionTips":["Disable the revoke button immediately after first click.","Check revokedAt before calling revokeApiKey.","Treat ALREADY_REVOKED as the desired terminal state."],"tags":["convex","api-keys","idempotency","revocation"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}