From 8bc956558ee117c79fe517b3b76e3ca9c39d7482 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 20:54:01 -0700 Subject: [PATCH 1/6] refactor(auth): update Google connection handling and auth mode determination - Renamed `reconnectGoogleForSession` to `repairGoogleConnection` to better reflect its purpose in handling Google connection repairs. - Enhanced the logic for determining the auth mode based on server-side state, replacing reliance on frontend intent. - Updated documentation to clarify the new auth mode classification and deprecated fields. - Refactored related tests to align with the new method names and logic, ensuring comprehensive coverage of the updated functionality. --- docs/google-sync-and-websocket-flow.md | 21 +- .../auth/schemas/reconnect-google.schemas.ts | 4 +- .../services/compass.auth.service.test.ts | 34 +-- .../src/auth/services/compass.auth.service.ts | 15 +- .../google.auth.success.service.test.ts | 283 +++++++++++++++--- .../google/google.auth.success.service.ts | 159 ++++++++-- .../middleware/supertokens.middleware.util.ts | 18 ++ packages/core/src/types/google-auth.types.ts | 11 + 8 files changed, 456 insertions(+), 89 deletions(-) diff --git a/docs/google-sync-and-websocket-flow.md b/docs/google-sync-and-websocket-flow.md index b446feb2f..7bcfa373a 100644 --- a/docs/google-sync-and-websocket-flow.md +++ b/docs/google-sync-and-websocket-flow.md @@ -125,8 +125,25 @@ Revocation and reconnect are handled across auth, sync, websocket, and repositor 1. Backend detects missing/invalid Google refresh token (middleware, sync, or Google API error handling). 2. Backend prunes Google-origin data and emits `GOOGLE_REVOKED`. 3. Web app marks Google as revoked in session memory and temporarily switches to local repository behavior. -4. OAuth connect while a session exists triggers reconnect logic (`reconnectGoogleForSession`) instead of normal signup/signin. -5. Reconnect updates Google credentials, marks metadata sync flags as `"restart"`, and restarts sync in background. +4. User initiates re-consent via OAuth flow. +5. Backend auth handler (`handleGoogleAuth`) determines auth mode server-side using: + - User existence (via `findCompassUserBy`) + - Refresh token presence (`user.google.gRefreshToken`) + - Sync health (`canDoIncrementalSync`) +6. If user exists but refresh token is missing or sync is unhealthy → `reconnect_repair` path via `repairGoogleConnection()`. +7. Reconnect updates Google credentials, marks metadata sync flags as `"restart"`, and restarts sync in background. + +### Auth Mode Classification + +The backend determines auth mode based on server-side state, not frontend intent: + +| Condition | Auth Mode | Handler | +| ----------------------------------------------------- | -------------------- | -------------------------- | +| No linked Compass user | `signup` | `googleSignup()` | +| User exists + missing refresh token OR unhealthy sync | `reconnect_repair` | `repairGoogleConnection()` | +| User exists + valid refresh token + healthy sync | `signin_incremental` | `googleSignin()` | + +Note: The `googleAuthIntent` field from frontend is deprecated and no longer authoritative for routing. Primary files: diff --git a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts index 49856fb41..4c4ad7560 100644 --- a/packages/backend/src/auth/schemas/reconnect-google.schemas.ts +++ b/packages/backend/src/auth/schemas/reconnect-google.schemas.ts @@ -8,12 +8,12 @@ export type ParsedReconnectGoogleParams = { }; export function parseReconnectGoogleParams( - sessionUserId: string, + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): ParsedReconnectGoogleParams { const cUserId = zObjectId - .parse(sessionUserId, { error: () => "Invalid credentials" }) + .parse(compassUserId, { error: () => "Invalid credentials" }) .toString(); StringV4Schema.parse(gUser.sub, { error: () => "Invalid Google user ID" }); const refreshToken = StringV4Schema.parse(oAuthTokens.refresh_token, { diff --git a/packages/backend/src/auth/services/compass.auth.service.test.ts b/packages/backend/src/auth/services/compass.auth.service.test.ts index f08699698..14ce90c24 100644 --- a/packages/backend/src/auth/services/compass.auth.service.test.ts +++ b/packages/backend/src/auth/services/compass.auth.service.test.ts @@ -1,4 +1,4 @@ -import { type Credentials } from "google-auth-library"; +import type { Credentials } from "google-auth-library"; import { faker } from "@faker-js/faker"; import { UserDriver } from "@backend/__tests__/drivers/user.driver"; import { @@ -18,10 +18,10 @@ describe("CompassAuthService", () => { beforeEach(cleanupCollections); afterAll(cleanupTestDb); - describe("reconnectGoogleForSession", () => { - it("relinks Google to the current Compass user and schedules a full reimport", async () => { + describe("repairGoogleConnection", () => { + it("relinks Google to the Compass user and schedules a full reimport", async () => { const user = await UserDriver.createUser(); - const sessionUserId = user._id.toString(); + const compassUserId = user._id.toString(); const gUser = UserDriver.generateGoogleUser({ sub: faker.string.uuid(), picture: faker.image.url(), @@ -34,20 +34,20 @@ describe("CompassAuthService", () => { .spyOn(userService, "restartGoogleCalendarSync") .mockResolvedValue(); - await userService.pruneGoogleData(sessionUserId); + await userService.pruneGoogleData(compassUserId); - const result = await compassAuthService.reconnectGoogleForSession( - sessionUserId, + const result = await compassAuthService.repairGoogleConnection( + compassUserId, gUser, oAuthTokens, ); const updatedUser = await mongoService.user.findOne({ _id: user._id }); const metadata = - await userMetadataService.fetchUserMetadata(sessionUserId); + await userMetadataService.fetchUserMetadata(compassUserId); - expect(result).toEqual({ cUserId: sessionUserId }); - expect(updatedUser?._id.toString()).toBe(sessionUserId); + expect(result).toEqual({ cUserId: compassUserId }); + expect(updatedUser?._id.toString()).toBe(compassUserId); expect(updatedUser?.google?.googleId).toBe(gUser.sub); expect(updatedUser?.google?.picture).toBe(gUser.picture); expect(updatedUser?.google?.gRefreshToken).toBe( @@ -55,14 +55,14 @@ describe("CompassAuthService", () => { ); expect(metadata.sync?.importGCal).toBe("restart"); expect(metadata.sync?.incrementalGCalSync).toBe("restart"); - expect(restartSpy).toHaveBeenCalledWith(sessionUserId); + expect(restartSpy).toHaveBeenCalledWith(compassUserId); restartSpy.mockRestore(); }); it("returns after persisting reconnect state even if the background sync fails", async () => { const user = await UserDriver.createUser(); - const sessionUserId = user._id.toString(); + const compassUserId = user._id.toString(); const gUser = UserDriver.generateGoogleUser({ sub: faker.string.uuid(), picture: faker.image.url(), @@ -76,19 +76,19 @@ describe("CompassAuthService", () => { .spyOn(userService, "restartGoogleCalendarSync") .mockRejectedValue(restartError); - await userService.pruneGoogleData(sessionUserId); + await userService.pruneGoogleData(compassUserId); await expect( - compassAuthService.reconnectGoogleForSession( - sessionUserId, + compassAuthService.repairGoogleConnection( + compassUserId, gUser, oAuthTokens, ), - ).resolves.toEqual({ cUserId: sessionUserId }); + ).resolves.toEqual({ cUserId: compassUserId }); await Promise.resolve(); - expect(restartSpy).toHaveBeenCalledWith(sessionUserId); + expect(restartSpy).toHaveBeenCalledWith(compassUserId); restartSpy.mockRestore(); }); diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index 67c3e351e..822937e03 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -126,8 +126,17 @@ class CompassAuthService { return user; } - async reconnectGoogleForSession( - sessionUserId: string, + /** + * Repairs a user's Google connection after revocation or disconnection. + * This method is called when the user has an existing Compass account but + * their refresh token is missing or their sync state is unhealthy. + * + * @param compassUserId - The Compass user ID (not session-based) + * @param gUser - Google user info from OAuth + * @param oAuthTokens - Fresh OAuth tokens from re-consent + */ + async repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ) { @@ -135,7 +144,7 @@ class CompassAuthService { cUserId, gUser: validatedGUser, refreshToken, - } = parseReconnectGoogleParams(sessionUserId, gUser, oAuthTokens); + } = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens); await userService.reconnectGoogleCredentials( cUserId, diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts index 40dfe39cd..b50bf80a3 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.test.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.test.ts @@ -1,10 +1,23 @@ -import { type Credentials, type TokenPayload } from "google-auth-library"; +import { Credentials, TokenPayload } from "google-auth-library"; +import { ObjectId } from "mongodb"; import { faker } from "@faker-js/faker"; import { - type GoogleSignInSuccess, - type GoogleSignInSuccessAuthService, + GoogleSignInSuccess, + GoogleSignInSuccessAuthService, handleGoogleAuth, } from "@backend/auth/services/google/google.auth.success.service"; +import * as syncQueries from "@backend/sync/util/sync.queries"; +import * as syncUtil from "@backend/sync/util/sync.util"; +import * as userQueries from "@backend/user/queries/user.queries"; + +// Mock the dependencies +jest.mock("@backend/user/queries/user.queries"); +jest.mock("@backend/sync/util/sync.queries"); +jest.mock("@backend/sync/util/sync.util"); + +const mockFindCompassUserBy = userQueries.findCompassUserBy as jest.Mock; +const mockGetSync = syncQueries.getSync as jest.Mock; +const mockCanDoIncrementalSync = syncUtil.canDoIncrementalSync as jest.Mock; function makeProviderUser(overrides?: Partial): TokenPayload { return { @@ -26,51 +39,43 @@ function makeOAuthTokens(): Pick< } function createMockAuthService(): GoogleSignInSuccessAuthService & { - reconnectGoogleForSession: jest.Mock; + repairGoogleConnection: jest.Mock; googleSignup: jest.Mock; googleSignin: jest.Mock; } { return { - reconnectGoogleForSession: jest + repairGoogleConnection: jest .fn() - .mockResolvedValue({ cUserId: "reconnect-id" }), + .mockResolvedValue({ cUserId: "repair-id" }), googleSignup: jest.fn().mockResolvedValue({ cUserId: "signup-id" }), googleSignin: jest.fn().mockResolvedValue({ cUserId: "signin-id" }), }; } -describe("handleGoogleSignInSuccess", () => { - describe("reconnect path", () => { - it("calls reconnectGoogleForSession when sessionUserId is set", async () => { - const authService = createMockAuthService(); - const providerUser = makeProviderUser(); - const oAuthTokens = makeOAuthTokens(); - const sessionUserId = faker.database.mongodbObjectId(); - - const success: GoogleSignInSuccess = { - providerUser, - oAuthTokens, - createdNewRecipeUser: false, - recipeUserId: sessionUserId, - loginMethodsLength: 1, - sessionUserId, - }; - - await handleGoogleAuth(success, authService); +function makeCompassUser(overrides?: { + hasRefreshToken?: boolean; + googleId?: string; +}) { + const _id = new ObjectId(); + return { + _id, + google: { + googleId: overrides?.googleId ?? faker.string.uuid(), + gRefreshToken: + overrides?.hasRefreshToken !== false ? faker.string.uuid() : null, + }, + }; +} - expect(authService.reconnectGoogleForSession).toHaveBeenCalledTimes(1); - expect(authService.reconnectGoogleForSession).toHaveBeenCalledWith( - sessionUserId, - providerUser, - oAuthTokens, - ); - expect(authService.googleSignup).not.toHaveBeenCalled(); - expect(authService.googleSignin).not.toHaveBeenCalled(); - }); +describe("handleGoogleAuth", () => { + beforeEach(() => { + jest.clearAllMocks(); }); - describe("sign up path", () => { - it("calls googleSignup when new user with single login method", async () => { + describe("signup path", () => { + it("calls googleSignup when no existing Compass user found", async () => { + mockFindCompassUserBy.mockResolvedValue(null); + const authService = createMockAuthService(); const providerUser = makeProviderUser(); const oAuthTokens = makeOAuthTokens(); @@ -93,11 +98,13 @@ describe("handleGoogleSignInSuccess", () => { oAuthTokens.refresh_token, recipeUserId, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignin).not.toHaveBeenCalled(); }); it("throws when refresh_token is missing for new user", async () => { + mockFindCompassUserBy.mockResolvedValue(null); + const authService = createMockAuthService(); const success: GoogleSignInSuccess = { providerUser: makeProviderUser(), @@ -116,50 +123,232 @@ describe("handleGoogleSignInSuccess", () => { }); }); - describe("sign in path", () => { - it("calls googleSignin when returning user", async () => { + describe("reconnect_repair path", () => { + it("calls repairGoogleConnection when user exists but refresh token is missing", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: false }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(true); + const authService = createMockAuthService(); - const providerUser = makeProviderUser(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); const oAuthTokens = makeOAuthTokens(); const success: GoogleSignInSuccess = { providerUser, oAuthTokens, createdNewRecipeUser: false, - recipeUserId: faker.database.mongodbObjectId(), + recipeUserId: compassUserId, loginMethodsLength: 1, sessionUserId: null, }; await handleGoogleAuth(success, authService); - expect(authService.googleSignin).toHaveBeenCalledTimes(1); - expect(authService.googleSignin).toHaveBeenCalledWith( + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.repairGoogleConnection).toHaveBeenCalledWith( + compassUserId, providerUser, oAuthTokens, ); - expect(authService.reconnectGoogleForSession).not.toHaveBeenCalled(); expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); }); - it("calls googleSignin when createdNewRecipeUser is true but loginMethodsLength > 1", async () => { + it("calls repairGoogleConnection when user exists but sync is unhealthy", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: true }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(false); // Unhealthy sync + const authService = createMockAuthService(); - const providerUser = makeProviderUser(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); const oAuthTokens = makeOAuthTokens(); const success: GoogleSignInSuccess = { providerUser, oAuthTokens, - createdNewRecipeUser: true, + createdNewRecipeUser: false, + recipeUserId: compassUserId, + loginMethodsLength: 1, + sessionUserId: null, + }; + + await handleGoogleAuth(success, authService); + + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.repairGoogleConnection).toHaveBeenCalledWith( + compassUserId, + providerUser, + oAuthTokens, + ); + expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); + }); + + it("calls repairGoogleConnection when both refresh token is missing and sync is unhealthy", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: false }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(false); + + const authService = createMockAuthService(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); + const oAuthTokens = makeOAuthTokens(); + + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens, + createdNewRecipeUser: false, + recipeUserId: compassUserId, + loginMethodsLength: 1, + sessionUserId: null, + }; + + await handleGoogleAuth(success, authService); + + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); + }); + + it("calls repairGoogleConnection when no sync record exists", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: true }); + const compassUserId = compassUser._id.toString(); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue(null); // No sync record + + const authService = createMockAuthService(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); + const oAuthTokens = makeOAuthTokens(); + + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens, + createdNewRecipeUser: false, + recipeUserId: compassUserId, + loginMethodsLength: 1, + sessionUserId: null, + }; + + await handleGoogleAuth(success, authService); + + expect(authService.repairGoogleConnection).toHaveBeenCalledTimes(1); + expect(authService.googleSignup).not.toHaveBeenCalled(); + expect(authService.googleSignin).not.toHaveBeenCalled(); + }); + }); + + describe("signin_incremental path", () => { + it("calls googleSignin when user exists with valid refresh token and healthy sync", async () => { + const compassUser = makeCompassUser({ hasRefreshToken: true }); + mockFindCompassUserBy.mockResolvedValue(compassUser); + mockGetSync.mockResolvedValue({ + google: { events: [{ nextSyncToken: "token" }] }, + }); + mockCanDoIncrementalSync.mockReturnValue(true); + + const authService = createMockAuthService(); + const providerUser = makeProviderUser({ + sub: compassUser.google.googleId, + }); + const oAuthTokens = makeOAuthTokens(); + + const success: GoogleSignInSuccess = { + providerUser, + oAuthTokens, + createdNewRecipeUser: false, recipeUserId: faker.database.mongodbObjectId(), - loginMethodsLength: 2, + loginMethodsLength: 1, sessionUserId: null, }; await handleGoogleAuth(success, authService); expect(authService.googleSignin).toHaveBeenCalledTimes(1); + expect(authService.googleSignin).toHaveBeenCalledWith( + providerUser, + oAuthTokens, + ); + expect(authService.repairGoogleConnection).not.toHaveBeenCalled(); expect(authService.googleSignup).not.toHaveBeenCalled(); }); }); + + describe("auth decision logging", () => { + it("determines correct auth mode for each scenario", async () => { + // This test verifies that determineAuthMode returns the expected values + // by checking which handler gets called + + const authService = createMockAuthService(); + + // Scenario 1: No user → signup + mockFindCompassUserBy.mockResolvedValue(null); + await handleGoogleAuth( + { + providerUser: makeProviderUser(), + oAuthTokens: makeOAuthTokens(), + createdNewRecipeUser: true, + recipeUserId: faker.database.mongodbObjectId(), + loginMethodsLength: 1, + sessionUserId: null, + }, + authService, + ); + expect(authService.googleSignup).toHaveBeenCalled(); + + jest.clearAllMocks(); + + // Scenario 2: User exists but no refresh token → reconnect_repair + const userNoToken = makeCompassUser({ hasRefreshToken: false }); + mockFindCompassUserBy.mockResolvedValue(userNoToken); + mockGetSync.mockResolvedValue({ google: { events: [] } }); + mockCanDoIncrementalSync.mockReturnValue(true); + await handleGoogleAuth( + { + providerUser: makeProviderUser({ sub: userNoToken.google.googleId }), + oAuthTokens: makeOAuthTokens(), + createdNewRecipeUser: false, + recipeUserId: userNoToken._id.toString(), + loginMethodsLength: 1, + sessionUserId: null, + }, + authService, + ); + expect(authService.repairGoogleConnection).toHaveBeenCalled(); + + jest.clearAllMocks(); + + // Scenario 3: User exists with token and healthy sync → signin_incremental + const healthyUser = makeCompassUser({ hasRefreshToken: true }); + mockFindCompassUserBy.mockResolvedValue(healthyUser); + mockGetSync.mockResolvedValue({ + google: { events: [{ nextSyncToken: "token" }] }, + }); + mockCanDoIncrementalSync.mockReturnValue(true); + await handleGoogleAuth( + { + providerUser: makeProviderUser({ sub: healthyUser.google.googleId }), + oAuthTokens: makeOAuthTokens(), + createdNewRecipeUser: false, + recipeUserId: healthyUser._id.toString(), + loginMethodsLength: 1, + sessionUserId: null, + }, + authService, + ); + expect(authService.googleSignin).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.ts b/packages/backend/src/auth/services/google/google.auth.success.service.ts index 0078a9479..0207da4ce 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.ts @@ -1,4 +1,10 @@ import { type Credentials, type TokenPayload } from "google-auth-library"; +import { Logger } from "@core/logger/winston.logger"; +import { getSync } from "@backend/sync/util/sync.queries"; +import { canDoIncrementalSync } from "@backend/sync/util/sync.util"; +import { findCompassUserBy } from "@backend/user/queries/user.queries"; + +const logger = Logger("app:google.auth.success"); export type GoogleSignInSuccess = { providerUser: TokenPayload; @@ -6,12 +12,33 @@ export type GoogleSignInSuccess = { createdNewRecipeUser: boolean; recipeUserId: string; loginMethodsLength: number; + /** + * @deprecated This field is transitional. Auth mode is now determined + * server-side based on refresh token presence and sync health. + * Will be removed once frontend stops sending googleAuthIntent. + */ sessionUserId: string | null; }; +/** + * Auth modes for Google sign-in flow: + * - signup: New user, no linked Compass account + * - signin_incremental: Existing user with valid refresh token and healthy sync + * - reconnect_repair: Existing user needing repair (missing refresh token or unhealthy sync) + */ +export type AuthMode = "signup" | "signin_incremental" | "reconnect_repair"; + +export type AuthDecision = { + authMode: AuthMode; + compassUserId: string | null; + hasStoredRefreshToken: boolean; + hasHealthySync: boolean; + createdNewRecipeUser: boolean; +}; + export interface GoogleSignInSuccessAuthService { - reconnectGoogleForSession( - sessionUserId: string, + repairGoogleConnection( + compassUserId: string, gUser: TokenPayload, oAuthTokens: Pick, ): Promise<{ cUserId: string }>; @@ -26,6 +53,77 @@ export interface GoogleSignInSuccessAuthService { ): Promise<{ cUserId: string }>; } +/** + * Determines the auth mode based on server-side state. + * + * Decision logic: + * - If no linked Compass user exists → signup + * - If user exists but refresh token is missing OR sync is unhealthy → reconnect_repair + * - Otherwise → signin_incremental + */ +async function determineAuthMode( + googleUserId: string, + createdNewRecipeUser: boolean, +): Promise { + // Look up existing user by Google ID + const user = await findCompassUserBy("google.googleId", googleUserId); + + if (!user) { + return { + authMode: "signup", + compassUserId: null, + hasStoredRefreshToken: false, + hasHealthySync: false, + createdNewRecipeUser, + }; + } + + const compassUserId = user._id.toString(); + const hasStoredRefreshToken = !!user.google?.gRefreshToken; + + // Check sync health + const sync = await getSync({ userId: compassUserId }); + const hasHealthySync = sync ? !!canDoIncrementalSync(sync) : false; + + // If missing refresh token OR unhealthy sync → needs repair + if (!hasStoredRefreshToken || !hasHealthySync) { + return { + authMode: "reconnect_repair", + compassUserId, + hasStoredRefreshToken, + hasHealthySync, + createdNewRecipeUser, + }; + } + + return { + authMode: "signin_incremental", + compassUserId, + hasStoredRefreshToken, + hasHealthySync, + createdNewRecipeUser, + }; +} + +/** + * Logs the auth decision for observability. + */ +function logAuthDecision( + decision: AuthDecision, + hasSession: boolean, + googleUserId: string, +): void { + logger.info("Google auth decision", { + auth_mode: decision.authMode, + created_new_recipe_user: decision.createdNewRecipeUser, + has_stored_refresh_token: decision.hasStoredRefreshToken, + has_healthy_sync: decision.hasHealthySync, + has_session: hasSession, + compass_user_id: decision.compassUserId, + google_user_id: googleUserId, + }); +} + export async function handleGoogleAuth( success: GoogleSignInSuccess, authService: GoogleSignInSuccessAuthService, @@ -39,25 +137,50 @@ export async function handleGoogleAuth( sessionUserId, } = success; - if (sessionUserId !== null) { - await authService.reconnectGoogleForSession( - sessionUserId, - providerUser, - oAuthTokens, - ); - return; + const googleUserId = providerUser.sub; + if (!googleUserId) { + throw new Error("Google user ID (sub) is required"); } - const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + // Determine auth mode based on server-side state + const decision = await determineAuthMode(googleUserId, createdNewRecipeUser); + + // Log the decision for observability + logAuthDecision(decision, sessionUserId !== null, googleUserId); - if (isNewUser) { - const refreshToken = oAuthTokens.refresh_token; - if (!refreshToken) { - throw new Error("Refresh token expected for new user sign-up"); + switch (decision.authMode) { + case "signup": { + const isNewUser = createdNewRecipeUser && loginMethodsLength === 1; + if (!isNewUser) { + // Edge case: no Compass user found but SuperTokens says not new + // This shouldn't happen in normal flow, treat as signup + logger.warn("No Compass user found but createdNewRecipeUser is false", { + google_user_id: googleUserId, + recipe_user_id: recipeUserId, + }); + } + const refreshToken = oAuthTokens.refresh_token; + if (!refreshToken) { + throw new Error("Refresh token expected for new user sign-up"); + } + await authService.googleSignup(providerUser, refreshToken, recipeUserId); + return; + } + + case "reconnect_repair": { + // User exists but needs repair (missing refresh token or unhealthy sync) + await authService.repairGoogleConnection( + decision.compassUserId!, + providerUser, + oAuthTokens, + ); + return; } - await authService.googleSignup(providerUser, refreshToken, recipeUserId); - return; - } - await authService.googleSignin(providerUser, oAuthTokens); + case "signin_incremental": { + // Healthy returning user - attempt incremental sync + await authService.googleSignin(providerUser, oAuthTokens); + return; + } + } } diff --git a/packages/backend/src/common/middleware/supertokens.middleware.util.ts b/packages/backend/src/common/middleware/supertokens.middleware.util.ts index beb365519..f3f092e2c 100644 --- a/packages/backend/src/common/middleware/supertokens.middleware.util.ts +++ b/packages/backend/src/common/middleware/supertokens.middleware.util.ts @@ -20,6 +20,13 @@ export type CreateGoogleSignInResponse = | { status: Exclude } | GoogleThirdPartySignInUpSuccess; +/** + * @deprecated This function is transitional. Auth mode is now determined + * server-side in handleGoogleAuth() based on refresh token presence and + * sync health. The googleAuthIntent is no longer authoritative for routing. + * + * Kept temporarily for backward compatibility during transition period. + */ export function getGoogleAuthIntent( value: unknown, ): GoogleAuthIntent | undefined { @@ -30,6 +37,14 @@ export function getGoogleAuthIntent( return undefined; } +/** + * @deprecated This function is transitional. Auth mode determination has + * moved to handleGoogleAuth() where it uses server-side signals (refresh + * token presence, sync health) instead of frontend-provided intent. + * + * The sessionUserId is still passed through for logging purposes but is + * no longer the primary routing signal for reconnect flows. + */ export function resolveGoogleSessionUserId({ sessionUserId, googleAuthIntent, @@ -41,6 +56,9 @@ export function resolveGoogleSessionUserId({ createdNewRecipeUser: boolean; recipeUserId: string; }): string | null { + // Note: This function's return value is no longer used for auth routing. + // Auth mode is now determined server-side in handleGoogleAuth(). + // We still pass sessionUserId through for observability/logging. if (sessionUserId) { return sessionUserId; } diff --git a/packages/core/src/types/google-auth.types.ts b/packages/core/src/types/google-auth.types.ts index f5b13809b..9da564cc0 100644 --- a/packages/core/src/types/google-auth.types.ts +++ b/packages/core/src/types/google-auth.types.ts @@ -1 +1,12 @@ +/** + * @deprecated This type is transitional. Auth mode is now determined + * server-side in handleGoogleAuth() based on refresh token presence and + * sync health, not frontend-provided intent. + * + * The frontend may still send this value, but it is no longer authoritative + * for routing auth flows. Backend determines auth mode using: + * - User existence (via findCompassUserBy) + * - Refresh token presence (user.google.gRefreshToken) + * - Sync health (canDoIncrementalSync) + */ export type GoogleAuthIntent = "connect" | "reconnect"; From 33e0e1656f71f1bf25a8bfc5df0b7df6a055b5e8 Mon Sep 17 00:00:00 2001 From: Tyler Dane Date: Thu, 12 Mar 2026 20:54:22 -0700 Subject: [PATCH 2/6] refactor(types): streamline interface definitions for better readability - Updated various interface definitions across the codebase to improve formatting and readability by aligning type extensions and removing unnecessary line breaks. - Adjusted interfaces in event, user, and component types to follow a consistent style, enhancing maintainability and clarity. --- packages/core/src/types/event.types.ts | 12 ++++-------- packages/core/src/types/user.types.ts | 9 +++++---- packages/web/src/__tests__/__mocks__/mock.render.tsx | 3 ++- packages/web/src/common/types/api.types.ts | 5 ++--- .../components/AuthModal/components/AuthInput.tsx | 6 ++---- packages/web/src/components/DND/Draggable.tsx | 9 +++++---- packages/web/src/components/IconButton/styled.ts | 3 ++- packages/web/src/components/Input/Input.tsx | 3 +-- packages/web/src/components/Textarea/types.ts | 4 +++- .../web/src/views/Forms/ActionsMenu/MenuItem.tsx | 3 ++- 10 files changed, 28 insertions(+), 29 deletions(-) diff --git a/packages/core/src/types/event.types.ts b/packages/core/src/types/event.types.ts index 7e8756dbd..1ae9d5033 100644 --- a/packages/core/src/types/event.types.ts +++ b/packages/core/src/types/event.types.ts @@ -104,19 +104,15 @@ export type Schema_Event_Regular = Omit< "recurrence" | "gRecurringEventId" >; -export interface Schema_Event_Recur_Base extends Omit< - Schema_Event, - "recurrence" | "gRecurringEventId" -> { +export interface Schema_Event_Recur_Base + extends Omit { recurrence: { rule: string[]; // No eventId since this is the base recurring event }; } -export interface Schema_Event_Recur_Instance extends Omit< - Schema_Event, - "recurrence" -> { +export interface Schema_Event_Recur_Instance + extends Omit { recurrence: { eventId: string; // No rule since this is an instance of the recurring event }; diff --git a/packages/core/src/types/user.types.ts b/packages/core/src/types/user.types.ts index 19e4fc025..ef43566dc 100644 --- a/packages/core/src/types/user.types.ts +++ b/packages/core/src/types/user.types.ts @@ -36,10 +36,11 @@ export interface UserMetadata extends SupertokensUserMetadata.JSONObject { }; } -export interface UserProfile extends Pick< - WithCompassId, - "firstName" | "lastName" | "name" | "email" | "locale" -> { +export interface UserProfile + extends Pick< + WithCompassId, + "firstName" | "lastName" | "name" | "email" | "locale" + > { picture: string; userId: string; } diff --git a/packages/web/src/__tests__/__mocks__/mock.render.tsx b/packages/web/src/__tests__/__mocks__/mock.render.tsx index 607fbf969..ed2f8b033 100644 --- a/packages/web/src/__tests__/__mocks__/mock.render.tsx +++ b/packages/web/src/__tests__/__mocks__/mock.render.tsx @@ -29,7 +29,8 @@ interface CustomRenderOptions extends RenderOptions { } interface CustomRenderHookOptions - extends CustomRenderOptions, Omit, "wrapper"> {} + extends CustomRenderOptions, + Omit, "wrapper"> {} const TestProviders = (props?: { router?: RouterProviderProps["router"]; diff --git a/packages/web/src/common/types/api.types.ts b/packages/web/src/common/types/api.types.ts index dc47b7ba2..629d67065 100644 --- a/packages/web/src/common/types/api.types.ts +++ b/packages/web/src/common/types/api.types.ts @@ -15,9 +15,8 @@ export interface Filters_Pagination { export type Options_FilterSort = Filters_Pagination & Options_Sort; -export interface Response_HttpPaginatedSuccess< - Data, -> extends Filters_Pagination { +export interface Response_HttpPaginatedSuccess + extends Filters_Pagination { data: Data; count: number; [key: string]: unknown | undefined; diff --git a/packages/web/src/components/AuthModal/components/AuthInput.tsx b/packages/web/src/components/AuthModal/components/AuthInput.tsx index b3350e413..c0cbe8382 100644 --- a/packages/web/src/components/AuthModal/components/AuthInput.tsx +++ b/packages/web/src/components/AuthModal/components/AuthInput.tsx @@ -1,10 +1,8 @@ import clsx from "clsx"; import { type InputHTMLAttributes, forwardRef, useId } from "react"; -interface AuthInputProps extends Omit< - InputHTMLAttributes, - "className" -> { +interface AuthInputProps + extends Omit, "className"> { /** Label text displayed above the input. Omit for placeholder-only style. */ label?: string; /** Accessible name when label is hidden (required when label is omitted) */ diff --git a/packages/web/src/components/DND/Draggable.tsx b/packages/web/src/components/DND/Draggable.tsx index a3aba839b..a3d39bf4f 100644 --- a/packages/web/src/components/DND/Draggable.tsx +++ b/packages/web/src/components/DND/Draggable.tsx @@ -25,10 +25,11 @@ export interface DraggableDNDData { view: "day" | "week" | "now"; } -export interface DNDChildProps extends Pick< - ReturnType, - "over" | "listeners" | "isDragging" -> { +export interface DNDChildProps + extends Pick< + ReturnType, + "over" | "listeners" | "isDragging" + > { id: UniqueIdentifier; setDisabled?: (disabled: boolean) => void; } diff --git a/packages/web/src/components/IconButton/styled.ts b/packages/web/src/components/IconButton/styled.ts index d6e3703b1..95163b677 100644 --- a/packages/web/src/components/IconButton/styled.ts +++ b/packages/web/src/components/IconButton/styled.ts @@ -8,7 +8,8 @@ const sizeMap: Record = { large: 34, }; -export interface IconButtonProps extends React.ButtonHTMLAttributes { +export interface IconButtonProps + extends React.ButtonHTMLAttributes { size?: IconButtonSize; } diff --git a/packages/web/src/components/Input/Input.tsx b/packages/web/src/components/Input/Input.tsx index 6e933a4b3..3918148f5 100644 --- a/packages/web/src/components/Input/Input.tsx +++ b/packages/web/src/components/Input/Input.tsx @@ -12,8 +12,7 @@ import { Focusable } from "../Focusable/Focusable"; import { StyledInput, type Props as StyledProps } from "./styled"; export interface Props - extends - ClassNamedComponent, + extends ClassNamedComponent, UnderlinedInput, StyledProps, HTMLAttributes { diff --git a/packages/web/src/components/Textarea/types.ts b/packages/web/src/components/Textarea/types.ts index 7b8d4dc29..c48e3a315 100644 --- a/packages/web/src/components/Textarea/types.ts +++ b/packages/web/src/components/Textarea/types.ts @@ -5,6 +5,8 @@ import { } from "@web/common/types/component.types"; export interface TextareaProps - extends UnderlinedInput, ClassNamedComponent, TextareaAutosizeProps { + extends UnderlinedInput, + ClassNamedComponent, + TextareaAutosizeProps { heightFitsContent?: boolean; } diff --git a/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx b/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx index e095af2a8..2d5cff4b3 100644 --- a/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx +++ b/packages/web/src/views/Forms/ActionsMenu/MenuItem.tsx @@ -10,7 +10,8 @@ import { import { useMenuContext } from "./ActionsMenu"; import { StyledMenuItem } from "./styled"; -export interface MenuItemProps extends React.ButtonHTMLAttributes { +export interface MenuItemProps + extends React.ButtonHTMLAttributes { /** * Content to render inside the delayed tooltip. If omitted, the tooltip is disabled. */ From 980b9474d0be537d37d2d85e57a5861232ca4ea8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 03:59:49 +0000 Subject: [PATCH 3/6] refactor(backend): improve canDoIncrementalSync edge case handling - Add explicit handling for undefined/empty events arrays - Add comprehensive JSDoc documentation - Ensure function returns false when sync data is missing or incomplete - This ensures reconnect_repair path is correctly triggered when sync is unhealthy Co-authored-by: Tyler Dane --- packages/backend/src/sync/util/sync.util.ts | 29 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/sync/util/sync.util.ts b/packages/backend/src/sync/util/sync.util.ts index 9305295d4..4b43e8d3b 100644 --- a/packages/backend/src/sync/util/sync.util.ts +++ b/packages/backend/src/sync/util/sync.util.ts @@ -42,11 +42,32 @@ export const hasGoogleHeaders = (headers: object) => { return hasHeaders; }; +/** + * Determines if incremental sync can be performed for a sync record. + * + * Returns true only if: + * - Sync record exists + * - Google events array exists and is not empty + * - Every calendar event has a non-null nextSyncToken + * + * Returns false if: + * - Sync record is missing Google events data + * - Any calendar event is missing a sync token + * - Events array is empty (no calendars to sync) + * + * This is used to determine if a user needs a full restart sync + * (reconnect_repair) vs incremental sync (signin_incremental). + */ export const canDoIncrementalSync = (sync: Schema_Sync) => { - const everyCalendarHasSyncToken = sync.google?.events?.every( - (event) => event.nextSyncToken !== null, - ); - return everyCalendarHasSyncToken; + const events = sync.google?.events; + + // If no events array exists, cannot do incremental sync + if (!events || events.length === 0) { + return false; + } + + // All events must have a sync token for incremental sync + return events.every((event) => event.nextSyncToken !== null); }; export const isUsingHttps = () => getBaseURL().includes("https"); From e5e537ea410b703b315129cd264fc2f014be95fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 04:02:01 +0000 Subject: [PATCH 4/6] fix(backend): correct warning log message for auth edge case Co-authored-by: Tyler Dane --- .../src/auth/services/google/google.auth.success.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/auth/services/google/google.auth.success.service.ts b/packages/backend/src/auth/services/google/google.auth.success.service.ts index 0207da4ce..41991f436 100644 --- a/packages/backend/src/auth/services/google/google.auth.success.service.ts +++ b/packages/backend/src/auth/services/google/google.auth.success.service.ts @@ -154,9 +154,11 @@ export async function handleGoogleAuth( if (!isNewUser) { // Edge case: no Compass user found but SuperTokens says not new // This shouldn't happen in normal flow, treat as signup - logger.warn("No Compass user found but createdNewRecipeUser is false", { + logger.warn("No Compass user found but isNewUser is false", { google_user_id: googleUserId, recipe_user_id: recipeUserId, + created_new_recipe_user: createdNewRecipeUser, + login_methods_length: loginMethodsLength, }); } const refreshToken = oAuthTokens.refresh_token; From 1950c76a4e3c4a82a74202bc16d0f98c70c55231 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 04:07:39 +0000 Subject: [PATCH 5/6] fix(backend): remove dead determineAuthMethod function Co-authored-by: Tyler Dane --- .../src/auth/services/compass.auth.service.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index 822937e03..d4f955b13 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -31,28 +31,6 @@ class CompassAuthService { }); }; - determineAuthMethod = async (gUserId: string) => { - const user = await findCompassUserBy("google.googleId", gUserId); - - if (!user) { - return { authMethod: "signup", user: null }; - } - const userId = user._id.toString(); - - const sync = await getSync({ userId }); - if (!sync) { - throw error( - SyncError.NoSyncRecordForUser, - "Did not verify sync record for user", - ); - } - - const canLogin = canDoIncrementalSync(sync); - const authMethod = user && canLogin ? "login" : "signup"; - - return { authMethod, user }; - }; - createSessionForUser = async (cUserId: string) => { const userId = cUserId; const sUserId = supertokens.convertToRecipeUserId(cUserId); From 073bfdac13c847bfe035d36e89868825b3a76be9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 13 Mar 2026 04:12:58 +0000 Subject: [PATCH 6/6] fix(backend): remove unused imports from compass.auth.service Co-authored-by: Tyler Dane --- packages/backend/src/auth/services/compass.auth.service.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/backend/src/auth/services/compass.auth.service.ts b/packages/backend/src/auth/services/compass.auth.service.ts index d4f955b13..be294286a 100644 --- a/packages/backend/src/auth/services/compass.auth.service.ts +++ b/packages/backend/src/auth/services/compass.auth.service.ts @@ -8,14 +8,10 @@ import { parseReconnectGoogleParams } from "@backend/auth/schemas/reconnect-goog import GoogleAuthService from "@backend/auth/services/google/google.auth.service"; import { ENV } from "@backend/common/constants/env.constants"; import { isMissingUserTagId } from "@backend/common/constants/env.util"; -import { error } from "@backend/common/errors/handlers/error.handler"; import { SyncError } from "@backend/common/errors/sync/sync.errors"; import mongoService from "@backend/common/services/mongo.service"; import EmailService from "@backend/email/email.service"; import syncService from "@backend/sync/services/sync.service"; -import { getSync } from "@backend/sync/util/sync.queries"; -import { canDoIncrementalSync } from "@backend/sync/util/sync.util"; -import { findCompassUserBy } from "@backend/user/queries/user.queries"; import userMetadataService from "@backend/user/services/user-metadata.service"; import userService from "@backend/user/services/user.service";