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
30 changes: 22 additions & 8 deletions apps/api/src/routes/chat-integration.http.ts
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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<string, string>, 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"))()
Expand All @@ -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",
),
)
}),
)
5 changes: 5 additions & 0 deletions apps/api/src/routes/integrations-callback.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/routes/integrations-callback.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,15 @@ const ROUTES: ReadonlyArray<CallbackRoute> = [
},
]

/**
* 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
Expand Down Expand Up @@ -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) =>
Expand Down
Loading