fix(clerk-webhook): sync emailVerified from Clerk verification status

The webhook handler now derives emailVerified from the primary email's
Clerk verification status (1 = verified, 0 = unverified) and writes it
on both user.created upserts and user.updated mutations. This aligns
manual test-user creation (scripts/create_test_user) with webhook-created
users, which previously diverged (manual set emailVerified=1, webhook
did not set it at all).

- resolveEmailVerified(): new helper reads the primary email's
  verification.status from the Clerk payload.
- INSERT/UPDATE SQL now includes emailVerified in the column list.
- Tests assert emailVerified=1 for verified emails and emailVerified=0
  for unverified emails.
This commit is contained in:
2026-07-23 01:49:05 -04:00
parent 7287f10c9a
commit 0385090f32
2 changed files with 40 additions and 4 deletions

View File

@@ -165,13 +165,14 @@ async function call(
function getUserByClerkId(clerkUserId: string) { function getUserByClerkId(clerkUserId: string) {
const row = db const row = db
.prepare( .prepare(
"SELECT id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?" "SELECT id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?"
) )
.get(clerkUserId) as .get(clerkUserId) as
| { | {
id: string; id: string;
clerkUserId: string; clerkUserId: string;
email: string | null; email: string | null;
emailVerified: number;
firstName: string | null; firstName: string | null;
lastName: string | null; lastName: string | null;
displayName: string | null; displayName: string | null;
@@ -194,6 +195,7 @@ describe("Clerk user.created webhook", () => {
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(user!.clerkUserId).toBe("user_abc123"); expect(user!.clerkUserId).toBe("user_abc123");
expect(user!.email).toBe("jane@example.com"); expect(user!.email).toBe("jane@example.com");
expect(user!.emailVerified).toBe(1); // Clerk marked the email verified
expect(user!.firstName).toBe("Jane"); expect(user!.firstName).toBe("Jane");
expect(user!.lastName).toBe("Doe"); expect(user!.lastName).toBe("Doe");
expect(user!.displayName).toBe("Jane Doe"); expect(user!.displayName).toBe("Jane Doe");
@@ -228,6 +230,26 @@ describe("Clerk user.created webhook", () => {
const user = getUserByClerkId("user_abc123"); const user = getUserByClerkId("user_abc123");
expect(user!.displayName).toBe("janedoe"); expect(user!.displayName).toBe("janedoe");
}); });
it("records emailVerified=0 when Clerk marks the email unverified", async () => {
const res = await call(
sign(
userCreatedPayload({
email_addresses: [
{
id: "idn_1",
email_address: "unverified@example.com",
verification: { status: "unverified" }
}
],
primary_email_address_id: "idn_1"
})
)
);
expect(res.status).toBe(200);
const user = getUserByClerkId("user_abc123");
expect(user!.emailVerified).toBe(0);
});
}); });
describe("Clerk user.updated webhook", () => { describe("Clerk user.updated webhook", () => {
@@ -245,6 +267,7 @@ describe("Clerk user.updated webhook", () => {
expect(after!.id).toBe(localId); // local UUID stable expect(after!.id).toBe(localId); // local UUID stable
expect(after!.clerkUserId).toBe("user_abc123"); expect(after!.clerkUserId).toBe("user_abc123");
expect(after!.email).toBe("jane.new@example.com"); expect(after!.email).toBe("jane.new@example.com");
expect(after!.emailVerified).toBe(1); // verification status carried through update
expect(after!.lastName).toBe("Smith"); expect(after!.lastName).toBe("Smith");
expect(after!.displayName).toBe("Jane Smith"); expect(after!.displayName).toBe("Jane Smith");
expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png"); expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png");

View File

@@ -65,6 +65,15 @@ function resolveEmail(data: ClerkUserData): string | null {
return primary?.email_address ?? null; return primary?.email_address ?? null;
} }
/** Resolve the primary email's verification status (1 = verified, 0 = unverified). */
function resolveEmailVerified(data: ClerkUserData): number {
const addresses = data.email_addresses ?? [];
const primaryId = data.primary_email_address_id ?? null;
const primary =
addresses.find((e) => e.id === primaryId) ?? addresses[0] ?? null;
return primary?.verification?.status === "verified" ? 1 : 0;
}
/** Resolve a display name (first + last, falling back to username). */ /** Resolve a display name (first + last, falling back to username). */
function resolveDisplayName(data: ClerkUserData): string | null { function resolveDisplayName(data: ClerkUserData): string | null {
const first = (data.first_name ?? "").trim(); const first = (data.first_name ?? "").trim();
@@ -164,6 +173,7 @@ export async function handleClerkUserWebhook(opts: {
const data = evt.data; const data = evt.data;
const clerkUserId = data.id; const clerkUserId = data.id;
const email = resolveEmail(data); const email = resolveEmail(data);
const emailVerified = resolveEmailVerified(data);
const firstName = data.first_name ?? null; const firstName = data.first_name ?? null;
const lastName = data.last_name ?? null; const lastName = data.last_name ?? null;
const displayName = resolveDisplayName(data); const displayName = resolveDisplayName(data);
@@ -174,10 +184,11 @@ export async function handleClerkUserWebhook(opts: {
// ON CONFLICT instead of erroring on a duplicate. // ON CONFLICT instead of erroring on a duplicate.
await conn.execute({ await conn.execute({
sql: `INSERT INTO users sql: `INSERT INTO users
(id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status) (id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'clerk', 'active') VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
ON CONFLICT(clerkUserId) DO UPDATE SET ON CONFLICT(clerkUserId) DO UPDATE SET
email = excluded.email, email = excluded.email,
emailVerified = excluded.emailVerified,
firstName = excluded.firstName, firstName = excluded.firstName,
lastName = excluded.lastName, lastName = excluded.lastName,
displayName = excluded.displayName, displayName = excluded.displayName,
@@ -187,6 +198,7 @@ export async function handleClerkUserWebhook(opts: {
crypto.randomUUID(), crypto.randomUUID(),
clerkUserId, clerkUserId,
email, email,
emailVerified,
firstName, firstName,
lastName, lastName,
displayName, displayName,
@@ -198,13 +210,14 @@ export async function handleClerkUserWebhook(opts: {
await conn.execute({ await conn.execute({
sql: `UPDATE users SET sql: `UPDATE users SET
email = ?, email = ?,
emailVerified = ?,
firstName = ?, firstName = ?,
lastName = ?, lastName = ?,
displayName = ?, displayName = ?,
avatarUrl = ?, avatarUrl = ?,
updatedAt = datetime('now') updatedAt = datetime('now')
WHERE clerkUserId = ?`, WHERE clerkUserId = ?`,
args: [email, firstName, lastName, displayName, avatarUrl, clerkUserId] args: [email, emailVerified, firstName, lastName, displayName, avatarUrl, clerkUserId]
}); });
} }