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
10 changes: 5 additions & 5 deletions apps/sim/lib/admin/dashboard-credit-grant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const hoistedMocks = vi.hoisted(() => ({
billingSubscriptions: [] as unknown[],
idempotencyCalls: [] as { namespace: string; requestFingerprint: string }[],
moveWorkspace: vi.fn(),
userHasDeletedAccountIdentity: vi.fn(async () => false),
identityHadFreeAllowance: vi.fn(async () => false),
}))

vi.mock('@sim/audit', () => auditMock)
Expand Down Expand Up @@ -54,8 +54,8 @@ vi.mock('@/lib/billing/enterprise-outbox', () => ({
resolveEnterpriseMetadataIntent: vi.fn(),
}))
vi.mock('@/lib/core/outbox/service', () => outboxServiceMock)
vi.mock('@/lib/users/deleted-identity', () => ({
userHasDeletedAccountIdentity: hoistedMocks.userHasDeletedAccountIdentity,
vi.mock('@/lib/users/free-allowance', () => ({
identityHadFreeAllowance: hoistedMocks.identityHadFreeAllowance,
}))

import { grantDashboardOrganizationBalance, grantDashboardUserBalance } from '@/lib/admin/dashboard'
Expand Down Expand Up @@ -154,8 +154,8 @@ describe('grantDashboardUserBalance', () => {
)
})

it('creates a missing stats row without the free allowance for a returning deleted identity', async () => {
mocks.userHasDeletedAccountIdentity.mockResolvedValueOnce(true)
it('creates a missing stats row without the free allowance for an identity that already had it', async () => {
mocks.identityHadFreeAllowance.mockResolvedValueOnce(true)
queueTableRows(user, [{ id: 'user-1' }])
queueTableRows(userStats, [
{ creditBalance: '0', currentUsageLimit: '0', freeTierEligible: false },
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/lib/admin/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import { executeTransactionallyIdempotent } from '@/lib/core/idempotency/transac
import { enqueueOutboxEvent } from '@/lib/core/outbox/service'
import type { DbOrTx } from '@/lib/db/types'
import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery'
import { userHasDeletedAccountIdentity } from '@/lib/users/deleted-identity'
import { identityHadFreeAllowance } from '@/lib/users/free-allowance'
import { ownedAttachableWorkspacesWhere } from '@/lib/workspaces/organization-workspaces'

interface PaginationInput {
Expand Down Expand Up @@ -1503,7 +1503,7 @@ export async function grantDashboardUserBalance(
* the same email check as every other row creation. Looked up before the
* transaction so it never holds a second pooled connection inside it.
*/
const freeTierEligibleIfCreated = !(await userHasDeletedAccountIdentity(userId, {
const freeTierEligibleIfCreated = !(await identityHadFreeAllowance(userId, {
includeSignIns: false,
}))
const outcome = await db.transaction(async (tx) => {
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/billing/core/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ vi.mock('@/lib/users/deleted-identity', () => ({
}))

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

Expand Down
67 changes: 30 additions & 37 deletions apps/sim/lib/billing/core/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,9 @@ import { isBillingEnabled } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import type { DbClient, DbOrTx } from '@/lib/db/types'
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
import {
isDeletedAccountIdentity,
userHasDeletedAccountIdentity,
} from '@/lib/users/deleted-identity'
import { userHasGmailAliasAccount } from '@/lib/users/gmail-alias'
import { isDeletedAccountIdentity } from '@/lib/users/deleted-identity'
import { identityHadFreeAllowance } from '@/lib/users/free-allowance'
import { acquireGmailInboxLock, laterGmailAliasesWithAllowance } from '@/lib/users/gmail-alias'

const logger = createLogger('UsageManagement')

Expand Down Expand Up @@ -192,22 +190,6 @@ 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 @@ -219,27 +201,38 @@ 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, or when another account holds its Gmail
* inbox. Every user Better Auth creates comes through here, including SCIM- and
* deleted within the retention window, or when an earlier account holds its
* Gmail inbox; it decides under that inbox's lock (see `lib/users/gmail-alias`).
* 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 identityHadFreeAllowance(userId, { includeSignIns: true }))
if (!freeTierEligible) {
logger.info('Withholding the free allowance from an identity that already had it', {
await db.transaction(async (tx) => {
await acquireGmailInboxLock(tx, userId)
const freeTierEligible = !(await identityHadFreeAllowance(
userId,
{ includeSignIns: true },
tx
))
if (!freeTierEligible) {
logger.info('Withholding the free allowance from an identity that already had it', {
userId,
})
}
await tx.insert(userStats).values({
id: generateId(),
userId: userId,
currentUsageLimit: getFreeTierAllowance(freeTierEligible).toString(),
freeTierEligible,
usageLimitUpdatedAt: new Date(),
})
}
await db.insert(userStats).values({
id: generateId(),
userId: userId,
currentUsageLimit: getFreeTierAllowance(freeTierEligible).toString(),
freeTierEligible,
usageLimitUpdatedAt: new Date(),
for (const aliasId of await laterGmailAliasesWithAllowance(userId, tx)) {
await forfeitFreeTierAllowance(tx, aliasId)
}
})

logger.info('User stats record created for new user', { userId })
Expand Down Expand Up @@ -305,10 +298,10 @@ export async function ensureUserStatsExists(

/**
* Creates a user's `user_stats` row with the free allowance when none exists, on `executor`
* (the caller's transaction, or `db`). Only a row this call creates gets the deleted-identity
* check signup makes: the email always, and the linked sign-ins only when `includeSignIns` is
* set. An existing row costs only the conflicting insert. A failed check leaves the allowance in
* place and never fails the caller or aborts its transaction.
* (the caller's transaction, or `db`). Only a row this call creates gets the check signup makes:
* the email always, and the linked sign-ins only when `includeSignIns` is set. An existing row
* costs only the conflicting insert. A failed check leaves the allowance in place and never fails
* the caller or aborts its transaction.
*
* @returns whether this call created the row
*/
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/lib/users/free-allowance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { db } from '@sim/db'
import type { DbOrTx } from '@/lib/db/types'
import { userHasDeletedAccountIdentity } from '@/lib/users/deleted-identity'
import { userHasGmailAliasAccount } from '@/lib/users/gmail-alias'

/**
* 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 an earlier account holds its Gmail inbox. Signup, the stats-row fallback,
* and the admin balance grant decide a new row's allowance with this.
*/
export async function identityHadFreeAllowance(
userId: string,
{ includeSignIns }: { includeSignIns: boolean },
executor: DbOrTx = db
): Promise<boolean> {
return (
(await userHasDeletedAccountIdentity(userId, { includeSignIns }, executor)) ||
(await userHasGmailAliasAccount(userId, executor))
)
}
92 changes: 83 additions & 9 deletions apps/sim/lib/users/gmail-alias.integration.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
/**
* 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`.
* free allowance, even when two signups on one inbox race. 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 { createDeferred } from '@sim/testing'
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 { alias } from 'drizzle-orm/pg-core'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { ensureUserStatsExists, handleNewUser } from '@/lib/billing/core/usage'
import { getFreeTierLimit } from '@/lib/billing/subscriptions/utils'
import { acquireGmailInboxLock } from '@/lib/users/gmail-alias'

vi.mock('@/lib/core/config/env-flags', () => ({
...envFlagsMock,
Expand Down Expand Up @@ -60,6 +64,11 @@ async function statsOf(userId: string) {
return { freeTierEligible: row?.freeTierEligible, limit: Number(row?.currentUsageLimit) }
}

/** An account created a minute ago, so any account inserted after it counts as later. */
function insertEarlierUser(email: string): Promise<string> {
return insertUser(email, { createdAt: new Date(Date.now() - 60_000) })
}

/** A Gmail local part no other test run can collide with. */
function uniqueLocalPart(): string {
return `alias${generateId().replaceAll('-', '')}`
Expand Down Expand Up @@ -127,18 +136,16 @@ describe('Gmail inboxes and the free allowance', () => {
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`)
const later = await insertUser(`${local.slice(0, 3)}.${local.slice(3)}@gmail.com`)

await ensureUserStatsExists(alias)
await ensureUserStatsExists(later)

expect(await statsOf(alias)).toEqual({ freeTierEligible: false, limit: 0 })
expect(await statsOf(later)).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 original = await insertEarlierUser(`${local}@gmail.com`)
const alias = await signUp(`${local}+later@gmail.com`)

await ensureUserStatsExists(original)
Expand All @@ -164,11 +171,78 @@ describe('Gmail inboxes and the free allowance', () => {
expect(eligible.sort()).toEqual([false, true])
})

it('withdraws it from an alias whose signup decided before the earlier account was visible', async () => {
const local = uniqueLocalPart()
const later = await signUp(`${local}+fast@gmail.com`)
const original = await insertEarlierUser(`${local}@gmail.com`)

await handleNewUser(original, {})

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

it('never withdraws it from a later alias when the fallback creates the earlier account’s row', async () => {
const local = uniqueLocalPart()
const original = await insertEarlierUser(`${local}@gmail.com`)
const later = await signUp(`${local}+later@gmail.com`)
await db.update(userStats).set({ freeTierEligible: true }).where(eq(userStats.userId, later))

await ensureUserStatsExists(original)

expect(await statsOf(original)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
expect((await statsOf(later)).freeTierEligible).toBe(true)
})

it('makes a racing signup on the same inbox wait for the inbox lock', async () => {
const local = uniqueLocalPart()
const original = await insertEarlierUser(`${local}@gmail.com`)
const racer = await insertUser(`${local.slice(0, 4)}.${local.slice(4)}@gmail.com`)
const held = createDeferred<number>()
const release = createDeferred<void>()
const pending: Promise<unknown>[] = []
try {
const holder = db.transaction(async (tx) => {
await acquireGmailInboxLock(tx, original)
const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`)
held.resolve(connection.pid)
await release.promise
})
pending.push(holder)
void holder.catch((error: unknown) => held.reject(error))
const blockerPid = await held.promise
const signup = handleNewUser(racer, {})
pending.push(signup)

await vi.waitFor(
async () => {
const [waiting] = await db.execute<{ pid: number }>(sql`
SELECT pid FROM pg_stat_activity
WHERE datname = current_database()
AND ${blockerPid}::int = ANY(pg_blocking_pids(pid))
`)
expect(waiting).toBeDefined()
},
{ timeout: 5_000, interval: 25 }
)
expect((await statsOf(racer)).freeTierEligible).toBeUndefined()

release.resolve()
await holder
await signup
expect(await statsOf(racer)).toEqual({ freeTierEligible: false, limit: 0 })
} finally {
release.resolve()
await Promise.allSettled(pending)
}
})

it('finds the inbox through user_gmail_mailbox_idx rather than a scan of user', async () => {
const subject = alias(user, 'subject')
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`
sql`EXPLAIN SELECT ${user.id} FROM ${user} AS "subject" JOIN ${user} ON ${gmailMailbox(user.email)} = ${gmailMailbox(subject.email)} WHERE ${subject.id} = ${generateId()}`
)
return rows.map((row) => row['QUERY PLAN']).join('\n')
})
Expand Down
Loading
Loading