diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 29f83b991..dd6160bc0 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -43,8 +43,9 @@ interface CloudPluginDeps { readonly workosVaultClient?: WorkOSVaultClient; readonly activeToolkitSlug?: string; /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets - * `microsoft.addGraph` point at a loopback emulator instead of the pinned - * Microsoft Graph URLs. Off by default; production leaves it unset. */ + * custom Microsoft workload adds point at a loopback emulator instead of + * the pinned Microsoft Graph URLs. Off by default; production leaves it + * unset. */ readonly allowLocalNetwork?: boolean; } diff --git a/apps/host-cloudflare/src/worker.e2e.node.test.ts b/apps/host-cloudflare/src/worker.e2e.node.test.ts index cc7aa2d15..ffb30ca22 100644 --- a/apps/host-cloudflare/src/worker.e2e.node.test.ts +++ b/apps/host-cloudflare/src/worker.e2e.node.test.ts @@ -167,8 +167,8 @@ describe("cloudflare host e2e (workerd/miniflare)", () => { expect(integration?.slug).toBe(slug); }, 60_000); - it("registers the Microsoft Graph API route in the runtime plugin set", async () => { - const res = await worker.fetch("/api/microsoft/graph", { + it("registers the Microsoft workloads API route in the runtime plugin set", async () => { + const res = await worker.fetch("/api/microsoft/workloads", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}), diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index 1a29f4507..085b40c3d 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -22,8 +22,8 @@ import { resolveSecretKey } from "./src/config"; interface SelfHostPluginDeps { readonly activeToolkitSlug?: string; /** Mirrors `HostConfig.allowLocalNetwork` (EXECUTOR_ALLOW_LOCAL_NETWORK): - * lets `microsoft.addGraph` point at a loopback emulator instead of the - * pinned Microsoft Graph URLs. Off by default. */ + * lets custom Microsoft workload adds point at a loopback emulator instead + * of the pinned Microsoft Graph URLs. Off by default. */ readonly allowLocalNetwork?: boolean; } diff --git a/e2e/scenarios/google-per-service-add-ui.test.ts b/e2e/scenarios/google-per-service-add-ui.test.ts index da023230d..3a59d82b5 100644 --- a/e2e/scenarios/google-per-service-add-ui.test.ts +++ b/e2e/scenarios/google-per-service-add-ui.test.ts @@ -4,15 +4,16 @@ // not one bundled "google" source. This drives the whole browser path: // // 1. Open /integrations/add/google (the Google-owned add flow). -// 2. Clear the featured defaults, then check exactly Calendar + Gmail + Drive. +// 2. Clear the featured defaults, check Calendar + Gmail + Drive, and add a +// custom Tasks Discovery URL. // 3. Submit via `google-add-submit`. The flow fetches each preset's real // Google Discovery document (www.googleapis.com) and registers one -// integration per product. -// 4. The result panel shows three `add-result-row-*` rows, each data-state +// integration per product, plus one custom integration. +// 4. The result panel shows four `add-result-row-*` rows, each data-state // "added", each with an Open link. // 5. Follow one Open link: the integration detail page shows the per-service // name ("Google Calendar"), proving the fan-out kept preset identities. -// 6. Back on the integrations list, all three separate integrations exist +// 6. Back on the integrations list, all four separate integrations exist // (asserted by their distinct slugs in the list UI). // 7. Re-open the add flow, check Calendar again, submit: its row now reports // data-state "skipped" (the integration already exists), proving the @@ -33,7 +34,7 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { setPresetChecked } from "./support/picker"; +import { clearCheckedPresets, setPresetChecked } from "./support/picker"; scenario( "Google · the per-service picker fans out to separate integrations and skips existing ones", @@ -42,6 +43,7 @@ scenario( const target = yield* Target; const browser = yield* Browser; const identity = yield* target.newIdentity(); + const customDiscoveryUrl = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; yield* browser.session(identity, async ({ page, step }) => { await step("Open the Google add flow", async () => { @@ -52,28 +54,34 @@ scenario( await page.getByTestId("preset-checkbox-google-calendar").waitFor(); }); - await step("Clear the defaults, then check exactly Calendar + Gmail + Drive", async () => { - // Clear every featured default, then select only the three under test. - const featuredDefaults = [ - "google-calendar", - "google-gmail", - "google-sheets", - "google-drive", - "google-docs", - ]; - for (const presetId of featuredDefaults) { - await setPresetChecked(page, presetId, false); - } + await step( + "Clear defaults, check Calendar + Gmail + Drive, and add a custom URL", + async () => { + // Clear every featured default, then select only the three preset + // products under test. + await clearCheckedPresets(page); + for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) { + await setPresetChecked(page, presetId, true); + } + // Everything else is unchecked: this is a scoped three-product selection. + expect(await page.getByTestId("preset-checkbox-google-sheets").isChecked()).toBe(false); + expect(await page.getByTestId("preset-checkbox-google-docs").isChecked()).toBe(false); - for (const presetId of ["google-calendar", "google-gmail", "google-drive"]) { - await setPresetChecked(page, presetId, true); - } - // Everything else is unchecked: this is a scoped three-product selection. - expect(await page.getByTestId("preset-checkbox-google-sheets").isChecked()).toBe(false); - expect(await page.getByTestId("preset-checkbox-google-docs").isChecked()).toBe(false); - }); + await page + .getByPlaceholder( + "https://www.googleapis.com/discovery/v1/apis///rest", + ) + .fill(customDiscoveryUrl); + await page + .getByPlaceholder( + "https://www.googleapis.com/discovery/v1/apis///rest", + ) + .press("Enter"); + await page.getByText(customDiscoveryUrl).waitFor(); + }, + ); - await step("Submit the fan-out and see three added integrations", async () => { + await step("Submit the fan-out and see four added integrations", async () => { await page.getByTestId("google-add-submit").click(); // The result panel appears once every product is registered. Discovery // fetch + spec compile per product; allow generous time. @@ -90,6 +98,15 @@ scenario( // Each added row links out to its own integration page. await row.getByRole("link", { name: "Open" }).waitFor(); } + + const customRow = page.getByTestId("add-result-row-google_tasks"); + await customRow.waitFor({ timeout: 120_000 }); + expect( + await customRow.getAttribute("data-state"), + "the custom Discovery URL is added through the services endpoint", + ).toBe("added"); + await customRow.getByText("Google Tasks").waitFor(); + await customRow.getByRole("link", { name: "Open" }).waitFor(); }); await step("Open one product: its integration page shows the per-service name", async () => { @@ -108,17 +125,18 @@ scenario( .waitFor({ timeout: 20_000 }); }); - await step("The integrations list has three separate Google integrations", async () => { + await step("The integrations list has four separate Google integrations", async () => { await page.goto("/integrations", { waitUntil: "networkidle" }); // Each fanned-out integration is its own list entry, keyed by its slug // (the list renders the slug as each entry's description). - for (const slug of ["google_calendar", "google_gmail", "google_drive"]) { + for (const slug of ["google_calendar", "google_gmail", "google_drive", "google_tasks"]) { await page .getByRole("main") .getByText(slug, { exact: true }) .first() .waitFor({ timeout: 20_000 }); } + await page.getByRole("main").getByText("Google Tasks").first().waitFor({ timeout: 20_000 }); }); await step("Re-adding an existing product reports it as skipped", async () => { @@ -126,9 +144,7 @@ scenario( await page.getByText("Customize your Google connection").waitFor(); // Clear the featured defaults so only Calendar (already added) is checked. - for (const presetId of ["google-gmail", "google-sheets", "google-drive", "google-docs"]) { - await setPresetChecked(page, presetId, false); - } + await clearCheckedPresets(page); await setPresetChecked(page, "google-calendar", true); await page.getByTestId("google-add-submit").click(); diff --git a/e2e/scenarios/microsoft-emulator.test.ts b/e2e/scenarios/microsoft-emulator.test.ts index f414bf4c8..d6532720e 100644 --- a/e2e/scenarios/microsoft-emulator.test.ts +++ b/e2e/scenarios/microsoft-emulator.test.ts @@ -81,15 +81,13 @@ return { ok: result.ok, path: item.path, result: result.ok ? result.data : resul scenario( "Microsoft · client credentials against the emulator mint a Graph connection and call /users", { - // Blocked (pre-existing, not this PR): `microsoft.addGraph` only accepts the - // canonical Graph spec in the streamable block-YAML profile — it structurally - // splits the doc to avoid OOMing the 128MB Workers isolate on the real 37MB - // spec (packages/plugins/microsoft/src/sdk/graph.ts), and hard-errors on - // anything else. The @executor-js/emulate Microsoft emulator serves a small - // custom Graph spec that isn't in that profile, so addGraph rejects it. Fix - // needs the emulator to serve a block-YAML-profile Graph spec (or a - // non-Workers compile path); tracked separately. - skip: "microsoft.addGraph requires the canonical block-YAML Graph spec; the emulator spec is not in that profile", + // Blocked (pre-existing, not this PR): Microsoft workload adds require the + // canonical Graph spec in the streamable block-YAML profile. The emulator + // serves a small custom Graph spec that is outside that profile, so the + // workload add rejects it. Fix needs the emulator to serve a + // block-YAML-profile Graph spec (or a non-Workers compile path); tracked + // separately. + skip: "Microsoft workload add requires the canonical block-YAML Graph spec; the emulator spec is not in that profile", timeout: 180_000, }, Effect.scoped( @@ -110,14 +108,19 @@ scenario( yield* Effect.ensuring( Effect.gen(function* () { - yield* client.microsoft.addGraph({ + yield* client.microsoft.addWorkloads({ payload: { - presetIds: ["users"], - customScopes: [], - slug: integration, - name: "Microsoft Graph Emulator", baseUrl: emulator.url, - specUrl: emulator.openapiUrl, + workloads: [ + { + custom: { + customScopes: ["User.Read.All"], + slug: integration, + name: "Microsoft Graph Emulator", + specUrl: emulator.openapiUrl, + }, + }, + ], }, }); diff --git a/e2e/scenarios/microsoft-graph-default.test.ts b/e2e/scenarios/microsoft-graph-default.test.ts deleted file mode 100644 index 2dd0df985..000000000 --- a/e2e/scenarios/microsoft-graph-default.test.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { randomBytes } from "node:crypto"; - -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; -import { composePluginApi } from "@executor-js/api/server"; -import { - MICROSOFT_AUTH_TEMPLATE_SLUG, - MICROSOFT_AUTHORIZATION_URL, - MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, - MICROSOFT_TOKEN_URL, -} from "@executor-js/plugin-microsoft"; -import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/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([microsoftHttpPlugin()] as const); - -type ToolView = { - readonly name: string; -}; - -const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; - -// Compiling the ~37MB Graph spec inside dev workerd needs more headroom than -// GitHub's 2-core runners have: /api/microsoft/graph 500s and the dev stack is -// dead for every scenario after it in the shard. Local runs (and the -// production Workers streaming path) are unaffected — CI-only quarantine. -const CI_GRAPH_SPEC_SKIP = process.env.CI - ? "compiling the full Microsoft Graph spec exhausts the 2-core CI runner and kills the dev stack for the rest of the shard" - : undefined; - -scenario( - "Microsoft Graph: default add stores common Microsoft 365 workloads", - { timeout: 180_000, skip: CI_GRAPH_SPEC_SKIP }, - Effect.gen(function* () { - const target = yield* Target; - const { client: makeApiClient } = yield* Api; - const identity = yield* target.newIdentity(); - const client = yield* makeApiClient(api, identity); - - const integration = unique("msgraph_default"); - const connection = ConnectionName.make("main"); - const oauthClient = OAuthClientSlug.make(unique("msgraph_app")); - - yield* Effect.ensuring( - Effect.gen(function* () { - const added = yield* client.microsoft.addGraph({ - payload: { - presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS], - customScopes: [], - slug: integration, - name: "Microsoft Graph Defaults", - }, - }); - expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration); - expect( - added.toolCount, - "the default Microsoft Graph add extracts common user-facing operations", - ).toBeGreaterThan(100); - - const config = yield* client.microsoft.getConfig({ - params: { slug: integration }, - }); - expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([ - ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, - ]); - expect( - config?.microsoftGraphCoversFullGraph, - "the default selection is not full Graph", - ).toBe(false); - expect( - config?.microsoftGraphScopes, - "default delegated OAuth requests the selected common workload scopes", - ).toEqual([ - "offline_access", - "User.Read", - "Mail.ReadWrite", - "Mail.Send", - "MailboxSettings.ReadWrite", - "Calendars.ReadWrite", - "Contacts.ReadWrite", - "People.Read.All", - "Tasks.ReadWrite", - "Files.ReadWrite.All", - "Sites.ReadWrite.All", - "Notes.ReadWrite", - "Chat.ReadWrite", - "Team.ReadBasic.All", - "Channel.ReadBasic.All", - "ChannelMessage.Read.All", - "ChannelMessage.Send", - "OnlineMeetings.ReadWrite", - ]); - - yield* client.oauth.createClient({ - payload: { - owner: "org", - slug: oauthClient, - authorizationUrl: MICROSOFT_AUTHORIZATION_URL, - tokenUrl: MICROSOFT_TOKEN_URL, - grant: "authorization_code", - clientId: "client-id", - clientSecret: "client-secret", - }, - }); - - const started = yield* client.oauth.start({ - payload: { - client: oauthClient, - clientOwner: "org", - owner: "org", - name: ConnectionName.make("oauth"), - integration: IntegrationSlug.make(integration), - template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), - }, - }); - expect(started.status, "authorization-code OAuth returns a browser redirect").toBe( - "redirect", - ); - const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; - const authorizeUrl = new URL(authorizationUrl || "https://invalid.example"); - expect( - authorizeUrl.toString().length, - "default Microsoft Graph OAuth authorize URLs stay under ordinary proxy limits", - ).toBeLessThan(2_000); - expect( - authorizeUrl.searchParams.get("scope"), - "default Microsoft Graph OAuth asks for common workload scopes", - ).toBe(config?.microsoftGraphScopes?.join(" ")); - - yield* client.connections.create({ - payload: { - owner: "org", - name: connection, - integration: IntegrationSlug.make(integration), - template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), - value: "token-xyz", - }, - }); - - const tools = yield* client.tools.list({ - query: { integration: IntegrationSlug.make(integration), connection }, - }); - const names = tools.map((tool: ToolView) => tool.name); - const messageTools = names.filter((name) => name.toLowerCase().includes("message")); - const siteTools = names.filter((name) => name.toLowerCase().includes("site")); - expect( - messageTools, - "the retrieved catalog includes Microsoft message operations", - ).not.toEqual([]); - expect(siteTools, "the retrieved catalog includes SharePoint site operations").not.toEqual( - [], - ); - }), - Effect.gen(function* () { - yield* client.connections - .remove({ - params: { - owner: "org", - integration: IntegrationSlug.make(integration), - name: connection, - }, - }) - .pipe(Effect.ignore); - yield* client.microsoft - .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) - .pipe(Effect.ignore); - yield* client.oauth - .removeClient({ - params: { slug: oauthClient }, - payload: { owner: "org" }, - }) - .pipe(Effect.ignore); - }), - ); - }), -); diff --git a/e2e/scenarios/microsoft-graph-full.test.ts b/e2e/scenarios/microsoft-graph-full.test.ts deleted file mode 100644 index 20a960f1f..000000000 --- a/e2e/scenarios/microsoft-graph-full.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { randomBytes } from "node:crypto"; - -import { expect } from "@effect/vitest"; -import { Effect } from "effect"; -import { composePluginApi } from "@executor-js/api/server"; -import { - MICROSOFT_AUTH_TEMPLATE_SLUG, - MICROSOFT_GRAPH_ALL_PRESET_IDS, - MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, -} from "@executor-js/plugin-microsoft"; -import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; -import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; - -import { scenario } from "../src/scenario"; -import { Api, Target } from "../src/services"; - -const api = composePluginApi([microsoftHttpPlugin()] as const); - -type ToolView = { - readonly name: string; -}; - -const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; - -// Adding *every* Graph workload pulls the full Microsoft Graph OpenAPI document -// (~37MB, ~16.5k operations) and persists a binding per operation. That whole- -// document path used to 503 on the Cloudflare worker: parsing the spec, and -// then re-parsing it on every tools/list, each rebuilt a ~300MB JS tree that -// blew the 128MB isolate. This scenario is the regression guard for both sites -// at real scale: the add streams the compile + persist, and tools/list serves -// the catalog back from the persisted bindings (+ the content-addressed defs -// blob) without ever re-parsing the spec. It drives only the public API, so a -// green run is evidence the full catalog lands and serves end to end. -scenario( - "Microsoft Graph: the full catalog adds and serves without re-parsing the spec", - { timeout: 300_000 }, - Effect.gen(function* () { - const target = yield* Target; - const { client: makeApiClient } = yield* Api; - const identity = yield* target.newIdentity(); - const client = yield* makeApiClient(api, identity); - - const integration = unique("msgraph_full"); - const connection = ConnectionName.make("main"); - - yield* Effect.ensuring( - Effect.gen(function* () { - // Add path (1st former OOM site): the full spec is fetched and - // stream-compiled into one persisted binding per operation. - const added = yield* client.microsoft.addGraph({ - payload: { - presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], - customScopes: [], - slug: integration, - name: "Microsoft Graph (full)", - }, - }); - expect(added.slug, "the full Graph source keeps the requested slug").toBe(integration); - expect( - added.toolCount, - "adding every Graph workload extracts the whole catalog (thousands of operations)", - ).toBeGreaterThan(5_000); - - const config = yield* client.microsoft.getConfig({ params: { slug: integration } }); - expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual( - [...MICROSOFT_GRAPH_ALL_PRESET_IDS], - ); - expect( - config?.microsoftGraphCoversFullGraph, - "selecting every workload is recognized as full Graph", - ).toBe(true); - expect( - config?.microsoftGraphScopes, - "full Graph delegates the app-registration default scope set", - ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]); - - yield* client.connections.create({ - payload: { - owner: "org", - name: connection, - integration: IntegrationSlug.make(integration), - template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), - value: "token-xyz", - }, - }); - - // Serve path (2nd former OOM site): tools/list rebuilds the catalog from - // the persisted bindings. The whole catalog must come back, with real - // descriptions, and without re-parsing the 37MB spec. - const tools = yield* client.tools.list({ - query: { integration: IntegrationSlug.make(integration), connection }, - }); - expect( - tools.length, - "the served catalog returns the whole set of operations, not a re-parse failure", - ).toBeGreaterThan(5_000); - - const names = tools.map((tool: ToolView) => tool.name); - const messageTools = names.filter((name) => name.toLowerCase().includes("message")); - const siteTools = names.filter((name) => name.toLowerCase().includes("site")); - const userTools = names.filter((name) => name.toLowerCase().includes("user")); - expect(messageTools, "the served catalog spans mail operations").not.toEqual([]); - expect(siteTools, "the served catalog spans SharePoint site operations").not.toEqual([]); - expect(userTools, "the served catalog spans directory user operations").not.toEqual([]); - }), - Effect.gen(function* () { - yield* client.connections - .remove({ - params: { - owner: "org", - integration: IntegrationSlug.make(integration), - name: connection, - }, - }) - .pipe(Effect.ignore); - yield* client.microsoft - .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) - .pipe(Effect.ignore); - }), - ); - }), -); diff --git a/e2e/scenarios/microsoft-per-workload-add-ui.test.ts b/e2e/scenarios/microsoft-per-workload-add-ui.test.ts index bd210d701..f0aee01c5 100644 --- a/e2e/scenarios/microsoft-per-workload-add-ui.test.ts +++ b/e2e/scenarios/microsoft-per-workload-add-ui.test.ts @@ -2,10 +2,11 @@ // spec. The add-Microsoft flow lets a user check several Graph workloads and // submit them in one action; each checked workload becomes its OWN integration // (microsoft_mail, microsoft_calendar), each a scope-filtered slice of the -// Graph, not one bundled "microsoft_graph" source. This drives the whole -// browser path: clear the featured defaults, check exactly Mail + Calendar, -// submit, assert two `add-result-row-*` rows added, follow one Open link to a -// per-workload integration page, confirm both slugs in the list, then re-add +// Graph, not one bundled "microsoft_graph" source. The same submit also adds +// one custom-scope workload entry. This drives the whole browser path: clear +// the featured defaults, check exactly Mail + Calendar, add a custom scope, +// submit, assert three `add-result-row-*` rows added, follow one Open link to +// a per-workload integration page, confirm all slugs in the list, then re-add // Mail and see it reported skipped. // // OUTBOUND SPEC (same shape as the Google spec, scoped-out reasons below): @@ -25,15 +26,16 @@ import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Target } from "../src/services"; -import { setPresetChecked } from "./support/picker"; +import { clearCheckedPresets, setPresetChecked } from "./support/picker"; scenario( "Microsoft · the per-workload picker fans out to separate integrations and skips existing ones", - { timeout: 420_000 }, + { timeout: 600_000 }, Effect.gen(function* () { const target = yield* Target; const browser = yield* Browser; const identity = yield* target.newIdentity(); + const customScope = "Sites.Read.All"; yield* browser.session(identity, async ({ page, step }) => { await step("Open the Microsoft add flow", async () => { @@ -43,24 +45,30 @@ scenario( await page.getByTestId("preset-checkbox-mail").waitFor(); }); - await step("Clear the defaults, then check exactly Mail + Calendar", async () => { - // The featured defaults (profile, mail, calendar, contacts, tasks, files) - // start checked; clear them, then select only the two under test. - for (const presetId of ["profile", "mail", "calendar", "contacts", "tasks", "files"]) { - await setPresetChecked(page, presetId, false); - } + await step("Clear defaults, check Mail + Calendar, and add a custom scope", async () => { + // Clear every checked default, then select only the two under test. + await clearCheckedPresets(page); for (const presetId of ["mail", "calendar"]) { await setPresetChecked(page, presetId, true); } expect(await page.getByTestId("preset-checkbox-contacts").isChecked()).toBe(false); expect(await page.getByTestId("preset-checkbox-files").isChecked()).toBe(false); + + await page.getByPlaceholder("Sites.Read.All").fill(customScope); + await page.getByPlaceholder("Sites.Read.All").press("Enter"); + await page.getByText(customScope).waitFor(); }); - await step("Submit the fan-out and see two added integrations", async () => { + await step("Submit the fan-out and see three added integrations", async () => { await page.getByTestId("microsoft-add-submit").click(); // Each workload fetches + stream-compiles the 37MB Graph spec; allow a // wide window for the result panel to populate. - await page.getByTestId("microsoft-add-results").waitFor({ timeout: 300_000 }); + const results = page.getByTestId("microsoft-add-results"); + await results.waitFor({ timeout: 300_000 }); + expect( + await results.locator('[data-testid^="add-result-row-"]').count(), + "only Mail, Calendar, and the custom scope are created", + ).toBe(3); for (const presetId of ["mail", "calendar"]) { const row = page.getByTestId(`add-result-row-${presetId}`); @@ -71,6 +79,14 @@ scenario( ).toBe("added"); await row.getByRole("link", { name: "Open" }).waitFor(); } + + const customRow = page.getByTestId("add-result-row-custom"); + await customRow.waitFor({ timeout: 300_000 }); + expect( + await customRow.getAttribute("data-state"), + "the custom scope is added through the workloads endpoint", + ).toBe("added"); + await customRow.getByRole("link", { name: "Open" }).waitFor(); }); await step( @@ -89,9 +105,9 @@ scenario( }, ); - await step("The integrations list has two separate Microsoft integrations", async () => { + await step("The integrations list has three separate Microsoft integrations", async () => { await page.goto("/integrations", { waitUntil: "networkidle" }); - for (const slug of ["microsoft_mail", "microsoft_calendar"]) { + for (const slug of ["microsoft_mail", "microsoft_calendar", "microsoft_graph_custom"]) { await page .getByRole("main") .getByText(slug, { exact: true }) @@ -104,9 +120,7 @@ scenario( await page.goto("/integrations/add/microsoft", { waitUntil: "domcontentloaded" }); await page.getByText("Customize Microsoft Graph").waitFor(); - for (const presetId of ["profile", "mail", "calendar", "contacts", "tasks", "files"]) { - await setPresetChecked(page, presetId, false); - } + await clearCheckedPresets(page); await setPresetChecked(page, "mail", true); await page.getByTestId("microsoft-add-submit").click(); diff --git a/e2e/scenarios/oauth-client-handoff.test.ts b/e2e/scenarios/oauth-client-handoff.test.ts index a0c23dfc5..1f0c6eaa7 100644 --- a/e2e/scenarios/oauth-client-handoff.test.ts +++ b/e2e/scenarios/oauth-client-handoff.test.ts @@ -311,15 +311,14 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => scenario( "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects", { - // Blocked (pre-existing, not this PR): this scenario drives the handoff - // through `microsoft.addGraph`, which only accepts the canonical Graph spec - // in the streamable block-YAML profile (structural split to avoid OOMing the - // 128MB Workers isolate on the 37MB doc — packages/plugins/microsoft/src/sdk/ - // graph.ts). The @executor-js/emulate Microsoft emulator serves a small spec - // outside that profile, so addGraph hard-errors. The other two OAuth-client - // scenarios in this file (createHandoff, approval-gating) do not touch Graph - // and pass. Fix needs a block-YAML-profile emulator spec; tracked separately. - skip: "drives microsoft.addGraph, which requires the canonical block-YAML Graph spec the emulator does not serve", + // Blocked (pre-existing, not this PR): this scenario needs a Microsoft + // workload backed by the emulator spec, but workload adds require the + // canonical Graph spec in the streamable block-YAML profile. The emulator + // serves a small spec outside that profile. The other two OAuth-client + // scenarios in this file (createHandoff, approval-gating) do not touch + // Graph and pass. Fix needs a block-YAML-profile emulator spec; tracked + // separately. + skip: "drives Microsoft workload add, which requires the canonical block-YAML Graph spec the emulator does not serve", timeout: 240_000, }, Effect.gen(function* () { @@ -356,14 +355,19 @@ scenario( Effect.gen(function* () { // Register the Microsoft Graph integration so the console has an OAuth // method to register a client against. - yield* client.microsoft.addGraph({ + yield* client.microsoft.addWorkloads({ payload: { - presetIds: ["users"], - customScopes: [], - slug: integration, - name: "Microsoft Graph Emulator", baseUrl: emulator.baseUrl, - specUrl: emulator.openapiUrl, + workloads: [ + { + custom: { + customScopes: ["User.Read.All"], + slug: integration, + name: "Microsoft Graph Emulator", + specUrl: emulator.openapiUrl, + }, + }, + ], }, }); diff --git a/e2e/scenarios/support/picker.ts b/e2e/scenarios/support/picker.ts index 79188e965..2a2c51116 100644 --- a/e2e/scenarios/support/picker.ts +++ b/e2e/scenarios/support/picker.ts @@ -27,3 +27,24 @@ export const setPresetChecked = async ( ) .toBe(String(checked)); }; + +export const clearCheckedPresets = async (page: Page): Promise => { + const boxes = page.locator('[data-testid^="preset-checkbox-"]'); + await boxes.first().waitFor(); + + const checkedPresetIds: string[] = []; + const count = await boxes.count(); + for (let index = 0; index < count; index += 1) { + const box = boxes.nth(index); + if ((await box.getAttribute("aria-checked")) !== "true") continue; + + const testId = await box.getAttribute("data-testid"); + if (testId?.startsWith("preset-checkbox-")) { + checkedPresetIds.push(testId.slice("preset-checkbox-".length)); + } + } + + for (const presetId of checkedPresetIds) { + await setPresetChecked(page, presetId, false); + } +}; diff --git a/examples/all-plugins/src/main.ts b/examples/all-plugins/src/main.ts index bc4e31f29..dcdeb90fc 100644 --- a/examples/all-plugins/src/main.ts +++ b/examples/all-plugins/src/main.ts @@ -418,7 +418,6 @@ const program = Effect.gen(function* () { console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath); // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." }); - // executor.google.addBundle({ urls: ["..."], slug: "google" }); // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." }); // ------------------------------------------------------------------------- diff --git a/packages/plugins/google/src/api/group.ts b/packages/plugins/google/src/api/group.ts index e01f98ace..367430cd4 100644 --- a/packages/plugins/google/src/api/group.ts +++ b/packages/plugins/google/src/api/group.ts @@ -46,24 +46,22 @@ const OAuthTemplatePayload = Schema.Struct({ const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); -const AddBundlePayload = Schema.Struct({ - urls: Schema.Array(Schema.String), +const AddPresetServicePayload = Schema.Struct({ + presetId: Schema.String, slug: Schema.optional(Schema.String), name: Schema.optional(Schema.String), - description: Schema.optional(Schema.String), - baseUrl: Schema.optional(Schema.String), }); -const AddBundleResponse = Schema.Struct({ - slug: IntegrationSlug, - toolCount: Schema.Number, +const AddCustomServicePayload = Schema.Struct({ + custom: Schema.Struct({ + urls: Schema.Array(Schema.String), + slug: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), + }), }); -const AddServicePayload = Schema.Struct({ - presetId: Schema.String, - slug: Schema.optional(Schema.String), - name: Schema.optional(Schema.String), -}); +const AddServicePayload = Schema.Union([AddPresetServicePayload, AddCustomServicePayload]); const AddServicesPayload = Schema.Struct({ services: Schema.Array(AddServicePayload), @@ -131,13 +129,6 @@ const ConfigureResponse = Schema.Struct({ }); export const GoogleGroup = HttpApiGroup.make("google") - .add( - HttpApiEndpoint.post("addBundle", "/google/bundles", { - payload: AddBundlePayload, - success: AddBundleResponse, - error: DomainErrors, - }), - ) .add( HttpApiEndpoint.post("addServices", "/google/services", { payload: AddServicesPayload, diff --git a/packages/plugins/google/src/api/handlers.ts b/packages/plugins/google/src/api/handlers.ts index 8dc071f8b..f352e14dd 100644 --- a/packages/plugins/google/src/api/handlers.ts +++ b/packages/plugins/google/src/api/handlers.ts @@ -14,20 +14,6 @@ const ExecutorApiWithGoogle = addGroup(GoogleGroup); export const GoogleHandlers = HttpApiBuilder.group(ExecutorApiWithGoogle, "google", (handlers) => handlers - .handle("addBundle", ({ payload }) => - capture( - Effect.gen(function* () { - const ext = yield* GoogleExtensionService; - return yield* ext.addBundle({ - urls: payload.urls, - slug: payload.slug, - name: payload.name, - description: payload.description, - baseUrl: payload.baseUrl, - }); - }), - ), - ) .handle("addServices", ({ payload }) => capture( Effect.gen(function* () { diff --git a/packages/plugins/google/src/react/AddGoogleSource.test.ts b/packages/plugins/google/src/react/AddGoogleSource.test.ts index cb022190d..1da67729c 100644 --- a/packages/plugins/google/src/react/AddGoogleSource.test.ts +++ b/packages/plugins/google/src/react/AddGoogleSource.test.ts @@ -93,6 +93,16 @@ describe("AddGoogleSource per-service submit", () => { presetId: "google-drive", error: "Discovery fetch failed", }, + { + slug: IntegrationSlug.make("google_custom"), + presetId: "custom", + error: "Custom Discovery fetch failed", + }, + { + slug: IntegrationSlug.make("google_tasks"), + presetId: "google_tasks", + error: "Tasks Discovery fetch failed", + }, ], }; @@ -110,6 +120,10 @@ describe("AddGoogleSource per-service submit", () => { expect(text).toContain("Already exists"); expect(text).toContain("Google Drive"); expect(text).toContain("Discovery fetch failed"); + expect(text).toContain("Custom Discovery URLs"); + expect(text).toContain("Custom Discovery fetch failed"); + expect(text).toContain("Google Tasks"); + expect(text).toContain("Tasks Discovery fetch failed"); expect(text).toContain("Retry"); }); @@ -173,4 +187,51 @@ describe("AddGoogleSource per-service submit", () => { ], }); }); + + it("submits custom Discovery URLs without generic identity overrides", () => { + expect( + googleAddServicesPayload({ + presetIds: ["google-calendar"], + custom: { + urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], + }, + baseUrl: " https://proxy.example ", + }), + ).toEqual({ + services: [ + { presetId: "google-calendar" }, + { + custom: { + urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], + }, + }, + ], + baseUrl: "https://proxy.example", + }); + }); + + it("passes explicit custom Discovery URL identity overrides when supplied", () => { + expect( + googleAddServicesPayload({ + presetIds: [], + custom: { + urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], + slug: "team_tasks", + name: "Team Tasks", + description: "Internal task workflow.", + }, + }), + ).toEqual({ + services: [ + { + custom: { + urls: ["https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"], + slug: "team_tasks", + name: "Team Tasks", + description: "Internal task workflow.", + }, + }, + ], + }); + }); }); diff --git a/packages/plugins/google/src/react/AddGoogleSource.tsx b/packages/plugins/google/src/react/AddGoogleSource.tsx index a410d50df..c0dfb36b5 100644 --- a/packages/plugins/google/src/react/AddGoogleSource.tsx +++ b/packages/plugins/google/src/react/AddGoogleSource.tsx @@ -13,16 +13,10 @@ import { Button } from "@executor-js/react/components/button"; import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; -import { - addIntegrationErrorMessage, - errorMessageFromExit, - FormErrorAlert, - SlugCollisionAlert, - useSlugAlreadyExists, -} from "@executor-js/react/lib/integration-add"; +import { errorMessageFromExit, FormErrorAlert } from "@executor-js/react/lib/integration-add"; import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; -import { addGoogleBundle, addGoogleServices } from "./atoms"; +import { addGoogleServices } from "./atoms"; import { GoogleProductPicker } from "./GoogleProductPicker"; import { GOOGLE_PHOTOS_PRESET_ID, @@ -31,11 +25,8 @@ import { googleServiceSlug, type GoogleOpenApiPreset, } from "../sdk/presets"; -import type { - GoogleAddServicesInput, - GoogleAddServicesResult, - GoogleBundleConfig, -} from "../sdk/plugin"; +import type { GoogleAddServicesInput, GoogleAddServicesResult } from "../sdk/plugin"; +import { GOOGLE_CUSTOM_SERVICE_ID } from "../sdk/plugin"; const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; @@ -54,6 +45,13 @@ export type GoogleServiceIdentityOverride = { readonly name: string; }; +export type GoogleCustomServiceInput = { + readonly urls: readonly string[]; + readonly slug?: string; + readonly name?: string; + readonly description?: string; +}; + export type AddGoogleServicesMutation = (input: { readonly payload: GoogleAddServicesInput; readonly reactivityKeys: typeof integrationWriteKeys; @@ -62,14 +60,32 @@ export type AddGoogleServicesMutation = (input: { export const googleAddServicesPayload = (input: { readonly presetIds: readonly string[]; readonly identityOverride?: GoogleServiceIdentityOverride; + readonly custom?: GoogleCustomServiceInput; readonly baseUrl?: string; }): GoogleAddServicesInput => { - const identityOverride = input.presetIds.length === 1 ? input.identityOverride : undefined; - const services = input.presetIds.map((presetId: string) => ({ + const identityOverride = + input.presetIds.length === 1 && !input.custom ? input.identityOverride : undefined; + const presetServices = input.presetIds.map((presetId: string) => ({ presetId, ...(identityOverride?.slug.trim() ? { slug: identityOverride.slug.trim() } : {}), ...(identityOverride?.name.trim() ? { name: identityOverride.name.trim() } : {}), })); + const custom = + input.custom && input.custom.urls.length > 0 + ? [ + { + custom: { + urls: [...input.custom.urls], + ...(input.custom.slug?.trim() ? { slug: input.custom.slug.trim() } : {}), + ...(input.custom.name?.trim() ? { name: input.custom.name.trim() } : {}), + ...(input.custom.description?.trim() + ? { description: input.custom.description.trim() } + : {}), + }, + }, + ] + : []; + const services = [...presetServices, ...custom]; const baseUrl = input.baseUrl?.trim() ?? ""; return baseUrl.length > 0 ? { services, baseUrl } : { services }; }; @@ -79,6 +95,7 @@ export const submitGoogleServicesSelection = ( input: { readonly presetIds: readonly string[]; readonly identityOverride?: GoogleServiceIdentityOverride; + readonly custom?: GoogleCustomServiceInput; readonly baseUrl?: string; }, ): Promise> => @@ -130,26 +147,73 @@ export const googleAddServicesResultRows = ( })), ]; +const googleAddServicesResultRowId = (row: GoogleServiceResultRow): string => + row.presetId === GOOGLE_CUSTOM_SERVICE_ID ? row.slug : row.presetId; + export const mergeGoogleAddServicesResult = ( previous: GoogleAddServicesResult, next: GoogleAddServicesResult, ): GoogleAddServicesResult => { - const nextPresetIds = new Set(googleAddServicesResultRows(next).map((row) => row.presetId)); + const nextRowIds = new Set(googleAddServicesResultRows(next).map(googleAddServicesResultRowId)); return { - added: [...previous.added.filter((entry) => !nextPresetIds.has(entry.presetId)), ...next.added], + added: [ + ...previous.added.filter( + (entry) => + !nextRowIds.has( + googleAddServicesResultRowId({ + status: "added", + presetId: entry.presetId, + slug: String(entry.slug), + toolCount: entry.toolCount, + }), + ), + ), + ...next.added, + ], skipped: [ - ...previous.skipped.filter((entry) => !nextPresetIds.has(entry.presetId)), + ...previous.skipped.filter( + (entry) => + !nextRowIds.has( + googleAddServicesResultRowId({ + status: "skipped", + presetId: entry.presetId, + slug: String(entry.slug), + reason: entry.reason, + }), + ), + ), ...next.skipped, ], failed: [ - ...previous.failed.filter((entry) => !nextPresetIds.has(entry.presetId)), + ...previous.failed.filter( + (entry) => + !nextRowIds.has( + googleAddServicesResultRowId({ + status: "failed", + presetId: entry.presetId, + slug: String(entry.slug), + error: entry.error, + }), + ), + ), ...next.failed, ], }; }; -const googlePresetName = (presetId: string): string => - googleOpenApiPresetById.get(presetId)?.name ?? presetId; +const humanizeGoogleSlug = (slug: string): string => + slug + .replace(/^google_/, "") + .split("_") + .filter((part) => part.length > 0) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + +const googlePresetName = (row: GoogleServiceResultRow): string => + row.presetId === GOOGLE_CUSTOM_SERVICE_ID || row.slug === "google_custom" + ? "Custom Discovery URLs" + : (googleOpenApiPresetById.get(row.presetId)?.name ?? + (row.slug.startsWith("google_") ? `Google ${humanizeGoogleSlug(row.slug)}` : row.slug)); export function GoogleServiceResultPanel(props: { readonly result: GoogleAddServicesResult; @@ -172,11 +236,12 @@ export function GoogleServiceResultPanel(props: {
    {rows.map((row: GoogleServiceResultRow) => { - const presetName = googlePresetName(row.presetId); + const rowId = googleAddServicesResultRowId(row); + const presetName = googlePresetName(row); return (
  • @@ -225,9 +290,9 @@ export function GoogleServiceResultPanel(props: { type="button" variant="outline" size="xs" - data-testid={`add-result-retry-${row.presetId}`} - loading={props.retryingPresetId === row.presetId} - onClick={() => void props.onRetry(row.presetId)} + data-testid={`add-result-retry-${rowId}`} + loading={props.retryingPresetId === rowId} + onClick={() => void props.onRetry(rowId)} > Retry @@ -266,36 +331,6 @@ const BaseUrlSettings = (props: { ); -const CustomGoogleBundleResult = (props: { readonly slug: string }) => ( -
    -
    -
    -

    Custom Discovery URLs

    -

    - Custom URLs were added through the legacy Google bundle path. -

    -
    - -
    -
    -); - -const googleBundlePayload = (config: GoogleBundleConfig): GoogleBundleConfig => { - const baseUrl = config.baseUrl?.trim() ?? ""; - const description = config.description?.trim() ?? ""; - return { - urls: config.urls, - ...(config.slug !== undefined ? { slug: config.slug } : {}), - ...(config.name !== undefined ? { name: config.name } : {}), - ...(description.length > 0 ? { description } : {}), - ...(baseUrl.length > 0 ? { baseUrl } : {}), - }; -}; - export default function AddGoogleSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; @@ -308,25 +343,23 @@ export default function AddGoogleSource(props: { ); const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState([]); const [baseUrl, setBaseUrl] = useState(""); - const [descriptionDraft, setDescriptionDraft] = useState(null); const [adding, setAdding] = useState(false); const [retryingPresetId, setRetryingPresetId] = useState(null); const [addError, setAddError] = useState(null); const [servicesResult, setServicesResult] = useState(null); - const [customBundleSlug, setCustomBundleSlug] = useState(null); const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]); const singleSelectedPreset = selectedIds.length === 1 ? googleOpenApiPresetById.get(selectedIds[0]!) : undefined; - const usesCustomBundleFallback = customDiscoveryUrls.length > 0; + const hasCustomDiscoveryUrls = customDiscoveryUrls.length > 0; const identity = useIntegrationIdentity({ - fallbackName: usesCustomBundleFallback + fallbackName: hasCustomDiscoveryUrls ? "Custom Google APIs" : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google")), fallbackNamespace: props.initialNamespace ?? - (usesCustomBundleFallback + (hasCustomDiscoveryUrls ? "google_custom" : singleSelectedPreset ? googleServiceSlug(singleSelectedPreset.id) @@ -357,85 +390,56 @@ export default function AddGoogleSource(props: { }, []); const doAddServices = useAtomSet(addGoogleServices, { mode: "promiseExit" }); - const doAddBundle = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); const resolvedSourceId = slugifyNamespace(identity.namespace) || "google_custom"; const resolvedDisplayName = identity.name.trim() || - (usesCustomBundleFallback + (hasCustomDiscoveryUrls ? "Custom Google APIs" : (singleSelectedPreset?.name ?? (isGooglePhotosPreset ? "Google Photos" : "Google"))); - const resolvedDescription = - descriptionDraft ?? - (usesCustomBundleFallback - ? "Custom Google APIs." - : isGooglePhotosPreset - ? "Google Photos albums, uploads, app-created media, and selected picker media." - : "Google APIs"); - const customSlugAlreadyExists = useSlugAlreadyExists( - usesCustomBundleFallback ? resolvedSourceId : "", - ); const identityOverride = - selectedIds.length === 1 && !usesCustomBundleFallback + selectedIds.length === 1 && !hasCustomDiscoveryUrls ? { slug: resolvedSourceId, name: resolvedDisplayName } : undefined; - const canAdd = - (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && - !customSlugAlreadyExists && - !adding; - - const addCustomDiscoveryBundle = async (): Promise => { - if (customDiscoveryUrls.length === 0) return true; - const exit = await doAddBundle({ - payload: googleBundlePayload({ - urls: [...customDiscoveryUrls], - slug: resolvedSourceId, - name: resolvedDisplayName, - description: resolvedDescription, - baseUrl, - }), - reactivityKeys: integrationWriteKeys, - }); - if (Exit.isFailure(exit)) { - setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google")); - return false; - } - setCustomBundleSlug(String(exit.value.slug)); - return true; - }; + const customService = + customDiscoveryUrls.length > 0 + ? { + urls: [...customDiscoveryUrls], + } + : undefined; + const canAdd = (selectedIds.length > 0 || customDiscoveryUrls.length > 0) && !adding; const handleAdd = async () => { setAdding(true); setAddError(null); setServicesResult(null); - setCustomBundleSlug(null); - if (selectedIds.length > 0) { - const exit = await submitGoogleServicesSelection(doAddServices, { - presetIds: selectedIds, - ...(identityOverride ? { identityOverride } : {}), - baseUrl, - }); - if (Exit.isFailure(exit)) { - setAddError(errorMessageFromExit(exit, "Failed to add Google services")); - setAdding(false); - return; - } - setServicesResult(exit.value); + const exit = await submitGoogleServicesSelection(doAddServices, { + presetIds: selectedIds, + ...(identityOverride ? { identityOverride } : {}), + ...(customService ? { custom: customService } : {}), + baseUrl, + }); + if (Exit.isFailure(exit)) { + setAddError(errorMessageFromExit(exit, "Failed to add Google services")); + setAdding(false); + return; } - await addCustomDiscoveryBundle(); + setServicesResult(exit.value); setAdding(false); }; const handleRetry = async (presetId: string) => { setRetryingPresetId(presetId); setAddError(null); + const retryingCustom = !googleOpenApiPresetById.has(presetId); const retryIdentityOverride = - identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId + !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId ? identityOverride : undefined; const exit = await submitGoogleServicesSelection(doAddServices, { - presetIds: [presetId], + presetIds: retryingCustom ? [] : [presetId], ...(retryIdentityOverride ? { identityOverride: retryIdentityOverride } : {}), + ...(retryingCustom && customService ? { custom: customService } : {}), baseUrl, }); if (Exit.isFailure(exit)) { @@ -449,22 +453,12 @@ export default function AddGoogleSource(props: { setRetryingPresetId(null); }; - const showIdentityDetails = selectedIds.length === 1 || usesCustomBundleFallback; - const detailTitle = usesCustomBundleFallback - ? "Custom Google Discovery URLs" - : (singleSelectedPreset?.name ?? "Google"); - const detailSubtitle = usesCustomBundleFallback - ? selectedIds.length > 0 - ? `${customDiscoveryUrls.length} custom URL${ - customDiscoveryUrls.length === 1 ? "" : "s" - } added through the legacy bundle path. Selected products keep preset names.` - : `${customDiscoveryUrls.length} custom URL${ - customDiscoveryUrls.length === 1 ? "" : "s" - } added through the legacy bundle path.` - : "This product is added as its own integration."; + const showIdentityDetails = selectedIds.length === 1 && !hasCustomDiscoveryUrls; + const detailTitle = singleSelectedPreset?.name ?? "Google"; + const detailSubtitle = "This product is added as its own integration."; const dismiss = () => { - if (servicesResult || customBundleSlug) { + if (servicesResult) { props.onComplete(); return; } @@ -493,9 +487,6 @@ export default function AddGoogleSource(props: { title={detailTitle} subtitle={detailSubtitle} identity={identity} - {...(usesCustomBundleFallback - ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } - : {})} baseUrl={baseUrl} onBaseUrlChange={setBaseUrl} baseUrlLabel="Base URL override (optional)" @@ -506,8 +497,6 @@ export default function AddGoogleSource(props: { )} - {customSlugAlreadyExists && !adding && } - {addError && } {servicesResult && ( @@ -518,11 +507,9 @@ export default function AddGoogleSource(props: { /> )} - {customBundleSlug && } - - - -); - -type MicrosoftGraphAddPayload = MicrosoftGraphConfig & { - readonly presetIds: readonly string[]; -}; - -// The payload for the legacy addGraph call that carries the custom scopes. -// The checked workloads already fan out as their own integrations through -// addWorkloads in the same submit, so the custom bundle must carry only the -// custom scopes (presetIds stays empty): folding the preset ids in again -// would create a second integration containing the same tools. -export const microsoftCustomGraphPayload = (input: { - readonly customScopes: readonly string[]; - readonly slug: string; - readonly name: string; - readonly description: string; - readonly baseUrl: string; -}): MicrosoftGraphAddPayload => { - const baseUrl = input.baseUrl.trim(); - const description = input.description.trim(); - return { - presetIds: [], - customScopes: [...input.customScopes], - slug: input.slug, - name: input.name, - ...(description.length > 0 ? { description } : {}), - ...(baseUrl.length > 0 ? { baseUrl } : {}), - }; -}; - export default function AddMicrosoftSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; @@ -316,20 +294,19 @@ export default function AddMicrosoftSource(props: { const [retryingPresetId, setRetryingPresetId] = useState(null); const [addError, setAddError] = useState(null); const [workloadsResult, setWorkloadsResult] = useState(null); - const [customGraphSlug, setCustomGraphSlug] = useState(null); const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]); const singleSelectedPreset = selectedIds.length === 1 ? microsoftGraphPresetForId(selectedIds[0]!) : undefined; - const usesCustomGraphFallback = customScopes.length > 0; + const hasCustomScopes = customScopes.length > 0; const identity = useIntegrationIdentity({ - fallbackName: usesCustomGraphFallback + fallbackName: hasCustomScopes ? "Custom Microsoft Graph" : (singleSelectedPreset?.name ?? "Microsoft Graph"), fallbackNamespace: props.initialNamespace ?? - (usesCustomGraphFallback + (hasCustomScopes ? "microsoft_graph_custom" : singleSelectedPreset ? microsoftServiceSlug(singleSelectedPreset.id) @@ -358,56 +335,43 @@ export default function AddMicrosoftSource(props: { }, []); const doAddWorkloads = useAtomSet(addMicrosoftWorkloads, { mode: "promiseExit" }); - const doAddGraph = useAtomSet(addMicrosoftGraph, { mode: "promiseExit" }); const resolvedSourceId = slugifyNamespace(identity.namespace) || "microsoft_graph_custom"; const resolvedDisplayName = identity.name.trim() || - (usesCustomGraphFallback + (hasCustomScopes ? "Custom Microsoft Graph" : (singleSelectedPreset?.name ?? "Microsoft Graph")); const resolvedDescription = descriptionDraft ?? - (usesCustomGraphFallback - ? "Custom Microsoft Graph scopes." - : "Selected Microsoft Graph workloads."); + (hasCustomScopes ? "Custom Microsoft Graph scopes." : "Selected Microsoft Graph workloads."); const customGraphSlugAlreadyExists = useSlugAlreadyExists( - usesCustomGraphFallback ? resolvedSourceId : "", + hasCustomScopes ? resolvedSourceId : "", ); const identityOverride = - selectedIds.length === 1 && !usesCustomGraphFallback + selectedIds.length === 1 && !hasCustomScopes ? { slug: resolvedSourceId, name: resolvedDisplayName } : undefined; - const canAdd = selectedIds.length > 0 && !customGraphSlugAlreadyExists && !adding; - - const addCustomGraphScopes = async (): Promise => { - if (customScopes.length === 0) return true; - const exit = await doAddGraph({ - payload: microsoftCustomGraphPayload({ - customScopes, - slug: resolvedSourceId, - name: resolvedDisplayName, - description: resolvedDescription, - baseUrl, - }), - reactivityKeys: integrationWriteKeys, - }); - if (Exit.isFailure(exit)) { - setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Microsoft")); - return false; - } - setCustomGraphSlug(String(exit.value.slug)); - return true; - }; + const customWorkload = + customScopes.length > 0 + ? { + customScopes: [...customScopes], + slug: resolvedSourceId, + name: resolvedDisplayName, + description: resolvedDescription, + } + : undefined; + const canAdd = + (selectedIds.length > 0 || customScopes.length > 0) && !customGraphSlugAlreadyExists && !adding; const handleAdd = async () => { setAdding(true); setAddError(null); setWorkloadsResult(null); - setCustomGraphSlug(null); const exit = await submitMicrosoftWorkloadsSelection(doAddWorkloads, { presetIds: selectedIds, ...(identityOverride ? { identityOverride } : {}), + ...(customWorkload ? { custom: customWorkload } : {}), baseUrl, }); if (Exit.isFailure(exit)) { @@ -416,20 +380,21 @@ export default function AddMicrosoftSource(props: { return; } setWorkloadsResult(exit.value); - await addCustomGraphScopes(); setAdding(false); }; const handleRetry = async (presetId: string) => { setRetryingPresetId(presetId); setAddError(null); + const retryingCustom = presetId === MICROSOFT_CUSTOM_WORKLOAD_ID; const retryIdentityOverride = - identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId + !retryingCustom && identityOverride && selectedIds.length === 1 && selectedIds[0] === presetId ? identityOverride : undefined; const exit = await submitMicrosoftWorkloadsSelection(doAddWorkloads, { - presetIds: [presetId], + presetIds: retryingCustom ? [] : [presetId], ...(retryIdentityOverride ? { identityOverride: retryIdentityOverride } : {}), + ...(retryingCustom && customWorkload ? { custom: customWorkload } : {}), baseUrl, }); if (Exit.isFailure(exit)) { @@ -443,18 +408,18 @@ export default function AddMicrosoftSource(props: { setRetryingPresetId(null); }; - const showIdentityDetails = selectedIds.length === 1 || usesCustomGraphFallback; - const detailTitle = usesCustomGraphFallback + const showIdentityDetails = selectedIds.length === 1 || hasCustomScopes; + const detailTitle = hasCustomScopes ? "Custom Microsoft Graph scopes" : (singleSelectedPreset?.name ?? "Microsoft Graph"); - const detailSubtitle = usesCustomGraphFallback + const detailSubtitle = hasCustomScopes ? `${customScopes.length} custom scope${ customScopes.length === 1 ? "" : "s" - } added through the legacy Graph path.` + } added as its own integration.` : "This workload is added as its own integration."; const dismiss = () => { - if (workloadsResult || customGraphSlug) { + if (workloadsResult) { props.onComplete(); return; } @@ -483,7 +448,7 @@ export default function AddMicrosoftSource(props: { title={detailTitle} subtitle={detailSubtitle} identity={identity} - {...(usesCustomGraphFallback + {...(hasCustomScopes ? { description: resolvedDescription, onDescriptionChange: setDescriptionDraft } : {})} baseUrl={baseUrl} @@ -509,11 +474,9 @@ export default function AddMicrosoftSource(props: { /> )} - {customGraphSlug && } -