diff --git a/apps/api/src/routes/chat-integration.http.ts b/apps/api/src/routes/chat-integration.http.ts index 31f2dc4d4..429f76dce 100644 --- a/apps/api/src/routes/chat-integration.http.ts +++ b/apps/api/src/routes/chat-integration.http.ts @@ -1,7 +1,8 @@ -import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http" +import { HttpRouter, HttpServerRequest } from "effect/http" import { ChatConnectorId, encodeOAuthReturnHash, type OAuthReturnFlow } from "@maple/domain/http" import { Effect, Option, Schema } from "effect" import { Env } from "@maple/backend/platform/Env" +import { forwardToDashboard } from "./integrations-callback.http" /** * The two public callbacks every chat connector redirects to: @@ -30,12 +31,16 @@ export const ChatCallbackRouter = HttpRouter.use((router) => /** Where the browser lands: the integrations page, with the outcome or the return to finish. */ const toDashboard = (search: Record, hash = "") => - HttpServerResponse.redirect( - `${appBaseUrl}/integrations?${new URLSearchParams(search).toString()}${hash}`, - ) + forwardToDashboard(`${appBaseUrl}/integrations?${new URLSearchParams(search).toString()}${hash}`) - const callback = (span: string, flow: OAuthReturnFlow, outcome: string) => - Effect.fn(span)(function* (req: HttpServerRequest.HttpServerRequest) { + // Server-kind like `IntegrationsCallbackRouter`: the auto server span is suppressed for these + // paths (ApiObservabilityLive), so this span is the callback's only trace root. + const callback = (span: string, path: string, flow: OAuthReturnFlow, outcome: string) => + Effect.fn(span, { + kind: "server", + attributes: { "http.route": path, "http.request.method": "GET" }, + })(function* (req: HttpServerRequest.HttpServerRequest) { + yield* Effect.annotateCurrentSpan({ "http.response.status_code": 302 }) const routeParams = yield* HttpRouter.params const connector = decodeConnectorParam(routeParams.connector) const url = Option.liftThrowable(() => new URL(req.url, "http://localhost"))() @@ -59,11 +64,20 @@ export const ChatCallbackRouter = HttpRouter.use((router) => ) }) - yield* router.add("GET", CHAT_CALLBACK_PATH, callback("ChatOAuth.callback", "chat_install", "chat")) + yield* router.add( + "GET", + CHAT_CALLBACK_PATH, + callback("ChatOAuth.callback", CHAT_CALLBACK_PATH, "chat_install", "chat"), + ) yield* router.add( "GET", CHAT_IDENTITY_CALLBACK_PATH, - callback("ChatOAuth.identityCallback", "chat_identity", "chat_identity"), + callback( + "ChatOAuth.identityCallback", + CHAT_IDENTITY_CALLBACK_PATH, + "chat_identity", + "chat_identity", + ), ) }), ) diff --git a/apps/api/src/routes/integrations-callback.http.test.ts b/apps/api/src/routes/integrations-callback.http.test.ts index 34a72b99c..71e497c41 100644 --- a/apps/api/src/routes/integrations-callback.http.test.ts +++ b/apps/api/src/routes/integrations-callback.http.test.ts @@ -60,6 +60,9 @@ describe("provider callbacks", () => { // Straight to the page that finishes it, not one the dashboard redirects away from. const location = new URL(response.headers.get("location") ?? "") expect(location.origin + location.pathname).toBe("https://app.example.com/sources/new") + // The location carries the provider's code: never cached, never sent on as a Referer. + expect(response.headers.get("cache-control")).toBe("no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") expect(location.searchParams.get("integration")).toBe("cloudflare") const handed = Option.getOrThrow(decodeOAuthReturnHash(location.hash)) expect(handed.flow).toBe("cloudflare") @@ -109,6 +112,8 @@ describe("provider callbacks", () => { expect(response.status).toBe(302) const location = new URL(response.headers.get("location") ?? "") expect(location.searchParams.get("integration")).toBe("chat-slack") + expect(response.headers.get("cache-control")).toBe("no-store") + expect(response.headers.get("referrer-policy")).toBe("no-referrer") const handed = Option.getOrThrow(decodeOAuthReturnHash(location.hash)) expect(handed.flow).toBe("chat_identity") expect(handed.connector).toBe("slack") diff --git a/apps/api/src/routes/integrations-callback.http.ts b/apps/api/src/routes/integrations-callback.http.ts index 8f1d2921b..20403cad1 100644 --- a/apps/api/src/routes/integrations-callback.http.ts +++ b/apps/api/src/routes/integrations-callback.http.ts @@ -288,6 +288,15 @@ const ROUTES: ReadonlyArray = [ }, ] +/** + * The redirect that hands a callback query to the dashboard. The location carries the provider's + * code, so no cache may keep it and the landing request sends no `Referer`. + */ +export const forwardToDashboard = (location: string) => + HttpServerResponse.redirect(location, { + headers: { "cache-control": "no-store", "referrer-policy": "no-referrer" }, + }) + /** * The provider callbacks. They redeem nothing: this request carries no Maple session, so it * cannot tell whose browser came back. The query goes to the dashboard in a URL fragment, and @@ -343,7 +352,7 @@ export const IntegrationsCallbackRouter = HttpRouter.use((router) => flow: route.flow, query: url.search.replace(/^\?/, ""), }) - return HttpServerResponse.redirect(`${appBaseUrl}${route.page}?${search}${hash}`) + return forwardToDashboard(`${appBaseUrl}${route.page}?${search}${hash}`) }, // Every branch returns a response, so the status is the only signal of a rejection. Effect.tap((response) =>