Skip to content

Commit a5be6a6

Browse files
committed
fix(billing): keep deleted identities 30 days, drop Google subjects, leave the dialog unchanged
- Identity digests now expire 30 days after the deletion, inside the privacy policy's 30-day Account Data window. - Google subject ids are no longer recorded or matched (the policy keeps Google API data only 7 days after deletion); a Google re-signup is still caught by its email digest. GitHub and Microsoft subjects are kept. - The deletion dialog is unchanged from staging. - Callers added on staging pass the deletion option.
1 parent 0b6aa02 commit a5be6a6

7 files changed

Lines changed: 63 additions & 33 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal.tsx‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { createLogger } from '@sim/logger'
66
import { sleep } from '@sim/utils/helpers'
77
import { formatQuotedNameList, normalizeEmail } from '@sim/utils/string'
88
import { signOut } from '@/lib/auth/client'
9-
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
109
import { useAccountDeletionPlan, useDeleteAccount } from '@/hooks/queries/account-deletion'
1110
import { clearUserData } from '@/stores'
1211

@@ -43,7 +42,6 @@ function names(workspaces: { name: string }[]): string {
4342
*/
4443
export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountModalProps) {
4544
const [confirmEmail, setConfirmEmail] = useState('')
46-
const { billingEnabled } = useDeploymentShape()
4745
const { data: plan, isFetching: isPlanFetching, error: planError } = useAccountDeletionPlan(open)
4846
const deleteAccount = useDeleteAccount()
4947

@@ -130,12 +128,6 @@ export function DeleteAccountModal({ open, onOpenChange, email }: DeleteAccountM
130128
workflows, chats, files, knowledge bases and credentials.{' '}
131129
<span className='text-[var(--text-error)]'>This cannot be undone.</span>
132130
</p>
133-
{billingEnabled && (
134-
<p className='text-[var(--text-secondary)] text-sm'>
135-
Free credits are granted once per person. A new account on this email or sign-in
136-
starts without them for a year.
137-
</p>
138-
)}
139131
{toDelete.length > 0 && (
140132
<p className='text-[var(--text-secondary)] text-sm'>
141133
{toDelete.length === 1 ? 'The workspace ' : 'The workspaces '}

‎apps/sim/lib/billing/core/usage.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { member, organization, userStats } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { generateId } from '@sim/utils/id'
55
import { and, eq, isNull, sql } from 'drizzle-orm'
6-
import { isSignInProviderAllowed } from '@/lib/auth/constants'
76
import { getEffectiveBillingStatus } from '@/lib/billing/core/access'
87
import { defaultBillingPeriod } from '@/lib/billing/core/billing-period'
98
import {
@@ -237,7 +236,8 @@ export async function handleNewUser(userId: string, authRequest?: object | null)
237236
* same auth request as its user is a signup. A sign-in linked to an existing
238237
* user in a later request never withholds anything; that includes a SCIM- or
239238
* SSO-provisioned user, whose email was already checked when it was created.
240-
* Integration connections are skipped.
239+
* Only GitHub and Microsoft subjects are matched (see `lib/users/deleted-identity`);
240+
* a Google sign-in is matched by its email, and integration connections never are.
241241
*
242242
* @param authRequest Better Auth's endpoint context for the request creating the account
243243
*/
@@ -254,7 +254,6 @@ export async function handleNewSignInAccount(
254254
authRequest: object | null | undefined
255255
): Promise<void> {
256256
if (!authRequest || usersCreatedInAuthRequest.get(authRequest) !== userId) return
257-
if (!isSignInProviderAllowed(providerId)) return
258257
if (!(await isDeletedAccountIdentity({ oauthSubjects: [{ providerId, accountId }] }))) return
259258
await forfeitFreeTierAllowance(userId)
260259
}

‎apps/sim/lib/execution/removal-stops.integration.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ describe.runIf(Boolean(redisUrl))('removals stop the workflow runs they orphan',
264264
)
265265
const keptQueued = await seedQueuedJob(keptWorkspaceId)
266266

267-
await deleteUserAccount(departingId)
267+
await deleteUserAccount(departingId, { recordIdentity: false })
268268

269269
await expect.poll(() => isCancelled(running)).toBe(true)
270270
await expect.poll(() => isCancelled(resuming)).toBe(true)

‎apps/sim/lib/users/account-deletion-attachments.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ describe('account deletion of private organization Assistant images', () => {
9797
])
9898
queueTableRows(schemaMock.document, [{ key: IMAGE_KEY }])
9999

100-
await deleteUserAccount('user-1')
100+
await deleteUserAccount('user-1', { recordIdentity: false })
101101

102102
expect(mockDeleteFiles).toHaveBeenCalledExactlyOnceWith([FAILED_IMAGE_KEY], 'mothership')
103103
})

‎apps/sim/lib/users/deleted-identity.integration.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,20 +249,47 @@ describe('deleted account identities and the free allowance', () => {
249249
})
250250

251251
it('never withholds from a stats-less user’s fallback row for a sign-in linked in a later request', async () => {
252-
const google = { providerId: 'google', accountId: `google-${generateId()}` }
253-
await deleteOwnAccount(
254-
await signUp(`google-gone.${generateId()}@deleted-identity.test`, google)
255-
)
252+
const microsoft = { providerId: 'microsoft', accountId: `ms-${generateId()}` }
253+
await deleteOwnAccount(await signUp(`ms-gone.${generateId()}@deleted-identity.test`, microsoft))
256254
const legacy = await insertUser(`legacy.${generateId()}@deleted-identity.test`)
257255
const laterRequest = {}
258-
await linkAccount(legacy, google)
256+
await linkAccount(legacy, microsoft)
259257

260258
await ensureUserStatsExists(legacy, laterRequest)
261-
await handleNewSignInAccount({ userId: legacy, ...google }, laterRequest)
259+
await handleNewSignInAccount({ userId: legacy, ...microsoft }, laterRequest)
262260

263261
expect(await statsOf(legacy)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
264262
})
265263

264+
it('catches a Google re-signup by its email and never records the Google subject', async () => {
265+
const email = `google.${generateId()}@deleted-identity.test`
266+
const google = { providerId: 'google', accountId: `google-${generateId()}` }
267+
const written = await deleteOwnAccount(await signUp(email, google))
268+
269+
const sameEmail = await signUp(email, google)
270+
const newEmail = await signUp(`google-new.${generateId()}@deleted-identity.test`, google)
271+
272+
expect(written).toHaveLength(1)
273+
expect(await statsOf(sameEmail)).toEqual({ freeTierEligible: false, limit: 0 })
274+
expect(await statsOf(newEmail)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
275+
})
276+
277+
it('keeps a deleted identity for 30 days from the deletion', async () => {
278+
const email = `window.${generateId()}@deleted-identity.test`
279+
const userId = await signUp(email)
280+
const deletedAt = Date.now()
281+
const written = await deleteOwnAccount(userId)
282+
283+
const rows = await db
284+
.select({ expiresAt: deletedAccountIdentity.expiresAt })
285+
.from(deletedAccountIdentity)
286+
.where(inArray(deletedAccountIdentity.identityHash, written))
287+
288+
const thirtyDays = 30 * 24 * 60 * 60 * 1000
289+
expect(rows).toHaveLength(1)
290+
expect(Math.abs(rows[0].expiresAt.getTime() - (deletedAt + thirtyDays))).toBeLessThan(60_000)
291+
})
292+
266293
it('records only sign-in identities, as keyed digests holding no address or subject', async () => {
267294
const email = `digest.${generateId()}@deleted-identity.test`
268295
const githubSubject = `gh-${generateId()}`

‎apps/sim/lib/users/deleted-identity.ts‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { createLogger } from '@sim/logger'
44
import { hmacSha256Hex } from '@sim/security/hmac'
55
import { getErrorMessage } from '@sim/utils/errors'
66
import { and, eq, gt, inArray, lte, sql } from 'drizzle-orm'
7-
import { isSignInProviderAllowed, SIGN_IN_PROVIDER_IDS } from '@/lib/auth/constants'
87
import { env } from '@/lib/core/config/env'
98
import { isBillingEnabled } from '@/lib/core/config/env-flags'
109
import type { DbOrTx } from '@/lib/db/types'
@@ -14,7 +13,7 @@ import type { DbOrTx } from '@/lib/db/types'
1413
*
1514
* The free allowance is a one-time grant per person, but deletion erases the
1615
* account row and the usage ledger with it, so without this a person could
17-
* delete and re-register the same Google, GitHub, or email identity for a fresh
16+
* delete and re-register the same email, GitHub, or Microsoft identity for a fresh
1817
* allowance each time. Deletion keeps only keyed one-way digests of the
1918
* account's sign-in identities; a new account on a matching identity is created
2019
* normally but without the free allowance. This is the "one free trial per
@@ -35,10 +34,12 @@ const logger = createLogger('DeletedAccountIdentity')
3534

3635
/**
3736
* How long a deleted account's identity digests are kept, and so how long they
38-
* withhold the free allowance from new accounts on the same identity: 365 days.
39-
* Each recorded deletion restarts the window for the identities it records.
37+
* withhold the free allowance from new accounts on the same identity: 30 days,
38+
* inside the privacy policy's stated retention of Account Data "for 30 days
39+
* after a deletion request". Each recorded deletion restarts the window for
40+
* the identities it records.
4041
*/
41-
const DELETED_IDENTITY_RETENTION_MS = 365 * 24 * 60 * 60 * 1000
42+
const DELETED_IDENTITY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
4243

4344
/** Expired rows removed per recorded deletion, which keeps the table bounded without a cron. */
4445
const EXPIRED_PURGE_BATCH = 100
@@ -66,11 +67,17 @@ const PLUS_ADDRESSING_DOMAINS = new Set([
6667
])
6768

6869
/**
69-
* One social sign-in identity: the provider and that provider's stable subject
70-
* id. Only `SIGN_IN_PROVIDER_IDS` count; integration connections share the
71-
* `account` table but carry per-connection ids, and the email/password row's
72-
* `accountId` is the user id itself.
70+
* Sign-in providers whose subject ids are recorded and matched. Google is left
71+
* out: the privacy policy keeps Google API data only 7 days after deletion, and
72+
* a Google re-signup is still caught by its email digest. Integration
73+
* connections share the `account` table but carry per-connection ids, and the
74+
* email/password row's `accountId` is the user id itself, so neither counts.
7375
*/
76+
const RECORDED_SIGN_IN_PROVIDER_IDS = ['github', 'microsoft'] as const
77+
78+
const recordedSignInProviderIdSet: ReadonlySet<string> = new Set(RECORDED_SIGN_IN_PROVIDER_IDS)
79+
80+
/** One social sign-in identity: the provider and that provider's stable subject id. */
7481
interface OAuthSubject {
7582
providerId: string
7683
accountId: string
@@ -114,7 +121,7 @@ function identityHashes(identity: SignInIdentity): string[] {
114121
const email = identity.email ? canonicalizeEmail(identity.email) : null
115122
if (email) preimages.push(`email:${email}`)
116123
for (const subject of identity.oauthSubjects ?? []) {
117-
if (!isSignInProviderAllowed(subject.providerId) || !subject.accountId) continue
124+
if (!recordedSignInProviderIdSet.has(subject.providerId) || !subject.accountId) continue
118125
preimages.push(`oauth:${subject.providerId}:${subject.accountId}`)
119126
}
120127
const key = getIdentityKey()
@@ -131,7 +138,12 @@ async function loadSignInIdentity(executor: DbOrTx, userId: string): Promise<Sig
131138
const oauthSubjects = await executor
132139
.select({ providerId: account.providerId, accountId: account.accountId })
133140
.from(account)
134-
.where(and(eq(account.userId, userId), inArray(account.providerId, [...SIGN_IN_PROVIDER_IDS])))
141+
.where(
142+
and(
143+
eq(account.userId, userId),
144+
inArray(account.providerId, [...RECORDED_SIGN_IN_PROVIDER_IDS])
145+
)
146+
)
135147
return { email: profile?.email, oauthSubjects }
136148
}
137149

‎apps/sim/lib/users/storage-sweep.integration.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ describe('account deletion storage sweep', () => {
278278
async () => {
279279
const f = await seed()
280280
const startedAt = Date.now()
281-
await deleteUserAccount(f.departingId)
281+
await deleteUserAccount(f.departingId, { recordIdentity: false })
282282

283283
const sweeps = await sweepEvents(f.doomedId)
284284
const objects = await objectEvents(f.departingId)
@@ -338,7 +338,7 @@ describe('account deletion storage sweep', () => {
338338
.update(user)
339339
.set({ image: avatarUrl(f.keys.othersAvatar) })
340340
.where(inArray(user.id, [f.ownerId, f.departingId]))
341-
await deleteUserAccount(f.departingId)
341+
await deleteUserAccount(f.departingId, { recordIdentity: false })
342342

343343
await runNow((await objectEvents(f.departingId)).map((event) => event.id))
344344

@@ -347,7 +347,7 @@ describe('account deletion storage sweep', () => {
347347

348348
check('a sweep for a workspace that exists again completes without deleting', async () => {
349349
const f = await seed()
350-
await deleteUserAccount(f.departingId)
350+
await deleteUserAccount(f.departingId, { recordIdentity: false })
351351
const [sweep] = await sweepEvents(f.doomedId)
352352
await insertAdminWorkspace(f.doomedId, f.ownerId)
353353
const restored = `workspace/${f.doomedId}/restored.txt`

0 commit comments

Comments
 (0)