diff --git a/backend/.env.example b/backend/.env.example
index 5f7332bdeb..033e3a9c35 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -17,6 +17,12 @@ R2_ENDPOINT_URL=https://your-account-id.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=your-r2-access-key
R2_SECRET_ACCESS_KEY=your-r2-secret-key
R2_BUCKET_NAME=mike
+# Only needed when the storage endpoint above is NOT reachable from the user's
+# browser (e.g. a compose-internal hostname). Presigned download URLs are
+# signed against this instead. Cloud R2/S3 endpoints are already public, so
+# leave it unset there. NOTE: for the docker-compose stack this must be set in
+# the compose-root .env or the shell, not here — see docker-compose.yml.
+# R2_PUBLIC_ENDPOINT_URL=https://files.your-domain.com
GEMINI_API_KEY=your-gemini-key
ANTHROPIC_API_KEY=your-anthropic-key
diff --git a/backend/src/__tests__/integration/user.routes.test.ts b/backend/src/__tests__/integration/user.routes.test.ts
index 6be4b8f996..4403e2e259 100644
--- a/backend/src/__tests__/integration/user.routes.test.ts
+++ b/backend/src/__tests__/integration/user.routes.test.ts
@@ -647,6 +647,77 @@ describe("user.routes", () => {
});
});
+ // ── GET /user/mcp-connectors/oauth/callback (popup hand-off page) ─────
+ describe("GET /user/mcp-connectors/oauth/callback", () => {
+ // The 400 path (missing state/code) renders the same popup HTML via
+ // the same header helper as the success path, without needing any
+ // real OAuth machinery — so it is the regression probe for both.
+ it("relaxes COOP so window.opener survives, alongside the nonce CSP", async () => {
+ const log = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ const res = await request(app).get(
+ "/user/mcp-connectors/oauth/callback",
+ );
+
+ expect(res.status).toBe(400);
+ // Load-bearing: helmet's default COOP of same-origin would sever
+ // window.opener the moment the popup returns from the
+ // cross-origin consent page, silently breaking the postMessage
+ // hand-off. This must hold through the full app assembly (helmet
+ // runs on this very request), not just on the bare router.
+ expect(res.headers["cross-origin-opener-policy"]).toBe(
+ "unsafe-none",
+ );
+ // The route-scoped CSP (with the per-response script nonce) must
+ // survive alongside the COOP relaxation.
+ expect(res.headers["content-security-policy"]).toContain(
+ "script-src 'nonce-",
+ );
+ log.mockRestore();
+ });
+
+ it("keeps helmet's default COOP on every other route", async () => {
+ // Contrast probe: the relaxation must stay scoped to the popup
+ // page. If it ever leaks app-wide, this fails.
+ supabaseState.tables.user_profiles = {
+ data: profileRow(),
+ error: null,
+ };
+
+ const res = await request(app).get("/user/profile").set(...AUTH);
+
+ expect(res.headers["cross-origin-opener-policy"]).toBe(
+ "same-origin",
+ );
+ });
+
+ it("neutralizes a breakout in the attacker-controlled error detail", async () => {
+ const log = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => undefined);
+
+ // ?error= flows into the inline script's JSON literal.
+ // JSON.stringify leaves "<" alone, so an unescaped payload of
+ // "),
+ );
+
+ expect(res.status).toBe(400);
+ // No breakout: the only left is the page's own closing
+ // tag, and the payload's "<" chars were escaped to \u003c inside
+ // the JS string literal.
+ expect(res.text).not.toContain("
+