@@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger'
44import { hmacSha256Hex } from '@sim/security/hmac'
55import { getErrorMessage } from '@sim/utils/errors'
66import { normalizeEmail } from '@sim/utils/string'
7- import { and , eq , gt , inArray , lte , sql } from 'drizzle-orm'
7+ import { and , asc , eq , gt , inArray , lte , sql } from 'drizzle-orm'
88import { env } from '@/lib/core/config/env'
99import { isBillingEnabled } from '@/lib/core/config/env-flags'
1010import type { DbOrTx } from '@/lib/db/types'
@@ -42,9 +42,18 @@ const logger = createLogger('DeletedAccountIdentity')
4242 */
4343const DELETED_IDENTITY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
4444
45- /** Expired rows removed per recorded deletion, which keeps the table bounded without a cron . */
45+ /** Expired rows a recorded deletion removes opportunistically on its way through . */
4646const EXPIRED_PURGE_BATCH = 100
4747
48+ /** Rows per batch of the scheduled purge, each its own short transaction. */
49+ const SCHEDULED_PURGE_BATCH = 1_000
50+
51+ /** Batches one scheduled run may delete before leaving the rest to the next run. */
52+ const SCHEDULED_PURGE_MAX_BATCHES = 50
53+
54+ /** Stops admitting batches well inside the cron request's 60-second timeout. */
55+ const SCHEDULED_PURGE_BUDGET_MS = 10_000
56+
4857const GMAIL_DOMAINS = new Set ( [ 'gmail.com' , 'googlemail.com' ] )
4958
5059/**
@@ -129,10 +138,13 @@ function identityHashes(identity: SignInIdentity): string[] {
129138 return [ ...new Set ( preimages ) ] . map ( ( preimage ) => hmacSha256Hex ( preimage , key ) )
130139}
131140
132- /** A user's email and the social sign-in subjects currently linked to it. */
133- async function loadSignInIdentity ( executor : DbOrTx , userId : string ) : Promise < SignInIdentity > {
141+ /** A user's email (and whether it is verified) and the social sign-in subjects linked to it. */
142+ async function loadSignInIdentity (
143+ executor : DbOrTx ,
144+ userId : string
145+ ) : Promise < SignInIdentity & { emailVerified : boolean } > {
134146 const [ profile ] = await executor
135- . select ( { email : user . email } )
147+ . select ( { email : user . email , emailVerified : user . emailVerified } )
136148 . from ( user )
137149 . where ( eq ( user . id , userId ) )
138150 . limit ( 1 )
@@ -145,14 +157,20 @@ async function loadSignInIdentity(executor: DbOrTx, userId: string): Promise<Sig
145157 inArray ( account . providerId , [ ...RECORDED_SIGN_IN_PROVIDER_IDS ] )
146158 )
147159 )
148- return { email : profile ?. email , oauthSubjects }
160+ return { email : profile ?. email , emailVerified : profile ?. emailVerified === true , oauthSubjects }
149161}
150162
151163/**
152164 * Records the sign-in identities of an account being deleted at its holder's
153165 * request. Runs inside the deletion transaction, before the `user` row and its
154166 * `account` rows cascade away, so the record and the erasure commit together.
155167 *
168+ * The email is recorded only when it was verified: an unverified address may
169+ * belong to someone else, who must not lose the allowance for an account they
170+ * never held. Its GitHub and Microsoft subjects are recorded either way. Where
171+ * email verification is off, password accounts are therefore recorded by
172+ * nothing (fails open).
173+ *
156174 * The write runs in a savepoint and never fails the deletion: an anti-abuse
157175 * record must not become a way to block an erasure request. On failure the
158176 * savepoint rolls back, nothing is recorded, and the deletion proceeds.
@@ -162,7 +180,8 @@ export async function recordDeletedAccountIdentities(tx: DbOrTx, userId: string)
162180
163181 try {
164182 await tx . transaction ( async ( savepoint ) => {
165- const hashes = identityHashes ( await loadSignInIdentity ( savepoint , userId ) )
183+ const { email, emailVerified, oauthSubjects } = await loadSignInIdentity ( savepoint , userId )
184+ const hashes = identityHashes ( { email : emailVerified ? email : null , oauthSubjects } )
166185 if ( hashes . length === 0 ) return
167186
168187 await savepoint . delete ( deletedAccountIdentity ) . where (
@@ -242,3 +261,40 @@ export async function userHasDeletedAccountIdentity(
242261 return false
243262 }
244263}
264+
265+ /**
266+ * Deletes every expired identity digest, so none outlives its retention window
267+ * even when no later deletion comes along to purge it. Drains in short batches
268+ * in `expires_at` order, skipping rows a concurrent deletion has locked, until
269+ * none remain, the batch cap is hit, or the time budget runs out; whatever is
270+ * left goes to the next run. Runs whether or not billing is enabled, since
271+ * deleting expired rows is always safe.
272+ */
273+ export async function purgeExpiredDeletedAccountIdentities ( ) : Promise < {
274+ deleted : number
275+ drained : boolean
276+ } > {
277+ const deadline = Date . now ( ) + SCHEDULED_PURGE_BUDGET_MS
278+ let deleted = 0
279+ for ( let batch = 0 ; batch < SCHEDULED_PURGE_MAX_BATCHES ; batch += 1 ) {
280+ if ( Date . now ( ) >= deadline ) return { deleted, drained : false }
281+ const removed = await db
282+ . delete ( deletedAccountIdentity )
283+ . where (
284+ inArray (
285+ deletedAccountIdentity . identityHash ,
286+ db
287+ . select ( { identityHash : deletedAccountIdentity . identityHash } )
288+ . from ( deletedAccountIdentity )
289+ . where ( lte ( deletedAccountIdentity . expiresAt , sql `now()` ) )
290+ . orderBy ( asc ( deletedAccountIdentity . expiresAt ) )
291+ . limit ( SCHEDULED_PURGE_BATCH )
292+ . for ( 'update' , { skipLocked : true } )
293+ )
294+ )
295+ . returning ( { identityHash : deletedAccountIdentity . identityHash } )
296+ deleted += removed . length
297+ if ( removed . length < SCHEDULED_PURGE_BATCH ) return { deleted, drained : true }
298+ }
299+ return { deleted, drained : false }
300+ }
0 commit comments