{"record":{"id":"a7ece7fed9407ec0","repo":"koala73/worldmonitor","slug":"key-limit-reached","errorCode":"KEY_LIMIT_REACHED","errorMessage":"KEY_LIMIT_REACHED","messagePattern":"KEY_LIMIT_REACHED","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/apiKeys.ts","lineNumber":109,"sourceCode":"    // revoking enough oldest overflow rows to make room for this create.\n    const existing = await ctx.db\n      .query(\"userApiKeys\")\n      .withIndex(\"by_userId\", (q) => q.eq(\"userId\", userId))\n      .collect();\n    const active = existing.filter((k) => !k.revokedAt);\n    let activeCount = active.length;\n    if (active.length > MAX_KEYS_PER_USER) {\n      active.sort((a, b) => a.createdAt - b.createdAt);\n      const toRevoke = active.slice(0, active.length - (MAX_KEYS_PER_USER - 1));\n      const now = Date.now();\n      for (const key of toRevoke) {\n        await ctx.db.patch(key._id, { revokedAt: now });\n      }\n      // After revoking overflow keys there is always exactly one slot free.\n      activeCount = MAX_KEYS_PER_USER - 1;\n    }\n    if (activeCount >= MAX_KEYS_PER_USER) {\n      throw new ConvexError(\"KEY_LIMIT_REACHED\");\n    }\n\n    // Guard against duplicate hash (astronomically unlikely, but belt-and-suspenders)\n    const dup = await ctx.db\n      .query(\"userApiKeys\")\n      .withIndex(\"by_keyHash\", (q) => q.eq(\"keyHash\", args.keyHash))\n      .first();\n    if (dup) {\n      throw new ConvexError(\"DUPLICATE_KEY\");\n    }\n\n    const id = await ctx.db.insert(\"userApiKeys\", {\n      userId,\n      name: args.name.trim(),\n      keyPrefix: args.keyPrefix,\n      keyHash: args.keyHash,\n      scopes,\n      companyMonitoringAccountId: companyMonitoringAccount?.logicalAccountId,","sourceCodeStart":91,"sourceCodeEnd":127,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/apiKeys.ts#L91-L127","documentation":"Thrown by createApiKey when, after auto-revoking overflow keys to converge toward the cap, the active key count is still >= MAX_KEYS_PER_USER (5). The mutation first tries to self-heal by revoking the oldest overflow rows to free a slot; this error means convergence did not yield a free slot (e.g. the overflow-revocation path did not reduce the count below the cap). It is an intentional reject-at-cap policy rather than silent rotation of a valid key.","triggerScenarios":"A user already has 5 or more active (non-revoked) API keys and calls createApiKey again; a prior concurrent race created more than MAX_KEYS_PER_USER rows and the convergence slice did not free a slot (the overflow math yields activeCount >= MAX_KEYS_PER_USER after the patch loop).","commonSituations":"Long-lived users who accumulated the maximum keys; concurrent createApiKey calls in a race that both passed the limit check; revokedAt values missing due to a partial write, so rows count as active unexpectedly.","solutions":["Revoke an existing key via revokeApiKey before creating a new one.","Call listApiKeys to inspect active keys and identify which to revoke.","If the limit seems wrong, check that revokedAt is set correctly on keys you believe are revoked; the cap counts only non-revoked rows.","Request raising MAX_KEYS_PER_USER if the use case genuinely needs more concurrent keys."],"exampleFix":"// before — creating a 6th key\nawait createApiKey(ctx, { name, keyPrefix, keyHash }); // throws KEY_LIMIT_REACHED\n// after — revoke oldest first\nconst keys = await listApiKeys(ctx, {});\nconst oldest = keys.filter(k => !k.revokedAt).sort((a,b) => a.createdAt - b.createdAt)[0];\nif (oldest) await revokeApiKey(ctx, { keyId: oldest.id });\nawait createApiKey(ctx, { name, keyPrefix, keyHash });","handlingStrategy":"validation","validationCode":"const keys = await listApiKeys(ctx, {});\nconst activeCount = keys.filter(k => !k.revokedAt).length;\nif (activeCount >= 5) {\n  // revoke oldest active before creating\n  const oldest = keys.filter(k => !k.revokedAt).sort((a,b) => a.createdAt - b.createdAt)[0];\n  if (oldest) await revokeApiKey(ctx, { keyId: oldest.id });\n}\nawait createApiKey(ctx, { name, keyPrefix, keyHash });","typeGuard":null,"tryCatchPattern":"try {\n  await createApiKey(ctx, { name, keyPrefix, keyHash });\n} catch (e) {\n  if (e instanceof ConvexError && e.message === \"KEY_LIMIT_REACHED\") {\n    // prompt user to revoke a key, then retry\n  } else throw e;\n}","preventionTips":["Track active key count in the UI and disable Create when at the cap.","Prompt the user to revoke before creating when near the limit.","Be aware the server self-heals overflow but still enforces a hard cap."],"tags":["convex","api-keys","quota","limit","concurrency"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}