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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ packages/core/src/

- Use `is` prefix for boolean variables. For example, `isLoading`, `isError`, `isSuccess`
- Do not use barrel (`index.ts`) files. Use named exports instead.
- When creating constants, use uppercase and underscores. Example: `SIGNIN_INCREMENTAL`

## Branch Naming & Commit Message Conventions

Expand Down
4 changes: 2 additions & 2 deletions docs/api-documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ Current metadata shape used by sync/auth flows:
```ts
{
sync?: {
importGCal?: "importing" | "errored" | "completed" | "restart" | null;
incrementalGCalSync?: "importing" | "errored" | "completed" | "restart" | null;
importGCal?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null;
incrementalGCalSync?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null;
};
google?: {
hasRefreshToken?: boolean;
Expand Down
29 changes: 23 additions & 6 deletions docs/google-sync-and-websocket-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Important error handling behavior:
- if no watch exists, backend logs and returns `410` without pruning (notification ignored)
- missing sync token:
- backend attempts forced resync in background
- resync is skipped if metadata already shows `sync.importGCal === "importing"`
- resync is skipped if metadata already shows `sync.importGCal === "IMPORTING"`
- response is `204` either way
- invalid/revoked Google token (`invalid_grant`):
- backend prunes Google data, emits `GOOGLE_REVOKED`, returns revoked payload
Expand Down Expand Up @@ -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, and the client only launches OAuth plus reacts to metadata/socket updates:

| 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: Frontend reconnect intent is no longer used for routing. The server is the source of truth for auth mode selection.

Primary files:

Expand All @@ -144,8 +161,8 @@ Primary files:
```ts
{
sync?: {
importGCal?: "importing" | "errored" | "completed" | "restart" | null;
incrementalGCalSync?: "importing" | "errored" | "completed" | "restart" | null;
importGCal?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null;
incrementalGCalSync?: "IMPORTING" | "ERRORED" | "COMPLETED" | "RESTART" | null;
};
google?: {
hasRefreshToken?: boolean;
Expand All @@ -164,7 +181,7 @@ Google import progress is also realtime:

1. backend starts import
2. websocket emits `IMPORT_GCAL_START`
3. client marks import pending
3. client waits for metadata/socket updates from the backend import flow
4. backend completes import and emits `IMPORT_GCAL_END`
5. client stores import results and triggers a refetch

Expand Down
5 changes: 3 additions & 2 deletions e2e/utils/event-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,9 @@ export const fillTitleAndSaveWithKeyboard = async (
await expect(titleInput).toBeVisible({ timeout: FORM_TIMEOUT });
await titleInput.fill(title);
// EventForm saves on Cmd+Enter (Mac) or Ctrl+Enter (Linux/Windows)
// ControlOrMeta maps to the platform-appropriate modifier
await page.keyboard.press("ControlOrMeta+Enter");
// ControlOrMeta maps to the platform-appropriate modifier.
// Use locator.press() so focus is on the input when the key is sent (fixes CI).
await titleInput.press("ControlOrMeta+Enter");
// Wait for form to close, confirming the save completed
await titleInput.waitFor({ state: "hidden", timeout: FORM_TIMEOUT });
};
Expand Down
4 changes: 2 additions & 2 deletions packages/backend/src/auth/schemas/reconnect-google.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ export type ParsedReconnectGoogleParams = {
};

export function parseReconnectGoogleParams(
sessionUserId: string,
compassUserId: string,
gUser: TokenPayload,
oAuthTokens: Pick<Credentials, "refresh_token" | "access_token">,
): 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, {
Expand Down
38 changes: 19 additions & 19 deletions packages/backend/src/auth/services/compass.auth.service.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(),
Expand All @@ -34,35 +34,35 @@ 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(
oAuthTokens.refresh_token,
);
expect(metadata.sync?.importGCal).toBe("restart");
expect(metadata.sync?.incrementalGCalSync).toBe("restart");
expect(restartSpy).toHaveBeenCalledWith(sessionUserId);
expect(metadata.sync?.importGCal).toBe("RESTART");
expect(metadata.sync?.incrementalGCalSync).toBe("RESTART");
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(),
Expand All @@ -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();
});
Expand Down
47 changes: 15 additions & 32 deletions packages/backend/src/auth/services/compass.auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -31,28 +27,6 @@ class CompassAuthService {
});
};

determineAuthMethod = async (gUserId: string) => {
Comment thread
cursor[bot] marked this conversation as resolved.
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);
Expand Down Expand Up @@ -100,7 +74,7 @@ class CompassAuthService {
userId,
data: {
skipOnboarding: false,
sync: { importGCal: "restart", incrementalGCalSync: "restart" },
sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" },
},
});

Expand All @@ -126,16 +100,25 @@ 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<Credentials, "refresh_token" | "access_token">,
) {
const {
cUserId,
gUser: validatedGUser,
refreshToken,
} = parseReconnectGoogleParams(sessionUserId, gUser, oAuthTokens);
} = parseReconnectGoogleParams(compassUserId, gUser, oAuthTokens);

await userService.reconnectGoogleCredentials(
cUserId,
Expand All @@ -146,7 +129,7 @@ class CompassAuthService {
await userMetadataService.updateUserMetadata({
userId: cUserId,
data: {
sync: { importGCal: "restart", incrementalGCalSync: "restart" },
sync: { importGCal: "RESTART", incrementalGCalSync: "RESTART" },
},
});

Expand Down Expand Up @@ -201,7 +184,7 @@ class CompassAuthService {
// mark in metadata to restart full import
await userMetadataService.updateUserMetadata({
userId: cUserId,
data: { sync: { importGCal: "restart" } },
data: { sync: { importGCal: "RESTART" } },
});

this.restartGoogleCalendarSyncInBackground(cUserId);
Expand Down
Loading
Loading