History

Fix OAuth callback org scope (#1134)

* Fix OAuth callback org scope * Show URLs in e2e PR media * Record OAuth org switch flow

Rhys Sullivan committed 4 months ago

Commit 78aa8711 parent 913c8c214 files · +586 −15Browse files

Unified · Split

14 files
  1. .changeset/oauth-callback-org-scope.md +5 −0
  2. apps/cloud/src/auth/organization.ts +2 −1
  3. apps/cloud/src/start.ts +15 −1
  4. e2e/cloud/oauth-callback-org-scope.test.ts +265 −0
  5. e2e/scripts/pr-media.ts +210 −8
  6. packages/core/api/src/server/oauth-origin.test.ts +10 −0
  7. packages/core/api/src/server/scoped-executor.ts +10 −2
  8. packages/core/sdk/src/index.ts +6 −1
  9. packages/core/sdk/src/oauth.ts +3 −0
  10. packages/core/sdk/src/server-connection.ts +1 −0
  11. packages/core/sdk/src/shared.ts +3 −0
  12. packages/react/src/api/server-connection.tsx +3 −1
  13. packages/react/src/plugins/oauth-sign-in.test.ts +46 −0
  14. packages/react/src/plugins/oauth-sign-in.tsx +7 −1
.changeset/oauth-callback-org-scope.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": patch
3---
4
5Fix OAuth callbacks in cloud so they preserve the URL-selected organization when the session cookie points at another org.
apps/cloud/src/auth/organization.ts+2−1
@@ -12,6 +12,7 @@ · 11 unmodified lines
12// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
13 13
14import { Effect } from "effect";14import { Effect } from "effect";
15import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared";
15 16
16import { UserStoreService } from "./context";17import { UserStoreService } from "./context";
17import { WorkOSClient } from "./workos";18import { 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 each97// silently re-scopes the other. Scoping per-request from the URL makes each
97// tab independent.98// tab independent.
98 99
99export const ORG_SELECTOR_HEADER = "x-executor-organization";100export 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. */
102export const orgSelectorFromRequest = (request: Request): string | null =>103export const orgSelectorFromRequest = (request: Request): string | null =>
apps/cloud/src/start.ts+15−1
@@ -1,9 +1,11 @@
1import { createMiddleware, createStart } from "@tanstack/react-start";1import { createMiddleware, createStart } from "@tanstack/react-start";
2import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared";
2 3
3import { cloudApiHandler } from "./app";4import { cloudApiHandler } from "./app";
4import { isAppOwnedPath } from "./app-paths";5import { isAppOwnedPath } from "./app-paths";
5import { authGateMiddleware } from "./auth/ssr-gate";6import { authGateMiddleware } from "./auth/ssr-gate";
6import { parseCookie } from "./auth/cookies";7import { parseCookie } from "./auth/cookies";
8import { ORG_SELECTOR_HEADER } from "./auth/organization";
7import { loginPath } from "./auth/return-to";9import { loginPath } from "./auth/return-to";
8import { prepareMcpOrgScope } from "./mcp/mount";10import { prepareMcpOrgScope } from "./mcp/mount";
9import {11import {
@@ -39,6 +41,14 @@ const getApp = () => (app ??= cloudApiHandler()); · 29 unmodified lines
39const SESSION_COOKIE = "wos-session";41const SESSION_COOKIE = "wos-session";
40const OAUTH_CALLBACK_PATH = "/api/oauth/callback";42const OAUTH_CALLBACK_PATH = "/api/oauth/callback";
41 43
44const 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
42const oauthCallbackSignInMiddleware = createMiddleware({ type: "request" }).server(52const 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/*`).
67const appRequestMiddleware = createMiddleware({ type: "request" }).server(77const 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);
e2e/cloud/oauth-callback-org-scope.test.ts+265−0
@@ -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.
7import { randomBytes } from "node:crypto";
8
9import { expect } from "@effect/vitest";
10import { Effect } from "effect";
11import type { Page } from "playwright";
12import { composePluginApi } from "@executor-js/api/server";
13import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
14import {
15 IntegrationSlug,
16 OAUTH_CALLBACK_ORG_QUERY_PARAM,
17 OAuthClientSlug,
18} from "@executor-js/sdk/shared";
19import { serveOAuthTestServer } from "@executor-js/sdk/testing";
20
21import { scenario } from "../src/scenario";
22import { Api, Browser, Target } from "../src/services";
23import type { Identity } from "../src/target";
24
25const api = composePluginApi([openApiHttpPlugin()] as const);
26
27const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
28
29const 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
36const 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
42const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? "";
43
44const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin });
45
46const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
48const 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
58const 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
72const 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
93const 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
108const 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
121const 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
141const 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
173scenario(
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);
e2e/scripts/pr-media.ts+210−8
@@ -16,13 +16,17 @@ · 15 unmodified lines
16// *.png run screenshot, uploaded as-is16// *.png run screenshot, uploaded as-is
17import { execFileSync } from "node:child_process";17import { execFileSync } from "node:child_process";
18import { createHash } from "node:crypto";18import { createHash } from "node:crypto";
19import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";19import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
20import { tmpdir } from "node:os";20import { tmpdir } from "node:os";
21import { basename, join, resolve } from "node:path";21import { basename, join, resolve } from "node:path";
22 22
23import { chromium } from "playwright";
24
23const MEDIA_BRANCH = "e2e-media";25const 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.
25const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;27const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;
28const GIF_WIDTH = 960;
29const CHROME_HEIGHT = 40;
26 30
27const run = (command: string, args: ReadonlyArray<string>): string =>31const 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
46interface Timeline {
47 readonly anchors: { readonly browser?: number };
48 readonly nav?: ReadonlyArray<{ readonly at: number; readonly url: string }>;
49}
50
51interface 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 showing83 // 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
88const 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
96const 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
103const 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
143const htmlEscape = (value: string): string =>
144 value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
145
146const 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
72const toGif = (artifact: Artifact): string => {235const 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
152const blocks: Array<string> = [];354const blocks: Array<string> = [];
153for (const input of inputs) {355for (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(
packages/core/api/src/server/oauth-origin.test.ts+10−0
@@ -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});
packages/core/api/src/server/scoped-executor.ts+10−2
@@ -35,6 +35,7 @@ import { Context, Effect, Option } from "effect"; · 34 unmodified lines
35 35
36import {36import {
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
151export const buildOAuthRedirectUri = (input: {152export 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();
packages/core/sdk/src/index.ts+6−1
@@ -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).
217export { OAUTH2_PROVIDER_KEY, OAUTH2_SESSION_TTL_MS } from "./oauth";217export {
218 OAUTH2_PROVIDER_KEY,
219 OAUTH2_SESSION_TTL_MS,
220 OAUTH_CALLBACK_ORG_QUERY_PARAM,
221} from "./oauth";
218export {222export {
219 OAuthStartError,223 OAuthStartError,
220 OAuthCompleteError,224 OAuthCompleteError,
@@ -243,6 +247,7 @@ export { · 22 unmodified lines
243export {247export {
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,
packages/core/sdk/src/oauth.ts+3−0
@@ -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. */
37export const OAUTH2_SESSION_TTL_MS = 15 * 60 * 1000;37export const OAUTH2_SESSION_TTL_MS = 15 * 60 * 1000;
38
39/** Query parameter used to carry the URL-selected org through OAuth callbacks. */
40export const OAUTH_CALLBACK_ORG_QUERY_PARAM = "executor_org" as const;
packages/core/sdk/src/server-connection.ts+1−0
@@ -2,6 +2,7 @@ import { Option, Schema } from "effect"; · 1 unmodified line
2 2
3export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000";3export const DEFAULT_EXECUTOR_SERVER_ORIGIN = "http://127.0.0.1:4000";
4export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor";4export const DEFAULT_EXECUTOR_SERVER_USERNAME = "executor";
5export const EXECUTOR_ORG_SELECTOR_HEADER = "x-executor-organization";
5 6
6export type ExecutorServerConnectionKind = "http" | "desktop-sidecar";7export type ExecutorServerConnectionKind = "http" | "desktop-sidecar";
7export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground";8export type ExecutorLocalServerKind = "cli-daemon" | "desktop-sidecar" | "foreground";
packages/core/sdk/src/shared.ts+3−0
@@ -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.
100export { ToolSchemaView, IntegrationDetectionResult } from "./types";100export { ToolSchemaView, IntegrationDetectionResult } from "./types";
101 101
102export { 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).
103export {105export {
104 type OAuthGrant,106 type OAuthGrant,
@@ -127,6 +129,7 @@ export { InternalError } from "./api-errors"; · 22 unmodified lines
127export {129export {
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,
packages/react/src/api/server-connection.tsx+3−1
@@ -2,6 +2,7 @@ import * as React from "react"; · 1 unmodified line
2import { isValidOrgSlug } from "@executor-js/api";2import { isValidOrgSlug } from "@executor-js/api";
3import {3import {
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
12export {13export {
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 the89// `/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.
90export const EXECUTOR_ORG_HEADER = "x-executor-organization";92export const EXECUTOR_ORG_HEADER = EXECUTOR_ORG_SELECTOR_HEADER;
91 93
92export const getActiveOrgSlug = (): string | null => {94export const getActiveOrgSlug = (): string | null => {
93 const pathname = globalThis.window?.location?.pathname;95 const pathname = globalThis.window?.location?.pathname;
packages/react/src/plugins/oauth-sign-in.test.ts+46−0
@@ -0,0 +1,46 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { OAUTH_CALLBACK_ORG_QUERY_PARAM } from "@executor-js/sdk/shared";
3
4import { oauthCallbackUrl } from "./oauth-sign-in";
5
6const originalWindow = globalThis.window;
7
8const setLocation = (href: string): void => {
9 Object.defineProperty(globalThis, "window", {
10 configurable: true,
11 value: { location: new URL(href) },
12 });
13};
14
15afterEach(() => {
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
26describe("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});
packages/react/src/plugins/oauth-sign-in.tsx+7−1
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
14 type OAuthPopupResult,14 type OAuthPopupResult,
15} from "../api/oauth-popup";15} from "../api/oauth-popup";
16import { connectionWriteKeys } from "../api/reactivity-keys";16import { connectionWriteKeys } from "../api/reactivity-keys";
17import { getActiveOrgSlug } from "../api/server-connection";
17 18
18type DesktopBridge = {19type 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
30import { Button } from "../components/button";31import { Button } from "../components/button";
31import {32import {
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
98export function oauthCallbackUrl(path = "/api/oauth/callback"): string {100export 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
102export function useOAuthPopupFlow<108export function useOAuthPopupFlow<