{"record":{"id":"73d7624fd04ef4d2","repo":"koala73/worldmonitor","slug":"invitee-email-unavailable","errorCode":"INVITEE_EMAIL_UNAVAILABLE","errorMessage":"INVITEE_EMAIL_UNAVAILABLE","messagePattern":"INVITEE_EMAIL_UNAVAILABLE","errorType":"error_code","errorClass":"ConvexError","httpStatus":null,"severity":"error","filePath":"convex/payments/businessSeats.ts","lineNumber":460,"sourceCode":"    return { ok: true as const, status: \"revoked\" as const };\n  },\n});\n\n/**\n * Invitee redeems an HMAC token to accept a Business Pro seat invite. Verifies\n * the token, matches the signed-in Clerk email against the invited address,\n * checks the underlying Business subscription is still covering, flips the\n * grant to `accepted`, stamps `inviteeUserId`, and recomputes the invitee's\n * entitlement. Single-use: accepted/revoked/expired tokens are rejected.\n */\nexport const acceptBusinessInvite = mutation({\n  args: { grantId: v.id(\"businessProGrants\"), token: v.string() },\n  handler: async (ctx, args) => {\n    const userId = await requireUserId(ctx);\n    const identity = await resolveUserIdentity(ctx);\n    const inviteeEmail = identity?.email?.trim().toLowerCase();\n    if (!inviteeEmail) {\n      throw new ConvexError({ kind: \"INVITEE_EMAIL_UNAVAILABLE\" });\n    }\n\n    const grant = await ctx.db.get(args.grantId);\n    if (!grant) {\n      throw new ConvexError({ kind: \"GRANT_NOT_FOUND\" });\n    }\n    if (grant.status !== \"pending\") {\n      throw new ConvexError({ kind: \"INVITE_ALREADY_USED\" });\n    }\n    const now = Date.now();\n    if (grant.expiresAt <= now) {\n      throw new ConvexError({ kind: \"INVITE_EXPIRED\" });\n    }\n    if (!(await verifyBusinessInviteToken(args.grantId, args.token))) {\n      throw new ConvexError({ kind: \"INVALID_INVITE_TOKEN\" });\n    }\n    if (grant.inviteeEmail !== inviteeEmail) {\n      throw new ConvexError({ kind: \"INVITE_EMAIL_MISMATCH\" });","sourceCodeStart":442,"sourceCodeEnd":478,"githubUrl":"https://github.com/koala73/worldmonitor/blob/ffec79ac339946fd2d24e85845da5755dcaa534b/convex/payments/businessSeats.ts#L442-L478","documentation":"`acceptBusinessInvite` resolves the invitee's email from the Clerk identity via `resolveUserIdentity`. If the signed-in user has no email claim, the accept cannot match the invited address and is rejected. This is rare auth drift that the code intentionally surfaces as a structured error for Sentry.","triggerScenarios":"Calling `acceptBusinessInvite` while the Clerk JWT carries no `email` claim — e.g., an OAuth login without email scope, or a phone-only/anonymous account.","commonSituations":"GitHub or other social login granted without email permissions; account provisioned without a verified email; Clerk session missing the email claim after a provider config change.","solutions":["Ensure the Clerk user has a verified email address on the account before accepting","Add an email to the Clerk account and re-authenticate so the claim is present","Verify the OAuth provider includes email scope in the Clerk JWT"],"exampleFix":null,"handlingStrategy":"validation","validationCode":"// Ensure the Clerk session exposes an email before allowing the accept flow.\nconst email = clerk.user?.primaryEmailAddress?.emailAddress;\nif (!email) {\n  // prompt user to add/verify an email before navigating to accept\n}","typeGuard":"function hasEmailClaim(identity: { email?: string } | null): identity is { email: string } {\n  return Boolean(identity && typeof identity.email === 'string' && identity.email.trim().length > 0);\n}","tryCatchPattern":null,"preventionTips":["Require email scope on all OAuth providers in Clerk","Prompt users without an email to add one before showing the accept-invite UI","Re-authenticate after adding an email so the claim is refreshed"],"tags":["payments","business-seats","auth","clerk","invites"],"backgroundTag":null,"analyzedSha":"ffec79ac339946fd2d24e85845da5755dcaa534b","analyzedAt":"2026-08-12T11:24:56.012Z","schemaVersion":2},"datasetVersion":"2026-08-13T09:17:06.757Z"}