diff --git a/.changeset/hosted-client-span-header-redaction.md b/.changeset/hosted-client-span-header-redaction.md
new file mode 100644
index 0000000000..ac53f0f276
--- /dev/null
+++ b/.changeset/hosted-client-span-header-redaction.md
@@ -0,0 +1,5 @@
+---
+"@executor-js/sdk": patch
+---
+
+Redact every span header attribute outside a safe allowlist on the hosted HTTP client. The tracer's default four-name blocklist let provider-specific credential headers reach the trace backend verbatim; the hosted client now inverts the model and masks everything except structurally safe negotiation, caching, and tracing headers.
diff --git a/.changeset/mcp-client-span-attribution.md b/.changeset/mcp-client-span-attribution.md
new file mode 100644
index 0000000000..c3fc4cda24
--- /dev/null
+++ b/.changeset/mcp-client-span-attribution.md
@@ -0,0 +1,8 @@
+---
+"@executor-js/host-mcp": patch
+"@executor-js/cloudflare": patch
+---
+
+**MCP execution spans now carry the client identity (`mcp.client.*`)**
+
+The `clientInfo` a client self-reports at `initialize` (or in a modern request's `_meta`) previously existed only on the initialize request itself, which has no session id yet, so execution telemetry could not be segmented by client. Execute, execute-action, and resume spans (and their descendants) now carry `mcp.client.name` / `mcp.client.version` / `mcp.client.title` alongside the existing session join keys. Cloudflare session Durable Objects persist the reported identity in session meta, so attribution survives cold restores; it feeds telemetry only, never behavior.
diff --git a/.github/workflows/publish-selfhost-docker.yml b/.github/workflows/publish-selfhost-docker.yml
index 51715ff860..1da6470320 100644
--- a/.github/workflows/publish-selfhost-docker.yml
+++ b/.github/workflows/publish-selfhost-docker.yml
@@ -281,9 +281,6 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
packages: write
- env:
- HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
-
steps:
- name: Download image digests
uses: actions/download-artifact@v4
@@ -330,10 +327,28 @@ jobs:
docker buildx imagetools create "${tag_args[@]}" "${sources[@]}"
+ # Mirrors each release to the pre-org-move namespace
+ # (ghcr.io/rhyssullivan/executor-selfhost), which GHCR cannot redirect.
+ # Deliberately a separate job: the canonical publish above never depends on
+ # it, and a failure here (an expired GHCR_LEGACY_TOKEN, historically) shows
+ # as one red job pointing at exactly what to fix.
+ mirror-legacy:
+ needs:
+ - metadata
+ - merge
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ env:
+ HAS_LEGACY_TOKEN: ${{ secrets.GHCR_LEGACY_TOKEN != '' }}
+
+ steps:
- name: Skip legacy GHCR mirror (GHCR_LEGACY_TOKEN not configured)
if: env.HAS_LEGACY_TOKEN != 'true'
run: echo "GHCR_LEGACY_TOKEN is not configured; skipping legacy GHCR mirror."
+ - name: Set up Docker Buildx
+ if: env.HAS_LEGACY_TOKEN == 'true'
+ uses: docker/setup-buildx-action@v3
+
- name: Log in to legacy GHCR namespace
if: env.HAS_LEGACY_TOKEN == 'true'
uses: docker/login-action@v3
@@ -342,7 +357,7 @@ jobs:
username: rhyssullivan
password: ${{ secrets.GHCR_LEGACY_TOKEN }}
- - name: Mirror self-host image to legacy GHCR namespace
+ - name: Mirror release tags to the legacy namespace
if: env.HAS_LEGACY_TOKEN == 'true'
shell: bash
env:
diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts
index 5deb62f6b8..ca8d3e0522 100644
--- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts
+++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts
@@ -77,7 +77,7 @@ const stubWorkOS = Layer.succeed(
);
const stubUsers = Layer.succeed(UserStoreService)({
- use: (fn) =>
+ use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts
index 739e91d27b..6e70a5b9a1 100644
--- a/apps/cloud/src/account/workos-account-service.ts
+++ b/apps/cloud/src/account/workos-account-service.ts
@@ -386,7 +386,9 @@ export const workosAccountProvider: Layer.Layer<
.updateOrganization(org.id, name)
.pipe(Effect.catchTag("WorkOSError", toAccountError));
yield* users
- .use((s) => s.upsertOrganization({ id: updated.id, name: updated.name }))
+ .use("upsertOrganization", (s) =>
+ s.upsertOrganization({ id: updated.id, name: updated.name }),
+ )
.pipe(Effect.catchTag("UserStoreError", toAccountError));
return { name: updated.name };
}),
diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts
index 6027133349..3ba01a61b4 100644
--- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts
+++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts
@@ -47,7 +47,7 @@ const stubWorkOS = Layer.succeed(
);
const stubUsers = Layer.succeed(UserStoreService)({
- use: (fn) =>
+ use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts
index 2120c9004c..dbf35e1c2c 100644
--- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts
+++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts
@@ -64,7 +64,7 @@ const stubWorkOS = Layer.succeed(
);
const stubUsers = Layer.succeed(UserStoreService)({
- use: (fn) =>
+ use: (_op, fn) =>
Effect.promise(() =>
fn({
ensureAccount: async (id: string) => ({ id, createdAt }),
diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts
index 9d14c8d643..5c64be3c5c 100644
--- a/apps/cloud/src/auth/api.ts
+++ b/apps/cloud/src/auth/api.ts
@@ -181,6 +181,15 @@ const McpApprovalErrors = [
/** Public auth endpoints — no authentication required */
export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic")
.add(HttpApiEndpoint.get("login", "/auth/login", { query: AuthLoginSearch }))
+ // Sign-out is PUBLIC on purpose. The console posts it as a top-level form
+ // navigation (the WorkOS hop is cross-origin, so it can't be a fetch), which
+ // means an error response is rendered as the page — and a browser whose
+ // session has already ended is exactly the browser most likely to click it
+ // (a second tab, a re-submit from history, an expired or revoked session).
+ // Behind SessionAuth all of those got a raw `{"_tag":"Unauthorized"}` screen
+ // instead of being signed out. There is nothing to authorize here anyway:
+ // the request can only end the session whose cookie it presents.
+ .add(HttpApiEndpoint.post("logout", "/auth/logout"))
.add(
HttpApiEndpoint.get("callback", "/auth/callback", {
query: AuthCallbackSearch,
@@ -201,7 +210,6 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
error: AuthErrors,
}),
)
- .add(HttpApiEndpoint.post("logout", "/auth/logout"))
.add(
HttpApiEndpoint.get("organizations", "/auth/organizations", {
success: AuthOrganizationsResponse,
diff --git a/apps/cloud/src/auth/context.ts b/apps/cloud/src/auth/context.ts
index bfd3ea25c0..dd713b189d 100644
--- a/apps/cloud/src/auth/context.ts
+++ b/apps/cloud/src/auth/context.ts
@@ -9,10 +9,13 @@ import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"
type RawStore = ReturnType
+ If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you + request through that integration. Depending on the service and permissions you choose, this may include accessing + or modifying Google Calendar events, Google Sheets spreadsheets, or other Google Workspace content. Executor stores + OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace + content when carrying out your requested tool calls and returning their results to you or the agent you directed to + make the call. +
++ We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or + machine-learning models. We disclose it only as needed to provide the user-requested integration, operate and secure + the Services, comply with law, or as otherwise described in this Policy with the user's consent. +
++ Executor's use and transfer of information received from Google Workspace APIs adheres to the + Google API Services User Data Policy, + including its Limited Use requirements. +
+
If you submit a URL to our website's API detection tool, contact us by email, or otherwise communicate with us,
diff --git a/e2e/cloud/logout-stale-session.test.ts b/e2e/cloud/logout-stale-session.test.ts
new file mode 100644
index 0000000000..8093cc24d2
--- /dev/null
+++ b/e2e/cloud/logout-stale-session.test.ts
@@ -0,0 +1,165 @@
+// Cloud-only: signing out must ALWAYS sign the browser out. The shell's
+// sign-out is a top-level form POST (the WorkOS hop is cross-origin, so it
+// can't be a fetch), which means whatever `/api/auth/logout` answers is
+// rendered AS THE PAGE. An error response is therefore not a silent failure —
+// it is a screenful of raw API JSON where the homepage should be. Reported
+// from the field as `{"_tag":"Unauthorized"}`.
+//
+// The endpoint used to sit behind SessionAuth, so a browser whose sealed
+// session no longer authenticated AT THE MOMENT OF THE CLICK got the
+// middleware's 401 instead of being signed out. Reaching that state needs
+// nothing exotic: a second open tab does it (sign out in one, the other's
+// shell is still painted but its cookie is gone), and an expired or
+// upstream-revoked session lands on the same branch. Signing out of a session
+// that is already over is the one request that must never fail — the user is
+// asking for the state they are already in.
+import { expect } from "@effect/vitest";
+import { Effect } from "effect";
+import type { Page } from "playwright";
+
+import { scenario } from "../src/scenario";
+import { Api, Browser, Target } from "../src/services";
+
+/** The display-only identity cookie the SSR gate mints (non-HttpOnly). */
+const HINT_COOKIE = "executor-auth-hint";
+
+/** First `Set-Cookie` header for `name`, as the raw header string. */
+const setCookieFor = (response: Response, name: string): string => {
+ for (const header of response.headers.getSetCookie()) {
+ if (header.startsWith(`${name}=`)) return header;
+ }
+ return "";
+};
+
+// Open the account dropdown and sign out. Bounded retry: a click can land
+// while the shell is still hydrating and the radix menu never opens (same
+// idiom as auth-hint / org-switcher).
+const clickSignOut = async (page: Page) => {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ await page.keyboard.press("Escape");
+ await page.getByRole("button", { name: /Test User/ }).click();
+ await page.getByRole("menuitem", { name: "Sign out" }).click({ timeout: 5_000 });
+ return;
+ } catch (error) {
+ if (attempt >= 3) throw error;
+ }
+ }
+};
+
+scenario(
+ "Sign-out · a browser whose session is already over is signed out, not handed an API error page",
+ {},
+ Effect.gen(function* () {
+ // Gate: the REST API plane is mounted on this target.
+ yield* Api;
+ const target = yield* Target;
+
+ const signOut = (cookie?: string) =>
+ Effect.promise(() =>
+ fetch(new URL("/api/auth/logout", target.baseUrl), {
+ method: "POST",
+ redirect: "manual",
+ ...(cookie ? { headers: { cookie } } : {}),
+ }),
+ );
+
+ // A session cookie that no longer authenticates — expired, or revoked
+ // upstream while the tab sat open. There is nothing to end at WorkOS, but
+ // this browser must still leave signed out: a stale session cookie left in
+ // place keeps routing / into the app instead of marketing.
+ const stale = yield* signOut("wos-session=stale-sealed-session");
+ const staleBody = yield* Effect.promise(() => stale.text());
+
+ // The response IS the page the user reads. This is the reported symptom.
+ expect(staleBody, "sign-out never renders an API error envelope").not.toContain("_tag");
+ expect(stale.status, "sign-out sends the browser onward").toBe(302);
+ expect(
+ new URL(stale.headers.get("location") ?? "", target.baseUrl).pathname,
+ "sign-out lands the browser home",
+ ).toBe("/");
+ expect(setCookieFor(stale, "wos-session"), "the dead session cookie is dropped").toContain(
+ "Max-Age=0",
+ );
+ expect(
+ setCookieFor(stale, HINT_COOKIE),
+ "the auth hint never outlives the session it describes",
+ ).toContain("Max-Age=0");
+
+ // A POST carrying no cookies at all is what a CROSS-SITE form reaching
+ // this endpoint looks like: both cookies are SameSite=Lax, so another
+ // origin's POST arrives bare. Signing out is public, so it must answer
+ // such a request without touching this browser's session — otherwise any
+ // site could sign our users out.
+ const bare = yield* signOut();
+ const bareBody = yield* Effect.promise(() => bare.text());
+
+ expect(bareBody, "a session-less sign-out renders no error envelope either").not.toContain(
+ "_tag",
+ );
+ expect(bare.status, "it is sent home like any other sign-out").toBe(302);
+ expect(
+ new URL(bare.headers.get("location") ?? "", target.baseUrl).pathname,
+ "it lands home",
+ ).toBe("/");
+ expect(
+ bare.headers.getSetCookie(),
+ "a request that presented no session cannot expire anyone else's",
+ ).toEqual([]);
+ }),
+);
+
+scenario(
+ "Sign-out · a second open tab signs out too instead of showing raw JSON",
+ {},
+ Effect.gen(function* () {
+ const browser = yield* Browser;
+ const target = yield* Target;
+ const identity = yield* target.newIdentity();
+
+ // `page` is the tab the user is LOOKING at when it breaks — the run's
+ // video and screenshots follow it, so the artifacts show the reported
+ // symptom rather than the tab that already signed out cleanly.
+ yield* browser.session(identity, async ({ page, step }) => {
+ const otherTab = await page.context().newPage();
+
+ await step("The user has the app open in two tabs", async () => {
+ await page.goto("/", { waitUntil: "commit" });
+ await page.getByRole("link", { name: "Policies" }).waitFor();
+ await otherTab.goto("/", { waitUntil: "commit" });
+ await otherTab.getByRole("link", { name: "Policies" }).waitFor();
+ });
+
+ await step("Sign out in the other tab", async () => {
+ await clickSignOut(otherTab);
+ await otherTab.waitForURL((url) => url.pathname === "/" || url.pathname === "/login", {
+ timeout: 15_000,
+ });
+ });
+
+ // This tab was never told: its shell is still painted, and its sign-out
+ // button still works the only way it knows how.
+ const before = page.url();
+ await step("Sign out in this tab, which still shows the app", async () => {
+ await clickSignOut(page);
+ await page.waitForURL((url) => url.toString() !== before, {
+ timeout: 15_000,
+ });
+ await page.waitForLoadState("networkidle");
+ });
+
+ const shown = (await page.locator("body").innerText()).trim();
+ expect(shown, "sign-out never renders the API's error envelope as a page").not.toContain(
+ "_tag",
+ );
+ expect(
+ new URL(page.url()).pathname,
+ "this tab lands on a signed-out page like the other one",
+ ).toMatch(/^\/(login)?$/);
+ expect(
+ (await page.context().cookies()).map((cookie) => cookie.name),
+ "the browser is left with no session either way",
+ ).not.toContain("wos-session");
+ });
+ }),
+);
diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts
new file mode 100644
index 0000000000..cb483882c5
--- /dev/null
+++ b/e2e/scenarios/first-party-oauth.test.ts
@@ -0,0 +1,252 @@
+// First-party OAuth clients: the cloud host declares executor-owned apps via
+// env (`FIRST_PARTY_GITHUB_CLIENT_ID/SECRET`, set by the e2e cloud boot), and
+// every org can connect through them with nothing to paste. Three guarantees:
+//
+// 1. Listing: `oauth.listClients` surfaces `first-party:github` with a
+// `first_party` origin and its public client id — no create call ever ran.
+// 2. Flow: `oauth.start` through the first-party slug redirects to the
+// provider's authorize endpoint carrying the env-configured client id and
+// this platform's `/api/oauth/callback` — proof the config-resolved
+// identity (not a stored row) drives the flow. The redirect is asserted,
+// never followed: github.com is not visited.
+// 3. Guardrails: the reserved `first-party:` namespace is rejected by
+// createClient, so no org can shadow the host's app with its own row.
+import { randomBytes } from "node:crypto";
+
+import { expect } from "@effect/vitest";
+import { Effect } from "effect";
+import { composePluginApi } from "@executor-js/api/server";
+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
+import {
+ AuthTemplateSlug,
+ ConnectionName,
+ IntegrationSlug,
+ OAuthClientSlug,
+} from "@executor-js/sdk/shared";
+
+import { scenario } from "../src/scenario";
+import { Api, Target } from "../src/services";
+
+const api = composePluginApi([openApiHttpPlugin()] as const);
+
+const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
+
+/** A minimal integration whose OAuth template points at GitHub's endpoints, so
+ * the first-party `first-party:github` app is the matching client for it. */
+const githubShapedIntegrationSpec = {
+ spec: {
+ kind: "blob" as const,
+ value: JSON.stringify({
+ openapi: "3.0.3",
+ info: { title: "GitHub-shaped API", version: "1.0.0" },
+ paths: {
+ "/user": {
+ get: {
+ operationId: "getUser",
+ tags: ["default"],
+ responses: { "200": { description: "the caller" } },
+ },
+ },
+ },
+ }),
+ },
+ baseUrl: "https://api.github.com",
+ authenticationTemplate: [
+ {
+ slug: "oauth",
+ kind: "oauth2" as const,
+ authorizationUrl: "https://github.com/login/oauth/authorize",
+ tokenUrl: "https://github.com/login/oauth/access_token",
+ scopes: ["repo", "read:org"],
+ },
+ ],
+} as const;
+
+const googleShapedIntegrationSpec = (scopes: readonly string[]) => ({
+ spec: {
+ kind: "blob" as const,
+ value: JSON.stringify({
+ openapi: "3.0.3",
+ info: { title: "Google-shaped API", version: "1.0.0" },
+ paths: {
+ "/resource": {
+ get: {
+ operationId: "getResource",
+ tags: ["default"],
+ responses: { "200": { description: "a Google resource" } },
+ },
+ },
+ },
+ }),
+ },
+ baseUrl: "https://www.googleapis.com",
+ authenticationTemplate: [
+ {
+ slug: "oauth",
+ kind: "oauth2" as const,
+ authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth",
+ tokenUrl: "https://oauth2.googleapis.com/token",
+ scopes,
+ },
+ ],
+});
+
+scenario(
+ "First-party OAuth · the host-declared GitHub app is listed and drives the authorize redirect",
+ {},
+ Effect.scoped(
+ Effect.gen(function* () {
+ const target = yield* Target;
+ // First-party registrations are a cloud-host capability. This scenario
+ // intentionally does not apply to self-host, whose operator supplies
+ // their own OAuth apps through its existing registration flow.
+ if (target.name !== "cloud") return;
+ const { client: makeApiClient } = yield* Api;
+ const identity = yield* target.newIdentity();
+ const client = yield* makeApiClient(api, identity);
+
+ // 1. The config-declared app appears in listings with its public id.
+ const clients = yield* client.oauth.listClients();
+ const firstParty = clients.find((c) => String(c.slug) === "first-party:github");
+ expect(firstParty, "the env-declared first-party GitHub app is listed").toBeDefined();
+ expect(firstParty?.origin.kind).toBe("first_party");
+ expect(firstParty?.clientId).toBe("e2e-first-party-github");
+
+ // 2. A start through the first-party slug builds GitHub's authorize URL
+ // from the config identity and this platform's served callback.
+ const integration = IntegrationSlug.make(unique("fpgh"));
+ yield* client.openapi.addSpec({
+ payload: { ...githubShapedIntegrationSpec, slug: integration },
+ });
+ const started = yield* client.oauth.start({
+ payload: {
+ client: OAuthClientSlug.make("first-party:github"),
+ clientOwner: "org",
+ owner: "org",
+ name: ConnectionName.make("main"),
+ integration,
+ template: AuthTemplateSlug.make("oauth"),
+ },
+ });
+ expect(started.status, "oauth.start redirects to the provider").toBe("redirect");
+ const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";
+ const authorize = new URL(authorizationUrl);
+ expect(authorize.origin + authorize.pathname).toBe(
+ "https://github.com/login/oauth/authorize",
+ );
+ expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-github");
+ expect(authorize.searchParams.get("redirect_uri")).toBe(
+ new URL("/api/oauth/callback", target.baseUrl).toString(),
+ );
+
+ // 3. The reserved namespace cannot be shadowed by a stored row. The
+ // server rejects with a StorageError, which the HTTP edge scrubs to an
+ // opaque InternalError — assert the rejection, then prove the listed
+ // app is still the config-declared one (same public id, same origin).
+ yield* client.oauth
+ .createClient({
+ payload: {
+ owner: "org",
+ slug: OAuthClientSlug.make("first-party:github"),
+ authorizationUrl: "https://github.com/login/oauth/authorize",
+ tokenUrl: "https://github.com/login/oauth/access_token",
+ grant: "authorization_code",
+ clientId: "impostor",
+ clientSecret: "impostor-secret",
+ },
+ })
+ .pipe(Effect.flip);
+ const after = yield* client.oauth.listClients();
+ const survivors = after.filter((c) => String(c.slug) === "first-party:github");
+ expect(survivors, "exactly one first-party:github remains listed").toHaveLength(1);
+ expect(survivors[0]?.origin.kind).toBe("first_party");
+ expect(survivors[0]?.clientId, "the impostor never shadowed the host's app").toBe(
+ "e2e-first-party-github",
+ );
+ }),
+ ),
+);
+
+scenario(
+ "First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes",
+ {},
+ Effect.scoped(
+ Effect.gen(function* () {
+ const target = yield* Target;
+ if (target.name !== "cloud") return;
+ const { client: makeApiClient } = yield* Api;
+ const identity = yield* target.newIdentity();
+ const client = yield* makeApiClient(api, identity);
+
+ const clients = yield* client.oauth.listClients();
+ const google = clients.find((candidate) => String(candidate.slug) === "first-party:google");
+ expect(google, "the env-declared first-party Google app is listed").toBeDefined();
+ expect(google?.origin.kind).toBe("first_party");
+ if (google?.origin.kind !== "first_party") return;
+ expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar");
+ expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets");
+ expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/");
+ expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive");
+
+ const calendar = IntegrationSlug.make(unique("google_calendar"));
+ yield* client.openapi.addSpec({
+ payload: {
+ ...googleShapedIntegrationSpec([
+ "openid",
+ "email",
+ "profile",
+ "https://www.googleapis.com/auth/calendar",
+ ]),
+ slug: calendar,
+ },
+ });
+ const started = yield* client.oauth.start({
+ payload: {
+ client: OAuthClientSlug.make("first-party:google"),
+ clientOwner: "org",
+ owner: "org",
+ name: ConnectionName.make("calendar"),
+ integration: calendar,
+ template: AuthTemplateSlug.make("oauth"),
+ },
+ });
+ expect(started.status).toBe("redirect");
+ const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : "";
+ const authorize = new URL(authorizationUrl);
+ expect(authorize.origin + authorize.pathname).toBe(
+ "https://accounts.google.com/o/oauth2/v2/auth",
+ );
+ expect(authorize.searchParams.get("client_id")).toBe("e2e-first-party-google");
+ expect(authorize.searchParams.get("access_type")).toBe("offline");
+ expect(new Set(authorize.searchParams.get("scope")?.split(" ") ?? [])).toEqual(
+ new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/calendar"]),
+ );
+
+ const gmail = IntegrationSlug.make(unique("google_gmail"));
+ yield* client.openapi.addSpec({
+ payload: {
+ ...googleShapedIntegrationSpec([
+ "openid",
+ "email",
+ "profile",
+ "https://mail.google.com/",
+ ]),
+ slug: gmail,
+ },
+ });
+ const blocked = yield* client.oauth
+ .start({
+ payload: {
+ client: OAuthClientSlug.make("first-party:google"),
+ clientOwner: "org",
+ owner: "org",
+ name: ConnectionName.make("gmail"),
+ integration: gmail,
+ template: AuthTemplateSlug.make("oauth"),
+ },
+ })
+ .pipe(Effect.flip);
+ expect(blocked).toBeDefined();
+ }),
+ ),
+);
diff --git a/e2e/setup/cloud.boot.ts b/e2e/setup/cloud.boot.ts
index 3c072a2f4b..3544afabe0 100644
--- a/e2e/setup/cloud.boot.ts
+++ b/e2e/setup/cloud.boot.ts
@@ -94,6 +94,21 @@ export const bootCloud = async (options: CloudBootOptions): Promise