diff --git a/packages/services/src/invitation/__tests__/invitation.test.ts b/packages/services/src/invitation/__tests__/invitation.test.ts index 02127565a..8dcbe71a4 100644 --- a/packages/services/src/invitation/__tests__/invitation.test.ts +++ b/packages/services/src/invitation/__tests__/invitation.test.ts @@ -74,6 +74,18 @@ describe("createInvitation", () => { }); }); + test("stores the email trimmed and lowercased", async () => { + await withTestTransaction(async (tx) => { + const email = `${TEST_PREFIX}-case-${Date.now()}@example.com`; + const row = await createInvitation({ + ctx: { ...teamCtx, db: tx }, + input: { email: ` ${email.toUpperCase()} ` }, + }); + + expect(row.email).toBe(email); + }); + }); + test("rejects read-only actor", async () => { await withTestTransaction(async (tx) => { const readOnlyCtx = { @@ -146,6 +158,39 @@ describe("getInvitationByToken", () => { }); }); + test("matches a legacy mixed-case invitation case-insensitively", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const email = `${TEST_PREFIX}-legacy-${Date.now()}@example.com`; + const token = crypto.randomUUID(); + const legacy = await tx + .insert(invitation) + .values({ + email: email.toUpperCase(), + workspaceId: teamCtx.workspace.id, + token, + expiresAt: new Date(Date.now() + 60_000), + }) + .returning() + .get(); + + const row = await getInvitationByToken({ + ctx, + input: { token, email }, + }); + expect(row.id).toBe(legacy.id); + + const workspaceRow = await acceptInvitation({ + ctx: { + ...makeUserCtx(teamCtx.workspace, { userId: acceptingUserId }), + db: tx, + }, + input: { id: legacy.id, email }, + }); + expect(workspaceRow.id).toBe(teamCtx.workspace.id); + }); + }); + test("rejects a valid token for the wrong email", async () => { await withTestTransaction(async (tx) => { const ctx = { ...teamCtx, db: tx }; diff --git a/packages/services/src/invitation/accept.ts b/packages/services/src/invitation/accept.ts index d8f7c0aad..ea7e4c158 100644 --- a/packages/services/src/invitation/accept.ts +++ b/packages/services/src/invitation/accept.ts @@ -1,4 +1,4 @@ -import { and, eq, gte, isNull } from "@openstatus/db"; +import { and, eq, gte, isNull, sql } from "@openstatus/db"; import { invitation, selectWorkspaceSchema, @@ -58,7 +58,8 @@ export async function acceptInvitation(args: { const existing = await tx.query.invitation.findFirst({ where: and( eq(invitation.id, input.id), - eq(invitation.email, input.email), + // Rows created before emails were normalized keep their original casing. + sql`lower(${invitation.email}) = ${input.email}`, isNull(invitation.acceptedAt), gte(invitation.expiresAt, new Date()), ), diff --git a/packages/services/src/invitation/list.ts b/packages/services/src/invitation/list.ts index ae607b670..3c1ed2514 100644 --- a/packages/services/src/invitation/list.ts +++ b/packages/services/src/invitation/list.ts @@ -1,4 +1,4 @@ -import { and, db as defaultDb, eq, gte, isNull } from "@openstatus/db"; +import { and, db as defaultDb, eq, gte, isNull, sql } from "@openstatus/db"; import { invitation, selectInvitationSchema, @@ -59,7 +59,8 @@ export async function getInvitationByToken(args: { eq(invitation.token, input.token), isNull(invitation.acceptedAt), gte(invitation.expiresAt, new Date()), - eq(invitation.email, input.email), + // Rows created before emails were normalized keep their original casing. + sql`lower(${invitation.email}) = ${input.email}`, ), with: { workspace: true }, }); diff --git a/packages/services/src/invitation/schemas.ts b/packages/services/src/invitation/schemas.ts index 85dac2ef0..bd163ebcc 100644 --- a/packages/services/src/invitation/schemas.ts +++ b/packages/services/src/invitation/schemas.ts @@ -1,7 +1,13 @@ import { z } from "zod"; +/** + * Invitation emails are stored and compared lowercase. Trim + lowercase run + * before the format check so `" Foo@Bar.com "` normalizes instead of failing. + */ +const InvitationEmail = z.string().trim().toLowerCase().pipe(z.email()); + export const CreateInvitationInput = z.object({ - email: z.email(), + email: InvitationEmail, }); export type CreateInvitationInput = z.infer; @@ -13,7 +19,7 @@ export type ListInvitationsInput = z.infer; export const GetInvitationByTokenInput = z.object({ token: z.string().min(1), - email: z.email(), + email: InvitationEmail, }); export type GetInvitationByTokenInput = z.infer< typeof GetInvitationByTokenInput @@ -21,6 +27,6 @@ export type GetInvitationByTokenInput = z.infer< export const AcceptInvitationInput = z.object({ id: z.number().int(), - email: z.email(), + email: InvitationEmail, }); export type AcceptInvitationInput = z.infer;