* Fix OAuth callback org scope * Show URLs in e2e PR media * Record OAuth org switch flow
Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Fix OAuth callbacks in cloud so they preserve the URL-selected organization when the session cookie points at another org. | ||
| @@ -12,6 +12,7 @@ · 11 unmodified lines | |||
| 12 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | ||
| 14 | import { Effect } from "effect"; | 14 | import { Effect } from "effect"; |
| 15 | import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; | ||
| 15 | 16 | ||
| 16 | import { UserStoreService } from "./context"; | 17 | import { UserStoreService } from "./context"; |
| 17 | import { WorkOSClient } from "./workos"; | 18 | import { WorkOSClient } from "./workos"; |
| @@ -96,7 +97,7 @@ export const authorizeOrganization = (userId: string, organizationId: string) => · 78 unmodified lines | |||
| 96 | // silently re-scopes the other. Scoping per-request from the URL makes each | 97 | // silently re-scopes the other. Scoping per-request from the URL makes each |
| 97 | // tab independent. | 98 | // tab independent. |
| 98 | 99 | ||
| 99 | export const ORG_SELECTOR_HEADER = "x-executor-organization"; | 100 | export const ORG_SELECTOR_HEADER = EXECUTOR_ORG_SELECTOR_HEADER; |
| 100 | 101 | ||
| 101 | /** The URL-pinned org selector for a request, or `null` to fall back to the session. */ | 102 | /** The URL-pinned org selector for a request, or `null` to fall back to the session. */ |
| 102 | export const orgSelectorFromRequest = (request: Request): string | null => | 103 | export const orgSelectorFromRequest = (request: Request): string | null => |
| @@ -1,9 +1,11 @@ | |||
| 1 | import { createMiddleware, createStart } from "@tanstack/react-start"; | 1 | import { createMiddleware, createStart } from "@tanstack/react-start"; |
| 2 | import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared"; | ||
| 2 | 3 | ||
| 3 | import { cloudApiHandler } from "./app"; | 4 | import { cloudApiHandler } from "./app"; |
| 4 | import { isAppOwnedPath } from "./app-paths"; | 5 | import { isAppOwnedPath } from "./app-paths"; |
| 5 | import { authGateMiddleware } from "./auth/ssr-gate"; | 6 | import { authGateMiddleware } from "./auth/ssr-gate"; |
| 6 | import { parseCookie } from "./auth/cookies"; | 7 | import { parseCookie } from "./auth/cookies"; |
| 8 | import { ORG_SELECTOR_HEADER } from "./auth/organization"; | ||
| 7 | import { loginPath } from "./auth/return-to"; | 9 | import { loginPath } from "./auth/return-to"; |
| 8 | import { prepareMcpOrgScope } from "./mcp/mount"; | 10 | import { prepareMcpOrgScope } from "./mcp/mount"; |
| 9 | import { | 11 | import { |
| @@ -39,6 +41,14 @@ const getApp = () => (app ??= cloudApiHandler()); · 29 unmodified lines | |||
| 39 | const SESSION_COOKIE = "wos-session"; | 41 | const SESSION_COOKIE = "wos-session"; |
| 40 | const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; | 42 | const OAUTH_CALLBACK_PATH = "/api/oauth/callback"; |
| 41 | 43 | ||
| 44 | const oauthCallbackOrgScopedRequest = (request: Request): Request => { | ||
| 45 | const orgSelector = new URL(request.url).searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM); | ||
| 46 | if (!orgSelector) return request; | ||
| 47 | const headers = new Headers(request.headers); | ||
| 48 | headers.set(ORG_SELECTOR_HEADER, orgSelector); | ||
| 49 | return new Request(request, { headers }); | ||
| 50 | }; | ||
| 51 | |||
| 42 | const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).server( | 52 | const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).server( |
| 43 | ({ pathname, request, next }) => { | 53 | ({ pathname, request, next }) => { |
| 44 | if ( | 54 | if ( |
| @@ -66,7 +76,11 @@ const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).serv · 21 unmodified lines | |||
| 66 | // else, including `/api/*`). | 76 | // else, including `/api/*`). |
| 67 | const appRequestMiddleware = createMiddleware({ type: "request" }).server( | 77 | const appRequestMiddleware = createMiddleware({ type: "request" }).server( |
| 68 | ({ pathname, request, next }) => { | 78 | ({ pathname, request, next }) => { |
| 69 | if (isAppOwnedPath(pathname)) return getApp().handler(prepareMcpOrgScope(request)); | 79 | if (isAppOwnedPath(pathname)) { |
| 80 | const scopedRequest = | ||
| 81 | pathname === OAUTH_CALLBACK_PATH ? oauthCallbackOrgScopedRequest(request) : request; | ||
| 82 | return getApp().handler(prepareMcpOrgScope(scopedRequest)); | ||
| 83 | } | ||
| 70 | return next(); | 84 | return next(); |
| 71 | }, | 85 | }, |
| 72 | ); | 86 | ); |
| @@ -0,0 +1,265 @@ | |||
| 1 | // Cloud-only: OAuth callbacks must preserve the URL-selected organization. | ||
| 2 | // | ||
| 3 | // A browser session cookie can be pinned to org B while a tab is operating in | ||
| 4 | // org A via the URL org selector. OAuth redirects leave the console route and | ||
| 5 | // land on /api/oauth/callback, so the callback URL itself must carry the same | ||
| 6 | // org selector that was present when oauth.start created the session. | ||
| 7 | import { randomBytes } from "node:crypto"; | ||
| 8 | |||
| 9 | import { expect } from "@effect/vitest"; | ||
| 10 | import { Effect } from "effect"; | ||
| 11 | import type { Page } from "playwright"; | ||
| 12 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 13 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 14 | import { | ||
| 15 | IntegrationSlug, | ||
| 16 | OAUTH_CALLBACK_ORG_QUERY_PARAM, | ||
| 17 | OAuthClientSlug, | ||
| 18 | } from "@executor-js/sdk/shared"; | ||
| 19 | import { serveOAuthTestServer } from "@executor-js/sdk/testing"; | ||
| 20 | |||
| 21 | import { scenario } from "../src/scenario"; | ||
| 22 | import { Api, Browser, Target } from "../src/services"; | ||
| 23 | import type { Identity } from "../src/target"; | ||
| 24 | |||
| 25 | const api = composePluginApi([openApiHttpPlugin()] as const); | ||
| 26 | |||
| 27 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
| 28 | |||
| 29 | const cookiePair = (response: Response, name: string): string | undefined => { | ||
| 30 | for (const header of response.headers.getSetCookie?.() ?? []) { | ||
| 31 | if (header.startsWith(`${name}=`)) return header.split(";")[0]; | ||
| 32 | } | ||
| 33 | return undefined; | ||
| 34 | }; | ||
| 35 | |||
| 36 | const cookieValue = (pair: string): string => { | ||
| 37 | const [, value] = pair.split(/=(.*)/s); | ||
| 38 | if (!value) throw new Error("cookie pair has no value"); | ||
| 39 | return value; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? ""; | ||
| 43 | |||
| 44 | const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); | ||
| 45 | |||
| 46 | const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | ||
| 47 | |||
| 48 | const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => { | ||
| 49 | await page.context().addCookies([ | ||
| 50 | { | ||
| 51 | name: "wos-session", | ||
| 52 | value: cookieValue(cookie), | ||
| 53 | url: baseUrl, | ||
| 54 | }, | ||
| 55 | ]); | ||
| 56 | }; | ||
| 57 | |||
| 58 | const expectOrgShell = async ( | ||
| 59 | page: Page, | ||
| 60 | org: { readonly name: string; readonly slug: string }, | ||
| 61 | ) => { | ||
| 62 | await page.waitForURL( | ||
| 63 | (url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`, | ||
| 64 | { | ||
| 65 | timeout: 30_000, | ||
| 66 | }, | ||
| 67 | ); | ||
| 68 | await page.getByRole("button", { name: new RegExp(escapeRegExp(org.name)) }).waitFor(); | ||
| 69 | await page.getByRole("heading", { name: "Integrations" }).waitFor(); | ||
| 70 | }; | ||
| 71 | |||
| 72 | const installSameWindowOAuthPopup = async (page: Page) => { | ||
| 73 | await page.addInitScript(() => { | ||
| 74 | window.open = () => | ||
| 75 | ({ | ||
| 76 | get closed() { | ||
| 77 | return false; | ||
| 78 | }, | ||
| 79 | close() {}, | ||
| 80 | focus() {}, | ||
| 81 | location: { | ||
| 82 | get href() { | ||
| 83 | return window.location.href; | ||
| 84 | }, | ||
| 85 | set href(value: string) { | ||
| 86 | window.location.assign(String(value)); | ||
| 87 | }, | ||
| 88 | }, | ||
| 89 | }) as Window; | ||
| 90 | }); | ||
| 91 | }; | ||
| 92 | |||
| 93 | const submitProviderLoginFromPage = async (page: Page): Promise<string> => | ||
| 94 | fetch(page.url(), { | ||
| 95 | method: "POST", | ||
| 96 | redirect: "manual", | ||
| 97 | headers: { | ||
| 98 | authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, | ||
| 99 | }, | ||
| 100 | }).then((response) => { | ||
| 101 | const location = response.headers.get("location"); | ||
| 102 | if (response.status !== 302 || !location) { | ||
| 103 | throw new Error(`provider did not return callback location (${response.status})`); | ||
| 104 | } | ||
| 105 | return new URL(location, page.url()).toString(); | ||
| 106 | }); | ||
| 107 | |||
| 108 | const activeOrg = (baseUrl: string, cookie: string) => | ||
| 109 | Effect.promise(async () => { | ||
| 110 | const response = await fetch(new URL("/api/auth/me", baseUrl), { | ||
| 111 | headers: { cookie }, | ||
| 112 | }); | ||
| 113 | if (!response.ok) throw new Error(`/api/auth/me failed (${response.status})`); | ||
| 114 | const body = (await response.json()) as { | ||
| 115 | organization: { id: string; name: string; slug: string } | null; | ||
| 116 | }; | ||
| 117 | if (!body.organization) throw new Error("identity has no active organization"); | ||
| 118 | return body.organization; | ||
| 119 | }); | ||
| 120 | |||
| 121 | const createOrganization = (baseUrl: string, cookie: string, name: string) => | ||
| 122 | Effect.promise(async () => { | ||
| 123 | const response = await fetch(new URL("/api/auth/create-organization", baseUrl), { | ||
| 124 | method: "POST", | ||
| 125 | headers: { | ||
| 126 | "content-type": "application/json", | ||
| 127 | cookie, | ||
| 128 | ...originHeaders(baseUrl), | ||
| 129 | }, | ||
| 130 | body: JSON.stringify({ name }), | ||
| 131 | }); | ||
| 132 | if (!response.ok) { | ||
| 133 | throw new Error(`/api/auth/create-organization failed (${response.status})`); | ||
| 134 | } | ||
| 135 | const session = cookiePair(response, "wos-session"); | ||
| 136 | if (!session) throw new Error("create organization did not refresh the session"); | ||
| 137 | const org = (await response.json()) as { id: string; name: string; slug: string }; | ||
| 138 | return { org, session }; | ||
| 139 | }); | ||
| 140 | |||
| 141 | const oauthIntegrationSpec = (oauth: { | ||
| 142 | readonly authorizationEndpoint: string; | ||
| 143 | readonly tokenEndpoint: string; | ||
| 144 | }) => | ||
| 145 | ({ | ||
| 146 | spec: { | ||
| 147 | kind: "blob" as const, | ||
| 148 | value: JSON.stringify({ | ||
| 149 | openapi: "3.0.3", | ||
| 150 | info: { title: "OAuth org scope", version: "1.0.0" }, | ||
| 151 | paths: { | ||
| 152 | "/me": { | ||
| 153 | get: { | ||
| 154 | operationId: "getMe", | ||
| 155 | responses: { "200": { description: "the caller" } }, | ||
| 156 | }, | ||
| 157 | }, | ||
| 158 | }, | ||
| 159 | }), | ||
| 160 | }, | ||
| 161 | baseUrl: "http://127.0.0.1:59999", | ||
| 162 | authenticationTemplate: [ | ||
| 163 | { | ||
| 164 | slug: "oauth", | ||
| 165 | kind: "oauth2" as const, | ||
| 166 | authorizationUrl: oauth.authorizationEndpoint, | ||
| 167 | tokenUrl: oauth.tokenEndpoint, | ||
| 168 | scopes: ["read"], | ||
| 169 | }, | ||
| 170 | ], | ||
| 171 | }) as const; | ||
| 172 | |||
| 173 | scenario( | ||
| 174 | "OAuth callback · URL-scoped org survives a callback while the session cookie points elsewhere", | ||
| 175 | {}, | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const target = yield* Target; | ||
| 178 | const { client: makeApiClient } = yield* Api; | ||
| 179 | const browser = yield* Browser; | ||
| 180 | const oauth = yield* serveOAuthTestServer(); | ||
| 181 | |||
| 182 | const identity = yield* target.newIdentity(); | ||
| 183 | const sessionA = cookieOf(identity); | ||
| 184 | const orgA = yield* activeOrg(target.baseUrl, sessionA); | ||
| 185 | |||
| 186 | const { org: orgB, session: sessionB } = yield* createOrganization( | ||
| 187 | target.baseUrl, | ||
| 188 | sessionA, | ||
| 189 | `OAuth Callback Org B ${randomBytes(3).toString("hex")}`, | ||
| 190 | ); | ||
| 191 | expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug); | ||
| 192 | |||
| 193 | const client = yield* makeApiClient(api, identity); | ||
| 194 | |||
| 195 | const integration = IntegrationSlug.make(unique("oauthscope")); | ||
| 196 | yield* client.openapi.addSpec({ | ||
| 197 | payload: { ...oauthIntegrationSpec(oauth), slug: integration }, | ||
| 198 | }); | ||
| 199 | |||
| 200 | const clientSlug = OAuthClientSlug.make(unique("oauthc")); | ||
| 201 | yield* client.oauth.createClient({ | ||
| 202 | payload: { | ||
| 203 | owner: "org", | ||
| 204 | slug: clientSlug, | ||
| 205 | authorizationUrl: oauth.authorizationEndpoint, | ||
| 206 | tokenUrl: oauth.tokenEndpoint, | ||
| 207 | grant: "authorization_code", | ||
| 208 | clientId: "test-client", | ||
| 209 | clientSecret: "test-secret", | ||
| 210 | }, | ||
| 211 | }); | ||
| 212 | |||
| 213 | let callback = new URL("http://invalid.example"); | ||
| 214 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 215 | await installSameWindowOAuthPopup(page); | ||
| 216 | |||
| 217 | await step("Land in the original organization", async () => { | ||
| 218 | await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); | ||
| 219 | await expectOrgShell(page, orgA); | ||
| 220 | }); | ||
| 221 | |||
| 222 | await step("The browser session is switched to another organization", async () => { | ||
| 223 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); | ||
| 224 | await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | ||
| 225 | await expectOrgShell(page, orgB); | ||
| 226 | }); | ||
| 227 | |||
| 228 | await step("Start OAuth from the original organization's add-connection flow", async () => { | ||
| 229 | await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, { | ||
| 230 | waitUntil: "networkidle", | ||
| 231 | }); | ||
| 232 | await page.getByRole("heading", { name: /Add connection/ }).waitFor({ | ||
| 233 | timeout: 30_000, | ||
| 234 | }); | ||
| 235 | await page.getByRole("button", { name: "Connect with OAuth" }).click(); | ||
| 236 | await page.waitForURL( | ||
| 237 | (url) => url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/login", | ||
| 238 | { timeout: 30_000 }, | ||
| 239 | ); | ||
| 240 | await page.getByText("OAuth test login").waitFor(); | ||
| 241 | }); | ||
| 242 | |||
| 243 | await step("The provider returns to the OAuth callback", async () => { | ||
| 244 | const callbackUrl = await submitProviderLoginFromPage(page); | ||
| 245 | callback = new URL(callbackUrl); | ||
| 246 | const response = await page.goto(callbackUrl, { waitUntil: "networkidle" }); | ||
| 247 | expect(response?.status(), "the callback renders its popup result page").toBe(200); | ||
| 248 | }); | ||
| 249 | |||
| 250 | const body = (await page.locator("body").textContent())?.trim() ?? ""; | ||
| 251 | expect( | ||
| 252 | body, | ||
| 253 | "the callback completes in the org where oauth.start stored the session", | ||
| 254 | ).toContain("Connected"); | ||
| 255 | expect(body, "the callback did not fall through to the cookie-pinned org").not.toContain( | ||
| 256 | "OAuth session expired or not found", | ||
| 257 | ); | ||
| 258 | }); | ||
| 259 | |||
| 260 | expect( | ||
| 261 | callback.searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM), | ||
| 262 | "the provider callback URL carries the original org selector", | ||
| 263 | ).toBe(orgA.slug); | ||
| 264 | }).pipe(Effect.scoped), | ||
| 265 | ); | ||
| @@ -16,13 +16,17 @@ · 15 unmodified lines | |||
| 16 | // *.png run screenshot, uploaded as-is | 16 | // *.png run screenshot, uploaded as-is |
| 17 | import { execFileSync } from "node:child_process"; | 17 | import { execFileSync } from "node:child_process"; |
| 18 | import { createHash } from "node:crypto"; | 18 | import { createHash } from "node:crypto"; |
| 19 | import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; | 19 | import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 20 | import { tmpdir } from "node:os"; | 20 | import { tmpdir } from "node:os"; |
| 21 | import { basename, join, resolve } from "node:path"; | 21 | import { basename, join, resolve } from "node:path"; |
| 22 | 22 | ||
| 23 | import { chromium } from "playwright"; | ||
| 24 | |||
| 23 | const MEDIA_BRANCH = "e2e-media"; | 25 | const MEDIA_BRANCH = "e2e-media"; |
| 24 | // Above this GitHub's image proxy stops rendering the gif inline. | 26 | // Above this GitHub's image proxy stops rendering the gif inline. |
| 25 | const RENDER_LIMIT_BYTES = 10 * 1024 * 1024; | 27 | const RENDER_LIMIT_BYTES = 10 * 1024 * 1024; |
| 28 | const GIF_WIDTH = 960; | ||
| 29 | const CHROME_HEIGHT = 40; | ||
| 26 | 30 | ||
| 27 | const run = (command: string, args: ReadonlyArray<string>): string => | 31 | const run = (command: string, args: ReadonlyArray<string>): string => |
| 28 | execFileSync(command, [...args], { | 32 | execFileSync(command, [...args], { |
| @@ -36,6 +40,18 @@ interface Artifact { · 7 unmodified lines | |||
| 36 | readonly path: string; | 40 | readonly path: string; |
| 37 | readonly label: string; | 41 | readonly label: string; |
| 38 | readonly slug: string; | 42 | readonly slug: string; |
| 43 | readonly runDir?: string; | ||
| 44 | } | ||
| 45 | |||
| 46 | interface Timeline { | ||
| 47 | readonly anchors: { readonly browser?: number }; | ||
| 48 | readonly nav?: ReadonlyArray<{ readonly at: number; readonly url: string }>; | ||
| 49 | } | ||
| 50 | |||
| 51 | interface UrlSegment { | ||
| 52 | readonly from: number; | ||
| 53 | readonly to: number; | ||
| 54 | readonly url: string; | ||
| 39 | } | 55 | } |
| 40 | 56 | ||
| 41 | /** A run dir resolves to its recording; a file stands for itself. */ | 57 | /** A run dir resolves to its recording; a file stands for itself. */ |
| @@ -66,20 +82,206 @@ const resolveArtifact = (input: string): Artifact => { · 24 unmodified lines | |||
| 66 | } catch { | 82 | } catch { |
| 67 | // skipped/partial runs still have a recording worth showing | 83 | // skipped/partial runs still have a recording worth showing |
| 68 | } | 84 | } |
| 69 | return { path: recording, label, slug: basename(path) }; | 85 | return { path: recording, label, slug: basename(path), runDir: path }; |
| 86 | }; | ||
| 87 | |||
| 88 | const videoDuration = (file: string): number => | ||
| 89 | Number( | ||
| 90 | run("ffprobe", [ | ||
| 91 | ...["-v", "quiet", "-show_entries", "format=duration"], | ||
| 92 | ...["-of", "csv=p=0", file], | ||
| 93 | ]).trim(), | ||
| 94 | ); | ||
| 95 | |||
| 96 | const readTimeline = (artifact: Artifact): Timeline | null => { | ||
| 97 | if (!artifact.runDir) return null; | ||
| 98 | const timelinePath = join(artifact.runDir, "timeline.json"); | ||
| 99 | if (!existsSync(timelinePath)) return null; | ||
| 100 | return JSON.parse(readFileSync(timelinePath, "utf8")) as Timeline; | ||
| 101 | }; | ||
| 102 | |||
| 103 | const urlSegments = (artifact: Artifact, duration: number): ReadonlyArray<UrlSegment> => { | ||
| 104 | if (basename(artifact.path) !== "session.mp4") return []; | ||
| 105 | const timeline = readTimeline(artifact); | ||
| 106 | const browserAnchor = timeline?.anchors.browser; | ||
| 107 | if (!timeline || browserAnchor === undefined) return []; | ||
| 108 | |||
| 109 | const nav = [...(timeline.nav ?? [])] | ||
| 110 | .map((entry) => ({ | ||
| 111 | at: Math.max(0, (entry.at - browserAnchor) / 1000), | ||
| 112 | url: entry.url, | ||
| 113 | })) | ||
| 114 | .filter((entry) => entry.at <= duration) | ||
| 115 | .sort((a, b) => a.at - b.at); | ||
| 116 | |||
| 117 | if (nav.length === 0) return [{ from: 0, to: duration, url: "about:blank" }]; | ||
| 118 | if ((nav[0]?.at ?? 0) <= 0.25) nav[0] = { ...nav[0], at: 0 }; | ||
| 119 | |||
| 120 | const segments: UrlSegment[] = []; | ||
| 121 | let cursor = 0; | ||
| 122 | let currentUrl = "about:blank"; | ||
| 123 | for (const entry of nav) { | ||
| 124 | if (entry.at > cursor) segments.push({ from: cursor, to: entry.at, url: currentUrl }); | ||
| 125 | currentUrl = entry.url; | ||
| 126 | cursor = entry.at; | ||
| 127 | } | ||
| 128 | segments.push({ from: cursor, to: duration, url: currentUrl }); | ||
| 129 | |||
| 130 | return segments | ||
| 131 | .filter((segment) => segment.to - segment.from > 0.01) | ||
| 132 | .reduce<UrlSegment[]>((merged, segment) => { | ||
| 133 | const previous = merged.at(-1); | ||
| 134 | if (previous?.url === segment.url && Math.abs(previous.to - segment.from) < 0.01) { | ||
| 135 | merged[merged.length - 1] = { ...previous, to: segment.to }; | ||
| 136 | } else { | ||
| 137 | merged.push(segment); | ||
| 138 | } | ||
| 139 | return merged; | ||
| 140 | }, []); | ||
| 141 | }; | ||
| 142 | |||
| 143 | const htmlEscape = (value: string): string => | ||
| 144 | value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | ||
| 145 | |||
| 146 | const renderUrlBars = async ( | ||
| 147 | segments: ReadonlyArray<UrlSegment>, | ||
| 148 | workDir: string, | ||
| 149 | ): Promise<ReadonlyArray<{ readonly path: string; readonly segment: UrlSegment }>> => { | ||
| 150 | const browser = await chromium.launch(); | ||
| 151 | try { | ||
| 152 | const page = await browser.newPage({ | ||
| 153 | viewport: { width: GIF_WIDTH, height: CHROME_HEIGHT }, | ||
| 154 | deviceScaleFactor: 1, | ||
| 155 | }); | ||
| 156 | const bars: Array<{ path: string; segment: UrlSegment }> = []; | ||
| 157 | for (const [index, segment] of segments.entries()) { | ||
| 158 | const displayUrl = segment.url.replace(/^https?:\/\//, "") || "about:blank"; | ||
| 159 | await page.setContent(`<!doctype html> | ||
| 160 | <html> | ||
| 161 | <head> | ||
| 162 | <style> | ||
| 163 | * { box-sizing: border-box; } | ||
| 164 | body { | ||
| 165 | margin: 0; | ||
| 166 | width: ${GIF_WIDTH}px; | ||
| 167 | height: ${CHROME_HEIGHT}px; | ||
| 168 | background: #111318; | ||
| 169 | color: #d7dde8; | ||
| 170 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | ||
| 171 | } | ||
| 172 | .chrome { | ||
| 173 | height: ${CHROME_HEIGHT}px; | ||
| 174 | display: flex; | ||
| 175 | align-items: center; | ||
| 176 | gap: 10px; | ||
| 177 | padding: 7px 12px; | ||
| 178 | border-bottom: 1px solid #272c36; | ||
| 179 | } | ||
| 180 | .lights { | ||
| 181 | display: flex; | ||
| 182 | gap: 6px; | ||
| 183 | flex: 0 0 auto; | ||
| 184 | } | ||
| 185 | .lights i { | ||
| 186 | width: 10px; | ||
| 187 | height: 10px; | ||
| 188 | border-radius: 50%; | ||
| 189 | display: block; | ||
| 190 | } | ||
| 191 | .lights i:nth-child(1) { background: #ff5f56; } | ||
| 192 | .lights i:nth-child(2) { background: #ffbd2e; } | ||
| 193 | .lights i:nth-child(3) { background: #27c93f; } | ||
| 194 | .urlbar { | ||
| 195 | min-width: 0; | ||
| 196 | flex: 1 1 auto; | ||
| 197 | height: 26px; | ||
| 198 | display: flex; | ||
| 199 | align-items: center; | ||
| 200 | border: 1px solid #313846; | ||
| 201 | border-radius: 6px; | ||
| 202 | background: #0b0d12; | ||
| 203 | padding: 0 10px; | ||
| 204 | color: #d7dde8; | ||
| 205 | font-size: 12px; | ||
| 206 | line-height: 1; | ||
| 207 | white-space: nowrap; | ||
| 208 | overflow: hidden; | ||
| 209 | text-overflow: ellipsis; | ||
| 210 | } | ||
| 211 | .lock { | ||
| 212 | color: #8b95a7; | ||
| 213 | margin-right: 7px; | ||
| 214 | flex: 0 0 auto; | ||
| 215 | } | ||
| 216 | </style> | ||
| 217 | </head> | ||
| 218 | <body> | ||
| 219 | <div class="chrome"> | ||
| 220 | <span class="lights"><i></i><i></i><i></i></span> | ||
| 221 | <div class="urlbar"><span class="lock">url</span>${htmlEscape(displayUrl)}</div> | ||
| 222 | </div> | ||
| 223 | </body> | ||
| 224 | </html>`); | ||
| 225 | const path = join(workDir, `urlbar-${index}.png`); | ||
| 226 | await page.screenshot({ path, animations: "disabled" }); | ||
| 227 | bars.push({ path, segment }); | ||
| 228 | } | ||
| 229 | return bars; | ||
| 230 | } finally { | ||
| 231 | await browser.close(); | ||
| 232 | } | ||
| 70 | }; | 233 | }; |
| 71 | 234 | ||
| 72 | const toGif = (artifact: Artifact): string => { | 235 | const toGif = async (artifact: Artifact): Promise<string> => { |
| 73 | if (artifact.path.endsWith(".png")) return artifact.path; | 236 | if (artifact.path.endsWith(".png")) return artifact.path; |
| 74 | const out = join(tmpdir(), `${artifact.slug}.gif`); | 237 | const out = join(tmpdir(), `${artifact.slug}.gif`); |
| 75 | if (artifact.path.endsWith(".cast")) { | 238 | if (artifact.path.endsWith(".cast")) { |
| 76 | run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]); | 239 | run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]); |
| 77 | } else { | 240 | } else { |
| 78 | // Two-pass palette keeps UI text crisp at a size GitHub will render. | 241 | // Two-pass palette keeps UI text crisp at a size GitHub will render. |
| 79 | const filter = | 242 | const duration = videoDuration(artifact.path); |
| 80 | "fps=8,scale=960:-2:flags=lanczos,split[a][b];" + | 243 | const segments = urlSegments(artifact, duration); |
| 81 | "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5"; | 244 | if (segments.length > 0) { |
| 82 | run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]); | 245 | const workDir = mkdtempSync(join(tmpdir(), "pr-media-urlbar-")); |
| 246 | try { | ||
| 247 | const bars = await renderUrlBars(segments, workDir); | ||
| 248 | const scaled = | ||
| 249 | `[0:v]fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,` + | ||
| 250 | `pad=iw:ih+${CHROME_HEIGHT}:0:${CHROME_HEIGHT}:color=0x111318,setsar=1[v0]`; | ||
| 251 | const overlays = bars.map(({ segment }, index) => { | ||
| 252 | const input = index + 1; | ||
| 253 | const previous = `v${index}`; | ||
| 254 | const next = `v${index + 1}`; | ||
| 255 | const from = segment.from.toFixed(3); | ||
| 256 | const to = segment.to.toFixed(3); | ||
| 257 | return `[${previous}][${input}:v]overlay=0:0:enable='between(t,${from},${to})'[${next}]`; | ||
| 258 | }); | ||
| 259 | const finalVideo = `v${bars.length}`; | ||
| 260 | const palette = | ||
| 261 | `[${finalVideo}]split[a][b];` + | ||
| 262 | "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5[out]"; | ||
| 263 | run("ffmpeg", [ | ||
| 264 | "-y", | ||
| 265 | "-loglevel", | ||
| 266 | "error", | ||
| 267 | "-i", | ||
| 268 | artifact.path, | ||
| 269 | ...bars.flatMap((bar) => ["-i", bar.path]), | ||
| 270 | "-filter_complex", | ||
| 271 | [scaled, ...overlays, palette].join(";"), | ||
| 272 | "-map", | ||
| 273 | "[out]", | ||
| 274 | out, | ||
| 275 | ]); | ||
| 276 | } finally { | ||
| 277 | rmSync(workDir, { recursive: true, force: true }); | ||
| 278 | } | ||
| 279 | } else { | ||
| 280 | const filter = | ||
| 281 | `fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,split[a][b];` + | ||
| 282 | "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5"; | ||
| 283 | run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]); | ||
| 284 | } | ||
| 83 | } | 285 | } |
| 84 | return out; | 286 | return out; |
| 85 | }; | 287 | }; |
| @@ -152,7 +354,7 @@ const repo = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameW · 66 unmodified lines | |||
| 152 | const blocks: Array<string> = []; | 354 | const blocks: Array<string> = []; |
| 153 | for (const input of inputs) { | 355 | for (const input of inputs) { |
| 154 | const artifact = resolveArtifact(input); | 356 | const artifact = resolveArtifact(input); |
| 155 | const gif = toGif(artifact); | 357 | const gif = await toGif(artifact); |
| 156 | const size = statSync(gif).size; | 358 | const size = statSync(gif).size; |
| 157 | if (size > RENDER_LIMIT_BYTES) { | 359 | if (size > RENDER_LIMIT_BYTES) { |
| 158 | console.error( | 360 | console.error( |
| @@ -48,4 +48,14 @@ describe("OAuth web origin resolution", () => { · 47 unmodified lines | |||
| 48 | }), | 48 | }), |
| 49 | ).toBe("https://executor.sh/api/oauth/callback"); | 49 | ).toBe("https://executor.sh/api/oauth/callback"); |
| 50 | }); | 50 | }); |
| 51 | |||
| 52 | it("carries the URL org selector in OAuth redirect URLs", () => { | ||
| 53 | expect( | ||
| 54 | buildOAuthRedirectUri({ | ||
| 55 | webBaseUrl: "https://executor.sh", | ||
| 56 | oauthCallbackPath: "/api/oauth/callback", | ||
| 57 | orgSlug: "acme", | ||
| 58 | }), | ||
| 59 | ).toBe("https://executor.sh/api/oauth/callback?executor_org=acme"); | ||
| 60 | }); | ||
| 51 | }); | 61 | }); |
| @@ -35,6 +35,7 @@ import { Context, Effect, Option } from "effect"; · 34 unmodified lines | |||
| 35 | 35 | ||
| 36 | import { | 36 | import { |
| 37 | createExecutor, | 37 | createExecutor, |
| 38 | OAUTH_CALLBACK_ORG_QUERY_PARAM, | ||
| 38 | Subject, | 39 | Subject, |
| 39 | Tenant, | 40 | Tenant, |
| 40 | type AnyPlugin, | 41 | type AnyPlugin, |
| @@ -151,8 +152,14 @@ export const resolveScopedWebBaseUrl = (input: { · 110 unmodified lines | |||
| 151 | export const buildOAuthRedirectUri = (input: { | 152 | export const buildOAuthRedirectUri = (input: { |
| 152 | readonly webBaseUrl: string | undefined; | 153 | readonly webBaseUrl: string | undefined; |
| 153 | readonly oauthCallbackPath: string; | 154 | readonly oauthCallbackPath: string; |
| 154 | }): string | undefined => | 155 | readonly orgSlug?: string | null; |
| 155 | input.webBaseUrl ? new URL(input.oauthCallbackPath, input.webBaseUrl).toString() : undefined; | 156 | }): string | undefined => { |
| 157 | if (!input.webBaseUrl) return undefined; | ||
| 158 | const url = new URL(input.oauthCallbackPath, input.webBaseUrl); | ||
| 159 | const slug = input.orgSlug?.trim(); | ||
| 160 | if (slug) url.searchParams.set(OAUTH_CALLBACK_ORG_QUERY_PARAM, slug); | ||
| 161 | return url.toString(); | ||
| 162 | }; | ||
| 156 | 163 | ||
| 157 | // --------------------------------------------------------------------------- | 164 | // --------------------------------------------------------------------------- |
| 158 | // PluginsProvider seam — the per-host (and possibly per-request) plugin array. | 165 | // PluginsProvider seam — the per-host (and possibly per-request) plugin array. |
| @@ -238,6 +245,7 @@ export const makeScopedExecutor = < · 79 unmodified lines | |||
| 238 | const redirectUri = buildOAuthRedirectUri({ | 245 | const redirectUri = buildOAuthRedirectUri({ |
| 239 | webBaseUrl, | 246 | webBaseUrl, |
| 240 | oauthCallbackPath: config.oauthCallbackPath, | 247 | oauthCallbackPath: config.oauthCallbackPath, |
| 248 | orgSlug, | ||
| 241 | }); | 249 | }); |
| 242 | 250 | ||
| 243 | const plugins = pluginsFactory(); | 251 | const plugins = pluginsFactory(); |
| @@ -214,7 +214,11 @@ export { · 213 unmodified lines | |||
| 214 | } from "./plugin-storage"; | 214 | } from "./plugin-storage"; |
| 215 | 215 | ||
| 216 | // OAuth (v2 contracts). | 216 | // OAuth (v2 contracts). |
| 217 | export { OAUTH2_PROVIDER_KEY, OAUTH2_SESSION_TTL_MS } from "./oauth"; | 217 | export { |
| 218 | OAUTH2_PROVIDER_KEY, | ||
| 219 | OAUTH2_SESSION_TTL_MS, | ||
| 220 | OAUTH_CALLBACK_ORG_QUERY_PARAM, | ||
| 221 | } from "./oauth"; | ||
| 218 | export { | 222 | export { |
| 219 | OAuthStartError, | 223 | OAuthStartError, |
| 220 | OAuthCompleteError, | 224 | OAuthCompleteError, |
| @@ -243,6 +247,7 @@ export { · 22 unmodified lines | |||
| 243 | export { | 247 | export { |
| 244 | DEFAULT_EXECUTOR_SERVER_ORIGIN, | 248 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 245 | DEFAULT_EXECUTOR_SERVER_USERNAME, | 249 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| 250 | EXECUTOR_ORG_SELECTOR_HEADER, | ||
| 246 | apiBaseUrlForServerOrigin, | 251 | apiBaseUrlForServerOrigin, |
| 247 | getExecutorServerAuthorizationHeader, | 252 | getExecutorServerAuthorizationHeader, |
| 248 | normalizeExecutorServerConnection, | 253 | normalizeExecutorServerConnection, |
| @@ -35,3 +35,6 @@ export const OAUTH2_PROVIDER_KEY = "oauth2" as const; · 34 unmodified lines | |||
| 35 | 35 | ||
| 36 | /** How long a pending authorization stays redeemable. */ | 36 | /** How long a pending authorization stays redeemable. */ |
| 37 | export const OAUTH2_SESSION_TTL_MS = 15 * 60 * 1000; | 37 | export const OAUTH2_SESSION_TTL_MS = 15 * 60 * 1000; |
| 38 | |||
| 39 | /** Query parameter used to carry the URL-selected org through OAuth callbacks. */ | ||
| 40 | export const OAUTH_CALLBACK_ORG_QUERY_PARAM = "executor_org" as const; | ||
| @@ -2,6 +2,7 @@ import { Option, Schema } from "effect"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000"; | 3 | export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000"; |
| 4 | export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor"; | 4 | export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor"; |
| 5 | export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"; | ||
| 5 | 6 | ||
| 6 | export type ExecutorServerConnectionKind = "http" | "desktop-sidecar"; | 7 | export type ExecutorServerConnectionKind = "http" | "desktop-sidecar"; |
| 7 | export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground"; | 8 | export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground"; |
| @@ -99,6 +99,8 @@ export type { ToolPolicyAction } from "./core-schema"; · 98 unmodified lines | |||
| 99 | // Schema-side views + onboarding autodetect. | 99 | // Schema-side views + onboarding autodetect. |
| 100 | export { ToolSchemaView, IntegrationDetectionResult } from "./types"; | 100 | export { ToolSchemaView, IntegrationDetectionResult } from "./types"; |
| 101 | 101 | ||
| 102 | export { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "./oauth"; | ||
| 103 | |||
| 102 | // OAuth wire contracts (data + tagged errors; the flow impl is server-only). | 104 | // OAuth wire contracts (data + tagged errors; the flow impl is server-only). |
| 103 | export { | 105 | export { |
| 104 | type OAuthGrant, | 106 | type OAuthGrant, |
| @@ -127,6 +129,7 @@ export { InternalError } from "./api-errors"; · 22 unmodified lines | |||
| 127 | export { | 129 | export { |
| 128 | DEFAULT_EXECUTOR_SERVER_ORIGIN, | 130 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 129 | DEFAULT_EXECUTOR_SERVER_USERNAME, | 131 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| 132 | EXECUTOR_ORG_SELECTOR_HEADER, | ||
| 130 | apiBaseUrlForServerOrigin, | 133 | apiBaseUrlForServerOrigin, |
| 131 | getExecutorServerAuthorizationHeader, | 134 | getExecutorServerAuthorizationHeader, |
| 132 | normalizeExecutorServerConnection, | 135 | normalizeExecutorServerConnection, |
| @@ -2,6 +2,7 @@ import * as React from "react"; · 1 unmodified line | |||
| 2 | import { isValidOrgSlug } from "@executor-js/api"; | 2 | import { isValidOrgSlug } from "@executor-js/api"; |
| 3 | import { | 3 | import { |
| 4 | DEFAULT_EXECUTOR_SERVER_ORIGIN, | 4 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 5 | EXECUTOR_ORG_SELECTOR_HEADER, | ||
| 5 | getExecutorServerAuthorizationHeader as getAuthorizationHeaderForConnection, | 6 | getExecutorServerAuthorizationHeader as getAuthorizationHeaderForConnection, |
| 6 | normalizeExecutorServerConnection, | 7 | normalizeExecutorServerConnection, |
| 7 | originFromApiBaseUrl, | 8 | originFromApiBaseUrl, |
| @@ -11,6 +12,7 @@ import { · 3 unmodified lines | |||
| 11 | 12 | ||
| 12 | export { | 13 | export { |
| 13 | DEFAULT_EXECUTOR_SERVER_ORIGIN, | 14 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 15 | EXECUTOR_ORG_SELECTOR_HEADER, | ||
| 14 | apiBaseUrlForServerOrigin, | 16 | apiBaseUrlForServerOrigin, |
| 15 | normalizeExecutorServerConnection, | 17 | normalizeExecutorServerConnection, |
| 16 | normalizeExecutorServerOrigin, | 18 | normalizeExecutorServerOrigin, |
| @@ -87,7 +89,7 @@ let activeConnection = resolveInitialExecutorServerConnection(); · 70 unmodified lines | |||
| 87 | // `/login`, …) are NOT valid slugs, so a bare path sends no header and the | 89 | // `/login`, …) are NOT valid slugs, so a bare path sends no header and the |
| 88 | // server falls back to the session org. Hosts without slugs (self-host, | 90 | // server falls back to the session org. Hosts without slugs (self-host, |
| 89 | // cloudflare) never produce one either. | 91 | // cloudflare) never produce one either. |
| 90 | export const EXECUTOR_ORG_HEADER = "x-executor-organization"; | 92 | export const EXECUTOR_ORG_HEADER = EXECUTOR_ORG_SELECTOR_HEADER; |
| 91 | 93 | ||
| 92 | export const getActiveOrgSlug = (): string | null => { | 94 | export const getActiveOrgSlug = (): string | null => { |
| 93 | const pathname = globalThis.window?.location?.pathname; | 95 | const pathname = globalThis.window?.location?.pathname; |
| @@ -0,0 +1,46 @@ | |||
| 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared"; | ||
| 3 | |||
| 4 | import { oauthCallbackUrl } from "./oauth-sign-in"; | ||
| 5 | |||
| 6 | const originalWindow = globalThis.window; | ||
| 7 | |||
| 8 | const setLocation = (href: string): void => { | ||
| 9 | Object.defineProperty(globalThis, "window", { | ||
| 10 | configurable: true, | ||
| 11 | value: { location: new URL(href) }, | ||
| 12 | }); | ||
| 13 | }; | ||
| 14 | |||
| 15 | afterEach(() => { | ||
| 16 | if (originalWindow) { | ||
| 17 | Object.defineProperty(globalThis, "window", { | ||
| 18 | configurable: true, | ||
| 19 | value: originalWindow, | ||
| 20 | }); | ||
| 21 | return; | ||
| 22 | } | ||
| 23 | Reflect.deleteProperty(globalThis, "window"); | ||
| 24 | }); | ||
| 25 | |||
| 26 | describe("oauthCallbackUrl", () => { | ||
| 27 | it("returns a relative callback path outside the browser", () => { | ||
| 28 | Reflect.deleteProperty(globalThis, "window"); | ||
| 29 | expect(oauthCallbackUrl()).toBe("/api/oauth/callback"); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("carries the active org slug from the console URL", () => { | ||
| 33 | setLocation("https://executor.sh/acme/integrations/posthog"); | ||
| 34 | |||
| 35 | const url = new URL(oauthCallbackUrl()); | ||
| 36 | |||
| 37 | expect(url.toString()).toBe("https://executor.sh/api/oauth/callback?executor_org=acme"); | ||
| 38 | expect(url.searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM)).toBe("acme"); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("does not add an org selector on bare app routes", () => { | ||
| 42 | setLocation("https://executor.sh/login"); | ||
| 43 | |||
| 44 | expect(oauthCallbackUrl()).toBe("https://executor.sh/api/oauth/callback"); | ||
| 45 | }); | ||
| 46 | }); | ||
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | |||
| 14 | type OAuthPopupResult, | 14 | type OAuthPopupResult, |
| 15 | } from "../api/oauth-popup"; | 15 | } from "../api/oauth-popup"; |
| 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; | 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 17 | import { getActiveOrgSlug } from "../api/server-connection"; | ||
| 17 | 18 | ||
| 18 | type DesktopBridge = { | 19 | type DesktopBridge = { |
| 19 | readonly openExternal: (url: string) => Promise<void>; | 20 | readonly openExternal: (url: string) => Promise<void>; |
| @@ -30,6 +31,7 @@ const getDesktopBridge = (): DesktopBridge | null => { · 10 unmodified lines | |||
| 30 | import { Button } from "../components/button"; | 31 | import { Button } from "../components/button"; |
| 31 | import { | 32 | import { |
| 32 | OAUTH_POPUP_MESSAGE_TYPE, | 33 | OAUTH_POPUP_MESSAGE_TYPE, |
| 34 | OAUTH_CALLBACK_ORG_QUERY_PARAM, | ||
| 33 | OAuthState, | 35 | OAuthState, |
| 34 | type AuthTemplateSlug, | 36 | type AuthTemplateSlug, |
| 35 | type ConnectionName, | 37 | type ConnectionName, |
| @@ -96,7 +98,11 @@ export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload · 60 unmodified lines | |||
| 96 | }; | 98 | }; |
| 97 | 99 | ||
| 98 | export function oauthCallbackUrl(path = "/api/oauth/callback"): string { | 100 | export function oauthCallbackUrl(path = "/api/oauth/callback"): string { |
| 99 | return typeof window === "undefined" ? path : `${window.location.origin}${path}`; | 101 | if (typeof window === "undefined") return path; |
| 102 | const url = new URL(path, window.location.origin); | ||
| 103 | const orgSlug = getActiveOrgSlug(); | ||
| 104 | if (orgSlug) url.searchParams.set(OAUTH_CALLBACK_ORG_QUERY_PARAM, orgSlug); | ||
| 105 | return url.toString(); | ||
| 100 | } | 106 | } |
| 101 | 107 | ||
| 102 | export function useOAuthPopupFlow< | 108 | export function useOAuthPopupFlow< |