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
50 changes: 44 additions & 6 deletions docs/development/local-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,13 @@ Optional but behavior-changing:

- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
- `GCAL_WEBHOOK_BASEURL`
- `TOKEN_GCAL_NOTIFICATION`
- `EMAILER_API_SECRET`
- `EMAILER_USER_TAG_ID`

Google is disabled unless both `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`
are set. When Google is enabled and `BASEURL` uses HTTPS,
are set. When Google is enabled and the effective Google webhook URL uses HTTPS,
`TOKEN_GCAL_NOTIFICATION` is required for Google Calendar webhook validation.

Derived backend values:
Expand Down Expand Up @@ -173,17 +174,54 @@ When testing changes around event loading, explicitly decide which user state yo

## Google Calendar Webhook Notes

Google OAuth and Google Calendar Watch have different local requirements.
Google sign-in can use localhost redirect URLs, but Calendar Watch
notifications are server-to-server callbacks from Google to Compass. For those
callbacks, Google needs an HTTPS backend URL that it can reach from the public
internet.

Compass does not start a local tunnel automatically. Google Calendar webhook
watch flows use `BASEURL` directly. If `BASEURL` is not a publicly routable
HTTPS URL, Google sign-in, Google Calendar connect, and initial import can
still work, but live Google-to-Compass notifications are skipped because the
base URL is not public HTTPS.
watch flows use `GCAL_WEBHOOK_BASEURL` when it is set and fall back to
`BASEURL` when it is not set.

For normal local development:

```bash
BASEURL=http://localhost:3000/api
```

Google sign-in, Google Calendar connect, and initial import can still work, but
live Google-to-Compass notifications are skipped because Google cannot call a
local HTTP backend.

For local end-to-end Google Watch testing, run a temporary HTTPS tunnel to the
backend:

```bash
cloudflared tunnel --url http://localhost:3000
```

Then set:

```bash
BASEURL=http://localhost:3000/api
GCAL_WEBHOOK_BASEURL=https://<generated-host>.trycloudflare.com/api
```

Keep `BASEURL` local so the browser and Server-Sent Events continue using
localhost. Only Google's webhook POST requests should use the tunnel.

Stop the tunnel when testing is complete. Do not use personal calendars with
sensitive data for manual tunnel tests.

## Common Failure Modes

- backend exits immediately because required env is missing
- backend/web/cli read from `.env.local`; using `.env` instead leaves required variables unset
- web points at the wrong API base URL
- session exists but user profile fetch fails
- sync endpoints work but notification/watch setup is skipped because `BASEURL` is not public HTTPS
- sync endpoints work but notification/watch setup is skipped because neither
`GCAL_WEBHOOK_BASEURL` nor `BASEURL` is public HTTPS
- `GCAL_WEBHOOK_BASEURL` points to a tunnel without `/api`, so Google posts to
the wrong route
- backend starts but `/api/health` returns `500` because `MONGO_URI` or database reachability is broken
4 changes: 2 additions & 2 deletions docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ Compass is a web app and a backend API. In manual setup, you provide the runtime

- A **Google Cloud project**, only if you want Google auth or to connect Google Calendar.

Leave Google credentials unset for password-only mode. If you provide Google credentials, provide both the client ID and client secret. Ongoing Google Calendar watch notifications need an HTTPS, publicly reachable backend, which manual local setup does not provide.
Leave Google credentials unset for password-only mode. If you provide Google credentials, provide both the client ID and client secret. Ongoing Google Calendar watch notifications need an HTTPS, publicly reachable backend URL. In local development this can be supplied with `GCAL_WEBHOOK_BASEURL`; in production/self-hosting this is usually just the public `BASEURL`.

### Manual Steps

Expand Down Expand Up @@ -233,7 +233,7 @@ A `200` response means the backend is running and can reach MongoDB.

- **Point at a different MongoDB** by updating `MONGO_URI`.
- **Use a different SuperTokens instance** by updating the SuperTokens values in your env file.
- **Use Google OAuth locally** by creating a Google Cloud project, adding real credentials to your env file, and restarting the backend. Ongoing Google Calendar watch notifications also need an HTTPS, publicly reachable backend.
- **Use Google OAuth locally** by creating a Google Cloud project, adding real credentials to your env file, and restarting the backend. Ongoing Google Calendar watch notifications also need an HTTPS, publicly reachable backend URL. In local development, use `GCAL_WEBHOOK_BASEURL` for that callback URL while keeping `BASEURL` pointed at localhost.

### Running On A Server

Expand Down
2 changes: 1 addition & 1 deletion e2e/allday/delete-allday-event-mouse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ test("should delete an all-day event using mouse interaction", async ({
}) => {
await prepareCalendarPage(page);

const title = createEventTitle("All-Day Event");
const title = createEventTitle("All-Day Delete Event");
await openAllDayEventFormWithMouse(page);
await fillTitleAndSaveEventForm(page, title);
await expectAllDayEventVisible(page, title);
Expand Down
16 changes: 8 additions & 8 deletions e2e/utils/event-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,17 +363,17 @@ export const openEventForEditingWithMouse = async (
page: Page,
eventTitle: string,
) => {
const eventButton = await findEventButton(page, eventTitle);

if (!eventButton) {
throw new Error(
`Unable to locate event "${eventTitle}" for mouse editing.`,
);
}

await retryUntil(
page,
async () => {
const eventButton = await findEventButton(page, eventTitle);

if (!eventButton) {
throw new Error(
`Unable to locate event "${eventTitle}" for mouse editing.`,
);
}

await page.waitForTimeout(200);
await eventButton.click({ force: true });
},
Expand Down
14 changes: 10 additions & 4 deletions packages/backend/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
# If you use local http, Google sign-in/import can work, but live
# notifications from changes made in Google Calendar are skipped.
BASEURL=http://localhost:3000/api
# Optional: public HTTPS URL used only for Google Calendar Watch callbacks.
# Use this for local end-to-end Google Watch testing with a temporary tunnel:
# cloudflared tunnel --url http://localhost:3000
# Then set this to the generated HTTPS URL plus /api.
# Keep BASEURL local so browser API traffic and SSE stay on localhost.
# GCAL_WEBHOOK_BASEURL=https://example.trycloudflare.com/api
CORS=http://localhost:3000,http://localhost:9080,https://app.yourdomain.com
LOG_LEVEL=debug # options: error, warn, info, http, verbose, debug, silly
NODE_ENV=development # options: test, development, production
Expand Down Expand Up @@ -75,10 +81,10 @@ FRONTEND_URL=http://localhost:9080
####################################################
# 7. Google Calendar webhook notifications #
####################################################
# Google Calendar sends live change notifications to BASEURL.
# To receive those notifications, BASEURL must be a public HTTPS API URL.
# Local HTTP development skips watch setup but can still use Google sign-in
# and initial calendar import when Google is configured.
# Google Calendar sends live change notifications to the effective webhook URL:
# GCAL_WEBHOOK_BASEURL when set, otherwise BASEURL.
# This URL must be public HTTPS. Local HTTP development skips watch setup but can
# still use Google sign-in and initial calendar import when Google is configured.

####################################################
# 8. Posthog (optional)
Expand Down
40 changes: 40 additions & 0 deletions packages/backend/src/common/constants/env.constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe("env.constants", () => {

expect(env.GOOGLE_CLIENT_ID).toBeUndefined();
expect(env.GOOGLE_CLIENT_SECRET).toBeUndefined();
expect(env.GCAL_WEBHOOK_BASEURL).toBeUndefined();
expect(env.TOKEN_GCAL_NOTIFICATION).toBe("");
expect(isGoogleConfigured(env)).toBe(false);
});
Expand Down Expand Up @@ -88,4 +89,43 @@ describe("env.constants", () => {
"Google Calendar webhook notifications require TOKEN_GCAL_NOTIFICATION",
);
});

it("accepts an HTTPS Google webhook base URL while BASEURL remains local", () => {
const env = parseBackendEnv({
...validEnv,
BASEURL: "http://localhost:3000/api",
GCAL_WEBHOOK_BASEURL: "https://example.trycloudflare.com/api",
GOOGLE_CLIENT_ID: "client-id",
GOOGLE_CLIENT_SECRET: "client-secret",
TOKEN_GCAL_NOTIFICATION: "notification-token",
});

expect(env.BASEURL).toBe("http://localhost:3000/api");
expect(env.GCAL_WEBHOOK_BASEURL).toBe(
"https://example.trycloudflare.com/api",
);
});

it("rejects a non-HTTPS Google webhook base URL", () => {
expect(() =>
parseBackendEnv({
...validEnv,
GCAL_WEBHOOK_BASEURL: "http://localhost:3000/api",
}),
).toThrow("GCAL_WEBHOOK_BASEURL must use HTTPS");
});

it("requires a Google notification token when the Google webhook URL uses HTTPS", () => {
expect(() =>
parseBackendEnv({
...validEnv,
BASEURL: "http://localhost:3000/api",
GCAL_WEBHOOK_BASEURL: "https://example.trycloudflare.com/api",
GOOGLE_CLIENT_ID: "client-id",
GOOGLE_CLIENT_SECRET: "client-secret",
}),
).toThrow(
"Google Calendar webhook notifications require TOKEN_GCAL_NOTIFICATION",
);
});
});
16 changes: 14 additions & 2 deletions packages/backend/src/common/constants/env.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ const EnvSchema = z
EMAILER_SECRET: z.string().nonempty().optional(),
EMAILER_USER_TAG_ID: z.string().nonempty().optional(),
FRONTEND_URL: z.string().url(),
GCAL_WEBHOOK_BASEURL: z
.string()
.url()
.refine((url) => url.startsWith("https://"), {
message: "GCAL_WEBHOOK_BASEURL must use HTTPS",
})
.optional(),
MONGO_URI: z.string().nonempty(),
NODE_ENV: z.nativeEnum(NodeEnv),
TZ: z.enum(["Etc/UTC", "UTC"]),
Expand Down Expand Up @@ -50,16 +57,20 @@ const EnvSchema = z
});
}

const usesHttpsGoogleWebhook =
env.GCAL_WEBHOOK_BASEURL?.startsWith("https://") ||
env.BASEURL.startsWith("https://");

if (
isGoogleConfigComplete &&
env.BASEURL.startsWith("https://") &&
usesHttpsGoogleWebhook &&
!env.TOKEN_GCAL_NOTIFICATION
) {
context.addIssue({
code: z.ZodIssueCode.custom,
fatal: true,
message:
"Google Calendar webhook notifications require TOKEN_GCAL_NOTIFICATION when BASEURL uses HTTPS",
"Google Calendar webhook notifications require TOKEN_GCAL_NOTIFICATION when Google webhook URL uses HTTPS",
path: ["TOKEN_GCAL_NOTIFICATION"],
});
}
Expand All @@ -85,6 +96,7 @@ export function parseBackendEnv(rawEnv: RawBackendEnv): BackendEnv {
EMAILER_SECRET: rawEnv["EMAILER_API_SECRET"],
EMAILER_USER_TAG_ID: rawEnv["EMAILER_USER_TAG_ID"],
FRONTEND_URL: rawEnv["FRONTEND_URL"],
GCAL_WEBHOOK_BASEURL: rawEnv["GCAL_WEBHOOK_BASEURL"],
MONGO_URI: rawEnv["MONGO_URI"],
NODE_ENV: nodeEnv,
TZ: rawEnv["TZ"],
Expand Down
60 changes: 60 additions & 0 deletions packages/backend/src/common/services/gcal/gcal.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
jest.mock("@backend/common/util/api-base-url.util", () => ({
getGcalWebhookBaseURL: jest.fn(() => "https://example.trycloudflare.com/api"),
}));

jest.mock("@backend/sync/util/watch.util", () => ({
encodeChannelToken: jest.fn(() => "encoded-token"),
}));

import { GCAL_NOTIFICATION_ENDPOINT } from "@core/constants/core.constants";
import gcalService from "./gcal.service";

describe("gcal.service watch callbacks", () => {
it("uses the Google webhook base URL for event watch callback addresses", async () => {
const watch = jest.fn().mockResolvedValue({
status: 200,
data: { id: "507f1f77bcf86cd799439011", resourceId: "resource-id" },
});

await gcalService.watchEvents({ events: { watch } } as never, {
channelId: "507f1f77bcf86cd799439011",
expiration: new Date("2030-01-01T00:00:00.000Z"),
gCalendarId: "primary",
});

expect(watch).toHaveBeenCalledWith(
expect.objectContaining({
calendarId: "primary",
requestBody: expect.objectContaining({
address:
"https://example.trycloudflare.com/api" +
GCAL_NOTIFICATION_ENDPOINT,
type: "web_hook",
}),
}),
);
});

it("uses the Google webhook base URL for calendar list watch callback addresses", async () => {
const watch = jest.fn().mockResolvedValue({
status: 200,
data: { id: "507f1f77bcf86cd799439011", resourceId: "resource-id" },
});

await gcalService.watchCalendars({ calendarList: { watch } } as never, {
channelId: "507f1f77bcf86cd799439011",
expiration: new Date("2030-01-01T00:00:00.000Z"),
});

expect(watch).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({
address:
"https://example.trycloudflare.com/api" +
GCAL_NOTIFICATION_ENDPOINT,
type: "web_hook",
}),
}),
);
});
});
9 changes: 6 additions & 3 deletions packages/backend/src/common/services/gcal/gcal.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import {
} from "@core/types/sync.types";
import { IDSchemaV4 } from "@core/types/type.utils";
import { GCAL_PRIMARY } from "@backend/common/constants/backend.constants";
import { getApiBaseURL } from "@backend/common/constants/env.constants";
import { error } from "@backend/common/errors/handlers/error.handler";
import { GcalError } from "@backend/common/errors/integration/gcal/gcal.errors";
import { getGcalWebhookBaseURL } from "@backend/common/util/api-base-url.util";
import { encodeChannelToken } from "@backend/sync/util/watch.util";

const getGcalNotificationAddress = () =>
getGcalWebhookBaseURL() + GCAL_NOTIFICATION_ENDPOINT;

class GCalService {
private validateGCalResponse<T>(
response: GaxiosResponse<T> | { status: number; data: T },
Expand Down Expand Up @@ -230,7 +233,7 @@ class GCalService {
quotaUser: params.quotaUser,
requestBody: {
// reminder: address always needs to be HTTPS
address: getApiBaseURL() + GCAL_NOTIFICATION_ENDPOINT,
address: getGcalNotificationAddress(),
expiration: params.expiration,
id: IDSchemaV4.parse(params.channelId),
token: encodeChannelToken({ resource: Resource_Sync.CALENDAR }),
Expand All @@ -250,7 +253,7 @@ class GCalService {
quotaUser: params.quotaUser,
requestBody: {
// reminder: address always needs to be HTTPS
address: getApiBaseURL() + GCAL_NOTIFICATION_ENDPOINT,
address: getGcalNotificationAddress(),
expiration: params.expiration,
id: IDSchemaV4.parse(params.channelId),
token: encodeChannelToken({ resource: Resource_Sync.EVENTS }),
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/common/util/api-base-url.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ENV, getApiBaseURL } from "@backend/common/constants/env.constants";

export function getGcalWebhookBaseURL(): string {
return ENV.GCAL_WEBHOOK_BASEURL ?? getApiBaseURL();
}
15 changes: 15 additions & 0 deletions packages/backend/src/config/config.routes.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type express from "express";
import { CommonRoutesConfig } from "@backend/common/common.routes.config";
import configController from "./controllers/config.controller";

export class ConfigRoutes extends CommonRoutesConfig {
constructor(app: express.Application) {
super(app, "ConfigRoutes");
}

configureRoutes(): express.Application {
this.app.route(`/api/config`).get(configController.get);

return this.app;
}
}
20 changes: 20 additions & 0 deletions packages/backend/src/config/controllers/config.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type Request, type Response } from "express";
import { type AppConfig, AppConfigSchema } from "@core/types/config.types";
import {
ENV,
isGoogleConfigured,
} from "@backend/common/constants/env.constants";

class ConfigController {
get = (_req: Request<never, AppConfig, never, never>, res: Response) => {
res.json(
AppConfigSchema.parse({
google: {
isConfigured: isGoogleConfigured(ENV),
},
}),
);
};
}

export default new ConfigController();
Loading
Loading