From 0fccbb5fa2d2fb6723dcb898f25e53ee83403799 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:17:28 +0000 Subject: [PATCH 1/4] fix: handle race condition in recordExternalDbSyncDeletion When concurrent operations delete the same row (e.g., password change bulk-deletes all tokens while a concurrent sign-out tries to record deletion of the same token), the INSERT...SELECT in recordExternalDbSyncDeletion finds 0 rows. Previously this threw a StackAssertionError. Now: - insertedCount === 0: log via captureError (race condition, not a bug) - insertedCount > 1: still throws (data integrity issue) Affected entity types: - ProjectUserRefreshToken - VerificationCode_TEAM_INVITATION Co-Authored-By: Konstantin Wohlwend --- apps/backend/src/lib/external-db-sync.ts | 12 ++- .../v1/auth/sessions/current/index.test.ts | 85 ++++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/lib/external-db-sync.ts b/apps/backend/src/lib/external-db-sync.ts index d638911a86..4142e68d6b 100644 --- a/apps/backend/src/lib/external-db-sync.ts +++ b/apps/backend/src/lib/external-db-sync.ts @@ -411,7 +411,11 @@ export async function recordExternalDbSyncDeletion( FOR UPDATE `); - if (insertedCount !== 1) { + if (insertedCount === 0) { + captureError("external-db-sync-deletion-race", new StackAssertionError( + `Expected to insert 1 DeletedRow entry for ProjectUserRefreshToken, got 0. This is likely a race condition where the row was already deleted by a concurrent operation.` + )); + } else if (insertedCount !== 1) { throw new StackAssertionError( `Expected to insert 1 DeletedRow entry for ProjectUserRefreshToken, got ${insertedCount}.` ); @@ -487,7 +491,11 @@ export async function recordExternalDbSyncDeletion( FOR UPDATE OF "VerificationCode" `); - if (insertedCount !== 1) { + if (insertedCount === 0) { + captureError("external-db-sync-deletion-race", new StackAssertionError( + `Expected to insert 1 DeletedRow entry for VerificationCode_TEAM_INVITATION, got 0. This is likely a race condition where the row was already deleted by a concurrent operation.` + )); + } else if (insertedCount !== 1) { throw new StackAssertionError( `Expected to insert 1 DeletedRow entry for VerificationCode_TEAM_INVITATION, got ${insertedCount}.` ); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/index.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/index.test.ts index 4d38c50046..cbf0c1b5cc 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/index.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/sessions/current/index.test.ts @@ -1,5 +1,88 @@ import { it } from "../../../../../../../helpers"; -import { Auth, niceBackendFetch } from "../../../../../../backend-helpers"; +import { Auth, backendContext, niceBackendFetch } from "../../../../../../backend-helpers"; + +it("should not crash when signing out a session that was already deleted by a bulk operation", async ({ expect }) => { + // Reproduce: sign up, then admin-delete all refresh tokens (simulating a + // concurrent password change), then attempt sign-out with the stale access token. + // Before fix: 500 assertion error in recordExternalDbSyncDeletion. + // After fix: 401 REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED. + const signUpRes = await Auth.Password.signUpWithEmail({ noWaitForEmail: true }); + const savedAuth = backendContext.value.userAuth; + + // Admin updates the user's password, which bulk-deletes all refresh tokens + await niceBackendFetch(`/api/v1/users/${signUpRes.userId}`, { + accessType: "admin", + method: "PATCH", + body: { password: "completely-new-password-12345" }, + }); + + // Try to sign out using the original access token (which still references the + // now-deleted refresh token). This should NOT throw a 500 assertion error. + const response = await niceBackendFetch("/api/v1/auth/sessions/current", { + method: "DELETE", + accessType: "client", + userAuth: savedAuth, + }); + expect(response.status).not.toBe(500); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 401, + "body": { + "code": "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED", + "error": "Refresh token not found for this project, or the session has expired/been revoked.", + }, + "headers": Headers { + "x-stack-known-error": "REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED", +