koala73/worldmonitor · error · ConvexError

INVITEE_EMAIL_UNAVAILABLE

INVITEE_EMAIL_UNAVAILABLE

Error message

INVITEE_EMAIL_UNAVAILABLE

What it means

`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.

Source

Thrown at convex/payments/businessSeats.ts:460

    return { ok: true as const, status: "revoked" as const };
  },
});

/**
 * Invitee redeems an HMAC token to accept a Business Pro seat invite. Verifies
 * the token, matches the signed-in Clerk email against the invited address,
 * checks the underlying Business subscription is still covering, flips the
 * grant to `accepted`, stamps `inviteeUserId`, and recomputes the invitee's
 * entitlement. Single-use: accepted/revoked/expired tokens are rejected.
 */
export const acceptBusinessInvite = mutation({
  args: { grantId: v.id("businessProGrants"), token: v.string() },
  handler: async (ctx, args) => {
    const userId = await requireUserId(ctx);
    const identity = await resolveUserIdentity(ctx);
    const inviteeEmail = identity?.email?.trim().toLowerCase();
    if (!inviteeEmail) {
      throw new ConvexError({ kind: "INVITEE_EMAIL_UNAVAILABLE" });
    }

    const grant = await ctx.db.get(args.grantId);
    if (!grant) {
      throw new ConvexError({ kind: "GRANT_NOT_FOUND" });
    }
    if (grant.status !== "pending") {
      throw new ConvexError({ kind: "INVITE_ALREADY_USED" });
    }
    const now = Date.now();
    if (grant.expiresAt <= now) {
      throw new ConvexError({ kind: "INVITE_EXPIRED" });
    }
    if (!(await verifyBusinessInviteToken(args.grantId, args.token))) {
      throw new ConvexError({ kind: "INVALID_INVITE_TOKEN" });
    }
    if (grant.inviteeEmail !== inviteeEmail) {
      throw new ConvexError({ kind: "INVITE_EMAIL_MISMATCH" });

View on GitHub (pinned to ffec79ac33)

Solutions

  1. Ensure the Clerk user has a verified email address on the account before accepting
  2. Add an email to the Clerk account and re-authenticate so the claim is present
  3. Verify the OAuth provider includes email scope in the Clerk JWT
Defensive patterns

Strategy: validation

Validate before calling

// Ensure the Clerk session exposes an email before allowing the accept flow.
const email = clerk.user?.primaryEmailAddress?.emailAddress;
if (!email) {
  // prompt user to add/verify an email before navigating to accept
}

Type guard

function hasEmailClaim(identity: { email?: string } | null): identity is { email: string } {
  return Boolean(identity && typeof identity.email === 'string' && identity.email.trim().length > 0);
}

Prevention

When it happens

Trigger: Calling `acceptBusinessInvite` while the Clerk JWT carries no `email` claim — e.g., an OAuth login without email scope, or a phone-only/anonymous account.

Common situations: 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.

Related errors


AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12). Data as JSON: /api/errors/73d7624fd04ef4d2. Report an issue: GitHub.