* 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 | 14 | import { Effect } from "effect"; |
| 15 | +import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; | |
| 15 | 16 | |
| 16 | 17 | import { UserStoreService } from "./context"; |
| 17 | 18 | import { WorkOSClient } from "./workos"; |
| @@ -96,7 +97,7 @@ export const authorizeOrganization = (userId: string, organizationId: string) => · 78 unmodified lines | ||
| 96 | 97 | // silently re-scopes the other. Scoping per-request from the URL makes each |
| 97 | 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 | 102 | /** The URL-pinned org selector for a request, or `null` to fall back to the session. */ |
| 102 | 103 | export const orgSelectorFromRequest = (request: Request): string | null => |
| @@ -1,9 +1,11 @@ | ||
| 1 | 1 | import { createMiddleware, createStart } from "@tanstack/react-start"; |
| 2 | +import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared"; | |
| 2 | 3 | |
| 3 | 4 | import { cloudApiHandler } from "./app"; |
| 4 | 5 | import { isAppOwnedPath } from "./app-paths"; |
| 5 | 6 | import { authGateMiddleware } from "./auth/ssr-gate"; |
| 6 | 7 | import { parseCookie } from "./auth/cookies"; |
| 8 | +import { ORG_SELECTOR_HEADER } from "./auth/organization"; | |
| 7 | 9 | import { loginPath } from "./auth/return-to"; |
| 8 | 10 | import { prepareMcpOrgScope } from "./mcp/mount"; |
| 9 | 11 | import { |
| @@ -39,6 +41,14 @@ const getApp = () => (app ??= cloudApiHandler()); · 29 unmodified lines | ||
| 39 | 41 | const SESSION_COOKIE = "wos-session"; |
| 40 | 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 | 52 | const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).server( |
| 43 | 53 | ({ pathname, request, next }) => { |
| 44 | 54 | if ( |
| @@ -66,7 +76,11 @@ const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).serv · 21 unmodified lines | ||
| 66 | 76 | // else, including `/api/*`). |
| 67 | 77 | const appRequestMiddleware = createMiddleware({ type: "request" }).server( |
| 68 | 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 | 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 | 16 | // *.png run screenshot, uploaded as-is |
| 17 | 17 | import { execFileSync } from "node:child_process"; |
| 18 | 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 | 20 | import { tmpdir } from "node:os"; |
| 21 | 21 | import { basename, join, resolve } from "node:path"; |
| 22 | 22 | |
| 23 | +import { chromium } from "playwright"; | |
| 24 | + | |
| 23 | 25 | const MEDIA_BRANCH = "e2e-media"; |
| 24 | 26 | // Above this GitHub's image proxy stops rendering the gif inline. |
| 25 | 27 | const RENDER_LIMIT_BYTES = 10 * 1024 * 1024; |
| 28 | +const GIF_WIDTH = 960; | |
| 29 | +const CHROME_HEIGHT = 40; | |
| 26 | 30 | |
| 27 | 31 | const run = (command: string, args: ReadonlyArray<string>): string => |
| 28 | 32 | execFileSync(command, [...args], { |
| @@ -36,6 +40,18 @@ interface Artifact { · 7 unmodified lines | ||
| 36 | 40 | readonly path: string; |
| 37 | 41 | readonly label: string; |
| 38 | 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 | 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 | 82 | } catch { |
| 67 | 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 | 236 | if (artifact.path.endsWith(".png")) return artifact.path; |
| 74 | 237 | const out = join(tmpdir(), `${artifact.slug}.gif`); |
| 75 | 238 | if (artifact.path.endsWith(".cast")) { |
| 76 | 239 | run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]); |
| 77 | 240 | } else { |
| 78 | 241 | // Two-pass palette keeps UI text crisp at a size GitHub will render. |
| 79 | − const filter = | |
| 80 | − "fps=8,scale=960:-2:flags=lanczos,split[a][b];" + | |
| 81 | − "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5"; | |
| 82 | − run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]); | |
| 242 | + const duration = videoDuration(artifact.path); | |
| 243 | + const segments = urlSegments(artifact, duration); | |
| 244 | + if (segments.length > 0) { | |
| 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 | 286 | return out; |
| 85 | 287 | }; |
| @@ -152,7 +354,7 @@ const repo = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameW · 66 unmodified lines | ||
| 152 | 354 | const blocks: Array<string> = []; |
| 153 | 355 | for (const input of inputs) { |
| 154 | 356 | const artifact = resolveArtifact(input); |
| 155 | − const gif = toGif(artifact); | |
| 357 | + const gif = await toGif(artifact); | |
| 156 | 358 | const size = statSync(gif).size; |
| 157 | 359 | if (size > RENDER_LIMIT_BYTES) { |
| 158 | 360 | console.error( |
| @@ -48,4 +48,14 @@ describe("OAuth web origin resolution", () => { · 47 unmodified lines | ||
| 48 | 48 | }), |
| 49 | 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 | 36 | import { |
| 37 | 37 | createExecutor, |
| 38 | + OAUTH_CALLBACK_ORG_QUERY_PARAM, | |
| 38 | 39 | Subject, |
| 39 | 40 | Tenant, |
| 40 | 41 | type AnyPlugin, |
| @@ -151,8 +152,14 @@ export const resolveScopedWebBaseUrl = (input: { · 110 unmodified lines | ||
| 151 | 152 | export const buildOAuthRedirectUri = (input: { |
| 152 | 153 | readonly webBaseUrl: string | undefined; |
| 153 | 154 | readonly oauthCallbackPath: string; |
| 154 | −}): string | undefined => | |
| 155 | − input.webBaseUrl ? new URL(input.oauthCallbackPath, input.webBaseUrl).toString() : undefined; | |
| 155 | + readonly orgSlug?: string | null; | |
| 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 | 165 | // PluginsProvider seam — the per-host (and possibly per-request) plugin array. |
| @@ -238,6 +245,7 @@ export const makeScopedExecutor = < · 79 unmodified lines | ||
| 238 | 245 | const redirectUri = buildOAuthRedirectUri({ |
| 239 | 246 | webBaseUrl, |
| 240 | 247 | oauthCallbackPath: config.oauthCallbackPath, |
| 248 | + orgSlug, | |
| 241 | 249 | }); |
| 242 | 250 | |
| 243 | 251 | const plugins = pluginsFactory(); |
| @@ -214,7 +214,11 @@ export { · 213 unmodified lines | ||
| 214 | 214 | } from "./plugin-storage"; |
| 215 | 215 | |
| 216 | 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 | 222 | export { |
| 219 | 223 | OAuthStartError, |
| 220 | 224 | OAuthCompleteError, |
| @@ -243,6 +247,7 @@ export { · 22 unmodified lines | ||
| 243 | 247 | export { |
| 244 | 248 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 245 | 249 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| 250 | + EXECUTOR_ORG_SELECTOR_HEADER, | |
| 246 | 251 | apiBaseUrlForServerOrigin, |
| 247 | 252 | getExecutorServerAuthorizationHeader, |
| 248 | 253 | normalizeExecutorServerConnection, |
| @@ -35,3 +35,6 @@ export const OAUTH2_PROVIDER_KEY = "oauth2" as const; · 34 unmodified lines | ||
| 35 | 35 | |
| 36 | 36 | /** How long a pending authorization stays redeemable. */ |
| 37 | 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 | 3 | export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000"; |
| 4 | 4 | export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor"; |
| 5 | +export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization"; | |
| 5 | 6 | |
| 6 | 7 | export type ExecutorServerConnectionKind = "http" | "desktop-sidecar"; |
| 7 | 8 | export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground"; |
| @@ -99,6 +99,8 @@ export type { ToolPolicyAction } from "./core-schema"; · 98 unmodified lines | ||
| 99 | 99 | // Schema-side views + onboarding autodetect. |
| 100 | 100 | export { ToolSchemaView, IntegrationDetectionResult } from "./types"; |
| 101 | 101 | |
| 102 | +export { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "./oauth"; | |
| 103 | + | |
| 102 | 104 | // OAuth wire contracts (data + tagged errors; the flow impl is server-only). |
| 103 | 105 | export { |
| 104 | 106 | type OAuthGrant, |
| @@ -127,6 +129,7 @@ export { InternalError } from "./api-errors"; · 22 unmodified lines | ||
| 127 | 129 | export { |
| 128 | 130 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 129 | 131 | DEFAULT_EXECUTOR_SERVER_USERNAME, |
| 132 | + EXECUTOR_ORG_SELECTOR_HEADER, | |
| 130 | 133 | apiBaseUrlForServerOrigin, |
| 131 | 134 | getExecutorServerAuthorizationHeader, |
| 132 | 135 | normalizeExecutorServerConnection, |
| @@ -2,6 +2,7 @@ import * as React from "react"; · 1 unmodified line | ||
| 2 | 2 | import { isValidOrgSlug } from "@executor-js/api"; |
| 3 | 3 | import { |
| 4 | 4 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 5 | + EXECUTOR_ORG_SELECTOR_HEADER, | |
| 5 | 6 | getExecutorServerAuthorizationHeader as getAuthorizationHeaderForConnection, |
| 6 | 7 | normalizeExecutorServerConnection, |
| 7 | 8 | originFromApiBaseUrl, |
| @@ -11,6 +12,7 @@ import { · 3 unmodified lines | ||
| 11 | 12 | |
| 12 | 13 | export { |
| 13 | 14 | DEFAULT_EXECUTOR_SERVER_ORIGIN, |
| 15 | + EXECUTOR_ORG_SELECTOR_HEADER, | |
| 14 | 16 | apiBaseUrlForServerOrigin, |
| 15 | 17 | normalizeExecutorServerConnection, |
| 16 | 18 | normalizeExecutorServerOrigin, |
| @@ -87,7 +89,7 @@ let activeConnection = resolveInitialExecutorServerConnection(); · 70 unmodified lines | ||
| 87 | 89 | // `/login`, …) are NOT valid slugs, so a bare path sends no header and the |
| 88 | 90 | // server falls back to the session org. Hosts without slugs (self-host, |
| 89 | 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 | 94 | export const getActiveOrgSlug = (): string | null => { |
| 93 | 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 | 14 | type OAuthPopupResult, |
| 15 | 15 | } from "../api/oauth-popup"; |
| 16 | 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 17 | +import { getActiveOrgSlug } from "../api/server-connection"; | |
| 17 | 18 | |
| 18 | 19 | type DesktopBridge = { |
| 19 | 20 | readonly openExternal: (url: string) => Promise<void>; |
| @@ -30,6 +31,7 @@ const getDesktopBridge = (): DesktopBridge | null => { · 10 unmodified lines | ||
| 30 | 31 | import { Button } from "../components/button"; |
| 31 | 32 | import { |
| 32 | 33 | OAUTH_POPUP_MESSAGE_TYPE, |
| 34 | + OAUTH_CALLBACK_ORG_QUERY_PARAM, | |
| 33 | 35 | OAuthState, |
| 34 | 36 | type AuthTemplateSlug, |
| 35 | 37 | type ConnectionName, |
| @@ -96,7 +98,11 @@ export type StartOAuthAuthorizationInput<TPayload extends OAuthCompletionPayload · 60 unmodified lines | ||
| 96 | 98 | }; |
| 97 | 99 | |
| 98 | 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 | 108 | export function useOAuthPopupFlow< |