{"record":{"id":"32bdd25c16671a26","repo":"koala73/worldmonitor","slug":"already-revoked-32bdd2","errorCode":"ALREADY_REVOKED","errorMessage":"ALREADY_REVOKED","messagePattern":"ALREADY_REVOKED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"warning","filePath":"convex/embedKeys.ts","lineNumber":167,"sourceCode":"      revokedAt: k.revokedAt,\n      supersededAt: k.supersededAt,\n      allowedOrigins: k.allowedOrigins,\n    }));\n  },\n});\n\n/** Revoke an embed key owned by the current user. */\nexport const revokeEmbedKey = mutation({\n  args: { keyId: v.id(\"embedKeys\") },\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 embed key by its SHA-256 hash.\n * Returns the key row (with userId) if found and not revoked, else null.\n * Used by the embed edge handler to resolve the embedding account.\n */\nexport const validateKeyByHash = internalQuery({\n  args: { keyHash: v.string() },","sourceCodeStart":149,"sourceCodeEnd":185,"githubUrl":"https://github.com/koala73/worldmonitor/blob/7d06c8633d256c18e38133030bc3613976a96ec9/convex/embedKeys.ts#L149-L185","documentation":"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.","triggerScenarios":"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).","commonSituations":"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.","solutions":["Check key.revokedAt === null via listEmbedKeys before calling revokeEmbedKey, and skip keys already revoked.","Treat the ALREADY_REVOKED ConvexError as a success signal in retry/reconcile paths — the desired end state (revoked) is already true.","Update local/optimistic state immediately after a successful revoke so the UI cannot re-issue the mutation.","In batch scripts, collect keyIds first and filter out those already revoked in a prior run."],"exampleFix":"// before\nawait client.mutation(api.embedKeys.revokeEmbedKey, { keyId }); // throws ALREADY_REVOKED on retry\n// after\ntry {\n  await client.mutation(api.embedKeys.revokeEmbedKey, { keyId });\n} catch (e) {\n  if (!String(e).includes('ALREADY_REVOKED')) throw e; // idempotent: already in desired state\n}","handlingStrategy":"try-catch","validationCode":"const keys = await client.query(api.embedKeys.listEmbedKeys, {});\nconst key = keys.find(k => k.id === keyId);\nif (key && key.revokedAt !== null) return { ok: true }; // already revoked, nothing to do","typeGuard":"function isActiveKey(key: { revokedAt: number | null }): boolean {\n  return key.revokedAt === null;\n}","tryCatchPattern":"try {\n  await client.mutation(api.embedKeys.revokeEmbedKey, { keyId });\n} catch (e) {\n  if (String(e).includes('ALREADY_REVOKED')) return { ok: true }; // desired state already reached\n  throw e;\n}","preventionTips":["Treat revocation as idempotent in client code: ALREADY_REVOKED means success.","Update UI state to 'revoked' immediately after the first successful revoke.","Debounce/revoke buttons and disable them while the mutation is pending.","In batch revocation scripts, pre-filter keys whose revokedAt is already set."],"tags":["idempotency","state-transition","convex","api-keys"],"backgroundTag":"invalid-state-transition","analyzedSha":"7d06c8633d256c18e38133030bc3613976a96ec9","analyzedAt":"2026-09-15T16:44:39.439Z","contentChangedAt":"2026-09-15T16:44:39.439Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}