{"record":{"id":"04c46bfe7ecd10a9","repo":"koala73/worldmonitor","slug":"not-found","errorCode":"NOT_FOUND","errorMessage":"NOT_FOUND","messagePattern":"NOT_FOUND","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/apiKeys.ts","lineNumber":180,"sourceCode":"      keyPrefix: k.keyPrefix,\n      createdAt: k.createdAt,\n      lastUsedAt: k.lastUsedAt,\n      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.","sourceCodeStart":162,"sourceCodeEnd":198,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/apiKeys.ts#L162-L198","documentation":"Thrown by revokeApiKey when the requested key row does not exist (ctx.db.get returned null) OR exists but belongs to a different userId. The two cases are intentionally collapsed into one error to avoid leaking which keyIds exist for other users (a tenancy/ownership guard). This is the standard not-found / forbidden-as-not-found pattern.","triggerScenarios":"Calling revokeApiKey with a keyId that was deleted, never existed, was passed as a malformed id, or belongs to another user; calling with a stale keyId from an outdated listApiKeys result.","commonSituations":"The UI passed a keyId from a stale list after another session revoked/deleted it; a cross-user bug passed the wrong keyId; the id was truncated or malformed in transit; the key never existed due to a typo.","solutions":["Refresh the key list via listApiKeys and pass a current, owned keyId.","Confirm the keyId is a valid Convex id for the userApiKeys table.","Treat NOT_FOUND as success if the goal is already achieved (key already gone).","Check for concurrent revocation from another session/device."],"exampleFix":"// before\nawait revokeApiKey(ctx, { keyId: staleId }); // NOT_FOUND\n// after\nconst keys = await listApiKeys(ctx, {});\nconst target = keys.find(k => k.id === requestedId);\nif (!target || target.revokedAt) return { ok: true }; // already gone\nawait revokeApiKey(ctx, { keyId: target.id });","handlingStrategy":"validation","validationCode":"const keys = await listApiKeys(ctx, {});\nconst owned = keys.find(k => k.id === keyId);\nif (!owned) return { ok: true }; // nothing to revoke\nawait revokeApiKey(ctx, { keyId: owned.id });","typeGuard":null,"tryCatchPattern":"try {\n  await revokeApiKey(ctx, { keyId });\n} catch (e) {\n  if (e instanceof ConvexError && e.message === \"NOT_FOUND\") {\n    // treat as already revoked/gone\n  } else throw e;\n}","preventionTips":["Refresh the key list before offering revoke actions.","Treat NOT_FOUND as success in idempotent revoke flows.","Guard against passing stale ids from cached UI state."],"tags":["convex","api-keys","not-found","tenancy","ownership"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}