Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/sim/lib/billing/core/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ vi.mock('@/lib/users/deleted-identity', () => ({
userHasDeletedAccountIdentity: mockUserHasDeletedAccountIdentity,
}))

vi.mock('@/lib/users/gmail-alias', () => ({
userHasGmailAliasAccount: vi.fn(async () => false),
}))

import {
ensureUserStatsExists,
getUserUsageLimit,
Expand Down
43 changes: 29 additions & 14 deletions apps/sim/lib/billing/core/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
isDeletedAccountIdentity,
userHasDeletedAccountIdentity,
} from '@/lib/users/deleted-identity'
import { userHasGmailAliasAccount } from '@/lib/users/gmail-alias'

const logger = createLogger('UsageManagement')

Expand Down Expand Up @@ -191,6 +192,22 @@ function calculateOrgUsageLimit(
*/
const usersCreatedInAuthRequest = new WeakMap<object, string>()

/**
* Whether a new user's identity already had the free allowance: its email or a
* linked sign-in (with `includeSignIns`) belonged to a recently deleted account,
* or another account already holds its Gmail inbox.
*/
async function identityHadFreeAllowance(
userId: string,
{ includeSignIns }: { includeSignIns: boolean },
executor: DbOrTx = db
): Promise<boolean> {
return (
(await userHasDeletedAccountIdentity(userId, { includeSignIns }, executor)) ||
(await userHasGmailAliasAccount(userId, executor))
)
}

function isUserCreatedInAuthRequest(
userId: string,
authRequest: object | null | undefined
Expand All @@ -202,20 +219,18 @@ function isUserCreatedInAuthRequest(
* Handle new user setup when they join the platform.
* Creates the userStats record with the free allowance, which is zero when the
* user's email or an already-linked social sign-in belongs to an account
* deleted within the retention window. Every user Better Auth creates comes
* through here, including SCIM- and SSO-provisioned users, so their email is
* always checked at creation.
* deleted within the retention window, or when another account holds its Gmail
* inbox. Every user Better Auth creates comes through here, including SCIM- and
* SSO-provisioned users, so their email is always checked at creation.
*
* @param authRequest Better Auth's endpoint context for the signup request, when there is one
*/
export async function handleNewUser(userId: string, authRequest?: object | null): Promise<void> {
if (authRequest) usersCreatedInAuthRequest.set(authRequest, userId)
try {
const freeTierEligible = !(await userHasDeletedAccountIdentity(userId, {
includeSignIns: true,
}))
const freeTierEligible = !(await identityHadFreeAllowance(userId, { includeSignIns: true }))
if (!freeTierEligible) {
logger.info('Withholding the free allowance from a returning deleted-account identity', {
logger.info('Withholding the free allowance from an identity that already had it', {
userId,
})
}
Expand Down Expand Up @@ -315,7 +330,7 @@ export async function createUserStatsIfMissing(
if (created.length === 0) return false

try {
if (await userHasDeletedAccountIdentity(userId, { includeSignIns }, executor)) {
if (await identityHadFreeAllowance(userId, { includeSignIns }, executor)) {
await forfeitFreeTierAllowance(executor, userId)
}
} catch (error) {
Expand Down Expand Up @@ -704,18 +719,18 @@ export async function checkUsageStatus(userId: string): Promise<{
}

/**
* Withholds the free allowance from a new account found to reuse the sign-in
* identity of a recently deleted one after its stats row was created. The flag
* and the limit it implies commit together (a transaction on `db`, a savepoint
* inside a caller's transaction), so a failed re-derivation can never leave the
* account flagged but still holding the free limit; repeating the call is harmless.
* Withholds the free allowance from a new account found, after its stats row was
* created, to reuse an identity that already had it. The flag and the limit it
* implies commit together (a transaction on `db`, a savepoint inside a caller's
* transaction), so a failed re-derivation can never leave the account flagged
* but still holding the free limit; repeating the call is harmless.
*/
async function forfeitFreeTierAllowance(executor: DbOrTx, userId: string): Promise<void> {
await executor.transaction(async (tx) => {
await tx.update(userStats).set({ freeTierEligible: false }).where(eq(userStats.userId, userId))
await syncUsageLimitsFromSubscription(userId, tx)
})
logger.info('Withheld the free allowance from a returning deleted-account identity', { userId })
logger.info('Withheld the free allowance from an identity that already had it', { userId })
}

/**
Expand Down
178 changes: 178 additions & 0 deletions apps/sim/lib/users/gmail-alias.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* A dot, `+tag`, or `googlemail.com` variant of an existing Gmail address must not get a second
* free allowance. Runs against real PostgreSQL through the production signup-stats and
* fallback-stats paths, and checks the lookup is served by `user_gmail_mailbox_idx`.
*/
import { db } from '@sim/db'
import { gmailMailbox, user, userStats } from '@sim/db/schema'
import { authMock } from '@sim/testing/mocks/auth.mock'
import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock'
import { generateId } from '@sim/utils/id'
import { eq, inArray, sql } from 'drizzle-orm'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { ensureUserStatsExists, handleNewUser } from '@/lib/billing/core/usage'
import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils'

vi.mock('@/lib/core/config/env-flags', () => ({
...envFlagsMock,
isHosted: true,
isBillingEnabled: true,
}))
vi.mock('@/lib/auth', () => authMock)

const userIds: string[] = []

async function insertUser(
email: string,
{ banned = false, emailVerified = true, createdAt = new Date() } = {}
): Promise<string> {
const id = generateId()
userIds.push(id)
await db.insert(user).values({
id,
name: 'Gmail alias fixture',
email,
emailVerified,
banned,
createdAt,
updatedAt: createdAt,
})
return id
}

async function signUp(
email: string,
options?: { banned?: boolean; emailVerified?: boolean }
): Promise<string> {
const id = await insertUser(email, options)
await handleNewUser(id, {})
return id
}

async function statsOf(userId: string) {
const [row] = await db
.select({
freeTierEligible: userStats.freeTierEligible,
currentUsageLimit: userStats.currentUsageLimit,
})
.from(userStats)
.where(eq(userStats.userId, userId))
return { freeTierEligible: row?.freeTierEligible, limit: Number(row?.currentUsageLimit) }
}

/** A Gmail local part no other test run can collide with. */
function uniqueLocalPart(): string {
return `alias${generateId().replaceAll('-', '')}`
}

afterAll(async () => {
if (userIds.length) await db.delete(user).where(inArray(user.id, userIds))
})

describe('Gmail inboxes and the free allowance', () => {
it('withholds the allowance from dot, +tag, and googlemail variants of a live account', async () => {
const local = uniqueLocalPart()
const original = await signUp(`${local}@gmail.com`)

const dotted = await signUp(`${local.slice(0, 4)}.${local.slice(4)}@gmail.com`)
const tagged = await signUp(`${local}+farm@gmail.com`)
const googlemail = await signUp(`${local.toUpperCase()}@GoogleMail.com`)

expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
for (const variant of [dotted, tagged, googlemail]) {
expect(await statsOf(variant)).toEqual({ freeTierEligible: false, limit: 0 })
}
})

it('withholds it when the inbox belongs to a banned account', async () => {
const local = uniqueLocalPart()
await signUp(`${local}@gmail.com`, { banned: true })

const returning = await signUp(`${local}+again@gmail.com`)

expect(await statsOf(returning)).toEqual({ freeTierEligible: false, limit: 0 })
})

it('withholds it from aliases registered before any of them verified the inbox', async () => {
const local = uniqueLocalPart()
const first = await signUp(`${local}@gmail.com`, { emailVerified: false })
const second = await signUp(`${local}+two@gmail.com`, { emailVerified: false })
await db
.update(user)
.set({ emailVerified: true })
.where(inArray(user.id, [first, second]))

const third = await signUp(`${local.slice(0, 5)}.${local.slice(5)}@gmail.com`)

expect(await statsOf(first)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
expect(await statsOf(second)).toEqual({ freeTierEligible: false, limit: 0 })
expect(await statsOf(third)).toEqual({ freeTierEligible: false, limit: 0 })
})

it('grants it to a different Gmail inbox and never folds addresses outside Gmail', async () => {
const local = uniqueLocalPart()
await signUp(`${local}@gmail.com`)
await signUp(`${local}+one@gmail-alias.test`)

const otherInbox = await signUp(`${local}x@gmail.com`)
const otherDomain = await signUp(`${local}@gmail-alias.test`)

expect(await statsOf(otherInbox)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
expect(await statsOf(otherDomain)).toEqual({
freeTierEligible: true,
limit: getFreeTierLimit(),
})
})

it('withholds it from a stats row the fallback creates after signup failed to', async () => {
const local = uniqueLocalPart()
await signUp(`${local}@gmail.com`)
const alias = await insertUser(`${local.slice(0, 3)}.${local.slice(3)}@gmail.com`)

await ensureUserStatsExists(alias)

expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 })
})

it('keeps the allowance with the inbox’s first account when its stats row comes after an alias’s', async () => {
const local = uniqueLocalPart()
const original = await insertUser(`${local}@gmail.com`, {
createdAt: new Date(Date.now() - 60_000),
})
const alias = await signUp(`${local}+later@gmail.com`)

await ensureUserStatsExists(original)

expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 })
})

it('withholds from exactly one of two accounts created at the same instant', async () => {
const local = uniqueLocalPart()
const createdAt = new Date()
const first = await insertUser(`${local}@gmail.com`, { createdAt })
const second = await insertUser(`${local.slice(0, 2)}.${local.slice(2)}@gmail.com`, {
createdAt,
})

await ensureUserStatsExists(first)
await ensureUserStatsExists(second)

const eligible = [await statsOf(first), await statsOf(second)].map(
(stats) => stats.freeTierEligible
)
expect(eligible.sort()).toEqual([false, true])
})

it('finds the inbox through user_gmail_mailbox_idx rather than a scan of user', async () => {
const plan = await db.transaction(async (tx) => {
await tx.execute(sql`SET LOCAL enable_seqscan = off`)
const rows = await tx.execute<{ 'QUERY PLAN': string }>(
sql`EXPLAIN SELECT ${user.id} FROM ${user} WHERE ${gmailMailbox(user.email)} = ${`${uniqueLocalPart()}@gmail.com`} LIMIT 1`
)
return rows.map((row) => row['QUERY PLAN']).join('\n')
})

expect(plan).toContain('user_gmail_mailbox_idx')
})
})
67 changes: 67 additions & 0 deletions apps/sim/lib/users/gmail-alias.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { db } from '@sim/db'
import { gmailMailbox, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, lt, or, sql } from 'drizzle-orm'
import { isBillingEnabled } from '@/lib/core/config/env-flags'
import type { DbOrTx } from '@/lib/db/types'

/**
* One free allowance per Gmail inbox. Gmail delivers every dot and `+tag`
* variant of an address, and its `googlemail.com` twin, to the same inbox, so
* without this one person could register `a.b@gmail.com`, `ab+1@gmail.com`, …
* for a fresh allowance each time. The later account is created normally but
* without the free allowance, like a returning deleted-account identity.
*
* Only meaningful where billing is enabled.
*/

const logger = createLogger('GmailAlias')

/**
* Whether an earlier account, banned or not, already holds the Gmail inbox this
* user's address delivers to. Only an account created before this one counts
* (the id breaks a creation-time tie), so the inbox's first account keeps the
* allowance however late its own stats row is created. The earlier account
* counts whether or not it verified the address: the allowance is granted at
* signup, before verification, and where verification is off a password
* account never verifies, so a verified-only rule would let every alias
* registered unverified keep its allowance. Pass the caller's transaction as
* `executor` to read on it; the read then runs in a savepoint, so a failed
* lookup never aborts that transaction. Fails open: a lookup error returns
* `false` and is logged, because refusing a legitimate person's allowance is
* worse than one missed match.
*/
export async function userHasGmailAliasAccount(
userId: string,
executor: DbOrTx = db
): Promise<boolean> {
if (!isBillingEnabled) return false
const read = async (reader: DbOrTx) => {
const [self] = await reader
.select({ mailbox: gmailMailbox(user.email), createdAt: user.createdAt })
.from(user)
.where(eq(user.id, userId))
.limit(1)
if (!self?.mailbox) return false
const [other] = await reader
Comment thread
waleedlatif1 marked this conversation as resolved.
.select({ id: user.id })
Comment thread
waleedlatif1 marked this conversation as resolved.
.from(user)
.where(
and(
sql`${gmailMailbox(user.email)} = ${self.mailbox}`,
or(
lt(user.createdAt, self.createdAt),
and(eq(user.createdAt, self.createdAt), lt(user.id, userId))
)
)
)
.limit(1)
return Boolean(other)
}
try {
return await (executor === db ? read(db) : executor.transaction(read))
} catch (error) {
logger.error('Gmail alias lookup failed; granting the free allowance', { userId, error })
return false
}
}
6 changes: 6 additions & 0 deletions packages/db/migrations/0439_gmail_mailbox_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
COMMIT;--> statement-breakpoint
SET lock_timeout = 0;--> statement-breakpoint
-- migration-safe: this new additive index has no deployed readers; remove any unjournaled prior build before rebuilding concurrently on replay.
DROP INDEX CONCURRENTLY IF EXISTS "user_gmail_mailbox_idx";--> statement-breakpoint
CREATE INDEX CONCURRENTLY IF NOT EXISTS "user_gmail_mailbox_idx" ON "user" USING btree ((CASE WHEN split_part(lower(btrim("email")), '@', 2) IN ('gmail.com', 'googlemail.com') THEN replace(split_part(split_part(lower(btrim("email")), '@', 1), '+', 1), '.', '') || '@gmail.com' END)) WHERE (CASE WHEN split_part(lower(btrim("user"."email")), '@', 2) IN ('gmail.com', 'googlemail.com') THEN replace(split_part(split_part(lower(btrim("user"."email")), '@', 1), '+', 1), '.', '') || '@gmail.com' END) IS NOT NULL;--> statement-breakpoint
SET lock_timeout = '5s';
Loading
Loading