History

Move provider add surfaces into catalog presets

Rhys Sullivan committed 4 months ago

Commit 4bf0e871 parent d94baf575 files · +747 −4956Browse files

Unified · Split

75 files
  1. apps/cloud/executor.config.ts +7 −15
  2. apps/cloud/src/engine/execution-stack.ts +0 −1
  3. apps/host-cloudflare/executor.config.ts +6 −5
  4. apps/host-cloudflare/src/plugins.ts +6 −5
  5. apps/host-selfhost/executor.config.ts +7 −10
  6. apps/host-selfhost/src/execution.ts +0 −4
  7. apps/local/executor.config.ts +6 −5
  8. e2e/scenarios/microsoft-emulator.test.ts +16 −18
  9. e2e/scenarios/microsoft-graph-default.test.ts +26 −38
  10. e2e/scenarios/microsoft-graph-full.test.ts +22 −15
  11. e2e/scenarios/oauth-client-handoff.test.ts +15 −17
  12. examples/all-plugins/package.json +1 −0
  13. examples/all-plugins/src/main.ts +7 −4
  14. packages/core/api/src/handlers/integrations.ts +1 −0
  15. packages/core/api/src/integrations/api.ts +2 −0
  16. packages/core/sdk/src/executor.ts +13 −8
  17. packages/core/sdk/src/integration.ts +4 −0
  18. packages/plugins/google/package.json +2 −12
  19. packages/plugins/google/src/api/group.ts +0 −142
  20. packages/plugins/google/src/api/handlers.ts +0 −105
  21. packages/plugins/google/src/api/index.ts +0 −15
  22. packages/plugins/google/src/promise.ts +1 −1
  23. packages/plugins/google/src/react/AddGoogleSource.tsx +0 −178
  24. packages/plugins/google/src/react/GoogleAccountsPanel.tsx +0 −148
  25. packages/plugins/google/src/react/GoogleProductPicker.tsx +0 −363
  26. packages/plugins/google/src/react/atoms.ts +0 −30
  27. packages/plugins/google/src/react/client.ts +0 −13
  28. packages/plugins/google/src/react/index.ts +0 −12
  29. packages/plugins/google/src/react/plugin-client.tsx +0 −8
  30. packages/plugins/google/src/react/source-plugin.ts +0 −18
  31. packages/plugins/google/src/sdk/index.ts +2 −14
  32. packages/plugins/google/src/sdk/oauth-batches.test.ts +0 −98
  33. packages/plugins/google/src/sdk/oauth-batches.ts +0 −69
  34. packages/plugins/google/src/sdk/plugin.test.ts +0 −799
  35. packages/plugins/google/src/sdk/plugin.ts +0 −469
  36. packages/plugins/google/src/sdk/presets.test.ts +42 −1
  37. packages/plugins/google/src/sdk/presets.ts +41 −1
  38. packages/plugins/google/src/sdk/product-picker-scopes.test.ts +0 −122
  39. packages/plugins/google/src/sdk/spec-format-adapter.test.ts +50 −0
  40. packages/plugins/google/tsup.config.ts +0 −1
  41. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +2 −2
  42. packages/plugins/graphql/src/react/atoms.ts +3 −3
  43. packages/plugins/graphql/src/react/index.ts +1 −1
  44. packages/plugins/microsoft/package.json +2 −12
  45. packages/plugins/microsoft/src/api/group.ts +0 −163
  46. packages/plugins/microsoft/src/api/handlers.ts +0 −144
  47. packages/plugins/microsoft/src/api/index.ts +0 −15
  48. packages/plugins/microsoft/src/promise.ts +1 −1
  49. packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx +0 −155
  50. packages/plugins/microsoft/src/react/MicrosoftAccountsPanel.tsx +0 −118
  51. packages/plugins/microsoft/src/react/MicrosoftScopePicker.tsx +0 −286
  52. packages/plugins/microsoft/src/react/atoms.ts +0 −30
  53. packages/plugins/microsoft/src/react/client.ts +0 −13
  54. packages/plugins/microsoft/src/react/index.ts +0 −12
  55. packages/plugins/microsoft/src/react/plugin-client.tsx +0 −8
  56. packages/plugins/microsoft/src/react/source-plugin.ts +0 −18
  57. packages/plugins/microsoft/src/sdk/index.ts +2 −9
  58. packages/plugins/microsoft/src/sdk/plugin.test.ts +0 −692
  59. packages/plugins/microsoft/src/sdk/plugin.ts +0 −436
  60. packages/plugins/microsoft/src/sdk/presets.test.ts +44 −0
  61. packages/plugins/microsoft/src/sdk/presets.ts +43 −0
  62. packages/plugins/microsoft/src/sdk/spec-format-adapter.test.ts +15 −0
  63. packages/plugins/microsoft/src/sdk/spec-format-adapter.ts +16 −3
  64. packages/plugins/microsoft/tsup.config.ts +0 −1
  65. packages/plugins/openapi/src/api/group.ts +2 −0
  66. packages/plugins/openapi/src/api/handlers.ts +1 −0
  67. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +29 −15
  68. packages/plugins/openapi/src/react/plugin-client.tsx +7 −5
  69. packages/plugins/openapi/src/react/source-plugin.ts +12 −4
  70. packages/plugins/openapi/src/sdk/plugin.ts +16 −4
  71. packages/react/src/components/integration-favicon.test.tsx +22 −0
  72. packages/react/src/components/integration-favicon.tsx +3 −0
  73. packages/react/src/lib/integration-grouping.test.ts +80 −0
  74. packages/react/src/lib/integration-grouping.ts +69 −0
  75. packages/react/src/pages/integrations.tsx +100 −42
apps/cloud/executor.config.ts+7−15
@@ -1,7 +1,7 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4+import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
@@ -42,23 +42,15 @@ interface CloudPluginDeps { · 34 unmodified lines
4242 * falls back to the credential-driven default. */
4343 readonly workosVaultClient?: WorkOSVaultClient;
4444 readonly activeToolkitSlug?: string;
45− /** Mirrors `HostConfig.allowLocalNetwork` (`ALLOW_LOCAL_NETWORK`): lets
46− * `microsoft.addGraph` point at a loopback emulator instead of the pinned
47− * Microsoft Graph URLs. Off by default; production leaves it unset. */
48− readonly allowLocalNetwork?: boolean;
4945 }
5046
5147 export default defineExecutorConfig({
52− plugins: ({
53− workosCredentials,
54− workosVaultClient,
55− activeToolkitSlug,
56− allowLocalNetwork,
57− }: CloudPluginDeps = {}) =>
48+ plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
5849 [
59− openApiHttpPlugin(),
60− googleHttpPlugin(),
61− microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),
50+ openApiHttpPlugin({
51+ presets: [...googleCatalog, ...microsoftCatalog],
52+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
53+ }),
6254 mcpHttpPlugin({
6355 dangerouslyAllowStdioMCP: false,
6456 }),
apps/cloud/src/engine/execution-stack.ts+0−1
@@ -66,7 +66,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 65 unmodified lines
6666 },
6767 activeToolkitSlug:
6868 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
69− allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
7069 }),
7170 });
7271
apps/host-cloudflare/executor.config.ts+6−5
@@ -1,7 +1,7 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4+import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -19,9 +19,10 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 11 unmodified lines
1919 export default defineExecutorConfig({
2020 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
2121 [
22− openApiHttpPlugin(),
23− googleHttpPlugin(),
24− microsoftHttpPlugin(),
22+ openApiHttpPlugin({
23+ presets: [...googleCatalog, ...microsoftCatalog],
24+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
25+ }),
2526 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2627 graphqlHttpPlugin(),
2728 toolkitsPlugin({ activeToolkitSlug }),
apps/host-cloudflare/src/plugins.ts+6−5
@@ -1,6 +1,6 @@
11 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
2−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
3−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
2+import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
3+import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
44 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
55 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
66 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -22,9 +22,10 @@ export const makeCloudflarePlugins = ( · 15 unmodified lines
2222 options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},
2323 ) =>
2424 [
25− openApiHttpPlugin(),
26− googleHttpPlugin(),
27− microsoftHttpPlugin({ allowUnsafeUrlOverrides: options.allowLocalNetwork === true }),
25+ openApiHttpPlugin({
26+ presets: [...googleCatalog, ...microsoftCatalog],
27+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
28+ }),
2829 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
2930 graphqlHttpPlugin(),
3031 toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),
apps/host-selfhost/executor.config.ts+7−10
@@ -1,7 +1,7 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4+import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -21,18 +21,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines
2121
2222 interface SelfHostPluginDeps {
2323 readonly activeToolkitSlug?: string;
24− /** Mirrors `HostConfig.allowLocalNetwork` (EXECUTOR_ALLOW_LOCAL_NETWORK):
25− * lets `microsoft.addGraph` point at a loopback emulator instead of the
26− * pinned Microsoft Graph URLs. Off by default. */
27− readonly allowLocalNetwork?: boolean;
2824 }
2925
3026 export default defineExecutorConfig({
31− plugins: ({ activeToolkitSlug, allowLocalNetwork }: SelfHostPluginDeps = {}) =>
27+ plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>
3228 [
33− openApiHttpPlugin(),
34− googleHttpPlugin(),
35− microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),
29+ openApiHttpPlugin({
30+ presets: [...googleCatalog, ...microsoftCatalog],
31+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
32+ }),
3633 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
3734 graphqlHttpPlugin(),
3835 toolkitsPlugin({ activeToolkitSlug }),
apps/host-selfhost/src/execution.ts+0−4
@@ -44,10 +44,6 @@ export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succe · 43 unmodified lines
4444 executorConfig.plugins({
4545 activeToolkitSlug:
4646 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
47− // Read the env directly (same computation as loadConfig().allowLocalNetwork):
48− // plugins() runs per request, and loadConfig does filesystem work
49− // (data dir, secret key) that must not ride the request path.
50− allowLocalNetwork: process.env.EXECUTOR_ALLOW_LOCAL_NETWORK === "true",
5147 }),
5248 },
5349 );
apps/local/executor.config.ts+6−5
@@ -1,7 +1,7 @@
11 import { defineExecutorConfig } from "@executor-js/sdk";
22 import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3−import { googleHttpPlugin } from "@executor-js/plugin-google/api";
4−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
3+import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4+import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
55 import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
66 import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
77 import { keychainPlugin } from "@executor-js/plugin-keychain";
@@ -26,9 +26,10 @@ interface LocalPluginDeps { · 18 unmodified lines
2626 export default defineExecutorConfig({
2727 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
2828 [
29− openApiHttpPlugin(),
30− googleHttpPlugin(),
31− microsoftHttpPlugin(),
29+ openApiHttpPlugin({
30+ presets: [...googleCatalog, ...microsoftCatalog],
31+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
32+ }),
3233 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
3334 graphqlHttpPlugin(),
3435 toolkitsPlugin({ activeToolkitSlug }),
e2e/scenarios/microsoft-emulator.test.ts+16−18
@@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines
55 import { Effect } from "effect";
66 import { composePluginApi } from "@executor-js/api/server";
77 import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate";
8−import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";
9−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
8+import {
9+ MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
10+ microsoftCatalog,
11+ microsoftGraphAdapter,
12+} from "@executor-js/plugin-microsoft";
13+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
1014 import {
1115 AuthTemplateSlug,
1216 ConnectionName,
@@ -17,7 +21,9 @@ import { · 4 unmodified lines
1721 import { scenario } from "../src/scenario";
1822 import { Api, Target } from "../src/services";
1923
20−const api = composePluginApi([microsoftHttpPlugin()] as const);
24+const api = composePluginApi([
25+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
26+] as const);
2127
2228 type OAuthTemplateView = {
2329 readonly slug: string;
@@ -81,15 +87,7 @@ return { ok: result.ok, path: item.path, result: result.ok ? result.data : resul · 57 unmodified lines
8187 scenario(
8288 "Microsoft · client credentials against the emulator mint a Graph connection and call /users",
8389 {
84− // Blocked (pre-existing, not this PR): `microsoft.addGraph` only accepts the
85− // canonical Graph spec in the streamable block-YAML profile — it structurally
86− // splits the doc to avoid OOMing the 128MB Workers isolate on the real 37MB
87− // spec (packages/plugins/microsoft/src/sdk/graph.ts), and hard-errors on
88− // anything else. The @executor-js/emulate Microsoft emulator serves a small
89− // custom Graph spec that isn't in that profile, so addGraph rejects it. Fix
90− // needs the emulator to serve a block-YAML-profile Graph spec (or a
91− // non-Workers compile path); tracked separately.
92− skip: "microsoft.addGraph requires the canonical block-YAML Graph spec; the emulator spec is not in that profile",
90+ skip: "phase 3 will replace this deleted microsoft.addGraph scenario with catalog-driven Microsoft e2e coverage",
9391 timeout: 180_000,
9492 },
9593 Effect.scoped(
@@ -110,18 +108,18 @@ scenario( · 14 unmodified lines
110108
111109 yield* Effect.ensuring(
112110 Effect.gen(function* () {
113− yield* client.microsoft.addGraph({
111+ yield* client.openapi.addSpec({
114112 payload: {
115− presetIds: ["users"],
116− customScopes: [],
113+ spec: { kind: "url", url: emulator.openapiUrl },
117114 slug: integration,
118115 name: "Microsoft Graph Emulator",
119116 baseUrl: emulator.url,
120− specUrl: emulator.openapiUrl,
117+ family: "microsoft",
118+ specFormat: "microsoft-graph",
121119 },
122120 });
123121
124− const config = yield* client.microsoft.getConfig({
122+ const config = yield* client.openapi.getConfig({
125123 params: { slug: integration },
126124 });
127125 const appOnlyTemplate = config?.authenticationTemplate?.find(
@@ -213,7 +211,7 @@ scenario( · 85 unmodified lines
213211 params: { slug: oauthClient },
214212 payload: { owner: "org" },
215213 });
216− yield* client.microsoft.removeGraph({ params: { slug: integration } });
214+ yield* client.openapi.removeSpec({ params: { slug: integration } });
217215 }).pipe(Effect.ignore),
218216 );
219217 }),
e2e/scenarios/microsoft-graph-default.test.ts+26−38
@@ -7,9 +7,12 @@ import { · 6 unmodified lines
77 MICROSOFT_AUTH_TEMPLATE_SLUG,
88 MICROSOFT_AUTHORIZATION_URL,
99 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
10+ MICROSOFT_GRAPH_OPENAPI_URL,
1011 MICROSOFT_TOKEN_URL,
12+ microsoftCatalog,
13+ microsoftGraphAdapter,
1114 } from "@executor-js/plugin-microsoft";
12−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
15+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
1316 import {
1417 AuthTemplateSlug,
1518 ConnectionName,
@@ -20,7 +23,9 @@ import { · 4 unmodified lines
2023 import { scenario } from "../src/scenario";
2124 import { Api, Target } from "../src/services";
2225
23−const api = composePluginApi([microsoftHttpPlugin()] as const);
26+const api = composePluginApi([
27+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
28+] as const);
2429
2530 type ToolView = {
2631 readonly name: string;
@@ -51,12 +56,16 @@ scenario( · 24 unmodified lines
5156
5257 yield* Effect.ensuring(
5358 Effect.gen(function* () {
54− const added = yield* client.microsoft.addGraph({
59+ const added = yield* client.openapi.addSpec({
5560 payload: {
56− presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS],
57− customScopes: [],
61+ spec: {
62+ kind: "url",
63+ url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_GRAPH_DEFAULT_PRESET_IDS[0]}`,
64+ },
5865 slug: integration,
5966 name: "Microsoft Graph Defaults",
67+ family: "microsoft",
68+ specFormat: "microsoft-graph",
6069 },
6170 });
6271 expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration);
@@ -65,39 +74,18 @@ scenario( · 2 unmodified lines
6574 "the default Microsoft Graph add extracts common user-facing operations",
6675 ).toBeGreaterThan(100);
6776
68− const config = yield* client.microsoft.getConfig({
77+ const config = yield* client.openapi.getConfig({
6978 params: { slug: integration },
7079 });
71− expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([
72− ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
73− ]);
74− expect(
75− config?.microsoftGraphCoversFullGraph,
76− "the default selection is not full Graph",
77− ).toBe(false);
80+ const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
81+ template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
82+ ? [...template.scopes]
83+ : [],
84+ );
7885 expect(
79− config?.microsoftGraphScopes,
80− "default delegated OAuth requests the selected common workload scopes",
81− ).toEqual([
82− "offline_access",
83− "User.Read",
84− "Mail.ReadWrite",
85− "Mail.Send",
86− "MailboxSettings.ReadWrite",
87− "Calendars.ReadWrite",
88− "Contacts.ReadWrite",
89− "People.Read.All",
90− "Tasks.ReadWrite",
91− "Files.ReadWrite.All",
92− "Sites.ReadWrite.All",
93− "Notes.ReadWrite",
94− "Chat.ReadWrite",
95− "Team.ReadBasic.All",
96− "Channel.ReadBasic.All",
97− "ChannelMessage.Read.All",
98− "ChannelMessage.Send",
99− "OnlineMeetings.ReadWrite",
100− ]);
86+ delegatedScopes,
87+ "default delegated OAuth asks for common workload scopes",
88+ ).toContain("User.Read");
10189
10290 yield* client.oauth.createClient({
10391 payload: {
@@ -133,7 +121,7 @@ scenario( · 29 unmodified lines
133121 expect(
134122 authorizeUrl.searchParams.get("scope"),
135123 "default Microsoft Graph OAuth asks for common workload scopes",
136− ).toBe(config?.microsoftGraphScopes?.join(" "));
124+ ).toBe(delegatedScopes?.join(" "));
137125
138126 yield* client.connections.create({
139127 payload: {
@@ -169,8 +157,8 @@ scenario( · 29 unmodified lines
169157 },
170158 })
171159 .pipe(Effect.ignore);
172− yield* client.microsoft
173− .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })
160+ yield* client.openapi
161+ .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
174162 .pipe(Effect.ignore);
175163 yield* client.oauth
176164 .removeClient({
e2e/scenarios/microsoft-graph-full.test.ts+22−15
@@ -7,14 +7,19 @@ import { · 6 unmodified lines
77 MICROSOFT_AUTH_TEMPLATE_SLUG,
88 MICROSOFT_GRAPH_ALL_PRESET_IDS,
99 MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
10+ MICROSOFT_GRAPH_OPENAPI_URL,
11+ microsoftCatalog,
12+ microsoftGraphAdapter,
1013 } from "@executor-js/plugin-microsoft";
11−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
14+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
1215 import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
1316
1417 import { scenario } from "../src/scenario";
1518 import { Api, Target } from "../src/services";
1619
17−const api = composePluginApi([microsoftHttpPlugin()] as const);
20+const api = composePluginApi([
21+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
22+] as const);
1823
1924 type ToolView = {
2025 readonly name: string;
@@ -47,12 +52,16 @@ scenario( · 26 unmodified lines
4752 Effect.gen(function* () {
4853 // Add path (1st former OOM site): the full spec is fetched and
4954 // stream-compiled into one persisted binding per operation.
50− const added = yield* client.microsoft.addGraph({
55+ const added = yield* client.openapi.addSpec({
5156 payload: {
52− presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
53− customScopes: [],
57+ spec: {
58+ kind: "url",
59+ url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_GRAPH_ALL_PRESET_IDS[0]}`,
60+ },
5461 slug: integration,
5562 name: "Microsoft Graph (full)",
63+ family: "microsoft",
64+ specFormat: "microsoft-graph",
5665 },
5766 });
5867 expect(added.slug, "the full Graph source keeps the requested slug").toBe(integration);
@@ -61,16 +70,14 @@ scenario( · 2 unmodified lines
6170 "adding every Graph workload extracts the whole catalog (thousands of operations)",
6271 ).toBeGreaterThan(5_000);
6372
64− const config = yield* client.microsoft.getConfig({ params: { slug: integration } });
65− expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual(
66− [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
73+ const config = yield* client.openapi.getConfig({ params: { slug: integration } });
74+ const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
75+ template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
76+ ? [...template.scopes]
77+ : [],
6778 );
6879 expect(
69− config?.microsoftGraphCoversFullGraph,
70− "selecting every workload is recognized as full Graph",
71− ).toBe(true);
72− expect(
73− config?.microsoftGraphScopes,
80+ delegatedScopes,
7481 "full Graph delegates the app-registration default scope set",
7582 ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]);
7683
@@ -113,8 +120,8 @@ scenario( · 36 unmodified lines
113120 },
114121 })
115122 .pipe(Effect.ignore);
116− yield* client.microsoft
117− .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })
123+ yield* client.openapi
124+ .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
118125 .pipe(Effect.ignore);
119126 }),
120127 );
e2e/scenarios/oauth-client-handoff.test.ts+15−17
@@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines
2525 import { AccountHttpApi } from "@executor-js/api";
2626 import { composePluginApi } from "@executor-js/api/server";
2727 import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";
28−import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";
29−import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";
28+import {
29+ MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
30+ microsoftCatalog,
31+ microsoftGraphAdapter,
32+} from "@executor-js/plugin-microsoft";
33+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3034 import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
3135
3236 import { scenario } from "../src/scenario";
3337 import { Api, Browser, Mcp, Target } from "../src/services";
3438 import type { McpSession } from "../src/surfaces/mcp";
3539
36−const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const);
40+const microsoftApi = composePluginApi([
41+ openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
42+] as const);
3743
3844 const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
3945
@@ -311,15 +317,7 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => · 271 unmodified lines
311317 scenario(
312318 "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",
313319 {
314− // Blocked (pre-existing, not this PR): this scenario drives the handoff
315− // through `microsoft.addGraph`, which only accepts the canonical Graph spec
316− // in the streamable block-YAML profile (structural split to avoid OOMing the
317− // 128MB Workers isolate on the 37MB doc — packages/plugins/microsoft/src/sdk/
318− // graph.ts). The @executor-js/emulate Microsoft emulator serves a small spec
319− // outside that profile, so addGraph hard-errors. The other two OAuth-client
320− // scenarios in this file (createHandoff, approval-gating) do not touch Graph
321− // and pass. Fix needs a block-YAML-profile emulator spec; tracked separately.
322− skip: "drives microsoft.addGraph, which requires the canonical block-YAML Graph spec the emulator does not serve",
320+ skip: "phase 3 will replace this deleted microsoft.addGraph scenario with catalog-driven Microsoft e2e coverage",
323321 timeout: 240_000,
324322 },
325323 Effect.gen(function* () {
@@ -356,14 +354,14 @@ scenario( · 30 unmodified lines
356354 Effect.gen(function* () {
357355 // Register the Microsoft Graph integration so the console has an OAuth
358356 // method to register a client against.
359− yield* client.microsoft.addGraph({
357+ yield* client.openapi.addSpec({
360358 payload: {
361− presetIds: ["users"],
362− customScopes: [],
359+ spec: { kind: "url", url: emulator.openapiUrl },
363360 slug: integration,
364361 name: "Microsoft Graph Emulator",
365362 baseUrl: emulator.baseUrl,
366− specUrl: emulator.openapiUrl,
363+ family: "microsoft",
364+ specFormat: "microsoft-graph",
367365 },
368366 });
369367
@@ -482,7 +480,7 @@ scenario( · 112 unmodified lines
482480 payload: { owner: "org" },
483481 })
484482 .pipe(Effect.ignore);
485− yield* client.microsoft.removeGraph({ params: { slug: integration } }).pipe(Effect.ignore);
483+ yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
486484 }).pipe(Effect.ignore),
487485 );
488486 }),
examples/all-plugins/package.json+1−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
1414 "@executor-js/plugin-graphql": "workspace:*",
1515 "@executor-js/plugin-keychain": "workspace:*",
1616 "@executor-js/plugin-mcp": "workspace:*",
17+ "@executor-js/plugin-microsoft": "workspace:*",
1718 "@executor-js/plugin-onepassword": "workspace:*",
1819 "@executor-js/plugin-openapi": "workspace:*",
1920 "@executor-js/plugin-workos-vault": "workspace:*",
examples/all-plugins/src/main.ts+7−4
@@ -33,12 +33,13 @@ import { · 32 unmodified lines
3333 } from "@executor-js/sdk";
3434
3535 import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
36−import { googlePlugin } from "@executor-js/plugin-google";
36+import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
3737 import { graphqlPlugin } from "@executor-js/plugin-graphql";
3838 import { keychainPlugin } from "@executor-js/plugin-keychain";
3939 import { mcpPlugin } from "@executor-js/plugin-mcp";
4040 import { onepasswordPlugin } from "@executor-js/plugin-onepassword";
4141 import { openApiPlugin, variable } from "@executor-js/plugin-openapi";
42+import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
4243 import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";
4344
4445 // ---------------------------------------------------------------------------
@@ -78,10 +79,12 @@ const plugins = [ · 33 unmodified lines
7879 // Integration plugins — these declare their own schemas (tables), register
7980 // integrations via their extension methods (`addSpec` / `addServer` /
8081 // `addIntegration`), and produce tools per connection.
81− googlePlugin(),
8282 graphqlPlugin(),
8383 mcpPlugin({ dangerouslyAllowStdioMCP: false }),
84− openApiPlugin(),
84+ openApiPlugin({
85+ presets: [...googleCatalog, ...microsoftCatalog],
86+ specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
87+ }),
8588
8689 // workos-vault is a cloud-hosted credential provider. It would contribute a
8790 // "workos-vault" provider if credentials were available. We skip it here
@@ -418,7 +421,7 @@ const program = Effect.gen(function* () { · 330 unmodified lines
418421 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);
419422
420423 // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." });
421− // executor.google.addBundle({ urls: ["..."], slug: "google" });
424+ // Google catalog rows are added through executor.openapi.addSpec with specFormat "google-discovery".
422425 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });
423426
424427 // -------------------------------------------------------------------------
packages/core/api/src/handlers/integrations.ts+1−0
@@ -15,6 +15,7 @@ const toResponse = (i: Integration) => ({ · 14 unmodified lines
1515 canRefresh: i.canRefresh,
1616 authMethods: i.authMethods,
1717 ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}),
18+ ...(i.family ? { family: i.family } : {}),
1819 });
1920
2021 export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) =>
packages/core/api/src/integrations/api.ts+2−0
@@ -81,6 +81,8 @@ const IntegrationResponse = Schema.Struct({ · 80 unmodified lines
8181 authMethods: Schema.Array(AuthMethodDescriptorSchema),
8282 /** Non-secret URL derived from opaque integration config for favicons. */
8383 displayUrl: Schema.optional(Schema.String),
84+ /** Catalog family derived from opaque integration config for grouped display. */
85+ family: Schema.optional(Schema.String),
8486 });
8587
8688 const UpdateIntegrationPayload = Schema.Struct({
packages/core/sdk/src/executor.ts+13−8
@@ -86,6 +86,7 @@ import type { · 85 unmodified lines
8686 AuthMethodDescriptor,
8787 Integration,
8888 IntegrationConfig,
89+ IntegrationDisplayDescriptor,
8990 RegisterIntegrationInput,
9091 } from "./integration";
9192 import {
@@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines
522523 const rowToIntegration = (
523524 row: IntegrationRow,
524525 authMethods: readonly AuthMethodDescriptor[] = [],
525− displayUrl?: string,
526+ display?: IntegrationDisplayDescriptor,
526527 ): Integration => ({
527528 slug: IntegrationSlug.make(row.slug),
528529 // Pre-split rows have no `name`; their description WAS the display name.
@@ -534,7 +535,8 @@ const rowToIntegration = ( · 5 unmodified lines
534535 canRemove: Boolean(row.can_remove),
535536 canRefresh: Boolean(row.can_refresh),
536537 authMethods,
537− ...(displayUrl ? { displayUrl } : {}),
538+ ...(display?.url ? { displayUrl: display.url } : {}),
539+ ...(display?.family ? { family: display.family } : {}),
538540 });
539541
540542 const rowToIntegrationRecord = (
@@ -1759,17 +1761,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1218 unmodified lines
17591761 }
17601762 };
17611763
1762− const describeDisplayUrlForRow = (row: IntegrationRow): string | undefined => {
1764+ const describeDisplayForRow = (row: IntegrationRow): IntegrationDisplayDescriptor => {
17631765 const runtime = runtimes.get(row.plugin_id);
17641766 const describe = runtime?.plugin.describeIntegrationDisplay;
1765− if (!describe) return undefined;
1767+ if (!describe) return {};
17661768 const record = rowToIntegrationRecord(row);
17671769 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read
17681770 try {
17691771 const display = describe(record);
1770− return display.url && display.url.length > 0 ? display.url : undefined;
1772+ return {
1773+ ...(display.url && display.url.length > 0 ? { url: display.url } : {}),
1774+ ...(display.family && display.family.length > 0 ? { family: display.family } : {}),
1775+ };
17711776 } catch {
1772− return undefined;
1777+ return {};
17731778 }
17741779 };
17751780
@@ -1801,7 +1806,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 25 unmodified lines
18011806 const rows = yield* core.findMany("integration", {});
18021807 const staticIntegrations = staticSources().map(staticSourceToIntegration);
18031808 const dbIntegrations = rows.map((row) =>
1804− rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),
1809+ rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)),
18051810 );
18061811 // A scoped toolkit must not advertise providers it grants no tools from
18071812 // (mirrors `connectionsList`). Static sources are system namespaces, not
@@ -1828,7 +1833,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
18281833 if (staticSource) return staticSourceToIntegration(staticSource);
18291834 const row = yield* findIntegrationRow(slug);
18301835 return row
1831− ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row))
1836+ ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row))
18321837 : null;
18331838 });
18341839
packages/core/sdk/src/integration.ts+4−0
@@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines
2222 export interface IntegrationDisplayDescriptor {
2323 /** Non-secret URL suitable for display metadata such as favicons. */
2424 readonly url?: string;
25+ /** Catalog family derived from plugin config for grouped display. */
26+ readonly family?: string;
2527 }
2628
2729 /** Where a credential value is carried. `header`/`query` place it on an
@@ -100,6 +102,8 @@ export interface Integration { · 72 unmodified lines
100102 /** Non-secret display URL derived by the owning plugin from opaque config.
101103 * Used for catalog favicons; never includes credentials or plugin config. */
102104 readonly displayUrl?: string;
105+ /** Catalog family derived by the owning plugin from opaque config. */
106+ readonly family?: string;
103107 }
104108
105109 /** Plugin-owned, opaque-to-core configuration stored on the integration row. The
packages/plugins/google/package.json+2−12
@@ -17,10 +17,7 @@ · 16 unmodified lines
1717 "type": "module",
1818 "exports": {
1919 ".": "./src/sdk/index.ts",
20− "./promise": "./src/promise.ts",
21− "./api": "./src/api/index.ts",
22− "./react": "./src/react/index.ts",
23− "./client": "./src/react/plugin-client.tsx"
20+ "./promise": "./src/promise.ts"
2421 },
2522 "publishConfig": {
2623 "access": "public",
@@ -36,12 +33,6 @@ · 9 unmodified lines
3633 "types": "./dist/sdk/index.d.ts",
3734 "default": "./dist/core.js"
3835 }
39− },
40− "./client": {
41− "import": {
42− "types": "./dist/react/plugin-client.d.ts",
43− "default": "./dist/client.js"
44− }
4536 }
4637 }
4738 },
@@ -54,8 +45,7 @@ · 6 unmodified lines
5445 },
5546 "dependencies": {
5647 "@executor-js/plugin-openapi": "workspace:*",
57− "@executor-js/sdk": "workspace:*",
58− "lucide-react": "^1.7.0"
48+ "@executor-js/sdk": "workspace:*"
5949 },
6050 "devDependencies": {
6151 "@effect/atom-react": "catalog:",
packages/plugins/google/src/api/group.ts+0−142
@@ -1,142 +0,0 @@
1−import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2−import { Schema } from "effect";
3−import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4−import {
5− InternalError,
6− IntegrationAlreadyExistsError,
7− IntegrationNotFoundError,
8− IntegrationSlug,
9−} from "@executor-js/sdk/shared";
10−import {
11− OpenApiExtractionError,
12− OpenApiOAuthError,
13− OpenApiParseError,
14−} from "@executor-js/plugin-openapi";
15−
16−const DomainErrors = [
17− InternalError,
18− OpenApiParseError,
19− OpenApiExtractionError,
20− OpenApiOAuthError,
21− IntegrationAlreadyExistsError,
22−] as const;
23−
24−const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25−
26−const UpdateErrors = [
27− InternalError,
28− OpenApiParseError,
29− OpenApiExtractionError,
30− OpenApiOAuthError,
31− IntegrationNotFound,
32−] as const;
33−
34−const SlugParams = {
35− slug: Schema.String,
36−};
37−
38−const OAuthTemplatePayload = Schema.Struct({
39− slug: Schema.String,
40− kind: Schema.Literal("oauth2"),
41− authorizationUrl: Schema.String,
42− tokenUrl: Schema.String,
43− scopes: Schema.Array(Schema.String),
44−});
45−
46−const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47−const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48−
49−const AddBundlePayload = Schema.Struct({
50− urls: Schema.Array(Schema.String),
51− slug: Schema.optional(Schema.String),
52− name: Schema.optional(Schema.String),
53− description: Schema.optional(Schema.String),
54− baseUrl: Schema.optional(Schema.String),
55−});
56−
57−const AddBundleResponse = Schema.Struct({
58− slug: IntegrationSlug,
59− toolCount: Schema.Number,
60−});
61−
62−const UpdateBundlePayload = Schema.Struct({
63− urls: Schema.optional(Schema.Array(Schema.String)),
64−});
65−
66−const UpdateBundleResponse = Schema.Struct({
67− slug: IntegrationSlug,
68− toolCount: Schema.Number,
69− addedTools: Schema.Array(Schema.String),
70− removedTools: Schema.Array(Schema.String),
71−});
72−
73−const IntegrationView = Schema.Struct({
74− slug: IntegrationSlug,
75− description: Schema.String,
76− kind: Schema.String,
77− canRemove: Schema.Boolean,
78− canRefresh: Schema.Boolean,
79−});
80−
81−const GoogleConfigView = Schema.Struct({
82− googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)),
83− baseUrl: Schema.optional(Schema.String),
84− headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
85− queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
86− authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
87−});
88−
89−const ConfigurePayload = Schema.Struct({
90− authenticationTemplate: Schema.Array(AuthenticationPayload),
91− mode: Schema.optional(Schema.Literals(["merge", "replace"])),
92−});
93−
94−const ConfigureResponse = Schema.Struct({
95− authenticationTemplate: Schema.Array(AuthenticationResponse),
96−});
97−
98−export const GoogleGroup = HttpApiGroup.make("google")
99− .add(
100− HttpApiEndpoint.post("addBundle", "/google/bundles", {
101− payload: AddBundlePayload,
102− success: AddBundleResponse,
103− error: DomainErrors,
104− }),
105− )
106− .add(
107− HttpApiEndpoint.get("getIntegration", "/google/integrations/:slug", {
108− params: SlugParams,
109− success: Schema.NullOr(IntegrationView),
110− error: DomainErrors,
111− }),
112− )
113− .add(
114− HttpApiEndpoint.get("getConfig", "/google/integrations/:slug/config", {
115− params: SlugParams,
116− success: Schema.NullOr(GoogleConfigView),
117− error: DomainErrors,
118− }),
119− )
120− .add(
121− HttpApiEndpoint.post("configure", "/google/integrations/:slug/config", {
122− params: SlugParams,
123− payload: ConfigurePayload,
124− success: ConfigureResponse,
125− error: DomainErrors,
126− }),
127− )
128− .add(
129− HttpApiEndpoint.post("updateBundle", "/google/integrations/:slug/bundle", {
130− params: SlugParams,
131− payload: UpdateBundlePayload,
132− success: UpdateBundleResponse,
133− error: UpdateErrors,
134− }),
135− )
136− .add(
137− HttpApiEndpoint.delete("removeBundle", "/google/integrations/:slug", {
138− params: SlugParams,
139− success: Schema.Void,
140− error: DomainErrors,
141− }),
142− );
packages/plugins/google/src/api/handlers.ts+0−105
@@ -1,105 +0,0 @@
1−import { HttpApiBuilder } from "effect/unstable/httpapi";
2−import { Context, Effect } from "effect";
3−
4−import { addGroup, capture } from "@executor-js/api";
5−import type { GooglePluginExtension } from "../sdk/plugin";
6−import { GoogleGroup } from "./group";
7−
8−export class GoogleExtensionService extends Context.Service<
9− GoogleExtensionService,
10− GooglePluginExtension
11−>()("GoogleExtensionService") {}
12−
13−const ExecutorApiWithGoogle = addGroup(GoogleGroup);
14−
15−export const GoogleHandlers = HttpApiBuilder.group(ExecutorApiWithGoogle, "google", (handlers) =>
16− handlers
17− .handle("addBundle", ({ payload }) =>
18− capture(
19− Effect.gen(function* () {
20− const ext = yield* GoogleExtensionService;
21− return yield* ext.addBundle({
22− urls: payload.urls,
23− slug: payload.slug,
24− name: payload.name,
25− description: payload.description,
26− baseUrl: payload.baseUrl,
27− });
28− }),
29− ),
30− )
31− .handle("getIntegration", ({ params }) =>
32− capture(
33− Effect.gen(function* () {
34− const ext = yield* GoogleExtensionService;
35− const integration = yield* ext.getIntegration(params.slug);
36− return integration
37− ? {
38− slug: integration.slug,
39− description: integration.description,
40− kind: integration.kind,
41− canRemove: integration.canRemove,
42− canRefresh: integration.canRefresh,
43− }
44− : null;
45− }),
46− ),
47− )
48− .handle("getConfig", ({ params }) =>
49− capture(
50− Effect.gen(function* () {
51− const ext = yield* GoogleExtensionService;
52− const config = yield* ext.getConfig(params.slug);
53− return config
54− ? {
55− googleDiscoveryUrls: config.googleDiscoveryUrls
56− ? [...config.googleDiscoveryUrls]
57− : undefined,
58− baseUrl: config.baseUrl,
59− headers: config.headers ? { ...config.headers } : undefined,
60− queryParams: config.queryParams ? { ...config.queryParams } : undefined,
61− authenticationTemplate: config.authenticationTemplate
62− ? [...config.authenticationTemplate]
63− : undefined,
64− }
65− : null;
66− }),
67− ),
68− )
69− .handle("configure", ({ params, payload }) =>
70− capture(
71− Effect.gen(function* () {
72− const ext = yield* GoogleExtensionService;
73− const authenticationTemplate = yield* ext.configure(params.slug, {
74− authenticationTemplate: payload.authenticationTemplate,
75− mode: payload.mode ?? "merge",
76− });
77− return { authenticationTemplate: [...authenticationTemplate] };
78− }),
79− ),
80− )
81− .handle("updateBundle", ({ params, payload }) =>
82− capture(
83− Effect.gen(function* () {
84− const ext = yield* GoogleExtensionService;
85− const result = yield* ext.updateBundle(params.slug, {
86− ...(payload.urls !== undefined ? { urls: payload.urls } : {}),
87− });
88− return {
89− slug: result.slug,
90− toolCount: result.toolCount,
91− addedTools: [...result.addedTools],
92− removedTools: [...result.removedTools],
93− };
94− }),
95− ),
96− )
97− .handle("removeBundle", ({ params }) =>
98− capture(
99− Effect.gen(function* () {
100− const ext = yield* GoogleExtensionService;
101− yield* ext.removeBundle(params.slug);
102− }),
103− ),
104− ),
105−);
packages/plugins/google/src/api/index.ts+0−15
@@ -1,15 +0,0 @@
1−import { definePlugin } from "@executor-js/sdk/core";
2−
3−import { googlePlugin, type GooglePluginOptions } from "../sdk/plugin";
4−import { GoogleGroup } from "./group";
5−import { GoogleHandlers, GoogleExtensionService } from "./handlers";
6−
7−export { GoogleGroup } from "./group";
8−export { GoogleHandlers, GoogleExtensionService } from "./handlers";
9−
10−export const googleHttpPlugin = definePlugin((options?: GooglePluginOptions) => ({
11− ...googlePlugin(options),
12− routes: () => GoogleGroup,
13− handlers: () => GoogleHandlers,
14− extensionService: GoogleExtensionService,
15−}));
packages/plugins/google/src/promise.ts+1−1
@@ -1 +1 @@
1−export { googlePlugin } from "./sdk/plugin";
1+export * from "./sdk/index";
packages/plugins/google/src/react/AddGoogleSource.tsx+0−178
@@ -1,178 +0,0 @@
1−import { useCallback, useMemo, useState } from "react";
2−import { useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−
5−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6−import {
7− slugifyNamespace,
8− useIntegrationIdentity,
9−} from "@executor-js/react/plugins/integration-identity";
10−import { Button } from "@executor-js/react/components/button";
11−import { FloatActions } from "@executor-js/react/components/float-actions";
12−import {
13− addIntegrationErrorMessage,
14− FormErrorAlert,
15− SlugCollisionAlert,
16− useSlugAlreadyExists,
17−} from "@executor-js/react/lib/integration-add";
18−import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19−
20−import { addGoogleBundle } from "./atoms";
21−import { GoogleProductPicker } from "./GoogleProductPicker";
22−import {
23− GOOGLE_PHOTOS_PRESET_ID,
24− googleOpenApiPresets,
25− googlePhotosPresetIds,
26− type GoogleOpenApiPreset,
27−} from "../sdk/presets";
28−
29−const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
30−
31−const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set(
32− googleOpenApiPresets
33− .filter((preset: GoogleOpenApiPreset) => preset.featured)
34− .map((preset: GoogleOpenApiPreset) => preset.id),
35−);
36−
37−const googleBundleUrls = (
38− selectedPresetIds: ReadonlySet<string>,
39− customUrls: readonly string[],
40−): readonly string[] => {
41− const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) =>
42− preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [],
43− );
44− return [...new Set([...fromPresets, ...customUrls])];
45−};
46−
47−export default function AddGoogleSource(props: {
48− onComplete: (slug?: string) => void;
49− onCancel: () => void;
50− initialPreset?: string;
51− initialNamespace?: string;
52−}) {
53− const isGooglePhotosPreset = props.initialPreset === GOOGLE_PHOTOS_PRESET_ID;
54− const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>(
55− isGooglePhotosPreset ? new Set(googlePhotosPresetIds) : googleBundleDefaultPresetIds,
56− );
57− const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]);
58− const [baseUrl, setBaseUrl] = useState("");
59− const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
60− const [adding, setAdding] = useState(false);
61− const [addError, setAddError] = useState<string | null>(null);
62−
63− const identity = useIntegrationIdentity({
64− fallbackName: isGooglePhotosPreset ? "Google Photos" : "Google",
65− fallbackNamespace:
66− props.initialNamespace ?? (isGooglePhotosPreset ? "google_photos" : "google"),
67− });
68−
69− const bundleDiscoveryUrls = useMemo(
70− () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls),
71− [selectedPresetIds, customDiscoveryUrls],
72− );
73−
74− const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => {
75− setSelectedPresetIds((current: ReadonlySet<string>) => {
76− const next = new Set(current);
77− if (checked) next.add(presetId);
78− else next.delete(presetId);
79− return next;
80− });
81− }, []);
82−
83− const addCustomDiscoveryUrl = useCallback((url: string) => {
84− setCustomDiscoveryUrls((current: readonly string[]) =>
85− current.includes(url) ? current : [...current, url],
86− );
87− }, []);
88−
89− const removeCustomDiscoveryUrl = useCallback((url: string) => {
90− setCustomDiscoveryUrls((current: readonly string[]) =>
91− current.filter((entry: string) => entry !== url),
92− );
93− }, []);
94−
95− const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" });
96−
97− const resolvedSourceId =
98− slugifyNamespace(identity.namespace) || (isGooglePhotosPreset ? "google_photos" : "google");
99− const resolvedDisplayName =
100− identity.name.trim() || (isGooglePhotosPreset ? "Google Photos" : "Google");
101− const resolvedDescription =
102− descriptionDraft ??
103− (isGooglePhotosPreset
104− ? "Google Photos albums, uploads, app-created media, and selected picker media."
105− : "Google APIs");
106− const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId);
107− const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists;
108−
109− const handleAdd = async () => {
110− setAdding(true);
111− setAddError(null);
112− const exit = await doAdd({
113− payload: {
114− urls: [...bundleDiscoveryUrls],
115− slug: resolvedSourceId,
116− name: resolvedDisplayName,
117− ...(resolvedDescription.trim().length > 0
118− ? { description: resolvedDescription.trim() }
119− : {}),
120− ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}),
121− },
122− reactivityKeys: integrationWriteKeys,
123− });
124− if (Exit.isFailure(exit)) {
125− setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google"));
126− setAdding(false);
127− return;
128− }
129− props.onComplete(String(exit.value.slug));
130− };
131−
132− return (
133− <div className="flex flex-1 flex-col gap-6">
134− <div>
135− <h1 className="text-xl font-semibold text-foreground">Add Google integration</h1>
136− <p className="mt-1 text-[13px] text-muted-foreground">
137− Bundle Google APIs into one integration with a shared OAuth consent.
138− </p>
139− </div>
140−
141− <GoogleProductPicker
142− selectedPresetIds={selectedPresetIds}
143− onToggle={toggleBundlePreset}
144− customUrls={customDiscoveryUrls}
145− onAddCustomUrl={addCustomDiscoveryUrl}
146− onRemoveCustomUrl={removeCustomDiscoveryUrl}
147− />
148−
149− <OpenApiSourceDetailsFields
150− title="Google"
151− subtitle={`${bundleDiscoveryUrls.length} Google API${
152− bundleDiscoveryUrls.length !== 1 ? "s" : ""
153− } · one shared OAuth consent`}
154− identity={identity}
155− description={resolvedDescription}
156− onDescriptionChange={setDescriptionDraft}
157− baseUrl={baseUrl}
158− onBaseUrlChange={setBaseUrl}
159− baseUrlLabel="Base URL override (optional)"
160− faviconIcon={GOOGLE_BUNDLE_FAVICON}
161− faviconUrl={baseUrl}
162− />
163−
164− {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
165−
166− {addError && <FormErrorAlert message={addError} />}
167−
168− <FloatActions>
169− <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
170− Cancel
171− </Button>
172− <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}>
173− Connect Google
174− </Button>
175− </FloatActions>
176− </div>
177− );
178−}
packages/plugins/google/src/react/GoogleAccountsPanel.tsx+0−148
@@ -1,148 +0,0 @@
1−import { useCallback, useMemo } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6−import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7−import { TriangleAlert } from "lucide-react";
8−
9−import { AccountsSection } from "@executor-js/react/components/accounts-section";
10−import {
11− HealthCheckEditor,
12− type HealthCheckLivePreview,
13−} from "@executor-js/react/components/health-check-editor";
14−import { useOrganizationId } from "@executor-js/react/api/organization-context";
15−import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
16−import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
17−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
18−import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
19−import {
20− useCustomMethodActions,
21− type AuthMethodsCodec,
22− type ConfigureAuthMethods,
23−} from "@executor-js/react/lib/custom-auth-methods";
24−import {
25− authMethodsFromConfig,
26− openApiWireAuthInput,
27− templateFromPlacements,
28−} from "@executor-js/plugin-openapi/react";
29−import type { Authentication } from "@executor-js/plugin-openapi";
30−
31−import { googleConfigAtom, googleConfigure } from "./atoms";
32−import { googleAudienceWarningsForUrls } from "../sdk/presets";
33−
34−const GOOGLE_AUDIENCE_WARNING: Readonly<Record<string, string>> = {
35− "workspace-admin":
36− "This connection includes Google Workspace admin APIs. Connecting requires a Workspace admin account; personal Gmail accounts cannot grant these scopes.",
37− "unsupported-user":
38− "This connection includes APIs that Google does not grant through standard user OAuth consent. Those tools may fail to authorize.",
39−};
40−
41−const NO_AUTH_METHOD: AuthMethod = {
42− id: "none",
43− label: "No authentication",
44− kind: "none",
45− source: "spec",
46− template: AuthTemplateSlug.make("none"),
47− placements: [],
48−};
49−
50−export default function GoogleAccountsPanel(props: {
51− readonly sourceId: string;
52− readonly integrationName: string;
53− readonly accountHandoff?: IntegrationAccountHandoff | null;
54−}) {
55− const { sourceId, integrationName, accountHandoff } = props;
56− const slug = IntegrationSlug.make(sourceId);
57− const configResult = useAtomValue(googleConfigAtom(slug));
58− const doConfigure = useAtomSet(googleConfigure, { mode: "promiseExit" });
59−
60− const existingTemplate = useMemo<readonly Authentication[]>(() => {
61− if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
62− return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[];
63− }, [configResult]);
64−
65− const methods = useMemo<readonly AuthMethod[]>(() => {
66− const declared = authMethodsFromConfig(existingTemplate);
67− return declared.length > 0 ? declared : [NO_AUTH_METHOD];
68− }, [existingTemplate]);
69−
70− const configure = useCallback<ConfigureAuthMethods<Authentication>>(
71− async (input) => {
72− const exit = await doConfigure({
73− params: { slug },
74− payload: {
75− authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
76− ...(input.mode ? { mode: input.mode } : {}),
77− },
78− reactivityKeys: integrationWriteKeys,
79− });
80− return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]);
81− },
82− [doConfigure, slug],
83− );
84−
85− const codec = useMemo<AuthMethodsCodec<Authentication>>(
86− () => ({
87− toAuthMethods: authMethodsFromConfig,
88− templatesFromPlacements: (placements: readonly Placement[]) => [
89− templateFromPlacements(placements),
90− ],
91− slugOf: (template: Authentication) => String(template.slug),
92− }),
93− [],
94− );
95−
96− const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
97− existing: existingTemplate,
98− codec,
99− configure,
100− });
101−
102− const audienceWarnings = useMemo<readonly string[]>(() => {
103− if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
104− const urls = configResult.value.googleDiscoveryUrls ?? [];
105− return googleAudienceWarningsForUrls(urls).flatMap((audience: string) => {
106− const message = GOOGLE_AUDIENCE_WARNING[audience];
107− return message ? [message] : [];
108− });
109− }, [configResult]);
110−
111− // Live-preview context for the health-check edit sheet (apiKey methods only;
112− // OAuth methods can't be previewed with a pasted key).
113− const organizationId = useOrganizationId();
114− const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => {
115− const templates = methods
116− .filter((m) => m.kind === "apikey")
117− .map((m) => ({ template: m.template, label: m.label }));
118− if (templates.length === 0) return undefined;
119− return { owner: defaultConnectionOwnerForHost(organizationId), templates };
120− }, [methods, organizationId]);
121−
122− return (
123− <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
124− {audienceWarnings.length > 0 && (
125− <Alert variant="destructive">
126− <TriangleAlert />
127− <AlertTitle>Some Google APIs need special consent</AlertTitle>
128− <AlertDescription>
129− {audienceWarnings.map((message: string) => (
130− <p key={message}>{message}</p>
131− ))}
132− </AlertDescription>
133− </Alert>
134− )}
135− <AccountsSection
136− integration={slug}
137− integrationName={integrationName}
138− methods={methods}
139− accountHandoff={accountHandoff}
140− createCustomMethod={createCustomMethod}
141− removeCustomMethod={removeCustomMethod}
142− />
143− {/* Google auto-configures the People identity probe at add time; this is
144− where an admin sees, adjusts, or clears it. */}
145− <HealthCheckEditor integration={slug} livePreview={livePreview} />
146− </div>
147− );
148−}
packages/plugins/google/src/react/GoogleProductPicker.tsx+0−363
@@ -1,363 +0,0 @@
1−import { useMemo, useState } from "react";
2−import { ChevronDownIcon, PlusIcon, TriangleAlert, XIcon } from "lucide-react";
3−
4−import { cn } from "@executor-js/react/lib/utils";
5−import { Badge } from "@executor-js/react/components/badge";
6−import { Button } from "@executor-js/react/components/button";
7−import { Checkbox } from "@executor-js/react/components/checkbox";
8−import {
9− Collapsible,
10− CollapsibleContent,
11− CollapsibleTrigger,
12−} from "@executor-js/react/components/collapsible";
13−import { FieldLabel } from "@executor-js/react/components/field";
14−import { Input } from "@executor-js/react/components/input";
15−import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16−
17−import {
18− googleOAuthConsentScopesForPreset,
19− googleOpenApiPresets,
20− type GoogleOpenApiOAuthAudience,
21− type GoogleOpenApiPreset,
22−} from "../sdk/presets";
23−import { googleOAuthConsentBatches } from "../sdk/oauth-batches";
24−import { isGoogleDiscoveryUrl } from "../sdk/discovery";
25−
26−// ---------------------------------------------------------------------------
27−// GoogleProductPicker - the "customize your Google connection" surface.
28−//
29−// A checkable card grid over `googleOpenApiPresets`, grouped/annotated by
30−// `oauthAudience`. The user picks which Google APIs to bundle into the single
31−// `google` integration; the parent turns the selected discovery URLs into a
32−// `{ kind: "googleDiscoveryBundle", urls }` add. A "View scopes" panel previews
33−// the unioned OAuth consent (via `googleOAuthConsentBatches`) BEFORE connecting,
34−// and a custom-URL escape hatch lets advanced users paste any Google Discovery
35−// document the preset list doesn't cover.
36−// ---------------------------------------------------------------------------
37−
38−// Audience groups, ordered from least- to most-privileged. The warning tiers
39−// (`workspace-admin`, `unsupported-user`) carry a caution chip so the user sees
40−// the consent risk before selecting.
41−const AUDIENCE_ORDER: readonly GoogleOpenApiOAuthAudience[] = [
42− "standard-user",
43− "advanced-user",
44− "workspace-admin",
45− "unsupported-user",
46−];
47−
48−const AUDIENCE_LABEL: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = {
49− "standard-user": "Core Google services",
50− "advanced-user": "Advanced services",
51− "workspace-admin": "Workspace admin",
52− "unsupported-user": "Limited user consent",
53−};
54−
55−const AUDIENCE_DESCRIPTION: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = {
56− "standard-user": "Connect with a normal Google account - one consent screen.",
57− "advanced-user": "Broader scopes that may need an unverified-app warning to be accepted.",
58− "workspace-admin": "Requires a Google Workspace admin account; not available on personal Gmail.",
59− "unsupported-user": "Google does not grant these scopes through standard user OAuth consent.",
60−};
61−
62−const audienceNeedsWarning = (audience: GoogleOpenApiOAuthAudience): boolean =>
63− audience === "workspace-admin" || audience === "unsupported-user";
64−
65−type GoogleProductPickerProps = {
66− readonly selectedPresetIds: ReadonlySet<string>;
67− readonly onToggle: (presetId: string, checked: boolean) => void;
68− readonly customUrls: readonly string[];
69− readonly onAddCustomUrl: (url: string) => void;
70− readonly onRemoveCustomUrl: (url: string) => void;
71−};
72−
73−const AudienceWarningChip = ({ audience }: { audience: GoogleOpenApiOAuthAudience }) =>
74− audience === "workspace-admin" ? (
75− <Badge variant="outline" className="shrink-0 border-border text-muted-foreground">
76− <TriangleAlert className="size-3" />
77− Admin only
78− </Badge>
79− ) : audience === "unsupported-user" ? (
80− <Badge variant="outline" className="shrink-0 border-destructive/40 text-destructive">
81− <TriangleAlert className="size-3" />
82− Limited consent
83− </Badge>
84− ) : null;
85−
86−// A Google API row - borderless and single-line, leaning on hover/selected
87−// fills instead of per-item card chrome (lightest separation that still reads).
88−// Name + truncated summary share one baseline for a dense, scannable two-column
89−// list; the audience warning chip trails on the right.
90−const ProductRow = ({
91− preset,
92− checked,
93− onToggle,
94−}: {
95− readonly preset: GoogleOpenApiPreset;
96− readonly checked: boolean;
97− readonly onToggle: (checked: boolean) => void;
98−}) => (
99− <FieldLabel
100− className={cn(
101− // `w-full` overrides FieldLabel's base `w-fit` (which would size the row to
102− // its content and overflow the column); `min-w-0` then lets the cell shrink
103− // to its track so the name/summary truncates instead of spilling over.
104− "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
105− checked ? "bg-primary/5" : "hover:bg-muted/40",
106− )}
107− >
108− <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} />
109− <div className="shrink-0">
110− <IntegrationFavicon icon={preset.icon} url={preset.url} size={16} />
111− </div>
112− {/* One truncating line - the name + summary clip to the cell with an
113− ellipsis instead of overflowing into the neighbouring column. */}
114− <div className="min-w-0 flex-1 truncate text-sm">
115− <span className="font-medium text-foreground">{preset.name}</span>{" "}
116− <span className="text-[11px] text-muted-foreground">{preset.summary}</span>
117− </div>
118− <AudienceWarningChip audience={preset.oauthAudience} />
119− </FieldLabel>
120−);
121−
122−const CustomUrlEscapeHatch = ({
123− customUrls,
124− onAddCustomUrl,
125− onRemoveCustomUrl,
126−}: {
127− readonly customUrls: readonly string[];
128− readonly onAddCustomUrl: (url: string) => void;
129− readonly onRemoveCustomUrl: (url: string) => void;
130−}) => {
131− const [draft, setDraft] = useState("");
132− const trimmed = draft.trim();
133− const isValid = isGoogleDiscoveryUrl(trimmed);
134− const isDuplicate = customUrls.includes(trimmed);
135−
136− const commit = () => {
137− if (!isValid || isDuplicate) return;
138− onAddCustomUrl(trimmed);
139− setDraft("");
140− };
141−
142− return (
143− <div className="space-y-2">
144− <FieldLabel className="text-[11px] font-medium text-muted-foreground">
145− Add a custom Google Discovery URL
146− </FieldLabel>
147− <div className="flex items-center gap-2">
148− <Input
149− value={draft}
150− onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)}
151− onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
152− if (event.key === "Enter") {
153− event.preventDefault();
154− commit();
155− }
156− }}
157− placeholder="https://www.googleapis.com/discovery/v1/apis/<service>/<version>/rest"
158− className="font-mono text-[11px]"
159− />
160− <Button
161− type="button"
162− variant="outline"
163− size="sm"
164− disabled={!isValid || isDuplicate}
165− onClick={commit}
166− >
167− <PlusIcon className="size-3.5" />
168− Add
169− </Button>
170− </div>
171− {trimmed.length > 0 && !isValid ? (
172− <p className="text-[11px] text-destructive">
173− Enter a Google Discovery document URL (a *.googleapis.com discovery/$discovery endpoint).
174− </p>
175− ) : null}
176− {customUrls.length > 0 ? (
177− <ul className="space-y-1">
178− {customUrls.map((url: string) => (
179− <li
180− key={url}
181− className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5"
182− >
183− <span className="truncate font-mono text-[11px] text-foreground">{url}</span>
184− <Button
185− type="button"
186− variant="ghost"
187− size="icon"
188− className="size-6 shrink-0"
189− onClick={() => onRemoveCustomUrl(url)}
190− aria-label={`Remove ${url}`}
191− >
192− <XIcon className="size-3.5" />
193− </Button>
194− </li>
195− ))}
196− </ul>
197− ) : null}
198− </div>
199− );
200−};
201−
202−export function GoogleProductPicker({
203− selectedPresetIds,
204− onToggle,
205− customUrls,
206− onAddCustomUrl,
207− onRemoveCustomUrl,
208−}: GoogleProductPickerProps) {
209− const [scopesOpen, setScopesOpen] = useState(false);
210−
211− const groups = useMemo(
212− () =>
213− AUDIENCE_ORDER.flatMap((audience: GoogleOpenApiOAuthAudience) => {
214− const presets = googleOpenApiPresets.filter(
215− (preset: GoogleOpenApiPreset) => preset.oauthAudience === audience,
216− );
217− return presets.length > 0 ? [{ audience, presets }] : [];
218− }),
219− [],
220− );
221−
222− // The "View scopes" preview unions the selected presets' representative
223− // consent scopes through `googleOAuthConsentBatches`, mirroring the unioned
224− // scopes the bundle converter ultimately stores on the integration.
225− const consentBatches = useMemo(
226− () =>
227− googleOAuthConsentBatches(
228− googleOpenApiPresets
229− .filter((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id))
230− .map((preset: GoogleOpenApiPreset) => ({
231− id: preset.id,
232− name: preset.name,
233− oauthAudience: preset.oauthAudience,
234− scopes: googleOAuthConsentScopesForPreset(preset.id),
235− })),
236− ),
237− [selectedPresetIds],
238− );
239−
240− const selectedCount = selectedPresetIds.size + customUrls.length;
241−
242− return (
243− <section className="space-y-4">
244− <div className="space-y-1">
245− <FieldLabel>Customize your Google connection</FieldLabel>
246− <p className="text-[11px] text-muted-foreground">
247− Pick the Google APIs to bundle into one connection. They share a single OAuth consent and
248− appear as merged tools under one Google integration.
249− </p>
250− </div>
251−
252− {groups.map(
253− ({
254− audience,
255− presets,
256− }: {
257− readonly audience: GoogleOpenApiOAuthAudience;
258− readonly presets: readonly GoogleOpenApiPreset[];
259− }) => (
260− <div key={audience} className="space-y-2">
261− <div className="flex items-center gap-2">
262− <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase">
263− {AUDIENCE_LABEL[audience]}
264− </span>
265− {audienceNeedsWarning(audience) ? <AudienceWarningChip audience={audience} /> : null}
266− <Button
267− type="button"
268− variant="ghost"
269− size="sm"
270− className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground"
271− onClick={() => {
272− const allSelected = presets.every((preset: GoogleOpenApiPreset) =>
273− selectedPresetIds.has(preset.id),
274− );
275− presets.forEach((preset: GoogleOpenApiPreset) =>
276− onToggle(preset.id, !allSelected),
277− );
278− }}
279− >
280− {presets.every((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id))
281− ? "Clear"
282− : "Select all"}
283− </Button>
284− </div>
285− <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p>
286− <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2">
287− {presets.map((preset: GoogleOpenApiPreset) => (
288− <ProductRow
289− key={preset.id}
290− preset={preset}
291− checked={selectedPresetIds.has(preset.id)}
292− onToggle={(checked: boolean) => onToggle(preset.id, checked)}
293− />
294− ))}
295− </div>
296− </div>
297− ),
298− )}
299−
300− <CustomUrlEscapeHatch
301− customUrls={customUrls}
302− onAddCustomUrl={onAddCustomUrl}
303− onRemoveCustomUrl={onRemoveCustomUrl}
304− />
305−
306− <div className="space-y-1.5 rounded-lg border border-border bg-muted/10 px-3 py-2.5">
307− <div className="flex items-center gap-2">
308− <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase">
309− Authentication
310− </span>
311− <Badge variant="secondary">OAuth</Badge>
312− </div>
313− <p className="text-[11px] text-muted-foreground">
314− The selected Google APIs share one OAuth consent. Review the scopes below, then connect a
315− Google account from the integration page after adding.
316− </p>
317− </div>
318−
319− <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}>
320− <CollapsibleTrigger asChild>
321− <Button type="button" variant="outline" size="sm" disabled={consentBatches.length === 0}>
322− <ChevronDownIcon
323− className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")}
324− />
325− View scopes
326− {selectedCount > 0 ? (
327− <Badge variant="secondary" className="ml-1">
328− {selectedCount}
329− </Badge>
330− ) : null}
331− </Button>
332− </CollapsibleTrigger>
333− <CollapsibleContent className="pt-3">
334− {consentBatches.length === 0 ? (
335− <p className="text-[11px] text-muted-foreground">
336− Select at least one Google API to preview the OAuth consent.
337− </p>
338− ) : (
339− <div className="space-y-3">
340− {consentBatches.map((batch) => (
341− <div key={batch.id} className="space-y-1.5">
342− <span className="text-[11px] font-semibold text-foreground">{batch.label}</span>
343− <ul className="space-y-1">
344− {batch.apiScopes.map((scope: string) => (
345− <li
346− key={scope}
347− className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground"
348− >
349− {scope}
350− </li>
351− ))}
352− </ul>
353− </div>
354− ))}
355− </div>
356− )}
357− </CollapsibleContent>
358− </Collapsible>
359− </section>
360− );
361−}
362−
363−export default GoogleProductPicker;
packages/plugins/google/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1−import type { IntegrationSlug } from "@executor-js/sdk/shared";
2−import * as Atom from "effect/unstable/reactivity/Atom";
3−import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4−import { GoogleClient } from "./client";
5−
6−export const googleIntegrationAtom = (slug: IntegrationSlug) =>
7− GoogleClient.query("google", "getIntegration", {
8− params: { slug },
9− timeToLive: "15 seconds",
10− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11− });
12−
13−export const googleConfigAtom = (slug: IntegrationSlug) =>
14− GoogleClient.query("google", "getConfig", {
15− params: { slug },
16− timeToLive: "15 seconds",
17− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18− });
19−
20−export const addGoogleBundle = GoogleClient.mutation("google", "addBundle");
21−
22−export const updateGoogleBundle = GoogleClient.mutation("google", "updateBundle");
23−
24−export const removeGoogleBundle = GoogleClient.mutation("google", "removeBundle");
25−
26−export const googleConfigure = GoogleClient.mutation("google", "configure");
27−
28−export const googleIntegrationFamily = Atom.family(googleIntegrationAtom);
29−
30−export const googleConfigFamily = Atom.family(googleConfigAtom);
packages/plugins/google/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1−import { createPluginAtomClient } from "@executor-js/sdk/client";
2−import {
3− getExecutorOrganizationHeaders,
4− getExecutorApiBaseUrl,
5− getExecutorServerAuthorizationHeader,
6−} from "@executor-js/react/api/server-connection";
7−import { GoogleGroup } from "../api/group";
8−
9−export const GoogleClient = createPluginAtomClient(GoogleGroup, {
10− baseUrl: getExecutorApiBaseUrl,
11− authorizationHeader: getExecutorServerAuthorizationHeader,
12− headers: getExecutorOrganizationHeaders,
13−});
packages/plugins/google/src/react/index.ts+0−12
@@ -1,12 +0,0 @@
1−export { googleIntegrationPlugin } from "./source-plugin";
2−export { GoogleClient } from "./client";
3−export {
4− addGoogleBundle,
5− googleConfigAtom,
6− googleConfigFamily,
7− googleConfigure,
8− googleIntegrationAtom,
9− googleIntegrationFamily,
10− removeGoogleBundle,
11− updateGoogleBundle,
12−} from "./atoms";
packages/plugins/google/src/react/plugin-client.tsx+0−8
@@ -1,8 +0,0 @@
1−import { defineClientPlugin } from "@executor-js/sdk/client";
2−
3−import { googleIntegrationPlugin } from "./source-plugin";
4−
5−export default defineClientPlugin({
6− id: "google" as const,
7− integrationPlugin: googleIntegrationPlugin,
8−});
packages/plugins/google/src/react/source-plugin.ts+0−18
@@ -1,18 +0,0 @@
1−import { lazy } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
3−import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets";
4−
5−const importAdd = () => import("./AddGoogleSource");
6−const importAccounts = () => import("./GoogleAccountsPanel");
7−
8−export const googleIntegrationPlugin: IntegrationPlugin = {
9− key: "google",
10− label: "Google",
11− add: lazy(importAdd),
12− accounts: lazy(importAccounts),
13− presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset],
14− preload: () => {
15− void importAdd();
16− void importAccounts();
17− },
18−};
packages/plugins/google/src/sdk/index.ts+2−14
@@ -7,6 +7,7 @@ export { · 6 unmodified lines
77 } from "./discovery";
88 export {
99 googleOpenApiBundlePreset,
10+ googleCatalog,
1011 googleOpenApiPresets,
1112 googlePhotosOpenApiBundlePreset,
1213 googlePhotosOpenApiPresets,
@@ -16,6 +17,7 @@ export { · 3 unmodified lines
1617 googleStandardUserOAuthPresets,
1718 googleOAuthConsentScopes,
1819 googleOAuthConsentScopesForPreset,
20+ googleServiceSlug,
1921 googleAudienceWarningsForUrls,
2022 googlePresetForDiscoveryUrl,
2123 type GoogleOpenApiOAuthAudience,
@@ -28,20 +30,6 @@ export { · 6 unmodified lines
2830 isGoogleUserConsentOAuthScope,
2931 } from "./oauth-scopes";
3032 export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
31−export {
32− googleOAuthConsentBatches,
33− type GoogleOAuthBatchInput,
34− type GoogleOAuthConsentBatch,
35−} from "./oauth-batches";
36−export {
37− googlePlugin,
38− type GoogleBundleConfig,
39− type GoogleConfigureInput,
40− type GooglePluginExtension,
41− type GooglePluginOptions,
42− type GoogleUpdateInput,
43− type GoogleUpdateResult,
44−} from "./plugin";
4533 export {
4634 googleOpenApiOwnershipDataMigration,
4735 runSqliteGoogleOpenApiOwnershipMigration,
packages/plugins/google/src/sdk/oauth-batches.test.ts+0−98
@@ -1,98 +0,0 @@
1−import { expect, it } from "@effect/vitest";
2−
3−import { googleOAuthConsentBatches } from "./oauth-batches";
4−
5−it("keeps core Google OAuth in one consent batch", () => {
6− expect(
7− googleOAuthConsentBatches([
8− {
9− id: "google-gmail",
10− name: "Gmail",
11− oauthAudience: "standard-user",
12− scopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/gmail.send"],
13− },
14− {
15− id: "google-calendar",
16− name: "Google Calendar",
17− oauthAudience: "standard-user",
18− scopes: [
19− "https://www.googleapis.com/auth/calendar",
20− "https://www.googleapis.com/auth/calendar.readonly",
21− ],
22− },
23− ]),
24− ).toEqual([
25− {
26− id: "google-core",
27− label: "Core Google services",
28− apiScopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/calendar"],
29− },
30− ]);
31−});
32−
33−it("splits advanced Google OAuth into smaller known-good consent batches", () => {
34− expect(
35− googleOAuthConsentBatches([
36− {
37− id: "google-youtube-data",
38− name: "YouTube Data",
39− oauthAudience: "advanced-user",
40− scopes: ["https://www.googleapis.com/auth/youtube"],
41− },
42− {
43− id: "google-classroom",
44− name: "Google Classroom",
45− oauthAudience: "advanced-user",
46− scopes: ["https://www.googleapis.com/auth/classroom.courses"],
47− },
48− {
49− id: "google-apps-script",
50− name: "Google Apps Script",
51− oauthAudience: "advanced-user",
52− scopes: [
53− "https://www.googleapis.com/auth/script.projects",
54− "https://www.googleapis.com/auth/drive",
55− ],
56− },
57− {
58− id: "google-bigquery",
59− name: "Google BigQuery",
60− oauthAudience: "advanced-user",
61− scopes: ["https://www.googleapis.com/auth/bigquery"],
62− },
63− {
64− id: "google-cloud-resource-manager",
65− name: "Google Cloud Resource Manager",
66− oauthAudience: "advanced-user",
67− scopes: ["https://www.googleapis.com/auth/cloud-platform"],
68− },
69− ]).map((batch) => ({ id: batch.id, label: batch.label, apiScopes: batch.apiScopes })),
70− ).toEqual([
71− {
72− id: "google-youtube-data",
73− label: "YouTube Data",
74− apiScopes: ["https://www.googleapis.com/auth/youtube"],
75− },
76− {
77− id: "google-classroom",
78− label: "Google Classroom",
79− apiScopes: ["https://www.googleapis.com/auth/classroom.courses"],
80− },
81− {
82− id: "google-apps-script",
83− label: "Google Apps Script",
84− apiScopes: [
85− "https://www.googleapis.com/auth/script.projects",
86− "https://www.googleapis.com/auth/drive",
87− ],
88− },
89− {
90− id: "google-cloud",
91− label: "Google Cloud services",
92− apiScopes: [
93− "https://www.googleapis.com/auth/bigquery",
94− "https://www.googleapis.com/auth/cloud-platform",
95− ],
96− },
97− ]);
98−});
packages/plugins/google/src/sdk/oauth-batches.ts+0−69
@@ -1,69 +0,0 @@
1−import type { GoogleOpenApiOAuthAudience } from "./presets";
2−import { compactGoogleOAuthScopes } from "./oauth-scopes";
3−
4−export type GoogleOAuthBatchInput = {
5− readonly id: string;
6− readonly name: string;
7− readonly oauthAudience: GoogleOpenApiOAuthAudience;
8− readonly scopes: readonly string[];
9−};
10−
11−export type GoogleOAuthConsentBatch = {
12− readonly id: string;
13− readonly label: string;
14− readonly apiScopes: readonly string[];
15−};
16−
17−const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]);
18−
19−export const googleOAuthConsentBatches = (
20− items: readonly GoogleOAuthBatchInput[],
21−): readonly GoogleOAuthConsentBatch[] => {
22− const standardScopes: string[] = [];
23− const cloudScopes: string[] = [];
24− const batches: GoogleOAuthConsentBatch[] = [];
25−
26− for (const item of items) {
27− if (item.scopes.length === 0) continue;
28− if (item.oauthAudience === "standard-user") {
29− standardScopes.push(...item.scopes);
30− continue;
31− }
32− if (GOOGLE_CLOUD_BATCH_IDS.has(item.id)) {
33− cloudScopes.push(...item.scopes);
34− continue;
35− }
36− batches.push({
37− id: item.id,
38− label: item.name,
39− apiScopes: item.scopes,
40− });
41− }
42−
43− const compactedStandardScopes = compactGoogleOAuthScopes(standardScopes);
44− const compactedCloudScopes = compactGoogleOAuthScopes(cloudScopes);
45− return [
46− ...(compactedStandardScopes.length > 0
47− ? [
48− {
49− id: "google-core",
50− label: "Core Google services",
51− apiScopes: compactedStandardScopes,
52− },
53− ]
54− : []),
55− ...batches.map((batch) => ({
56− ...batch,
57− apiScopes: compactGoogleOAuthScopes(batch.apiScopes),
58− })),
59− ...(compactedCloudScopes.length > 0
60− ? [
61− {
62− id: "google-cloud",
63− label: "Google Cloud services",
64− apiScopes: compactedCloudScopes,
65− },
66− ]
67− : []),
68− ].filter((batch) => batch.apiScopes.length > 0);
69−};
packages/plugins/google/src/sdk/plugin.test.ts+0−799
@@ -1,799 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// Google bundle add flow, "customize your Google connection".
3−//
4−// The product picker emits a URL list. The server fetches each Discovery
5−// document, merges them into ONE `google`
6−// integration spec, and stores the unioned `googleOAuth2` auth template. These
7−// tests exercise that path end-to-end against a stubbed Discovery host:
8−// - a 3-API bundle (calendar + gmail + drive) produces a single `google`
9−// integration whose merged tools carry NO name collisions (each method id
10−// is service-prefixed) even when two APIs share a generic method name;
11−// - the stored oauth template carries the UNION of every API's scopes.
12−// ---------------------------------------------------------------------------
13−
14−import { describe, expect, it } from "@effect/vitest";
15−import { Effect, Exit, Layer } from "effect";
16−import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
17−
18−import {
19− ConnectionName,
20− IntegrationSlug,
21− createExecutor,
22− AuthTemplateSlug,
23−} from "@executor-js/sdk";
24−import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
25−
26−import { defaultGoogleHealthCheck, googlePlugin } from "./plugin";
27−import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets";
28−
29−// --- Canned Discovery documents -------------------------------------------
30−// Each carries one method. Calendar and Gmail BOTH expose a generic `list`
31−// method id segment, so a naive merge that keyed tools on the trailing method
32−// name would collide. The bundle converter keys on the full method id
33−// (`calendar.events.list`, `gmail.users.messages.list`, …), so they don't.
34−
35−const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest";
36−const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
37−const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest";
38−const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
39−const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest";
40−const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
41−const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
42−const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest";
43−const OAUTH2_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
44−const OAUTH2_USERINFO_SCOPES = [
45− "openid",
46− "https://www.googleapis.com/auth/userinfo.email",
47− "https://www.googleapis.com/auth/userinfo.profile",
48−] as const;
49−
50−const calendarDoc = {
51− name: "calendar",
52− version: "v3",
53− title: "Calendar API",
54− rootUrl: "https://www.googleapis.com/",
55− servicePath: "calendar/v3/",
56− auth: {
57− oauth2: {
58− scopes: {
59− "https://www.googleapis.com/auth/calendar": { description: "Manage calendars" },
60− "https://www.googleapis.com/auth/calendar.readonly": { description: "Read calendars" },
61− },
62− },
63− },
64− resources: {
65− events: {
66− methods: {
67− list: {
68− id: "calendar.events.list",
69− httpMethod: "GET",
70− path: "calendars/{calendarId}/events",
71− scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
72− parameters: {
73− calendarId: { location: "path", required: true, type: "string" },
74− },
75− },
76− },
77− },
78− },
79− schemas: {
80− Event: { id: "Event", type: "object", properties: { id: { type: "string" } } },
81− },
82−};
83−
84−const gmailDoc = {
85− name: "gmail",
86− version: "v1",
87− title: "Gmail API",
88− rootUrl: "https://gmail.googleapis.com/",
89− servicePath: "",
90− auth: {
91− oauth2: {
92− scopes: {
93− "https://mail.google.com/": { description: "Full Gmail access" },
94− "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" },
95− },
96− },
97− },
98− resources: {
99− users: {
100− resources: {
101− messages: {
102− methods: {
103− // Same trailing `list` as calendar.events.list - would collide on a
104− // naive merge; service-prefixed method id keeps them distinct.
105− list: {
106− id: "gmail.users.messages.list",
107− httpMethod: "GET",
108− path: "gmail/v1/users/{userId}/messages",
109− scopes: ["https://www.googleapis.com/auth/gmail.readonly"],
110− parameters: {
111− userId: { location: "path", required: true, type: "string" },
112− },
113− },
114− },
115− },
116− },
117− },
118− },
119− schemas: {
120− Message: { id: "Message", type: "object", properties: { id: { type: "string" } } },
121− },
122−};
123−
124−const driveDoc = {
125− name: "drive",
126− version: "v3",
127− title: "Drive API",
128− rootUrl: "https://www.googleapis.com/",
129− servicePath: "drive/v3/",
130− auth: {
131− oauth2: {
132− scopes: {
133− "https://www.googleapis.com/auth/drive": { description: "Manage Drive" },
134− },
135− },
136− },
137− resources: {
138− files: {
139− methods: {
140− // A third `list` - three generic method names across three APIs.
141− list: {
142− id: "drive.files.list",
143− httpMethod: "GET",
144− path: "files",
145− scopes: ["https://www.googleapis.com/auth/drive"],
146− parameters: {},
147− },
148− },
149− },
150− },
151− schemas: {
152− File: { id: "File", type: "object", properties: { id: { type: "string" } } },
153− },
154−};
155−
156−const sheetsDoc = {
157− name: "sheets",
158− version: "v4",
159− title: "Google Sheets API",
160− rootUrl: "https://sheets.googleapis.com/",
161− servicePath: "v4/",
162− auth: {
163− oauth2: {
164− scopes: {
165− "https://www.googleapis.com/auth/spreadsheets": { description: "Manage spreadsheets" },
166− },
167− },
168− },
169− resources: {
170− spreadsheets: {
171− methods: {
172− get: {
173− id: "sheets.spreadsheets.get",
174− httpMethod: "GET",
175− path: "spreadsheets/{spreadsheetId}",
176− scopes: ["https://www.googleapis.com/auth/spreadsheets"],
177− parameters: {
178− spreadsheetId: { location: "path", required: true, type: "string" },
179− },
180− },
181− },
182− },
183− },
184− schemas: {
185− Spreadsheet: { id: "Spreadsheet", type: "object", properties: { id: { type: "string" } } },
186− },
187−};
188−
189−const docsDoc = {
190− name: "docs",
191− version: "v1",
192− title: "Google Docs API",
193− rootUrl: "https://docs.googleapis.com/",
194− servicePath: "v1/",
195− auth: {
196− oauth2: {
197− scopes: {
198− "https://www.googleapis.com/auth/documents": { description: "Manage documents" },
199− },
200− },
201− },
202− resources: {
203− documents: {
204− methods: {
205− get: {
206− id: "docs.documents.get",
207− httpMethod: "GET",
208− path: "documents/{documentId}",
209− scopes: ["https://www.googleapis.com/auth/documents"],
210− parameters: {
211− documentId: { location: "path", required: true, type: "string" },
212− },
213− },
214− },
215− },
216− },
217− schemas: {
218− Document: { id: "Document", type: "object", properties: { documentId: { type: "string" } } },
219− },
220−};
221−
222−const photosLibraryDoc = {
223− name: "photoslibrary",
224− version: "v1",
225− title: "Google Photos Library API",
226− rootUrl: "https://photoslibrary.googleapis.com/",
227− servicePath: "v1/",
228− auth: {
229− oauth2: {
230− scopes: {
231− "https://www.googleapis.com/auth/photoslibrary": {
232− description: "Manage the full Google Photos library",
233− },
234− "https://www.googleapis.com/auth/photoslibrary.appendonly": {
235− description: "Upload to Google Photos",
236− },
237− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata": {
238− description: "Read app-created Google Photos media",
239− },
240− },
241− },
242− },
243− resources: {
244− albums: {
245− methods: {
246− list: {
247− id: "photoslibrary.albums.list",
248− httpMethod: "GET",
249− path: "albums",
250− scopes: ["https://www.googleapis.com/auth/photoslibrary"],
251− parameters: {},
252− },
253− },
254− },
255− mediaItems: {
256− methods: {
257− search: {
258− id: "photoslibrary.mediaItems.search",
259− httpMethod: "POST",
260− path: "mediaItems:search",
261− scopes: ["https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"],
262− parameters: {},
263− },
264− },
265− },
266− },
267− schemas: {},
268−};
269−
270−const photosPickerDoc = {
271− name: "photospicker",
272− version: "v1",
273− title: "Google Photos Picker API",
274− rootUrl: "https://photospicker.googleapis.com/",
275− servicePath: "v1/",
276− resources: {
277− mediaItems: {
278− methods: {
279− list: {
280− id: "photospicker.mediaItems.list",
281− httpMethod: "GET",
282− path: "mediaItems",
283− parameters: {},
284− },
285− },
286− },
287− },
288− schemas: {},
289−};
290−
291−const peopleDoc = {
292− name: "people",
293− version: "v1",
294− title: "People API",
295− rootUrl: "https://people.googleapis.com/",
296− servicePath: "",
297− auth: {
298− oauth2: {
299− scopes: {
300− "https://www.googleapis.com/auth/userinfo.email": { description: "See your email" },
301− },
302− },
303− },
304− resources: {
305− people: {
306− methods: {
307− get: {
308− id: "people.people.get",
309− httpMethod: "GET",
310− path: "v1/{+resourceName}",
311− scopes: ["https://www.googleapis.com/auth/userinfo.email"],
312− parameters: {
313− resourceName: { location: "path", required: true, type: "string" },
314− personFields: { location: "query", type: "string" },
315− },
316− response: { $ref: "Person" },
317− },
318− },
319− },
320− },
321− schemas: {
322− Person: {
323− id: "Person",
324− type: "object",
325− properties: {
326− resourceName: { type: "string" },
327− emailAddresses: { type: "array", items: { $ref: "EmailAddress" } },
328− },
329− },
330− EmailAddress: { id: "EmailAddress", type: "object", properties: { value: { type: "string" } } },
331− },
332−};
333−
334−const oauth2Doc = {
335− name: "oauth2",
336− version: "v2",
337− title: "Google OAuth2 API",
338− rootUrl: "https://www.googleapis.com/",
339− servicePath: "",
340− auth: {
341− oauth2: {
342− scopes: {
343− openid: { description: "Associate you with your personal info on Google" },
344− "https://www.googleapis.com/auth/userinfo.email": {
345− description: "See your primary Google Account email address",
346− },
347− "https://www.googleapis.com/auth/userinfo.profile": {
348− description: "See your personal info",
349− },
350− },
351− },
352− },
353− methods: {
354− tokeninfo: {
355− id: "oauth2.tokeninfo",
356− httpMethod: "POST",
357− path: "oauth2/v2/tokeninfo",
358− response: { $ref: "Tokeninfo" },
359− },
360− },
361− resources: {
362− userinfo: {
363− methods: {
364− get: {
365− id: "oauth2.userinfo.get",
366− httpMethod: "GET",
367− path: "oauth2/v2/userinfo",
368− scopes: OAUTH2_USERINFO_SCOPES,
369− response: { $ref: "Userinfo" },
370− },
371− },
372− resources: {
373− v2: {
374− resources: {
375− me: {
376− methods: {
377− get: {
378− id: "oauth2.userinfo.v2.me.get",
379− httpMethod: "GET",
380− path: "userinfo/v2/me",
381− scopes: OAUTH2_USERINFO_SCOPES,
382− response: { $ref: "Userinfo" },
383− },
384− },
385− },
386− },
387− },
388− },
389− },
390− },
391− schemas: {
392− Tokeninfo: {
393− id: "Tokeninfo",
394− type: "object",
395− properties: {
396− audience: { type: "string" },
397− scope: { type: "string" },
398− },
399− },
400− Userinfo: {
401− id: "Userinfo",
402− type: "object",
403− properties: {
404− email: { type: "string" },
405− family_name: { type: "string" },
406− gender: { type: "string" },
407− given_name: { type: "string" },
408− hd: { type: "string" },
409− id: { type: "string" },
410− link: { type: "string" },
411− locale: { type: "string" },
412− name: { type: "string" },
413− picture: { type: "string" },
414− verified_email: { type: "boolean" },
415− },
416− },
417− },
418−};
419−
420−const toJson = (value: unknown): string => JSON.stringify(value);
421−
422−const DISCOVERY_BODIES: Readonly<Record<string, string>> = {
423− [CALENDAR_URL]: toJson(calendarDoc),
424− [GMAIL_URL]: toJson(gmailDoc),
425− [SHEETS_URL]: toJson(sheetsDoc),
426− [DRIVE_URL]: toJson(driveDoc),
427− [DOCS_URL]: toJson(docsDoc),
428− [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc),
429− [PHOTOS_PICKER_URL]: toJson(photosPickerDoc),
430− [PEOPLE_URL]: toJson(peopleDoc),
431− [OAUTH2_URL]: toJson(oauth2Doc),
432−};
433−
434−// A stub HTTP client that serves the canned Discovery document for whichever
435−// URL the bundle converter fetches. Service-hosted Discovery URLs carry their
436−// version in the query string, so match the full URL before falling back to the
437−// path-only key used by central Discovery URLs.
438−const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
439− HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
440− const url = new URL(request.url);
441− const key = `${url.origin}${url.pathname}`;
442− const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key];
443− return Effect.succeed(
444− HttpClientResponse.fromWeb(
445− request,
446− body === undefined
447− ? new Response("not found", { status: 404 })
448− : new Response(body, {
449− status: 200,
450− headers: { "content-type": "application/json" },
451− }),
452− ),
453− );
454− }),
455−);
456−
457−const bundlePlugins = () =>
458− [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;
459−
460−describe("Google bundle add flow", () => {
461− it("SDK catalog includes the Google Photos focused preset", () => {
462− const presetIds =
463− googlePlugin({ httpClientLayer: discoveryHttpClientLayer }).integrationPresets?.map(
464− (preset) => preset.id,
465− ) ?? [];
466−
467− expect(presetIds).toContain("google");
468− expect(presetIds).toContain("google-photos");
469− });
470−
471− it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () =>
472− Effect.scoped(
473− Effect.gen(function* () {
474− let requests = 0;
475− const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
476− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
477− Effect.sync(() => {
478− requests += 1;
479− return HttpClientResponse.fromWeb(
480− request,
481− new Response("unexpected request", { status: 500 }),
482− );
483− }),
484− ),
485− );
486− const executor = yield* createExecutor(
487− makeTestConfig({
488− plugins: [
489− googlePlugin({ httpClientLayer: blockedHttpClientLayer }),
490− memoryCredentialsPlugin(),
491− ],
492− }),
493− );
494−
495− const exit = yield* executor.google
496− .addBundle({
497− urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"],
498− slug: "bad_google",
499− })
500− .pipe(Effect.exit);
501−
502− expect(Exit.isFailure(exit)).toBe(true);
503− expect(requests).toBe(0);
504− }),
505− ),
506− );
507−
508− it.effect(
509− "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions",
510− () =>
511− Effect.scoped(
512− Effect.gen(function* () {
513− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
514−
515− const result = yield* executor.google.addBundle({
516− urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL],
517− slug: "google",
518− description: "Google",
519− });
520− expect(String(result.slug)).toBe("google");
521−
522− // ONE integration, not three.
523− const integration = yield* executor.google.getIntegration("google");
524− expect(integration?.slug).toBe(IntegrationSlug.make("google"));
525−
526− // The stored oauth template carries the COMPACTED union of every API's
527− // scopes plus the hidden OAuth2 identity scopes that `oauth.start`
528− // requests.
529− // `calendar.readonly` collapses under `calendar`, and `gmail.readonly`
530− // collapses under `https://mail.google.com/`, so the requested consent
531− // is clean rather than the raw per-method union.
532− const config = yield* executor.google.getConfig("google");
533− expect(config?.googleDiscoveryUrls).toEqual([
534− CALENDAR_URL,
535− GMAIL_URL,
536− DRIVE_URL,
537− OAUTH2_URL,
538− ]);
539− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
540− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
541− [
542− "email",
543− "https://mail.google.com/",
544− "https://www.googleapis.com/auth/calendar",
545− "https://www.googleapis.com/auth/drive",
546− "openid",
547− "profile",
548− ].sort(),
549− );
550−
551− // A connection stamps the merged tools; assert all three `list`s are
552− // present under distinct service-prefixed names (no collision).
553− yield* executor.connections.create({
554− owner: "org",
555− name: ConnectionName.make("main"),
556− integration: IntegrationSlug.make("google"),
557− template: AuthTemplateSlug.make("googleOAuth2"),
558− value: "token-xyz",
559− });
560−
561− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
562− expect(toolNames).toContain("calendar.events.list");
563− expect(toolNames).toContain("gmail.users.messages.list");
564− expect(toolNames).toContain("drive.files.list");
565−
566− // No duplicate tool names across the merged surface.
567− const googleTools = toolNames.filter((name) => name.endsWith(".list"));
568− expect(new Set(googleTools).size).toBe(googleTools.length);
569− expect(googleTools.length).toBe(3);
570− }),
571− ),
572− );
573−
574− it.effect("addBundle constrains Google Photos to the preset scopes and upload tool", () =>
575− Effect.scoped(
576− Effect.gen(function* () {
577− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
578−
579− yield* executor.google.addBundle({
580− urls: [
581− PHOTOS_LIBRARY_URL,
582− "https://photospicker.googleapis.com/$discovery/rest?version=v1",
583− ],
584− slug: "google_photos",
585− name: "Google Photos",
586− });
587−
588− const config = yield* executor.google.getConfig("google_photos");
589− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
590− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
591− [
592− "email",
593− "https://www.googleapis.com/auth/photoslibrary.appendonly",
594− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
595− "https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
596− "openid",
597− "profile",
598− ].sort(),
599− );
600−
601− yield* executor.connections.create({
602− owner: "org",
603− name: ConnectionName.make("main"),
604− integration: IntegrationSlug.make("google_photos"),
605− template: AuthTemplateSlug.make("googleOAuth2"),
606− value: "token-xyz",
607− });
608−
609− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
610− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
611− expect(toolNames).toContain("photoslibrary.mediaItems.search");
612− expect(toolNames).toContain("photospicker.mediaItems.list");
613− expect(toolNames).not.toContain("photoslibrary.albums.list");
614− }),
615− ),
616− );
617−
618− it.effect("addBundle keeps Google Photos scoped when combined with another API", () =>
619− Effect.scoped(
620− Effect.gen(function* () {
621− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
622−
623− yield* executor.google.addBundle({
624− urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL],
625− slug: "google_photos_calendar",
626− name: "Google Photos and Calendar",
627− });
628−
629− const config = yield* executor.google.getConfig("google_photos_calendar");
630− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
631− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
632− [
633− "email",
634− "https://www.googleapis.com/auth/calendar",
635− "https://www.googleapis.com/auth/photoslibrary.appendonly",
636− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
637− "https://www.googleapis.com/auth/photospicker.mediaitems.readonly",
638− "openid",
639− "profile",
640− ].sort(),
641− );
642−
643− yield* executor.connections.create({
644− owner: "org",
645− name: ConnectionName.make("main"),
646− integration: IntegrationSlug.make("google_photos_calendar"),
647− template: AuthTemplateSlug.make("googleOAuth2"),
648− value: "token-xyz",
649− });
650−
651− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
652− expect(toolNames).toContain("calendar.events.list");
653− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
654− expect(toolNames).toContain("photoslibrary.mediaItems.search");
655− expect(toolNames).toContain("photospicker.mediaItems.list");
656− expect(toolNames).not.toContain("photoslibrary.albums.list");
657− }),
658− ),
659− );
660−
661− it.effect("addBundle scopes a partial Google Photos bundle when mixed with another API", () =>
662− Effect.scoped(
663− Effect.gen(function* () {
664− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
665−
666− yield* executor.google.addBundle({
667− urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL],
668− slug: "google_photos_library_calendar",
669− name: "Google Photos Library and Calendar",
670− });
671−
672− const config = yield* executor.google.getConfig("google_photos_library_calendar");
673− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
674− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
675− [
676− "email",
677− "https://www.googleapis.com/auth/calendar",
678− "https://www.googleapis.com/auth/photoslibrary.appendonly",
679− "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata",
680− "openid",
681− "profile",
682− ].sort(),
683− );
684−
685− yield* executor.connections.create({
686− owner: "org",
687− name: ConnectionName.make("main"),
688− integration: IntegrationSlug.make("google_photos_library_calendar"),
689− template: AuthTemplateSlug.make("googleOAuth2"),
690− value: "token-xyz",
691− });
692−
693− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
694− expect(toolNames).toContain("calendar.events.list");
695− expect(toolNames).toContain("photoslibrary.mediaItems.upload");
696− expect(toolNames).toContain("photoslibrary.mediaItems.search");
697− expect(toolNames).not.toContain("photospicker.mediaItems.list");
698− expect(toolNames).not.toContain("photoslibrary.albums.list");
699− }),
700− ),
701− );
702−});
703−
704−describe("Google health-check default", () => {
705− it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () =>
706− Effect.scoped(
707− Effect.gen(function* () {
708− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
709− const defaultUrls = googleOpenApiPresets
710− .filter((preset: GoogleOpenApiPreset) => preset.featured)
711− .flatMap((preset: GoogleOpenApiPreset) => (preset.url ? [preset.url] : []));
712−
713− expect(defaultUrls).toEqual([CALENDAR_URL, GMAIL_URL, SHEETS_URL, DRIVE_URL, DOCS_URL]);
714−
715− yield* executor.google.addBundle({
716− urls: defaultUrls,
717− slug: "google_default",
718− description: "Google",
719− });
720−
721− const stored = yield* executor.integrations.healthCheck.get(
722− IntegrationSlug.make("google_default"),
723− );
724− expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe(
725− "oauth2.userinfo.get",
726− );
727− expect(stored?.args, "userinfo needs no args").toBeUndefined();
728− expect(stored?.identityField, "the default reads the account email").toBe("email");
729−
730− const config = yield* executor.google.getConfig("google_default");
731− expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]);
732− const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
733− expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual(
734− [
735− "email",
736− "https://mail.google.com/",
737− "https://www.googleapis.com/auth/calendar",
738− "https://www.googleapis.com/auth/documents",
739− "https://www.googleapis.com/auth/drive",
740− "https://www.googleapis.com/auth/spreadsheets",
741− "openid",
742− "profile",
743− ].sort(),
744− );
745−
746− const candidates = yield* executor.integrations.healthCheck.candidates(
747− IntegrationSlug.make("google_default"),
748− );
749− const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get");
750− expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined();
751− expect(
752− (userinfo?.responseFields ?? []).map((field) => field.path),
753− "the email identity field is projected from the response schema",
754− ).toContain("email");
755− }),
756− ),
757− );
758−
759− it.effect("addBundle with People still prefers OAuth2 userinfo", () =>
760− Effect.scoped(
761− Effect.gen(function* () {
762− const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() }));
763−
764− yield* executor.google.addBundle({
765− urls: [PEOPLE_URL, CALENDAR_URL],
766− slug: "google_people",
767− description: "Google",
768− });
769−
770− const stored = yield* executor.integrations.healthCheck.get(
771− IntegrationSlug.make("google_people"),
772− );
773− expect(stored?.operation, "OAuth2 userinfo beats the People identity call").toBe(
774− "oauth2.userinfo.get",
775− );
776− expect(stored?.args).toBeUndefined();
777− expect(stored?.identityField).toBe("email");
778− }),
779− ),
780− );
781−
782− it("falls back to People when OAuth2 userinfo is absent", () => {
783− expect(
784− defaultGoogleHealthCheck(
785− [PEOPLE_URL],
786− [
787− {
788− toolPath: "people.people.get",
789− operation: { method: "get", pathTemplate: "/v1/{+resourceName}" },
790− },
791− ],
792− ),
793− ).toEqual({
794− operation: "people.people.get",
795− args: { resourceName: "people/me", personFields: "names,emailAddresses" },
796− identityField: "emailAddresses.0.value",
797− });
798− });
799−});
packages/plugins/google/src/sdk/plugin.ts+0−469
@@ -1,469 +0,0 @@
1−import { Effect } from "effect";
2−import type { Layer } from "effect";
3−import { HttpClient } from "effect/unstable/http";
4−
5−import {
6− IntegrationAlreadyExistsError,
7− IntegrationDetectionResult,
8− IntegrationNotFoundError,
9− IntegrationSlug,
10− definePlugin,
11− mergeAuthTemplates,
12− sha256Hex,
13− type AuthMethodDescriptor,
14− type HealthCheckSpec,
15− type Integration,
16− type IntegrationConfig,
17− type IntegrationRecord,
18− type PluginCtx,
19−} from "@executor-js/sdk/core";
20−import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21−import {
22− checkHealthOpenApi,
23− compileOpenApiSpec,
24− invokeOpenApiBackedTool,
25− listHealthCheckCandidatesOpenApi,
26− makeDefaultOpenapiStore,
27− normalizeOpenApiAuthInputs,
28− OpenApiParseError,
29− openApiStoredOperationsFromCompiled,
30− resolveOpenApiBackedAnnotations,
31− resolveOpenApiBackedTools,
32− type Authentication,
33− type AuthenticationInput,
34− type OpenapiStore,
35−} from "@executor-js/plugin-openapi";
36−
37−import {
38− convertGoogleDiscoveryBundleToOpenApi,
39− fetchGoogleDiscoveryDocument,
40− normalizeGoogleDiscoveryUrl,
41−} from "./discovery";
42−import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
43−import {
44− googleOAuthConsentScopesForPreset,
45− googleOpenApiBundlePreset,
46− googlePhotosOpenApiBundlePreset,
47− googlePhotosOpenApiPresets,
48−} from "./presets";
49−
50−const GOOGLE_OAUTH2_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
51−
52−/** The default health check for a Google bundle: prefer the lightweight
53− * OAuth2 userinfo identity call, which every new Google bundle includes.
54− * Older bundles may only have People API, so keep that fallback. */
55−export const defaultGoogleHealthCheck = (
56− urls: readonly string[],
57− definitions: readonly {
58− readonly toolPath: string;
59− readonly operation: { readonly method: string; readonly pathTemplate: string };
60− }[],
61−): HealthCheckSpec | undefined => {
62− const userinfoGet = definitions.find(
63− (def) =>
64− def.operation.method.toLowerCase() === "get" &&
65− (def.toolPath === "oauth2.userinfo.get" ||
66− def.operation.pathTemplate === "/oauth2/v2/userinfo"),
67− );
68− if (userinfoGet) {
69− return {
70− operation: userinfoGet.toolPath,
71− identityField: "email",
72− };
73− }
74−
75− const hasPeopleApi = urls.some((url) => url.includes("/people/"));
76− if (!hasPeopleApi) return undefined;
77− const peopleGet = definitions.find(
78− (def) =>
79− def.operation.method.toLowerCase() === "get" &&
80− (def.toolPath === "people.people.get" ||
81− def.operation.pathTemplate === "/v1/{+resourceName}"),
82− );
83− return peopleGet
84− ? {
85− operation: peopleGet.toolPath,
86− args: { resourceName: "people/me", personFields: "names,emailAddresses" },
87− identityField: "emailAddresses.0.value",
88− }
89− : undefined;
90−};
91−
92−export interface GoogleBundleConfig {
93− readonly urls: readonly string[];
94− readonly slug?: string;
95− readonly name?: string;
96− readonly description?: string;
97− readonly baseUrl?: string;
98−}
99−
100−export interface GoogleConfigureInput {
101− readonly authenticationTemplate: readonly AuthenticationInput[];
102− readonly mode?: "merge" | "replace";
103−}
104−
105−export interface GoogleUpdateInput {
106− readonly urls?: readonly string[];
107−}
108−
109−export interface GoogleUpdateResult {
110− readonly slug: IntegrationSlug;
111− readonly toolCount: number;
112− readonly addedTools: readonly string[];
113− readonly removedTools: readonly string[];
114−}
115−
116−export interface GooglePluginOptions {
117− readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
118−}
119−
120−const DEFAULT_GOOGLE_SLUG = "google";
121−
122−const googlePhotosBundlePresetIdByUrl = new Map(
123− googlePhotosOpenApiPresets.flatMap((preset) =>
124− preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],
125− ),
126−);
127−
128−const googlePhotosBundleConsentScopes = (
129− urls: readonly string[],
130−): readonly string[] | undefined => {
131− const normalized = new Set(urls);
132− const presetIds = [...googlePhotosBundlePresetIdByUrl.entries()].flatMap(([url, presetId]) =>
133− normalized.has(url) ? [presetId] : [],
134− );
135− return presetIds.length > 0
136− ? presetIds.flatMap((presetId) => googleOAuthConsentScopesForPreset(presetId))
137− : undefined;
138−};
139−
140−const fetchGoogleBundleConversion = (
141− urls: readonly string[],
142− httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
143−) =>
144− Effect.forEach(
145− urls,
146− (url) =>
147− fetchGoogleDiscoveryDocument(url).pipe(
148− Effect.provide(httpClientLayer),
149− Effect.map((documentText) => ({ discoveryUrl: url, documentText })),
150− ),
151− { concurrency: 4 },
152− ).pipe(
153− Effect.flatMap((documents) => {
154− const consentScopes = googlePhotosBundleConsentScopes(urls);
155− return convertGoogleDiscoveryBundleToOpenApi({
156− documents,
157− ...(consentScopes ? { consentScopes } : {}),
158− });
159− }),
160− );
161−
162−const uniqueUrls = (urls: readonly string[]): readonly string[] => [
163− ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])),
164−];
165−
166−const googleBundleUrlsWithIdentity = (
167− urls: readonly string[],
168−): Effect.Effect<readonly string[], OpenApiParseError> =>
169− Effect.gen(function* () {
170− const normalized: string[] = [];
171− for (const url of urls) {
172− const discoveryUrl = normalizeGoogleDiscoveryUrl(url);
173− if (!discoveryUrl) {
174− return yield* new OpenApiParseError({
175− message:
176− "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint",
177− });
178− }
179− normalized.push(discoveryUrl);
180− }
181− return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]);
182− });
183−
184−const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => {
185− const config = decodeGoogleIntegrationConfig(record.config);
186− if (!config) return [];
187− return (config.authenticationTemplate ?? []).map(
188− (template: Authentication): AuthMethodDescriptor => {
189− if (template.kind === "oauth2") {
190− return {
191− id: String(template.slug),
192− label: "OAuth2",
193− kind: "oauth",
194− template: String(template.slug),
195− oauth: {
196− authorizationUrl: template.authorizationUrl,
197− tokenUrl: template.tokenUrl,
198− scopes: template.scopes,
199− },
200− };
201− }
202− return describeApiKeyAuthMethod(template);
203− },
204− );
205−};
206−
207−const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {
208− const config = decodeGoogleIntegrationConfig(record.config);
209− return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };
210−};
211−
212−const makeGooglePluginExtension = (
213− options: GooglePluginOptions | undefined,
214− ctx: PluginCtx<OpenapiStore>,
215−) => {
216− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
217−
218− const addBundle = (config: GoogleBundleConfig) =>
219− Effect.gen(function* () {
220− const urls = yield* googleBundleUrlsWithIdentity(config.urls);
221− const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
222− const compiled = yield* compileOpenApiSpec(conversion.specText);
223− const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG);
224−
225− const existing = yield* ctx.core.integrations.get(slug);
226− if (existing) {
227− return yield* new IntegrationAlreadyExistsError({ slug });
228− }
229−
230− const specHash = yield* sha256Hex(conversion.specText);
231− const integrationConfig: GoogleIntegrationConfig = {
232− specHash,
233− googleDiscoveryUrls: urls,
234− ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
235− ...(conversion.authenticationTemplate
236− ? { authenticationTemplate: conversion.authenticationTemplate }
237− : {}),
238− };
239−
240− yield* ctx.storage.putSpec(specHash, conversion.specText);
241− yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
242−
243− yield* ctx.transaction(
244− Effect.gen(function* () {
245− yield* ctx.core.integrations.register({
246− slug,
247− name: config.name?.trim() || "Google",
248− description: config.description ?? "Google APIs",
249− config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
250− canRemove: true,
251− canRefresh: true,
252− });
253− yield* ctx.storage.putOperations(
254− String(slug),
255− openApiStoredOperationsFromCompiled(String(slug), compiled),
256− );
257− }),
258− );
259−
260− // Default the health check to the light OAuth2 userinfo identity call
261− // added to every new bundle. Older bundles without oauth2/v2 can still
262− // fall back to the People API identity operation.
263− const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions);
264− if (defaultHealthCheck) {
265− yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck);
266− }
267−
268− return { slug, toolCount: compiled.definitions.length };
269− });
270−
271− const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) =>
272− Effect.gen(function* () {
273− const slug = IntegrationSlug.make(rawSlug);
274− const record = yield* ctx.core.integrations.get(slug);
275− const current = record ? decodeGoogleIntegrationConfig(record.config) : null;
276− if (!record || !current) {
277− return yield* new IntegrationNotFoundError({ slug });
278− }
279−
280− const urls = yield* googleBundleUrlsWithIdentity(
281− input?.urls ?? current.googleDiscoveryUrls ?? [],
282− );
283− const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer);
284− const compiled = yield* compileOpenApiSpec(conversion.specText);
285−
286− const previousOperations = yield* ctx.storage.listOperations(rawSlug);
287− const previousNames = new Set(previousOperations.map((op) => op.toolName));
288− const nextNames = new Set(compiled.definitions.map((def) => def.toolPath));
289−
290− const specHash = yield* sha256Hex(conversion.specText);
291− yield* ctx.storage.putSpec(specHash, conversion.specText);
292− yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs));
293−
294− const nextConfig: GoogleIntegrationConfig = {
295− ...current,
296− specHash,
297− googleDiscoveryUrls: urls,
298− };
299−
300− yield* ctx.transaction(
301− Effect.gen(function* () {
302− yield* ctx.core.integrations.update(slug, {
303− config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig,
304− });
305− yield* ctx.storage.putOperations(
306− rawSlug,
307− openApiStoredOperationsFromCompiled(rawSlug, compiled),
308− );
309− }),
310− );
311−
312− const connections = yield* ctx.connections.list({ integration: slug });
313− yield* Effect.forEach(
314− connections,
315− (connection) =>
316− ctx.connections
317− .refresh({
318− owner: connection.owner,
319− integration: connection.integration,
320− name: connection.name,
321− })
322− .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
323− { discard: true },
324− ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));
325−
326− return {
327− slug,
328− toolCount: compiled.definitions.length,
329− addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
330− removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
331− };
332− });
333−
334− return {
335− addBundle,
336− updateBundle,
337− removeBundle: (slug: string) =>
338− ctx.transaction(
339− Effect.gen(function* () {
340− yield* ctx.storage.removeOperations(slug);
341− yield* ctx.core.integrations
342− .remove(IntegrationSlug.make(slug))
343− .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
344− }),
345− ),
346− getIntegration: (slug: string) =>
347− ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
348− Effect.map((record) =>
349− record
350− ? ({
351− slug: record.slug,
352− description: record.description,
353− kind: record.kind,
354− canRemove: record.canRemove,
355− canRefresh: record.canRefresh,
356− } as Integration)
357− : null,
358− ),
359− ),
360− getConfig: (slug: string) =>
361− ctx.core.integrations
362− .get(IntegrationSlug.make(slug))
363− .pipe(
364− Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)),
365− ),
366− configure: (slug: string, input: GoogleConfigureInput) =>
367− ctx.transaction(
368− Effect.gen(function* () {
369− const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
370− if (!record) return [] as readonly Authentication[];
371− const current = decodeGoogleIntegrationConfig(record.config);
372− if (!current) return [] as readonly Authentication[];
373−
374− const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
375− const merged =
376− input.mode === "replace"
377− ? incoming
378− : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
379−
380− const next: GoogleIntegrationConfig = {
381− ...current,
382− authenticationTemplate: merged,
383− };
384−
385− yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
386− config: next satisfies GoogleIntegrationConfig as IntegrationConfig,
387− });
388−
389− return merged;
390− }),
391− ),
392− };
393−};
394−
395−export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>;
396−
397−export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({
398− id: "google" as const,
399− packageName: "@executor-js/plugin-google",
400− integrationPresets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset],
401− storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
402−
403− extension: (ctx: PluginCtx<OpenapiStore>) => makeGooglePluginExtension(options, ctx),
404−
405− describeAuthMethods: describeGoogleAuthMethods,
406− describeIntegrationDisplay: describeGoogleIntegrationDisplay,
407−
408− resolveTools: ({ integration, config, storage }) =>
409− resolveOpenApiBackedTools({ integration, config, storage }),
410−
411− invokeTool: ({ ctx, toolRow, credential, args }) => {
412− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
413− return invokeOpenApiBackedTool({
414− ctx,
415− toolRow,
416− credential,
417− args,
418− httpClientLayer,
419− });
420− },
421−
422− resolveAnnotations: ({ ctx, integration, toolRows }) =>
423− resolveOpenApiBackedAnnotations({
424− ctx,
425− integration: String(integration),
426− toolRows,
427− }),
428−
429− // Health checks reuse the OpenAPI backing (same store). The People API
430− // identity call is auto-defaulted at addBundle when present; core owns the
431− // stored spec, the user adjusts it via the editor.
432− listHealthCheckCandidates: (input) =>
433− listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }),
434− checkHealth: (input) =>
435− checkHealthOpenApi({
436− ctx: input.ctx,
437− integration: input.integration,
438− credential: input.credential,
439− spec: input.spec,
440− httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,
441− }),
442−
443− removeConnection: () => Effect.void,
444−
445− detect: ({ ctx, url }) =>
446− Effect.gen(function* () {
447− const trimmed = url.trim();
448− const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed);
449− if (!trimmed || !discoveryUrl) return null;
450− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
451− const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe(
452− Effect.provide(httpClientLayer),
453− Effect.flatMap((documentText) =>
454− convertGoogleDiscoveryBundleToOpenApi({
455− documents: [{ discoveryUrl, documentText }],
456− }),
457− ),
458− Effect.catch(() => Effect.succeed(null)),
459− );
460− if (!conversion) return null;
461− return IntegrationDetectionResult.make({
462− kind: "google",
463− confidence: "high",
464− endpoint: discoveryUrl,
465− name: conversion.title,
466− slug: DEFAULT_GOOGLE_SLUG,
467− });
468− }),
469−}));
packages/plugins/google/src/sdk/presets.test.ts+42−1
@@ -1,6 +1,30 @@
11 import { expect, it } from "@effect/vitest";
22
3−import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";
3+import { googleCatalog, googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";
4+
5+const FROZEN_GOOGLE_SLUGS = [
6+ "google_calendar",
7+ "google_gmail",
8+ "google_sheets",
9+ "google_drive",
10+ "google_docs",
11+ "google_slides",
12+ "google_forms",
13+ "google_tasks",
14+ "google_people",
15+ "google_photos_library",
16+ "google_photos_picker",
17+ "google_chat",
18+ "google_keep",
19+ "google_youtube_data",
20+ "google_search_console",
21+ "google_classroom",
22+ "google_admin_directory",
23+ "google_admin_reports",
24+ "google_apps_script",
25+ "google_bigquery",
26+ "google_cloud_resource_manager",
27+] as const;
428
529 it("keeps Select all limited to Google services that can use normal user OAuth", () => {
630 const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id));
@@ -27,3 +51,20 @@ it("classifies every Google service for bundle OAuth UX", () => { · 20 unmodified lines
2751 })),
2852 ).toMatchSnapshot();
2953 });
54+
55+it("exports one catalog row per Google service with stable slugs and per-service OAuth", () => {
56+ expect(googleCatalog.map((preset) => preset.defaultSlug)).toEqual([...FROZEN_GOOGLE_SLUGS]);
57+ for (const preset of googleCatalog) {
58+ expect(preset.family).toBe("google");
59+ expect(preset.specFormat).toBe("google-discovery");
60+ expect(preset.defaultSlug).toBeTruthy();
61+ expect(preset.authTemplate).toHaveLength(1);
62+ const oauthTemplates = (preset.authTemplate ?? []).filter(
63+ (template) => template.kind === "oauth2",
64+ );
65+ expect(oauthTemplates).toHaveLength(1);
66+ expect(oauthTemplates[0]?.scopes).toEqual(
67+ expect.arrayContaining(["openid", "email", "profile"]),
68+ );
69+ }
70+});
packages/plugins/google/src/sdk/presets.ts+41−1
@@ -1,4 +1,6 @@
11 import { normalizeGoogleDiscoveryUrl } from "./discovery";
2+import { compactGoogleOAuthScopes } from "./oauth-scopes";
3+import type { IntegrationPreset } from "@executor-js/sdk/core";
24
35 export interface GooglePreset {
46 readonly id: string;
@@ -238,7 +240,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 233 unmodified lines
238240 // The picker shows the OAuth consent a user is about to grant BEFORE connecting
239241 // (the "View scopes" panel), but the authoritative scope list only exists in
240242 // each API's live Discovery document, which the add flow fetches lazily at
241−// `addBundle` time. To preview consent without N network round-trips, each preset
243+// provider add time. To preview consent without N network round-trips, each preset
242244 // declares the broad top-level scope(s) a full integration grants. These flow
243245 // through `googleOAuthConsentBatches` (which compacts sub-scopes under their
244246 // broad parent), so the previewed grant matches the unioned scopes the bundle
@@ -276,6 +278,44 @@ export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[] · 31 unmodified lines
276278 export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>
277279 googleOAuthConsentScopes[presetId] ?? [];
278280
281+export const googleServiceSlug = (presetId: string): string => presetId.replaceAll("-", "_");
282+
283+const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
284+const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
285+const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
286+const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"];
287+const GOOGLE_USERINFO_HEALTH_CHECK = {
288+ operation: "oauth2.userinfo.get",
289+ identityField: "email",
290+} as const;
291+
292+const googleCatalogAuthTemplate = (presetId: string) => [
293+ {
294+ slug: GOOGLE_OAUTH_SECURITY_SCHEME,
295+ kind: "oauth2" as const,
296+ authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL,
297+ tokenUrl: GOOGLE_OAUTH_TOKEN_URL,
298+ scopes: compactGoogleOAuthScopes([
299+ ...GOOGLE_IDENTITY_SCOPES,
300+ ...googleOAuthConsentScopesForPreset(presetId),
301+ ]),
302+ },
303+];
304+
305+export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets.map((preset) => ({
306+ id: preset.id,
307+ name: preset.name,
308+ summary: preset.summary,
309+ ...(preset.url ? { url: preset.url } : {}),
310+ ...(preset.icon ? { icon: preset.icon } : {}),
311+ ...(preset.featured ? { featured: preset.featured } : {}),
312+ family: "google",
313+ specFormat: "google-discovery",
314+ defaultSlug: googleServiceSlug(preset.id),
315+ authTemplate: googleCatalogAuthTemplate(preset.id),
316+ healthCheck: GOOGLE_USERINFO_HEALTH_CHECK,
317+}));
318+
279319 // ---------------------------------------------------------------------------
280320 // Resolve a stored/normalized Discovery URL back to its preset, so a bundled
281321 // `google` integration can surface each selected API's `oauthAudience` (e.g. a
packages/plugins/google/src/sdk/product-picker-scopes.test.ts+0−122
@@ -1,122 +0,0 @@
1−// ---------------------------------------------------------------------------
2−// GoogleProductPicker "View scopes" data source.
3−//
4−// The picker previews the OAuth consent BEFORE connecting by feeding each
5−// selected preset's representative scopes through `googleOAuthConsentBatches`.
6−// These tests pin that bridge: the consent-scope map is complete and the
7−// previewed batches match what the user is about to grant (core services
8−// collapsed into one batch; admin/cloud services split out).
9−// ---------------------------------------------------------------------------
10−
11−import { expect, it } from "@effect/vitest";
12−
13−import {
14− googleAudienceWarningsForUrls,
15− googleOAuthConsentScopes,
16− googleOAuthConsentScopesForPreset,
17− googleOpenApiPresets,
18− googlePresetForDiscoveryUrl,
19− type GoogleOpenApiPreset,
20−} from "./presets";
21−import { googleOAuthConsentBatches } from "./oauth-batches";
22−import { compactGoogleOAuthScopes } from "./oauth-scopes";
23−
24−const batchInputsFor = (presetIds: readonly string[]) =>
25− googleOpenApiPresets
26− .filter((preset: GoogleOpenApiPreset) => presetIds.includes(preset.id))
27− .map((preset: GoogleOpenApiPreset) => ({
28− id: preset.id,
29− name: preset.name,
30− oauthAudience: preset.oauthAudience,
31− scopes: googleOAuthConsentScopesForPreset(preset.id),
32− }));
33−
34−it("declares representative consent scopes for every Google preset", () => {
35− for (const preset of googleOpenApiPresets) {
36− expect(googleOAuthConsentScopesForPreset(preset.id).length).toBeGreaterThan(0);
37− }
38− // No stray keys for presets that no longer exist.
39− const presetIds = new Set(googleOpenApiPresets.map((preset) => preset.id));
40− for (const key of Object.keys(googleOAuthConsentScopes)) {
41− expect(presetIds.has(key)).toBe(true);
42− }
43−});
44−
45−it("previews the featured selection as a single core-services consent batch", () => {
46− const featuredIds = googleOpenApiPresets
47− .filter((preset: GoogleOpenApiPreset) => preset.featured)
48− .map((preset: GoogleOpenApiPreset) => preset.id);
49−
50− const batches = googleOAuthConsentBatches(batchInputsFor(featuredIds));
51−
52− // The featured presets are all standard-user, so they collapse into one batch.
53− expect(batches.map((batch) => batch.id)).toEqual(["google-core"]);
54− expect(batches[0]?.apiScopes).toEqual([
55− "https://www.googleapis.com/auth/calendar",
56− "https://mail.google.com/",
57− "https://www.googleapis.com/auth/spreadsheets",
58− "https://www.googleapis.com/auth/drive",
59− "https://www.googleapis.com/auth/documents",
60− ]);
61−});
62−
63−it("maps stored Discovery URLs back to presets and flags caution-tier audiences", () => {
64− // A normalized stored URL (trailing slash, sorted query) still resolves.
65− expect(
66− googlePresetForDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest/")
67− ?.id,
68− ).toBe("google-calendar");
69−
70− // A bundle of only standard/advanced APIs raises no audience warning.
71− expect(
72− googleAudienceWarningsForUrls([
73− "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
74− "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest",
75− ]),
76− ).toEqual([]);
77−
78− // Admin-only (Chat) and unsupported-consent (Keep) APIs both flag.
79− expect(
80− [
81− ...googleAudienceWarningsForUrls([
82− "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
83− "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest",
84− "https://keep.googleapis.com/$discovery/rest?version=v1",
85− ]),
86− ].sort(),
87− ).toEqual(["unsupported-user", "workspace-admin"]);
88−});
89−
90−it("previews a consent set that matches what the bundle persists (compaction parity)", () => {
91− // The bundle converter runs the unioned Discovery scopes through
92− // `compactGoogleOAuthScopes` before persisting the auth template. The picker
93− // preview must agree: feeding every preview batch's scopes back through the
94− // same compaction is idempotent (already collapsed/filtered), so the previewed
95− // grant is exactly the persisted/requested grant.
96− const allPresetIds = googleOpenApiPresets.map((preset: GoogleOpenApiPreset) => preset.id);
97− const batches = googleOAuthConsentBatches(batchInputsFor(allPresetIds));
98− for (const batch of batches) {
99− expect(compactGoogleOAuthScopes(batch.apiScopes)).toEqual([...batch.apiScopes]);
100− }
101−});
102−
103−it("splits a mixed selection into core + per-service consent batches", () => {
104− const batches = googleOAuthConsentBatches(
105− batchInputsFor(["google-calendar", "google-youtube-data", "google-bigquery"]),
106− );
107−
108− expect(batches.map((batch) => ({ id: batch.id, apiScopes: batch.apiScopes }))).toEqual([
109− {
110− id: "google-core",
111− apiScopes: ["https://www.googleapis.com/auth/calendar"],
112− },
113− {
114− id: "google-youtube-data",
115− apiScopes: ["https://www.googleapis.com/auth/youtube"],
116− },
117− {
118− id: "google-cloud",
119− apiScopes: ["https://www.googleapis.com/auth/bigquery"],
120− },
121− ]);
122−});
packages/plugins/google/src/sdk/spec-format-adapter.test.ts+50−0
@@ -4,8 +4,10 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 3 unmodified lines
44 import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
55 import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
66 import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7+import type { AuthenticationInput } from "@executor-js/plugin-openapi";
78
89 import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
10+import { googleCatalog } from "./presets";
911
1012 const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
1113
@@ -115,3 +117,51 @@ it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived i · 103 unmodified lines
115117 expect(added.toolCount).toBe(1);
116118 }),
117119 );
120+
121+it.effect(
122+ "adds a Google catalog preset through OpenAPI with family, format, and default slug",
123+ () =>
124+ Effect.gen(function* () {
125+ const tasksPreset = googleCatalog.find((preset) => preset.defaultSlug === "google_tasks")!;
126+ const authTemplate: readonly AuthenticationInput[] = (tasksPreset.authTemplate ?? []).flatMap(
127+ (template) => (template.kind === "oauth2" ? [template] : []),
128+ );
129+ const executor = yield* createExecutor(
130+ makeTestConfig({
131+ plugins: [
132+ openApiPlugin({
133+ httpClientLayer: discoveryHttpClientLayer,
134+ presets: [tasksPreset],
135+ specFormats: [googleDiscoveryAdapter],
136+ }),
137+ memoryCredentialsPlugin(),
138+ ],
139+ }),
140+ );
141+
142+ const added = yield* executor.openapi.addSpec({
143+ spec: { kind: "url", url: tasksPreset.url! },
144+ slug: tasksPreset.defaultSlug,
145+ specFormat: tasksPreset.specFormat,
146+ family: tasksPreset.family,
147+ authenticationTemplate: authTemplate,
148+ });
149+ const config = yield* executor.openapi.getConfig("google_tasks");
150+
151+ expect(String(added.slug)).toBe("google_tasks");
152+ expect(config?.family).toBe("google");
153+ expect(config?.specFormat).toBe("google-discovery");
154+ expect(config?.authenticationTemplate?.[0]?.kind).toBe("oauth2");
155+ const storedOAuthTemplates = (config?.authenticationTemplate ?? []).filter(
156+ (template) => template.kind === "oauth2",
157+ );
158+ expect(storedOAuthTemplates[0]?.scopes).toEqual(
159+ expect.arrayContaining([
160+ "openid",
161+ "email",
162+ "profile",
163+ "https://www.googleapis.com/auth/tasks",
164+ ]),
165+ );
166+ }),
167+);
packages/plugins/google/tsup.config.ts+0−1
@@ -4,7 +4,6 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7− client: "src/react/plugin-client.tsx",
87 },
98 format: ["esm"],
109 dts: false,
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−2
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
2323 useSlugAlreadyExists,
2424 } from "@executor-js/react/lib/integration-add";
2525
26−import { addGraphqlIntegrationOptimistic } from "./atoms";
26+import { createGraphqlIntegrationOptimistic } from "./atoms";
2727 import { GraphqlSourceFields } from "./GraphqlSourceFields";
2828 import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
2929 import type { GraphqlAuthMethodInput } from "../sdk/types";
@@ -52,7 +52,7 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines
5252 const [adding, setAdding] = useState(false);
5353 const [addError, setAddError] = useState<string | null>(null);
5454
55− const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, {
55+ const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, {
5656 mode: "promiseExit",
5757 });
5858
packages/plugins/graphql/src/react/atoms.ts+3−3
@@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines
3333 // Mutation atoms
3434 // ---------------------------------------------------------------------------
3535
36−export const addGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");
36+export const createGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");
3737
3838 // Merge-append custom auth methods onto an integration's `authenticationTemplate`.
3939 export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure");
@@ -41,7 +41,7 @@ export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); · 1 unmodified line
4141 // Optimistically slot a pending row into the shared integration catalog so the
4242 // integrations list reflects the new GraphQL integration before the server
4343 // round-trips. The reducer mirrors the catalog `Integration` shape.
44−export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(
44+export const createGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(
4545 Atom.optimisticFn({
4646 reducer: (
4747 current,
@@ -69,6 +69,6 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 21 unmodified lines
6969 a.name.localeCompare(b.name),
7070 );
7171 }),
72− fn: addGraphqlIntegration,
72+ fn: createGraphqlIntegration,
7373 }),
7474 );
packages/plugins/graphql/src/react/index.ts+1−1
@@ -1,3 +1,3 @@
11 export { graphqlIntegrationPlugin } from "./source-plugin";
22 export { GraphqlClient } from "./client";
3−export { addGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms";
3+export { createGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms";
packages/plugins/microsoft/package.json+2−12
@@ -17,10 +17,7 @@ · 16 unmodified lines
1717 "type": "module",
1818 "exports": {
1919 ".": "./src/sdk/index.ts",
20− "./promise": "./src/promise.ts",
21− "./api": "./src/api/index.ts",
22− "./react": "./src/react/index.ts",
23− "./client": "./src/react/plugin-client.tsx"
20+ "./promise": "./src/promise.ts"
2421 },
2522 "publishConfig": {
2623 "access": "public",
@@ -36,12 +33,6 @@ · 9 unmodified lines
3633 "types": "./dist/sdk/index.d.ts",
3734 "default": "./dist/core.js"
3835 }
39− },
40− "./client": {
41− "import": {
42− "types": "./dist/react/plugin-client.d.ts",
43− "default": "./dist/client.js"
44− }
4536 }
4637 }
4738 },
@@ -54,8 +45,7 @@ · 6 unmodified lines
5445 },
5546 "dependencies": {
5647 "@executor-js/plugin-openapi": "workspace:*",
57− "@executor-js/sdk": "workspace:*",
58− "lucide-react": "^1.7.0"
48+ "@executor-js/sdk": "workspace:*"
5949 },
6050 "devDependencies": {
6151 "@effect/atom-react": "catalog:",
packages/plugins/microsoft/src/api/group.ts+0−163
@@ -1,163 +0,0 @@
1−import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2−import { Schema } from "effect";
3−import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4−import {
5− InternalError,
6− IntegrationAlreadyExistsError,
7− IntegrationNotFoundError,
8− IntegrationSlug,
9−} from "@executor-js/sdk/shared";
10−import {
11− OpenApiExtractionError,
12− OpenApiOAuthError,
13− OpenApiParseError,
14−} from "@executor-js/plugin-openapi";
15−
16−const DomainErrors = [
17− InternalError,
18− OpenApiParseError,
19− OpenApiExtractionError,
20− OpenApiOAuthError,
21− IntegrationAlreadyExistsError,
22−] as const;
23−
24−const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25−
26−const UpdateErrors = [
27− InternalError,
28− OpenApiParseError,
29− OpenApiExtractionError,
30− OpenApiOAuthError,
31− IntegrationNotFound,
32−] as const;
33−
34−const SlugParams = {
35− slug: Schema.String,
36−};
37−
38−const OAuthTemplatePayload = Schema.Struct({
39− slug: Schema.String,
40− kind: Schema.Literal("oauth2"),
41− authorizationUrl: Schema.String,
42− tokenUrl: Schema.String,
43− scopes: Schema.Array(Schema.String),
44−});
45−
46−const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47−const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48−
49−const AddGraphPayload = Schema.Struct({
50− presetIds: Schema.Array(Schema.String),
51− customScopes: Schema.optional(Schema.Array(Schema.String)),
52− slug: Schema.optional(Schema.String),
53− name: Schema.optional(Schema.String),
54− description: Schema.optional(Schema.String),
55− baseUrl: Schema.optional(Schema.String),
56− specUrl: Schema.optional(Schema.String),
57− authorizationUrl: Schema.optional(Schema.String),
58− tokenUrl: Schema.optional(Schema.String),
59− clientCredentialsTokenUrl: Schema.optional(Schema.String),
60−});
61−
62−const AddGraphResponse = Schema.Struct({
63− slug: IntegrationSlug,
64− toolCount: Schema.Number,
65−});
66−
67−const UpdateGraphPayload = Schema.Struct({
68− presetIds: Schema.optional(Schema.Array(Schema.String)),
69− customScopes: Schema.optional(Schema.Array(Schema.String)),
70− baseUrl: Schema.optional(Schema.String),
71− specUrl: Schema.optional(Schema.String),
72− authorizationUrl: Schema.optional(Schema.String),
73− tokenUrl: Schema.optional(Schema.String),
74− clientCredentialsTokenUrl: Schema.optional(Schema.String),
75−});
76−
77−const UpdateGraphResponse = Schema.Struct({
78− slug: IntegrationSlug,
79− toolCount: Schema.Number,
80− addedTools: Schema.Array(Schema.String),
81− removedTools: Schema.Array(Schema.String),
82−});
83−
84−const IntegrationView = Schema.Struct({
85− slug: IntegrationSlug,
86− description: Schema.String,
87− kind: Schema.String,
88− canRemove: Schema.Boolean,
89− canRefresh: Schema.Boolean,
90−});
91−
92−const MicrosoftConfigView = Schema.Struct({
93− sourceUrl: Schema.optional(Schema.String),
94− microsoftGraphPresetIds: Schema.optional(Schema.Array(Schema.String)),
95− microsoftGraphCustomScopes: Schema.optional(Schema.Array(Schema.String)),
96− microsoftGraphScopes: Schema.optional(Schema.Array(Schema.String)),
97− microsoftGraphExactPaths: Schema.optional(Schema.Array(Schema.String)),
98− microsoftGraphPathPrefixes: Schema.optional(Schema.Array(Schema.String)),
99− microsoftGraphTagPrefixes: Schema.optional(Schema.Array(Schema.String)),
100− microsoftGraphCoversFullGraph: Schema.optional(Schema.Boolean),
101− microsoftGraphAuthorizationUrl: Schema.optional(Schema.String),
102− microsoftGraphTokenUrl: Schema.optional(Schema.String),
103− microsoftGraphClientCredentialsTokenUrl: Schema.optional(Schema.String),
104− baseUrl: Schema.optional(Schema.String),
105− headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
106− queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
107− authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)),
108−});
109−
110−const ConfigurePayload = Schema.Struct({
111− authenticationTemplate: Schema.Array(AuthenticationPayload),
112− mode: Schema.optional(Schema.Literals(["merge", "replace"])),
113−});
114−
115−const ConfigureResponse = Schema.Struct({
116− authenticationTemplate: Schema.Array(AuthenticationResponse),
117−});
118−
119−export const MicrosoftGroup = HttpApiGroup.make("microsoft")
120− .add(
121− HttpApiEndpoint.post("addGraph", "/microsoft/graph", {
122− payload: AddGraphPayload,
123− success: AddGraphResponse,
124− error: DomainErrors,
125− }),
126− )
127− .add(
128− HttpApiEndpoint.get("getIntegration", "/microsoft/integrations/:slug", {
129− params: SlugParams,
130− success: Schema.NullOr(IntegrationView),
131− error: DomainErrors,
132− }),
133− )
134− .add(
135− HttpApiEndpoint.get("getConfig", "/microsoft/integrations/:slug/config", {
136− params: SlugParams,
137− success: Schema.NullOr(MicrosoftConfigView),
138− error: DomainErrors,
139− }),
140− )
141− .add(
142− HttpApiEndpoint.post("configure", "/microsoft/integrations/:slug/config", {
143− params: SlugParams,
144− payload: ConfigurePayload,
145− success: ConfigureResponse,
146− error: DomainErrors,
147− }),
148− )
149− .add(
150− HttpApiEndpoint.post("updateGraph", "/microsoft/integrations/:slug/graph", {
151− params: SlugParams,
152− payload: UpdateGraphPayload,
153− success: UpdateGraphResponse,
154− error: UpdateErrors,
155− }),
156− )
157− .add(
158− HttpApiEndpoint.delete("removeGraph", "/microsoft/integrations/:slug", {
159− params: SlugParams,
160− success: Schema.Void,
161− error: DomainErrors,
162− }),
163− );
packages/plugins/microsoft/src/api/handlers.ts+0−144
@@ -1,144 +0,0 @@
1−import { HttpApiBuilder } from "effect/unstable/httpapi";
2−import { Context, Effect } from "effect";
3−
4−import { addGroup, capture } from "@executor-js/api";
5−import type { MicrosoftPluginExtension } from "../sdk/plugin";
6−import { MicrosoftGroup } from "./group";
7−
8−export class MicrosoftExtensionService extends Context.Service<
9− MicrosoftExtensionService,
10− MicrosoftPluginExtension
11−>()("MicrosoftExtensionService") {}
12−
13−const ExecutorApiWithMicrosoft = addGroup(MicrosoftGroup);
14−
15−export const MicrosoftHandlers = HttpApiBuilder.group(
16− ExecutorApiWithMicrosoft,
17− "microsoft",
18− (handlers) =>
19− handlers
20− .handle("addGraph", ({ payload }) =>
21− capture(
22− Effect.gen(function* () {
23− const ext = yield* MicrosoftExtensionService;
24− return yield* ext.addGraph({
25− presetIds: payload.presetIds,
26− customScopes: payload.customScopes,
27− slug: payload.slug,
28− name: payload.name,
29− description: payload.description,
30− baseUrl: payload.baseUrl,
31− specUrl: payload.specUrl,
32− authorizationUrl: payload.authorizationUrl,
33− tokenUrl: payload.tokenUrl,
34− clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl,
35− });
36− }),
37− ),
38− )
39− .handle("getIntegration", ({ params }) =>
40− capture(
41− Effect.gen(function* () {
42− const ext = yield* MicrosoftExtensionService;
43− const integration = yield* ext.getIntegration(params.slug);
44− return integration
45− ? {
46− slug: integration.slug,
47− description: integration.description,
48− kind: integration.kind,
49− canRemove: integration.canRemove,
50− canRefresh: integration.canRefresh,
51− }
52− : null;
53− }),
54− ),
55− )
56− .handle("getConfig", ({ params }) =>
57− capture(
58− Effect.gen(function* () {
59− const ext = yield* MicrosoftExtensionService;
60− const config = yield* ext.getConfig(params.slug);
61− return config
62− ? {
63− sourceUrl: config.sourceUrl,
64− microsoftGraphPresetIds: config.microsoftGraphPresetIds
65− ? [...config.microsoftGraphPresetIds]
66− : undefined,
67− microsoftGraphCustomScopes: config.microsoftGraphCustomScopes
68− ? [...config.microsoftGraphCustomScopes]
69− : undefined,
70− microsoftGraphScopes: config.microsoftGraphScopes
71− ? [...config.microsoftGraphScopes]
72− : undefined,
73− microsoftGraphExactPaths: config.microsoftGraphExactPaths
74− ? [...config.microsoftGraphExactPaths]
75− : undefined,
76− microsoftGraphPathPrefixes: config.microsoftGraphPathPrefixes
77− ? [...config.microsoftGraphPathPrefixes]
78− : undefined,
79− microsoftGraphTagPrefixes: config.microsoftGraphTagPrefixes
80− ? [...config.microsoftGraphTagPrefixes]
81− : undefined,
82− microsoftGraphCoversFullGraph: config.microsoftGraphCoversFullGraph,
83− microsoftGraphAuthorizationUrl: config.microsoftGraphAuthorizationUrl,
84− microsoftGraphTokenUrl: config.microsoftGraphTokenUrl,
85− microsoftGraphClientCredentialsTokenUrl:
86− config.microsoftGraphClientCredentialsTokenUrl,
87− baseUrl: config.baseUrl,
88− headers: config.headers ? { ...config.headers } : undefined,
89− queryParams: config.queryParams ? { ...config.queryParams } : undefined,
90− authenticationTemplate: config.authenticationTemplate
91− ? [...config.authenticationTemplate]
92− : undefined,
93− }
94− : null;
95− }),
96− ),
97− )
98− .handle("configure", ({ params, payload }) =>
99− capture(
100− Effect.gen(function* () {
101− const ext = yield* MicrosoftExtensionService;
102− const authenticationTemplate = yield* ext.configure(params.slug, {
103− authenticationTemplate: payload.authenticationTemplate,
104− mode: payload.mode ?? "merge",
105− });
106− return { authenticationTemplate: [...authenticationTemplate] };
107− }),
108− ),
109− )
110− .handle("updateGraph", ({ params, payload }) =>
111− capture(
112− Effect.gen(function* () {
113− const ext = yield* MicrosoftExtensionService;
114− const result = yield* ext.updateGraph(params.slug, {
115− ...(payload.presetIds !== undefined ? { presetIds: payload.presetIds } : {}),
116− ...(payload.customScopes !== undefined ? { customScopes: payload.customScopes } : {}),
117− ...(payload.baseUrl !== undefined ? { baseUrl: payload.baseUrl } : {}),
118− ...(payload.specUrl !== undefined ? { specUrl: payload.specUrl } : {}),
119− ...(payload.authorizationUrl !== undefined
120− ? { authorizationUrl: payload.authorizationUrl }
121− : {}),
122− ...(payload.tokenUrl !== undefined ? { tokenUrl: payload.tokenUrl } : {}),
123− ...(payload.clientCredentialsTokenUrl !== undefined
124− ? { clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl }
125− : {}),
126− });
127− return {
128− slug: result.slug,
129− toolCount: result.toolCount,
130− addedTools: [...result.addedTools],
131− removedTools: [...result.removedTools],
132− };
133− }),
134− ),
135− )
136− .handle("removeGraph", ({ params }) =>
137− capture(
138− Effect.gen(function* () {
139− const ext = yield* MicrosoftExtensionService;
140− yield* ext.removeGraph(params.slug);
141− }),
142− ),
143− ),
144−);
packages/plugins/microsoft/src/api/index.ts+0−15
@@ -1,15 +0,0 @@
1−import { definePlugin } from "@executor-js/sdk/core";
2−
3−import { microsoftPlugin, type MicrosoftPluginOptions } from "../sdk/plugin";
4−import { MicrosoftGroup } from "./group";
5−import { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
6−
7−export { MicrosoftGroup } from "./group";
8−export { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
9−
10−export const microsoftHttpPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({
11− ...microsoftPlugin(options),
12− routes: () => MicrosoftGroup,
13− handlers: () => MicrosoftHandlers,
14− extensionService: MicrosoftExtensionService,
15−}));
packages/plugins/microsoft/src/promise.ts+1−1
@@ -1 +1 @@
1−export { microsoftPlugin } from "./sdk/plugin";
1+export * from "./sdk/index";
packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx+0−155
@@ -1,155 +0,0 @@
1−import { useCallback, useMemo, useState } from "react";
2−import { useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−
5−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6−import {
7− slugifyNamespace,
8− useIntegrationIdentity,
9−} from "@executor-js/react/plugins/integration-identity";
10−import { Button } from "@executor-js/react/components/button";
11−import { FloatActions } from "@executor-js/react/components/float-actions";
12−import {
13− addIntegrationErrorMessage,
14− FormErrorAlert,
15− SlugCollisionAlert,
16− useSlugAlreadyExists,
17−} from "@executor-js/react/lib/integration-add";
18−import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19−
20−import { addMicrosoftGraph } from "./atoms";
21−import { MicrosoftScopePicker } from "./MicrosoftScopePicker";
22−import {
23− MICROSOFT_GRAPH_BASE_URL,
24− MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
25− microsoftGraphScopesForPresetIds,
26−} from "../sdk/presets";
27−
28−const MICROSOFT_FAVICON = "https://www.microsoft.com/favicon.ico";
29−
30−const defaultPresetIds: ReadonlySet<string> = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
31−
32−export default function AddMicrosoftSource(props: {
33− onComplete: (slug?: string) => void;
34− onCancel: () => void;
35− initialNamespace?: string;
36−}) {
37− const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>(defaultPresetIds);
38− const [customScopes, setCustomScopes] = useState<readonly string[]>([]);
39− const [baseUrl, setBaseUrl] = useState("");
40− const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null);
41− const [adding, setAdding] = useState(false);
42− const [addError, setAddError] = useState<string | null>(null);
43−
44− const identity = useIntegrationIdentity({
45− fallbackName: "Microsoft Graph",
46− fallbackNamespace: props.initialNamespace ?? "microsoft_graph",
47− });
48−
49− const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]);
50− const selectedScopes = useMemo(
51− () => microsoftGraphScopesForPresetIds(selectedIds, customScopes),
52− [selectedIds, customScopes],
53− );
54−
55− const togglePreset = useCallback((presetId: string, checked: boolean) => {
56− setSelectedPresetIds((current: ReadonlySet<string>) => {
57− const next = new Set(current);
58− if (checked) next.add(presetId);
59− else next.delete(presetId);
60− return next;
61− });
62− }, []);
63−
64− const addCustomScope = useCallback((scope: string) => {
65− setCustomScopes((current: readonly string[]) =>
66− current.includes(scope) ? current : [...current, scope],
67− );
68− }, []);
69−
70− const removeCustomScope = useCallback((scope: string) => {
71− setCustomScopes((current: readonly string[]) =>
72− current.filter((entry: string) => entry !== scope),
73− );
74− }, []);
75−
76− const doAdd = useAtomSet(addMicrosoftGraph, { mode: "promiseExit" });
77−
78− const resolvedSourceId = slugifyNamespace(identity.namespace) || "microsoft_graph";
79− const resolvedDisplayName = identity.name.trim() || "Microsoft Graph";
80− const resolvedDescription = descriptionDraft ?? "Selected Microsoft Graph workloads.";
81− const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId);
82− const canAdd = selectedIds.length > 0 && !slugAlreadyExists;
83−
84− const handleAdd = async () => {
85− setAdding(true);
86− setAddError(null);
87− const exit = await doAdd({
88− payload: {
89− presetIds: selectedIds,
90− customScopes: [...customScopes],
91− slug: resolvedSourceId,
92− name: resolvedDisplayName,
93− ...(resolvedDescription.trim().length > 0
94− ? { description: resolvedDescription.trim() }
95− : {}),
96− ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}),
97− },
98− reactivityKeys: integrationWriteKeys,
99− });
100− if (Exit.isFailure(exit)) {
101− setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Microsoft"));
102− setAdding(false);
103− return;
104− }
105− props.onComplete(String(exit.value.slug));
106− };
107−
108− return (
109− <div className="flex flex-1 flex-col gap-6">
110− <div>
111− <h1 className="text-xl font-semibold text-foreground">Add Microsoft integration</h1>
112− <p className="mt-1 text-[13px] text-muted-foreground">
113− Pick Microsoft 365 workloads and connect them through one delegated OAuth consent.
114− </p>
115− </div>
116−
117− <MicrosoftScopePicker
118− selectedPresetIds={selectedPresetIds}
119− onToggle={togglePreset}
120− customScopes={customScopes}
121− onAddCustomScope={addCustomScope}
122− onRemoveCustomScope={removeCustomScope}
123− />
124−
125− <OpenApiSourceDetailsFields
126− title="Microsoft Graph"
127− subtitle={`${selectedIds.length} operation group${
128− selectedIds.length !== 1 ? "s" : ""
129− } with ${selectedScopes.length} preview scope${selectedScopes.length !== 1 ? "s" : ""}`}
130− identity={identity}
131− description={resolvedDescription}
132− onDescriptionChange={setDescriptionDraft}
133− baseUrl={baseUrl}
134− onBaseUrlChange={setBaseUrl}
135− baseUrlLabel="Base URL override (optional)"
136− baseUrlPlaceholder={MICROSOFT_GRAPH_BASE_URL}
137− faviconIcon={MICROSOFT_FAVICON}
138− faviconUrl={baseUrl || MICROSOFT_GRAPH_BASE_URL}
139− />
140−
141− {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />}
142−
143− {addError && <FormErrorAlert message={addError} />}
144−
145− <FloatActions>
146− <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
147− Cancel
148− </Button>
149− <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}>
150− Connect Microsoft
151− </Button>
152− </FloatActions>
153− </div>
154− );
155−}
packages/plugins/microsoft/src/react/MicrosoftAccountsPanel.tsx+0−118
@@ -1,118 +0,0 @@
1−import { useCallback, useMemo } from "react";
2−import { useAtomValue, useAtomSet } from "@effect/atom-react";
3−import * as Exit from "effect/Exit";
4−import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5−import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6−import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7−
8−import { AccountsSection } from "@executor-js/react/components/accounts-section";
9−import {
10− HealthCheckEditor,
11− type HealthCheckLivePreview,
12−} from "@executor-js/react/components/health-check-editor";
13−import { useOrganizationId } from "@executor-js/react/api/organization-context";
14−import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
15−import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
16−import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
17−import {
18− useCustomMethodActions,
19− type AuthMethodsCodec,
20− type ConfigureAuthMethods,
21−} from "@executor-js/react/lib/custom-auth-methods";
22−import {
23− authMethodsFromConfig,
24− openApiWireAuthInput,
25− templateFromPlacements,
26−} from "@executor-js/plugin-openapi/react";
27−import type { Authentication } from "@executor-js/plugin-openapi";
28−
29−import { microsoftConfigAtom, microsoftConfigure } from "./atoms";
30−
31−const NO_AUTH_METHOD: AuthMethod = {
32− id: "none",
33− label: "No authentication",
34− kind: "none",
35− source: "spec",
36− template: AuthTemplateSlug.make("none"),
37− placements: [],
38−};
39−
40−export default function MicrosoftAccountsPanel(props: {
41− readonly sourceId: string;
42− readonly integrationName: string;
43− readonly accountHandoff?: IntegrationAccountHandoff | null;
44−}) {
45− const { sourceId, integrationName, accountHandoff } = props;
46− const slug = IntegrationSlug.make(sourceId);
47− const configResult = useAtomValue(microsoftConfigAtom(slug));
48− const doConfigure = useAtomSet(microsoftConfigure, { mode: "promiseExit" });
49−
50− const existingTemplate = useMemo<readonly Authentication[]>(() => {
51− if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return [];
52− return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[];
53− }, [configResult]);
54−
55− const methods = useMemo<readonly AuthMethod[]>(() => {
56− const declared = authMethodsFromConfig(existingTemplate);
57− return declared.length > 0 ? declared : [NO_AUTH_METHOD];
58− }, [existingTemplate]);
59−
60− const configure = useCallback<ConfigureAuthMethods<Authentication>>(
61− async (input) => {
62− const exit = await doConfigure({
63− params: { slug },
64− payload: {
65− authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput),
66− ...(input.mode ? { mode: input.mode } : {}),
67− },
68− reactivityKeys: integrationWriteKeys,
69− });
70− return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]);
71− },
72− [doConfigure, slug],
73− );
74−
75− const codec = useMemo<AuthMethodsCodec<Authentication>>(
76− () => ({
77− toAuthMethods: authMethodsFromConfig,
78− templatesFromPlacements: (placements: readonly Placement[]) => [
79− templateFromPlacements(placements),
80− ],
81− slugOf: (template: Authentication) => String(template.slug),
82− }),
83− [],
84− );
85−
86− const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({
87− existing: existingTemplate,
88− codec,
89− configure,
90− });
91−
92− // Live-preview context for the health-check edit sheet (apiKey methods only;
93− // OAuth methods can't be previewed with a pasted key).
94− const organizationId = useOrganizationId();
95− const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => {
96− const templates = methods
97− .filter((m) => m.kind === "apikey")
98− .map((m) => ({ template: m.template, label: m.label }));
99− if (templates.length === 0) return undefined;
100− return { owner: defaultConnectionOwnerForHost(organizationId), templates };
101− }, [methods, organizationId]);
102−
103− return (
104− <div className="mx-auto max-w-3xl space-y-8 px-6 py-8">
105− <AccountsSection
106− integration={slug}
107− integrationName={integrationName}
108− methods={methods}
109− accountHandoff={accountHandoff}
110− createCustomMethod={createCustomMethod}
111− removeCustomMethod={removeCustomMethod}
112− />
113− {/* Graph auto-configures the /me identity probe at add time; this is
114− where an admin sees, adjusts, or clears it. */}
115− <HealthCheckEditor integration={slug} livePreview={livePreview} />
116− </div>
117− );
118−}
packages/plugins/microsoft/src/react/MicrosoftScopePicker.tsx+0−286
@@ -1,286 +0,0 @@
1−import { useMemo, useState } from "react";
2−import { ChevronDownIcon, PlusIcon, XIcon } from "lucide-react";
3−
4−import { cn } from "@executor-js/react/lib/utils";
5−import { Badge } from "@executor-js/react/components/badge";
6−import { Button } from "@executor-js/react/components/button";
7−import { Checkbox } from "@executor-js/react/components/checkbox";
8−import {
9− Collapsible,
10− CollapsibleContent,
11− CollapsibleTrigger,
12−} from "@executor-js/react/components/collapsible";
13−import { FieldLabel } from "@executor-js/react/components/field";
14−import { Input } from "@executor-js/react/components/input";
15−import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16−
17−import {
18− microsoftGraphScopePresets,
19− microsoftGraphScopesForPresetIds,
20− type MicrosoftGraphScopeAudience,
21− type MicrosoftGraphScopePreset,
22−} from "../sdk/presets";
23−
24−type MicrosoftScopePickerProps = {
25− readonly selectedPresetIds: ReadonlySet<string>;
26− readonly onToggle: (presetId: string, checked: boolean) => void;
27− readonly customScopes: readonly string[];
28− readonly onAddCustomScope: (scope: string) => void;
29− readonly onRemoveCustomScope: (scope: string) => void;
30−};
31−
32−const AUDIENCE_ORDER: readonly MicrosoftGraphScopeAudience[] = [
33− "productivity",
34− "files-content",
35− "collaboration",
36− "directory-identity",
37− "admin-security",
38− "platform-business",
39−];
40−
41−const AUDIENCE_LABEL: Readonly<Record<MicrosoftGraphScopeAudience, string>> = {
42− productivity: "Productivity",
43− "files-content": "Files and content",
44− collaboration: "Collaboration",
45− "directory-identity": "Directory and identity",
46− "admin-security": "Admin and security",
47− "platform-business": "Platform and business",
48−};
49−
50−const AUDIENCE_DESCRIPTION: Readonly<Record<MicrosoftGraphScopeAudience, string>> = {
51− productivity: "User-facing Microsoft 365 operations for the connected account.",
52− "files-content": "Drive, SharePoint, OneNote, workbook, and content APIs.",
53− collaboration: "Teams, chats, online meetings, calls, and teamwork APIs.",
54− "directory-identity": "Tenant directory, app, identity, policy, and governance APIs.",
55− "admin-security": "Admin, audit, security, compliance, device, and education APIs.",
56− "platform-business": "Search, connectors, extensions, print, places, and business services.",
57−};
58−
59−const ScopeRow = ({
60− preset,
61− checked,
62− onToggle,
63−}: {
64− readonly preset: MicrosoftGraphScopePreset;
65− readonly checked: boolean;
66− readonly onToggle: (checked: boolean) => void;
67−}) => (
68− <FieldLabel
69− className={cn(
70− "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors",
71− checked ? "bg-primary/5" : "hover:bg-muted/40",
72− )}
73− >
74− <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} />
75− <div className="shrink-0">
76− <IntegrationFavicon icon={preset.icon} size={16} />
77− </div>
78− <div className="min-w-0 flex-1">
79− <div className="flex min-w-0 items-center gap-2">
80− <span className="truncate text-sm font-medium text-foreground">{preset.name}</span>
81− </div>
82− <p className="truncate text-[11px] text-muted-foreground">{preset.summary}</p>
83− </div>
84− </FieldLabel>
85−);
86−
87−const CustomScopeInput = ({
88− customScopes,
89− onAddCustomScope,
90− onRemoveCustomScope,
91−}: {
92− readonly customScopes: readonly string[];
93− readonly onAddCustomScope: (scope: string) => void;
94− readonly onRemoveCustomScope: (scope: string) => void;
95−}) => {
96− const [draft, setDraft] = useState("");
97− const trimmed = draft.trim();
98− const isValid = trimmed.length > 0 && !/\s/.test(trimmed);
99− const isDuplicate = customScopes.includes(trimmed);
100−
101− const commit = () => {
102− if (!isValid || isDuplicate) return;
103− onAddCustomScope(trimmed);
104− setDraft("");
105− };
106−
107− return (
108− <div className="space-y-2">
109− <FieldLabel className="text-[11px] font-medium text-muted-foreground">
110− Add custom Graph scope
111− </FieldLabel>
112− <div className="flex items-center gap-2">
113− <Input
114− value={draft}
115− onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)}
116− onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => {
117− if (event.key === "Enter") {
118− event.preventDefault();
119− commit();
120− }
121− }}
122− placeholder="Sites.Read.All"
123− className="font-mono text-[11px]"
124− />
125− <Button
126− type="button"
127− variant="outline"
128− size="sm"
129− disabled={!isValid || isDuplicate}
130− onClick={commit}
131− >
132− <PlusIcon className="size-3.5" />
133− Add
134− </Button>
135− </div>
136− {trimmed.length > 0 && !isValid ? (
137− <p className="text-[11px] text-destructive">Scopes cannot contain whitespace.</p>
138− ) : null}
139− {customScopes.length > 0 ? (
140− <ul className="space-y-1">
141− {customScopes.map((scope: string) => (
142− <li
143− key={scope}
144− className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5"
145− >
146− <span className="truncate font-mono text-[11px] text-foreground">{scope}</span>
147− <Button
148− type="button"
149− variant="ghost"
150− size="icon"
151− className="size-6 shrink-0"
152− onClick={() => onRemoveCustomScope(scope)}
153− aria-label={`Remove ${scope}`}
154− >
155− <XIcon className="size-3.5" />
156− </Button>
157− </li>
158− ))}
159− </ul>
160− ) : null}
161− </div>
162− );
163−};
164−
165−export function MicrosoftScopePicker({
166− selectedPresetIds,
167− onToggle,
168− customScopes,
169− onAddCustomScope,
170− onRemoveCustomScope,
171−}: MicrosoftScopePickerProps) {
172− const [scopesOpen, setScopesOpen] = useState(false);
173−
174− const groups = useMemo(
175− () =>
176− AUDIENCE_ORDER.flatMap((audience: MicrosoftGraphScopeAudience) => {
177− const presets = microsoftGraphScopePresets.filter(
178− (preset: MicrosoftGraphScopePreset) => preset.audience === audience,
179− );
180− return presets.length > 0 ? [{ audience, presets }] : [];
181− }),
182− [],
183− );
184−
185− const scopes = useMemo(
186− () => microsoftGraphScopesForPresetIds([...selectedPresetIds], customScopes),
187− [selectedPresetIds, customScopes],
188− );
189−
190− return (
191− <section className="space-y-4">
192− <div className="space-y-1">
193− <FieldLabel>Customize Microsoft Graph</FieldLabel>
194− <p className="text-[11px] text-muted-foreground">
195− Pick the operation groups to expose as tools. They share one Microsoft OAuth consent and
196− one account connection.
197− </p>
198− </div>
199−
200− {groups.map(
201− ({
202− audience,
203− presets,
204− }: {
205− readonly audience: MicrosoftGraphScopeAudience;
206− readonly presets: readonly MicrosoftGraphScopePreset[];
207− }) => (
208− <div key={audience} className="space-y-2">
209− <div className="flex items-center gap-2">
210− <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase">
211− {AUDIENCE_LABEL[audience]}
212− </span>
213− <Button
214− type="button"
215− variant="ghost"
216− size="sm"
217− className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground"
218− onClick={() => {
219− const allSelected = presets.every((preset: MicrosoftGraphScopePreset) =>
220− selectedPresetIds.has(preset.id),
221− );
222− presets.forEach((preset: MicrosoftGraphScopePreset) =>
223− onToggle(preset.id, !allSelected),
224− );
225− }}
226− >
227− {presets.every((preset: MicrosoftGraphScopePreset) =>
228− selectedPresetIds.has(preset.id),
229− )
230− ? "Clear"
231− : "Select all"}
232− </Button>
233− </div>
234− <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p>
235− <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2">
236− {presets.map((preset: MicrosoftGraphScopePreset) => (
237− <ScopeRow
238− key={preset.id}
239− preset={preset}
240− checked={selectedPresetIds.has(preset.id)}
241− onToggle={(checked: boolean) => onToggle(preset.id, checked)}
242− />
243− ))}
244− </div>
245− </div>
246− ),
247− )}
248−
249− <CustomScopeInput
250− customScopes={customScopes}
251− onAddCustomScope={onAddCustomScope}
252− onRemoveCustomScope={onRemoveCustomScope}
253− />
254−
255− <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}>
256− <CollapsibleTrigger asChild>
257− <Button type="button" variant="outline" size="sm" disabled={scopes.length === 0}>
258− <ChevronDownIcon
259− className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")}
260− />
261− View scopes
262− {scopes.length > 0 ? (
263− <Badge variant="secondary" className="ml-1">
264− {scopes.length}
265− </Badge>
266− ) : null}
267− </Button>
268− </CollapsibleTrigger>
269− <CollapsibleContent className="pt-3">
270− <ul className="space-y-1">
271− {scopes.map((scope: string) => (
272− <li
273− key={scope}
274− className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground"
275− >
276− {scope}
277− </li>
278− ))}
279− </ul>
280− </CollapsibleContent>
281− </Collapsible>
282− </section>
283− );
284−}
285−
286−export default MicrosoftScopePicker;
packages/plugins/microsoft/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1−import type { IntegrationSlug } from "@executor-js/sdk/shared";
2−import * as Atom from "effect/unstable/reactivity/Atom";
3−import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4−import { MicrosoftClient } from "./client";
5−
6−export const microsoftIntegrationAtom = (slug: IntegrationSlug) =>
7− MicrosoftClient.query("microsoft", "getIntegration", {
8− params: { slug },
9− timeToLive: "15 seconds",
10− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11− });
12−
13−export const microsoftConfigAtom = (slug: IntegrationSlug) =>
14− MicrosoftClient.query("microsoft", "getConfig", {
15− params: { slug },
16− timeToLive: "15 seconds",
17− reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18− });
19−
20−export const addMicrosoftGraph = MicrosoftClient.mutation("microsoft", "addGraph");
21−
22−export const updateMicrosoftGraph = MicrosoftClient.mutation("microsoft", "updateGraph");
23−
24−export const removeMicrosoftGraph = MicrosoftClient.mutation("microsoft", "removeGraph");
25−
26−export const microsoftConfigure = MicrosoftClient.mutation("microsoft", "configure");
27−
28−export const microsoftIntegrationFamily = Atom.family(microsoftIntegrationAtom);
29−
30−export const microsoftConfigFamily = Atom.family(microsoftConfigAtom);
packages/plugins/microsoft/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1−import { createPluginAtomClient } from "@executor-js/sdk/client";
2−import {
3− getExecutorOrganizationHeaders,
4− getExecutorApiBaseUrl,
5− getExecutorServerAuthorizationHeader,
6−} from "@executor-js/react/api/server-connection";
7−import { MicrosoftGroup } from "../api/group";
8−
9−export const MicrosoftClient = createPluginAtomClient(MicrosoftGroup, {
10− baseUrl: getExecutorApiBaseUrl,
11− authorizationHeader: getExecutorServerAuthorizationHeader,
12− headers: getExecutorOrganizationHeaders,
13−});
packages/plugins/microsoft/src/react/index.ts+0−12
@@ -1,12 +0,0 @@
1−export { microsoftIntegrationPlugin } from "./source-plugin";
2−export { MicrosoftClient } from "./client";
3−export {
4− addMicrosoftGraph,
5− microsoftConfigAtom,
6− microsoftConfigFamily,
7− microsoftConfigure,
8− microsoftIntegrationAtom,
9− microsoftIntegrationFamily,
10− removeMicrosoftGraph,
11− updateMicrosoftGraph,
12−} from "./atoms";
packages/plugins/microsoft/src/react/plugin-client.tsx+0−8
@@ -1,8 +0,0 @@
1−import { defineClientPlugin } from "@executor-js/sdk/client";
2−
3−import { microsoftIntegrationPlugin } from "./source-plugin";
4−
5−export default defineClientPlugin({
6− id: "microsoft" as const,
7− integrationPlugin: microsoftIntegrationPlugin,
8−});
packages/plugins/microsoft/src/react/source-plugin.ts+0−18
@@ -1,18 +0,0 @@
1−import { lazy } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
3−import { microsoftGraphPreset } from "../sdk/presets";
4−
5−const importAdd = () => import("./AddMicrosoftSource");
6−const importAccounts = () => import("./MicrosoftAccountsPanel");
7−
8−export const microsoftIntegrationPlugin: IntegrationPlugin = {
9− key: "microsoft",
10− label: "Microsoft",
11− add: lazy(importAdd),
12− accounts: lazy(importAccounts),
13− presets: [microsoftGraphPreset],
14− preload: () => {
15− void importAdd();
16− void importAccounts();
17− },
18−};
packages/plugins/microsoft/src/sdk/index.ts+2−9
@@ -13,6 +13,7 @@ export { · 12 unmodified lines
1313 MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,
1414 MICROSOFT_GRAPH_PRESET_ID,
1515 MICROSOFT_TOKEN_URL,
16+ microsoftCatalog,
1617 microsoftGraphExactPathsForPresetIds,
1718 microsoftGraphPathPrefixesForPresetIds,
1819 microsoftGraphPreset,
@@ -21,6 +22,7 @@ export { · 2 unmodified lines
2122 microsoftGraphScopePresets,
2223 microsoftGraphScopesForPresetIds,
2324 microsoftGraphTagPrefixesForPresetIds,
25+ microsoftServiceSlug,
2426 type MicrosoftGraphPreset,
2527 type MicrosoftGraphScopeAudience,
2628 type MicrosoftGraphScopePreset,
@@ -37,12 +39,3 @@ export { · 10 unmodified lines
3739 type MicrosoftGraphSpecBuild,
3840 } from "./graph";
3941 export { microsoftGraphAdapter } from "./spec-format-adapter";
40−export {
41− microsoftPlugin,
42− type MicrosoftConfigureInput,
43− type MicrosoftGraphConfig,
44− type MicrosoftPluginExtension,
45− type MicrosoftPluginOptions,
46− type MicrosoftUpdateInput,
47− type MicrosoftUpdateResult,
48−} from "./plugin";
packages/plugins/microsoft/src/sdk/plugin.test.ts+0−692
@@ -1,692 +0,0 @@
1−import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Exit, Layer } from "effect";
3−import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4−
5−import {
6− AuthTemplateSlug,
7− ConnectionName,
8− IntegrationSlug,
9− createExecutor,
10−} from "@executor-js/sdk";
11−import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
12−
13−import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin";
14−import {
15− MICROSOFT_AUTH_TEMPLATE_SLUG,
16− MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
17− MICROSOFT_GRAPH_ALL_PRESET_IDS,
18− MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES,
19− MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
20− MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
21− MICROSOFT_GRAPH_OPENAPI_URL,
22− MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,
23−} from "./presets";
24−
25−const graphFixture = `
26−openapi: 3.0.4
27−info:
28− title: Microsoft Graph Fixture
29− version: v1.0
30−servers:
31− - url: https://graph.microsoft.com/v1.0
32−paths:
33− /me:
34− get:
35− operationId: me.GetUser
36− summary: Get the signed-in user profile
37− security:
38− - azureAdDelegated:
39− - User.Read
40− responses:
41− "200":
42− description: OK
43− /me/messages:
44− get:
45− operationId: me.messages.ListMessages
46− security:
47− - azureAdDelegated:
48− - Mail.ReadWrite
49− responses:
50− "200":
51− description: OK
52− /me/events:
53− get:
54− operationId: me.events.ListEvents
55− security:
56− - azureAdDelegated:
57− - Calendars.ReadWrite
58− responses:
59− "200":
60− description: OK
61− /me/onenote/pages:
62− get:
63− operationId: me.onenote.pages.ListPages
64− security:
65− - azureAdDelegated:
66− - Notes.ReadWrite
67− responses:
68− "200":
69− description: OK
70− /sites:
71− get:
72− operationId: sites.ListSites
73− responses:
74− "200":
75− description: OK
76−components:
77− schemas:
78− user:
79− type: object
80−`;
81−
82−const permissionsReferenceFixture = `
83−### User.Read
84−
85−| Category | Application | Delegated |
86−|--|--|--|
87−| Identifier | - | e1fe6dd8-ba31-4d61-89e7-88639da4683d |
88−
89−---
90−
91−### Mail.ReadWrite
92−
93−| Category | Application | Delegated |
94−|--|--|--|
95−| Identifier | e2a3a72e-5f79-4c64-b1b1-878b674786c9 | 024d486e-b451-40bb-833d-3e66d98c5c73 |
96−
97−---
98−
99−### Calendars.ReadWrite
100−
101−| Category | Application | Delegated |
102−|--|--|--|
103−| Identifier | ef54d2bf-783f-4e0f-bca1-3210c0444d99 | 1ec239c2-d7c9-4623-a91a-a9775856bb36 |
104−
105−---
106−
107−### Notes.ReadWrite
108−
109−| Category | Application | Delegated |
110−|--|--|--|
111−| Identifier | 085ca537-6565-41c2-aca7-db852babc212 | 615e82d5-1f7f-4f99-a456-0a0484a820d5 |
112−
113−---
114−
115−### AppCatalog.Read.All
116−
117−| Category | Application | Delegated |
118−|--|--|--|
119−| Identifier | e12dae10-5a57-4817-b79d-dfbec5348930 | - |
120−`;
121−
122−const EMULATOR_SPEC_URL = "https://microsoft.emulators.dev/_emulate/openapi";
123−const EMULATOR_BASE_URL = "https://microsoft.emulators.dev";
124−const LOCAL_EMULATOR_SPEC_URL = "http://localhost:4123/_emulate/openapi";
125−const LOCAL_EMULATOR_BASE_URL = "http://localhost:4123";
126−const emulatorGraphFixture = `
127−openapi: 3.0.3
128−info:
129− title: Microsoft Graph Emulator
130− version: 1.0.0
131−servers:
132− - url: ${EMULATOR_BASE_URL}
133−paths:
134− /v1.0/users:
135− get:
136− operationId: graphUser_List
137− responses:
138− "200":
139− description: OK
140− /v1.0/me:
141− get:
142− operationId: graphUser_GetMyProfile
143− responses:
144− "200":
145− description: OK
146−components:
147− securitySchemes:
148− azureAdDelegated:
149− type: oauth2
150− flows:
151− authorizationCode:
152− authorizationUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/authorize
153− tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token
154− scopes:
155− User.Read: User.Read
156− User.Read.All: User.Read.All
157− clientCredentials:
158− tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token
159− scopes:
160− https://graph.microsoft.com/.default: https://graph.microsoft.com/.default
161−`;
162−
163−const localEmulatorGraphFixture = `
164−openapi: 3.0.3
165−info:
166− title: Microsoft Graph Local Emulator
167− version: 1.0.0
168−servers:
169− - url: ${LOCAL_EMULATOR_BASE_URL}
170−paths:
171− /v1.0/users:
172− get:
173− operationId: graphUser_List
174− responses:
175− "200":
176− description: OK
177−components:
178− securitySchemes:
179− azureAdDelegated:
180− type: oauth2
181− flows:
182− clientCredentials:
183− tokenUrl: ${LOCAL_EMULATOR_BASE_URL}/oauth2/v2.0/token
184− scopes:
185− https://graph.microsoft.com/.default: https://graph.microsoft.com/.default
186−`;
187−
188−const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
189− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
190− Effect.succeed(
191− HttpClientResponse.fromWeb(
192− request,
193− new Response(
194− request.url === MICROSOFT_GRAPH_OPENAPI_URL
195− ? graphFixture
196− : request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL
197− ? permissionsReferenceFixture
198− : request.url === EMULATOR_SPEC_URL
199− ? emulatorGraphFixture
200− : request.url === LOCAL_EMULATOR_SPEC_URL
201− ? localEmulatorGraphFixture
202− : "not found",
203− {
204− status:
205− request.url === MICROSOFT_GRAPH_OPENAPI_URL ||
206− request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL ||
207− request.url === EMULATOR_SPEC_URL ||
208− request.url === LOCAL_EMULATOR_SPEC_URL
209− ? 200
210− : 404,
211− headers: {
212− "content-type":
213− request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL
214− ? "text/markdown"
215− : "application/yaml",
216− },
217− },
218− ),
219− ),
220− ),
221− ),
222−);
223−
224−const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) =>
225− [
226− microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }),
227− memoryCredentialsPlugin(),
228− ] as const;
229−
230−describe("Microsoft Graph provider", () => {
231− it.effect("rejects non-Microsoft URL overrides before fetching the Graph spec", () =>
232− Effect.scoped(
233− Effect.gen(function* () {
234− let requests = 0;
235− const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
236− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
237− Effect.sync(() => {
238− requests += 1;
239− return HttpClientResponse.fromWeb(
240− request,
241− new Response("unexpected request", { status: 500 }),
242− );
243− }),
244− ),
245− );
246− const executor = yield* createExecutor(
247− makeTestConfig({
248− plugins: [
249− microsoftPlugin({ httpClientLayer: blockedHttpClientLayer }),
250− memoryCredentialsPlugin(),
251− ],
252− }),
253− );
254−
255− const exit = yield* executor.microsoft
256− .addGraph({
257− slug: "bad_graph",
258− baseUrl: "https://attacker.example/v1.0",
259− specUrl: "https://attacker.example/openapi.yaml",
260− authorizationUrl: "https://attacker.example/oauth2/v2.0/authorize",
261− tokenUrl: "https://attacker.example/oauth2/v2.0/token",
262− clientCredentialsTokenUrl: "https://attacker.example/oauth2/v2.0/token",
263− })
264− .pipe(Effect.exit);
265−
266− expect(Exit.isFailure(exit)).toBe(true);
267− expect(requests).toBe(0);
268− }),
269− ),
270− );
271−
272− it.effect("adds a selected Graph workload source with one OAuth template", () =>
273− Effect.scoped(
274− Effect.gen(function* () {
275− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
276−
277− const result = yield* executor.microsoft.addGraph({
278− presetIds: ["profile", "mail"],
279− slug: "microsoft_graph",
280− description: "Microsoft Graph",
281− });
282−
283− expect(String(result.slug)).toBe("microsoft_graph");
284−
285− const config = yield* executor.microsoft.getConfig("microsoft_graph");
286− expect(config?.microsoftGraphPresetIds).toEqual(["profile", "mail"]);
287− expect(config?.microsoftGraphCoversFullGraph).toBe(false);
288− expect(config?.microsoftGraphScopes).toEqual([
289− "offline_access",
290− "User.Read",
291− "Mail.ReadWrite",
292− "Mail.Send",
293− "MailboxSettings.ReadWrite",
294− ]);
295−
296− const oauthTemplates = config?.authenticationTemplate?.filter(
297− (entry) => entry.kind === "oauth2",
298− );
299− const delegated = oauthTemplates?.find(
300− (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
301− );
302− const clientCredentials = oauthTemplates?.find(
303− (entry) => String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
304− );
305− expect(delegated?.kind === "oauth2" ? delegated.slug : undefined).toBe(
306− AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
307− );
308− expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([
309− "offline_access",
310− "User.Read",
311− "Mail.ReadWrite",
312− "Mail.Send",
313− "MailboxSettings.ReadWrite",
314− ]);
315− expect(clientCredentials?.kind === "oauth2" ? clientCredentials.slug : undefined).toBe(
316− AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG),
317− );
318− expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual(
319− [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
320− );
321−
322− yield* executor.connections.create({
323− owner: "org",
324− name: ConnectionName.make("main"),
325− integration: IntegrationSlug.make("microsoft_graph"),
326− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
327− value: "token-xyz",
328− });
329−
330− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
331− expect(toolNames).toContain("me.getUser");
332− expect(toolNames).toContain("me.messagesListMessages");
333− expect(toolNames).not.toContain("sites.listSites");
334− }),
335− ),
336− );
337−
338− it.effect("adds common Microsoft Graph workloads by default", () =>
339− Effect.scoped(
340− Effect.gen(function* () {
341− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
342−
343− yield* executor.microsoft.addGraph({
344− slug: "microsoft_graph_all",
345− description: "Microsoft Graph",
346− });
347−
348− const config = yield* executor.microsoft.getConfig("microsoft_graph_all");
349− expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
350− expect(config?.microsoftGraphCoversFullGraph).toBe(false);
351− expect(config?.microsoftGraphScopes).toEqual([
352− "offline_access",
353− "User.Read",
354− "Mail.ReadWrite",
355− "Mail.Send",
356− "MailboxSettings.ReadWrite",
357− "Calendars.ReadWrite",
358− "Contacts.ReadWrite",
359− "People.Read.All",
360− "Tasks.ReadWrite",
361− "Files.ReadWrite.All",
362− "Sites.ReadWrite.All",
363− "Notes.ReadWrite",
364− "Chat.ReadWrite",
365− "Team.ReadBasic.All",
366− "Channel.ReadBasic.All",
367− "ChannelMessage.Read.All",
368− "ChannelMessage.Send",
369− "OnlineMeetings.ReadWrite",
370− ]);
371−
372− const delegated = config?.authenticationTemplate?.find(
373− (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
374− );
375− expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual(
376− config?.microsoftGraphScopes,
377− );
378−
379− yield* executor.connections.create({
380− owner: "org",
381− name: ConnectionName.make("all"),
382− integration: IntegrationSlug.make("microsoft_graph_all"),
383− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
384− value: "token-xyz",
385− });
386−
387− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
388− expect(toolNames).toContain("me.getUser");
389− expect(toolNames).toContain("me.messagesListMessages");
390− expect(toolNames).toContain("me.eventsListEvents");
391− expect(toolNames).toContain("me.onenotePagesListPages");
392− expect(toolNames).toContain("sites.listSites");
393− }),
394− ),
395− );
396−
397− it.effect("uses the app registration default scope when every Graph workload is selected", () =>
398− Effect.scoped(
399− Effect.gen(function* () {
400− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
401−
402− yield* executor.microsoft.addGraph({
403− presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
404− slug: "microsoft_graph_full",
405− description: "Microsoft Graph",
406− });
407−
408− const config = yield* executor.microsoft.getConfig("microsoft_graph_full");
409− expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_ALL_PRESET_IDS);
410− expect(config?.microsoftGraphCoversFullGraph).toBe(true);
411− expect(config?.microsoftGraphScopes).toEqual(MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES);
412−
413− const delegated = config?.authenticationTemplate?.find(
414− (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
415− );
416− expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([
417− ...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
418− ]);
419−
420− // Full-graph add routes through the streaming compile (the path the
421− // real 37MB spec takes): the source text is structurally split and each
422− // op's binding plus a `description` is persisted, alongside the
423− // content-addressed defs blob, never materializing the whole-document
424− // tree. Read the operations back through the live serve path to prove
425− // they landed in storage AND that the serve fast path rebuilds tools
426− // from the persisted bindings (no spec parse).
427− yield* executor.connections.create({
428− owner: "org",
429− name: ConnectionName.make("full"),
430− integration: IntegrationSlug.make("microsoft_graph_full"),
431− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
432− value: "token-xyz",
433− });
434−
435− const tools = yield* executor.tools.list();
436− const toolNames = tools.map((tool) => String(tool.name));
437− expect(toolNames).toContain("me.getUser");
438− expect(toolNames).toContain("me.messagesListMessages");
439− expect(toolNames).toContain("sites.listSites");
440−
441− // The serve fast path must rebuild every tool's description from the
442− // persisted operation, not drop it. Each graph tool carries a non-empty
443− // description.
444− for (const tool of tools) {
445− expect(tool.description.length).toBeGreaterThan(0);
446− }
447−
448− // `me.getUser`'s spec summary survives the add -> persist -> serve
449− // round-trip. The bare `${METHOD} ${path}` fallback inside the serve
450− // path would be "GET /me", so matching the summary proves the persisted
451− // `description` field is what's served.
452− const getUser = tools.find((tool) => String(tool.name) === "me.getUser");
453− expect(getUser?.description).toBe("Get the signed-in user profile");
454−
455− // An op without a spec summary falls back to `${METHOD} ${path}`, also
456− // sourced from the persisted binding on the serve fast path.
457− const listSites = tools.find((tool) => String(tool.name) === "sites.listSites");
458− expect(listSites?.description).toBe("GET /sites");
459− }),
460− ),
461− );
462−
463− it.effect("uses explicit full Graph scopes when custom dynamic scopes are added", () =>
464− Effect.scoped(
465− Effect.gen(function* () {
466− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
467−
468− yield* executor.microsoft.addGraph({
469− slug: "microsoft_graph_all_custom",
470− presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],
471− customScopes: ["Custom.Scope"],
472− });
473−
474− const config = yield* executor.microsoft.getConfig("microsoft_graph_all_custom");
475− expect(config?.microsoftGraphCoversFullGraph).toBe(true);
476− expect(config?.microsoftGraphScopes).toEqual([
477− "offline_access",
478− "User.Read",
479− "Mail.ReadWrite",
480− "Calendars.ReadWrite",
481− "Notes.ReadWrite",
482− "Custom.Scope",
483− ]);
484− }),
485− ),
486− );
487−
488− it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () =>
489− Effect.scoped(
490− Effect.gen(function* () {
491− const executor = yield* createExecutor(
492− makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }),
493− );
494−
495− yield* executor.microsoft.addGraph({
496− presetIds: ["users"],
497− slug: "microsoft_graph_emulated",
498− baseUrl: EMULATOR_BASE_URL,
499− specUrl: EMULATOR_SPEC_URL,
500− });
501−
502− const config = yield* executor.microsoft.getConfig("microsoft_graph_emulated");
503− expect(config?.sourceUrl).toBe(EMULATOR_SPEC_URL);
504− expect(config?.baseUrl).toBe(EMULATOR_BASE_URL);
505− expect(config?.microsoftGraphAuthorizationUrl).toBe(
506− `${EMULATOR_BASE_URL}/oauth2/v2.0/authorize`,
507− );
508− expect(config?.microsoftGraphTokenUrl).toBe(`${EMULATOR_BASE_URL}/oauth2/v2.0/token`);
509− expect(config?.microsoftGraphClientCredentialsTokenUrl).toBe(
510− `${EMULATOR_BASE_URL}/oauth2/v2.0/token`,
511− );
512−
513− const delegated = config?.authenticationTemplate?.find(
514− (entry) => entry.kind === "oauth2" && String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG,
515− );
516− const clientCredentials = config?.authenticationTemplate?.find(
517− (entry) =>
518− entry.kind === "oauth2" &&
519− String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
520− );
521− expect(delegated?.kind === "oauth2" ? delegated.tokenUrl : undefined).toBe(
522− `${EMULATOR_BASE_URL}/oauth2/v2.0/token`,
523− );
524− expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual(
525− [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
526− );
527−
528− yield* executor.connections.create({
529− owner: "org",
530− name: ConnectionName.make("machine"),
531− integration: IntegrationSlug.make("microsoft_graph_emulated"),
532− template: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG),
533− value: "token-xyz",
534− });
535−
536− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
537− expect(toolNames).toContain("users.graphUserList");
538− }),
539− ),
540− );
541−
542− it.effect("accepts a loopback http emulator spec only when the override is enabled", () =>
543− Effect.scoped(
544− Effect.gen(function* () {
545− const executor = yield* createExecutor(
546− makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }),
547− );
548−
549− yield* executor.microsoft.addGraph({
550− presetIds: ["users"],
551− slug: "microsoft_graph_local_emulated",
552− baseUrl: LOCAL_EMULATOR_BASE_URL,
553− specUrl: LOCAL_EMULATOR_SPEC_URL,
554− });
555−
556− const config = yield* executor.microsoft.getConfig("microsoft_graph_local_emulated");
557− expect(config?.sourceUrl).toBe(LOCAL_EMULATOR_SPEC_URL);
558− expect(config?.baseUrl).toBe(LOCAL_EMULATOR_BASE_URL);
559− }),
560− ),
561− );
562−
563− it.effect("rejects a loopback http spec URL when the override is disabled", () =>
564− Effect.scoped(
565− Effect.gen(function* () {
566− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
567−
568− const exit = yield* executor.microsoft
569− .addGraph({
570− slug: "microsoft_graph_local_disabled",
571− baseUrl: LOCAL_EMULATOR_BASE_URL,
572− specUrl: LOCAL_EMULATOR_SPEC_URL,
573− })
574− .pipe(Effect.exit);
575−
576− expect(Exit.isFailure(exit)).toBe(true);
577− }),
578− ),
579− );
580−
581− it.effect("rejects a non-loopback http override even with allowUnsafeUrlOverrides", () =>
582− Effect.scoped(
583− Effect.gen(function* () {
584− let requests = 0;
585− const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
586− HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
587− Effect.sync(() => {
588− requests += 1;
589− return HttpClientResponse.fromWeb(
590− request,
591− new Response("unexpected request", { status: 500 }),
592− );
593− }),
594− ),
595− );
596− const executor = yield* createExecutor(
597− makeTestConfig({
598− plugins: [
599− microsoftPlugin({
600− httpClientLayer: blockedHttpClientLayer,
601− allowUnsafeUrlOverrides: true,
602− }),
603− memoryCredentialsPlugin(),
604− ],
605− }),
606− );
607−
608− const exit = yield* executor.microsoft
609− .addGraph({
610− slug: "microsoft_graph_http_example",
611− baseUrl: "http://example.com/v1.0",
612− specUrl: "http://example.com/openapi.yaml",
613− })
614− .pipe(Effect.exit);
615−
616− expect(Exit.isFailure(exit)).toBe(true);
617− expect(requests).toBe(0);
618− }),
619− ),
620− );
621−});
622−
623−describe("Microsoft Graph health-check default", () => {
624− it.effect("addGraph without profile still auto-configures the /me identity check", () =>
625− Effect.scoped(
626− Effect.gen(function* () {
627− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
628−
629− yield* executor.microsoft.addGraph({
630− presetIds: ["mail"],
631− slug: "microsoft_graph_mail_hc",
632− description: "Microsoft Graph",
633− });
634−
635− const config = yield* executor.microsoft.getConfig("microsoft_graph_mail_hc");
636− expect(config?.microsoftGraphExactPaths).toEqual([]);
637− expect(config?.microsoftGraphScopes).toEqual([
638− "offline_access",
639− "User.Read",
640− "Mail.ReadWrite",
641− "Mail.Send",
642− "MailboxSettings.ReadWrite",
643− ]);
644−
645− const stored = yield* executor.integrations.healthCheck.get(
646− IntegrationSlug.make("microsoft_graph_mail_hc"),
647− );
648− expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser");
649− expect(stored?.identityField, "the default reads the principal name").toBe(
650− "userPrincipalName",
651− );
652−
653− yield* executor.connections.create({
654− owner: "org",
655− name: ConnectionName.make("mail"),
656− integration: IntegrationSlug.make("microsoft_graph_mail_hc"),
657− template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG),
658− value: "token-xyz",
659− });
660−
661− const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name));
662− expect(toolNames).toContain("me.getUser");
663− expect(toolNames).toContain("me.messagesListMessages");
664− expect(toolNames).not.toContain("me.eventsListEvents");
665− }),
666− ),
667− );
668−
669− it.effect("addGraph with a /me workload auto-configures the identity health check", () =>
670− Effect.scoped(
671− Effect.gen(function* () {
672− const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() }));
673−
674− yield* executor.microsoft.addGraph({
675− presetIds: ["profile", "mail"],
676− slug: "microsoft_graph_hc",
677− description: "Microsoft Graph",
678− });
679−
680− // GET /me is the canonical Graph identity endpoint: the default probe
681− // answers alive/expired + who-am-I with zero configuration.
682− const stored = yield* executor.integrations.healthCheck.get(
683− IntegrationSlug.make("microsoft_graph_hc"),
684− );
685− expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser");
686− expect(stored?.identityField, "the default reads the principal name").toBe(
687− "userPrincipalName",
688− );
689− }),
690− ),
691− );
692−});
packages/plugins/microsoft/src/sdk/plugin.ts+0−436
@@ -1,436 +0,0 @@
1−import { Effect } from "effect";
2−import type { Layer } from "effect";
3−import { HttpClient } from "effect/unstable/http";
4−
5−import {
6− IntegrationAlreadyExistsError,
7− IntegrationDetectionResult,
8− IntegrationNotFoundError,
9− IntegrationSlug,
10− definePlugin,
11− mergeAuthTemplates,
12− sha256Hex,
13− type AuthMethodDescriptor,
14− type Integration,
15− type IntegrationConfig,
16− type IntegrationRecord,
17− type PluginCtx,
18− type StorageFailure,
19−} from "@executor-js/sdk/core";
20−import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21−import {
22− checkHealthOpenApi,
23− compileAndPersistOpenApiSpecStreaming,
24− listHealthCheckCandidatesOpenApi,
25− decodeOpenApiIntegrationConfig,
26− invokeOpenApiBackedTool,
27− makeDefaultOpenapiStore,
28− normalizeOpenApiAuthInputs,
29− OpenApiExtractionError,
30− resolveOpenApiBackedAnnotations,
31− resolveOpenApiBackedTools,
32− type Authentication,
33− type AuthenticationInput,
34− type OpenApiPersistResult,
35− type OpenapiStore,
36−} from "@executor-js/plugin-openapi";
37−
38−import {
39− buildMicrosoftGraphOpenApiSpec,
40− decodeMicrosoftGraphIntegrationConfig,
41− microsoftGraphKeepPathItem,
42− type MicrosoftGraphUrlPolicy,
43− type MicrosoftGraphIntegrationConfig,
44− type MicrosoftGraphSpecBuild,
45−} from "./graph";
46−import {
47− MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
48− MICROSOFT_GRAPH_BASE_URL,
49− microsoftGraphPreset,
50−} from "./presets";
51−
52−export interface MicrosoftGraphConfig {
53− readonly presetIds?: readonly string[];
54− readonly customScopes?: readonly string[];
55− readonly slug?: string;
56− readonly name?: string;
57− readonly description?: string;
58− readonly baseUrl?: string;
59− readonly specUrl?: string;
60− readonly authorizationUrl?: string;
61− readonly tokenUrl?: string;
62− readonly clientCredentialsTokenUrl?: string;
63−}
64−
65−export interface MicrosoftConfigureInput {
66− readonly authenticationTemplate: readonly AuthenticationInput[];
67− readonly mode?: "merge" | "replace";
68−}
69−
70−export interface MicrosoftUpdateInput {
71− readonly presetIds?: readonly string[];
72− readonly customScopes?: readonly string[];
73− readonly baseUrl?: string;
74− readonly specUrl?: string;
75− readonly authorizationUrl?: string;
76− readonly tokenUrl?: string;
77− readonly clientCredentialsTokenUrl?: string;
78−}
79−
80−export interface MicrosoftUpdateResult {
81− readonly slug: IntegrationSlug;
82− readonly toolCount: number;
83− readonly addedTools: readonly string[];
84− readonly removedTools: readonly string[];
85−}
86−
87−export interface MicrosoftPluginOptions {
88− readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
89− /**
90− * Allows `addGraph` to point spec/base/OAuth URLs at a trusted https host
91− * other than the pinned Microsoft Graph endpoints, or at plain http on
92− * loopback (local Graph emulators). Off by default; hosts wire this to
93− * their own local-network dev posture (e.g. `allowLocalNetwork`), never on
94− * in production.
95− */
96− readonly allowUnsafeUrlOverrides?: boolean;
97−}
98−
99−const DEFAULT_MICROSOFT_SLUG = "microsoft_graph";
100−
101−const describeMicrosoftAuthMethods = (
102− record: IntegrationRecord,
103−): readonly AuthMethodDescriptor[] => {
104− const config = decodeOpenApiIntegrationConfig(record.config);
105− if (!config) return [];
106− return (config.authenticationTemplate ?? []).map(
107− (template: Authentication): AuthMethodDescriptor => {
108− if (template.kind === "oauth2") {
109− const machineFlow =
110− String(template.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG;
111− return {
112− id: String(template.slug),
113− label: machineFlow ? "Microsoft OAuth (client credentials)" : "Microsoft OAuth",
114− kind: "oauth",
115− template: String(template.slug),
116− oauth: {
117− authorizationUrl: template.authorizationUrl,
118− tokenUrl: template.tokenUrl,
119− scopes: template.scopes,
120− },
121− };
122− }
123− return describeApiKeyAuthMethod(template);
124− },
125− );
126−};
127−
128−const describeMicrosoftIntegrationDisplay = (
129− record: IntegrationRecord,
130−): { readonly url?: string } => {
131− const config = decodeMicrosoftGraphIntegrationConfig(record.config);
132− return { url: config?.baseUrl ?? MICROSOFT_GRAPH_BASE_URL };
133−};
134−
135−const makeMicrosoftPluginExtension = (
136− ctx: PluginCtx<OpenapiStore>,
137− httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
138− urlPolicy?: MicrosoftGraphUrlPolicy,
139−) => {
140− const persistGraphOperations = (
141− graph: MicrosoftGraphSpecBuild,
142− integration: string,
143− specHash: string,
144− ): Effect.Effect<OpenApiPersistResult, OpenApiExtractionError | StorageFailure> =>
145− // Stream the (full 37MB) source straight to persisted bindings + a
146− // content-addressed defs blob, never materializing the whole-document tree
147− // that OOMs the 128MB Workers isolate. `keepPathItem` applies the Microsoft
148− // Graph scope selection per path-item during the stream; a full-graph
149− // selection returns `undefined` (keep everything).
150− compileAndPersistOpenApiSpecStreaming({
151− specText: graph.specText,
152− integration,
153− storage: ctx.storage,
154− specHash,
155− keepPathItem: microsoftGraphKeepPathItem(graph),
156− });
157−
158− const addGraph = (config: MicrosoftGraphConfig) =>
159− Effect.gen(function* () {
160− const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer, urlPolicy);
161− const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG);
162−
163− const existing = yield* ctx.core.integrations.get(slug);
164− if (existing) {
165− return yield* new IntegrationAlreadyExistsError({ slug });
166− }
167−
168− const specHash = yield* sha256Hex(graph.specText);
169− const integrationConfig: MicrosoftGraphIntegrationConfig = {
170− specHash,
171− sourceUrl: graph.specUrl,
172− microsoftGraphPresetIds: graph.presetIds,
173− microsoftGraphCustomScopes: graph.customScopes,
174− microsoftGraphScopes: graph.scopes,
175− microsoftGraphExactPaths: graph.exactPaths,
176− microsoftGraphPathPrefixes: graph.pathPrefixes,
177− microsoftGraphTagPrefixes: graph.tagPrefixes,
178− microsoftGraphCoversFullGraph: graph.coversFullGraph,
179− microsoftGraphAuthorizationUrl: graph.authorizationUrl,
180− microsoftGraphTokenUrl: graph.tokenUrl,
181− microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl,
182− authenticationTemplate: graph.authenticationTemplate,
183− ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
184− };
185−
186− yield* ctx.storage.putSpec(specHash, graph.specText);
187−
188− const persisted = yield* ctx.transaction(
189− Effect.gen(function* () {
190− yield* ctx.core.integrations.register({
191− slug,
192− name: config.name?.trim() || "Microsoft Graph",
193− description: config.description ?? "Selected Microsoft Graph workloads.",
194− config:
195− integrationConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig,
196− canRemove: true,
197− canRefresh: true,
198− });
199− return yield* persistGraphOperations(graph, String(slug), specHash);
200− }),
201− );
202−
203− // Default the health check to `GET /me` (the canonical Graph identity
204− // endpoint) when the selected workloads include it, so connections report
205− // alive/expired + who-am-I out of the box. The operations were streamed
206− // to storage above, so find the binding there. Best-effort: a Graph
207− // selection without /me just leaves the check unconfigured (the editor
208− // remains available).
209− const meOperation = (yield* ctx.storage.listOperations(String(slug))).find(
210− (op) => op.binding.method.toLowerCase() === "get" && op.binding.pathTemplate === "/me",
211− );
212− if (meOperation) {
213− yield* ctx.core.integrations.setHealthCheck(slug, {
214− operation: meOperation.toolName,
215− identityField: "userPrincipalName",
216− });
217− }
218−
219− return { slug, toolCount: persisted.toolCount };
220− });
221−
222− const updateGraph = (rawSlug: string, input?: MicrosoftUpdateInput) =>
223− Effect.gen(function* () {
224− const slug = IntegrationSlug.make(rawSlug);
225− const record = yield* ctx.core.integrations.get(slug);
226− const current = record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null;
227− if (!record || !current) {
228− return yield* new IntegrationNotFoundError({ slug });
229− }
230−
231− const graph = yield* buildMicrosoftGraphOpenApiSpec(
232− {
233− presetIds: input?.presetIds ?? current.microsoftGraphPresetIds,
234− customScopes: input?.customScopes ?? current.microsoftGraphCustomScopes,
235− baseUrl: input?.baseUrl ?? current.baseUrl,
236− specUrl: input?.specUrl ?? current.sourceUrl,
237− authorizationUrl: input?.authorizationUrl ?? current.microsoftGraphAuthorizationUrl,
238− tokenUrl: input?.tokenUrl ?? current.microsoftGraphTokenUrl,
239− clientCredentialsTokenUrl:
240− input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl,
241− },
242− httpClientLayer,
243− urlPolicy,
244− );
245− const previousOperations = yield* ctx.storage.listOperations(rawSlug);
246− const previousNames = new Set(previousOperations.map((op) => op.toolName));
247−
248− const specHash = yield* sha256Hex(graph.specText);
249− yield* ctx.storage.putSpec(specHash, graph.specText);
250−
251− const nextConfig: MicrosoftGraphIntegrationConfig = {
252− ...current,
253− specHash,
254− sourceUrl: graph.specUrl,
255− microsoftGraphPresetIds: graph.presetIds,
256− microsoftGraphCustomScopes: graph.customScopes,
257− microsoftGraphScopes: graph.scopes,
258− microsoftGraphExactPaths: graph.exactPaths,
259− microsoftGraphPathPrefixes: graph.pathPrefixes,
260− microsoftGraphTagPrefixes: graph.tagPrefixes,
261− microsoftGraphCoversFullGraph: graph.coversFullGraph,
262− microsoftGraphAuthorizationUrl: graph.authorizationUrl,
263− microsoftGraphTokenUrl: graph.tokenUrl,
264− microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl,
265− authenticationTemplate: graph.authenticationTemplate,
266− ...(input?.baseUrl ? { baseUrl: input.baseUrl } : {}),
267− };
268−
269− const persisted = yield* ctx.transaction(
270− Effect.gen(function* () {
271− yield* ctx.core.integrations.update(slug, {
272− config: nextConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig,
273− });
274− return yield* persistGraphOperations(graph, rawSlug, specHash);
275− }),
276− );
277− const nextNames = new Set(persisted.toolNames);
278−
279− const connections = yield* ctx.connections.list({ integration: slug });
280− yield* Effect.forEach(
281− connections,
282− (connection) =>
283− ctx.connections
284− .refresh({
285− owner: connection.owner,
286− integration: connection.integration,
287− name: connection.name,
288− })
289− .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))),
290− { discard: true },
291− ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void));
292−
293− return {
294− slug,
295− toolCount: persisted.toolCount,
296− addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(),
297− removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(),
298− };
299− });
300−
301− return {
302− addGraph,
303− updateGraph,
304− removeGraph: (slug: string) =>
305− ctx.transaction(
306− Effect.gen(function* () {
307− yield* ctx.storage.removeOperations(slug);
308− yield* ctx.core.integrations
309− .remove(IntegrationSlug.make(slug))
310− .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void));
311− }),
312− ),
313− getIntegration: (slug: string) =>
314− ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe(
315− Effect.map((record) =>
316− record
317− ? ({
318− slug: record.slug,
319− description: record.description,
320− kind: record.kind,
321− canRemove: record.canRemove,
322− canRefresh: record.canRefresh,
323− } as Integration)
324− : null,
325− ),
326− ),
327− getConfig: (slug: string) =>
328− ctx.core.integrations
329− .get(IntegrationSlug.make(slug))
330− .pipe(
331− Effect.map((record) =>
332− record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null,
333− ),
334− ),
335− configure: (slug: string, input: MicrosoftConfigureInput) =>
336− ctx.transaction(
337− Effect.gen(function* () {
338− const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug));
339− if (!record) return [] as readonly Authentication[];
340− const current = decodeMicrosoftGraphIntegrationConfig(record.config);
341− if (!current) return [] as readonly Authentication[];
342−
343− const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate);
344− const merged =
345− input.mode === "replace"
346− ? incoming
347− : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming);
348−
349− const next: MicrosoftGraphIntegrationConfig = {
350− ...current,
351− authenticationTemplate: merged,
352− };
353−
354− yield* ctx.core.integrations.update(IntegrationSlug.make(slug), {
355− config: next satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig,
356− });
357−
358− return merged;
359− }),
360− ),
361− };
362−};
363−
364−export type MicrosoftPluginExtension = ReturnType<typeof makeMicrosoftPluginExtension>;
365−
366−export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({
367− id: "microsoft" as const,
368− packageName: "@executor-js/plugin-microsoft",
369− integrationPresets: [microsoftGraphPreset],
370− storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps),
371−
372− extension: (ctx) =>
373− makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, {
374− allowUnsafeUrlOverrides: options?.allowUnsafeUrlOverrides === true,
375− }),
376−
377− describeAuthMethods: describeMicrosoftAuthMethods,
378− describeIntegrationDisplay: describeMicrosoftIntegrationDisplay,
379−
380− resolveTools: ({ integration, config, storage }) =>
381− resolveOpenApiBackedTools({ integration, config, storage }),
382−
383− invokeTool: ({ ctx, toolRow, credential, args }) => {
384− const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
385− return invokeOpenApiBackedTool({
386− ctx,
387− toolRow,
388− credential,
389− args,
390− httpClientLayer,
391− });
392− },
393−
394− resolveAnnotations: ({ ctx, integration, toolRows }) =>
395− resolveOpenApiBackedAnnotations({
396− ctx,
397− integration: String(integration),
398− toolRows,
399− }),
400−
401− // Health checks reuse the OpenAPI backing (same store). GET /me is
402− // auto-defaulted at addGraph when present; core owns the stored spec.
403− listHealthCheckCandidates: (input) =>
404− listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }),
405− checkHealth: (input) =>
406− checkHealthOpenApi({
407− ctx: input.ctx,
408− integration: input.integration,
409− credential: input.credential,
410− spec: input.spec,
411− httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer,
412− }),
413−
414− removeConnection: () => Effect.void,
415−
416− detect: ({ url }) =>
417− Effect.sync(() => {
418− const trimmed = url.trim();
419− if (!URL.canParse(trimmed)) return null;
420− const parsed = new URL(trimmed);
421− const host = parsed.hostname.toLowerCase();
422− const isGraph =
423− host === "graph.microsoft.com" ||
424− (host === "learn.microsoft.com" && parsed.pathname.startsWith("/graph/")) ||
425− (host === "raw.githubusercontent.com" &&
426− parsed.pathname.includes("/microsoftgraph/msgraph-metadata/"));
427− if (!isGraph) return null;
428− return IntegrationDetectionResult.make({
429− kind: "microsoft",
430− confidence: "high",
431− endpoint: trimmed,
432− name: "Microsoft Graph",
433− slug: DEFAULT_MICROSOFT_SLUG,
434− });
435− }),
436−}));
packages/plugins/microsoft/src/sdk/presets.test.ts+44−0
@@ -4,6 +4,7 @@ import { · 3 unmodified lines
44 MICROSOFT_GRAPH_ALL_PRESET_IDS,
55 MICROSOFT_GRAPH_BASE_SCOPES,
66 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
7+ microsoftCatalog,
78 microsoftGraphExactPathsForPresetIds,
89 microsoftGraphPathPrefixesForPresetIds,
910 microsoftGraphPresetIdsCoverFullGraph,
@@ -12,6 +13,36 @@ import { · 2 unmodified lines
1213 microsoftGraphTagPrefixesForPresetIds,
1314 } from "./presets";
1415
16+const FROZEN_MICROSOFT_SLUGS = [
17+ "microsoft_profile",
18+ "microsoft_me_surface",
19+ "microsoft_mail",
20+ "microsoft_calendar",
21+ "microsoft_contacts",
22+ "microsoft_tasks",
23+ "microsoft_planner",
24+ "microsoft_files",
25+ "microsoft_excel",
26+ "microsoft_sites",
27+ "microsoft_onenote",
28+ "microsoft_teams_chat",
29+ "microsoft_teams_channels",
30+ "microsoft_meetings_calls",
31+ "microsoft_users",
32+ "microsoft_groups",
33+ "microsoft_directory",
34+ "microsoft_applications",
35+ "microsoft_identity",
36+ "microsoft_admin_reports",
37+ "microsoft_security_compliance",
38+ "microsoft_devices",
39+ "microsoft_education",
40+ "microsoft_search",
41+ "microsoft_external_connections",
42+ "microsoft_solutions",
43+ "microsoft_platform_services",
44+] as const;
45+
1546 describe("Microsoft Graph scope presets", () => {
1647 it("keeps default workload ids backed by categorized presets", () => {
1748 const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id));
@@ -166,4 +197,17 @@ describe("Microsoft Graph scope presets", () => { · 148 unmodified lines
166197 expect(preset.icon).toMatch(/^https:\/\/svgl\.app\/library\/.+\.svg$/);
167198 }
168199 });
200+
201+ it("exports one catalog row per workload with stable slugs and Graph auth templates", () => {
202+ expect(microsoftCatalog.map((preset) => preset.defaultSlug)).toEqual([
203+ ...FROZEN_MICROSOFT_SLUGS,
204+ ]);
205+ for (const preset of microsoftCatalog) {
206+ expect(preset.family).toBe("microsoft");
207+ expect(preset.specFormat).toBe("microsoft-graph");
208+ expect(preset.defaultSlug).toBeTruthy();
209+ expect(preset.authTemplate).toHaveLength(2);
210+ expect(preset.authTemplate?.map((template) => template.kind)).toEqual(["oauth2", "oauth2"]);
211+ }
212+ });
169213 });
packages/plugins/microsoft/src/sdk/presets.ts+43−0
@@ -1,3 +1,5 @@
1+import type { IntegrationPreset } from "@executor-js/sdk/core";
2+
13 export interface MicrosoftGraphPreset {
24 readonly id: string;
35 readonly name: string;
@@ -522,3 +524,44 @@ export const microsoftGraphTagPrefixesForPresetIds = ( · 518 unmodified lines
522524 orderedUnique(
523525 [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []),
524526 );
527+
528+export const microsoftServiceSlug = (presetId: string): string =>
529+ `microsoft_${presetId.replaceAll("-", "_")}`;
530+
531+const microsoftGraphCatalogUrl = (presetId: string): string =>
532+ `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${encodeURIComponent(presetId)}`;
533+
534+const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => [
535+ {
536+ slug: MICROSOFT_AUTH_TEMPLATE_SLUG,
537+ kind: "oauth2" as const,
538+ authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
539+ tokenUrl: MICROSOFT_TOKEN_URL,
540+ scopes: microsoftGraphScopesForPresetIds([preset.id]),
541+ },
542+ {
543+ slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
544+ kind: "oauth2" as const,
545+ authorizationUrl: MICROSOFT_AUTHORIZATION_URL,
546+ tokenUrl: MICROSOFT_TOKEN_URL,
547+ scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES],
548+ },
549+];
550+
551+export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScopePresets.map(
552+ (preset) => ({
553+ id: `microsoft-${preset.id}`,
554+ name: preset.name,
555+ summary: preset.summary,
556+ url: microsoftGraphCatalogUrl(preset.id),
557+ ...(preset.icon ? { icon: preset.icon } : {}),
558+ ...(preset.featured ? { featured: preset.featured } : {}),
559+ family: "microsoft",
560+ specFormat: "microsoft-graph",
561+ defaultSlug: microsoftServiceSlug(preset.id),
562+ authTemplate: microsoftGraphCatalogAuthTemplate(preset),
563+ ...(preset.id === "profile"
564+ ? { healthCheck: { operation: "me.GetUser", identityField: "mail" } }
565+ : {}),
566+ }),
567+);
packages/plugins/microsoft/src/sdk/spec-format-adapter.test.ts+15−0
@@ -75,3 +75,18 @@ it.effect("wraps Microsoft Graph structural split with a streaming keep filter", · 74 unmodified lines
7575 expect(structure!.pathItems.length).toBe(2);
7676 }),
7777 );
78+
79+it.effect("uses catalog URL fragments to select one Graph workload", () =>
80+ Effect.gen(function* () {
81+ const converted = yield* microsoftGraphAdapter.fetch({
82+ urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`],
83+ httpClientLayer: graphHttpClientLayer,
84+ });
85+ const keepPathItem = converted.keepPathItem!;
86+
87+ expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({
88+ get: { operationId: "me.GetUser" },
89+ });
90+ expect(keepPathItem("/irrelevant", { get: { operationId: "irrelevant.Get" } })).toBeNull();
91+ }),
92+);
packages/plugins/microsoft/src/sdk/spec-format-adapter.ts+16−3
@@ -3,13 +3,26 @@ import type { SpecFormatAdapter } from "@executor-js/plugin-openapi"; · 2 unmodified lines
33
44 import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
55
6+const graphCatalogSelection = (
7+ rawUrl: string | undefined,
8+): { readonly specUrl?: string; readonly presetIds?: readonly string[] } => {
9+ if (!rawUrl || !URL.canParse(rawUrl)) return rawUrl ? { specUrl: rawUrl } : {};
10+ const parsed = new URL(rawUrl);
11+ const preset = parsed.hash.startsWith("#preset=")
12+ ? decodeURIComponent(parsed.hash.slice("#preset=".length))
13+ : "";
14+ parsed.hash = "";
15+ return {
16+ specUrl: parsed.toString(),
17+ ...(preset.length > 0 ? { presetIds: [preset] } : {}),
18+ };
19+};
20+
621 export const microsoftGraphAdapter: SpecFormatAdapter = {
722 id: "microsoft-graph",
823 fetch: (input) =>
924 buildMicrosoftGraphOpenApiSpec(
10− {
11− ...(input.urls[0] ? { specUrl: input.urls[0] } : {}),
12− },
25+ graphCatalogSelection(input.urls[0]),
1326 input.httpClientLayer,
1427 ).pipe(
1528 Effect.map((graphSpec) => ({
packages/plugins/microsoft/tsup.config.ts+0−1
@@ -4,7 +4,6 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7− client: "src/react/plugin-client.tsx",
87 },
98 format: ["esm"],
109 dts: false,
packages/plugins/openapi/src/api/group.ts+2−0
@@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; · 1 unmodified line
22 import { Schema } from "effect";
33 import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
44 import {
5+ HealthCheckSpec,
56 InternalError,
67 IntegrationAlreadyExistsError,
78 IntegrationNotFoundError,
@@ -76,6 +77,7 @@ const AddSpecPayload = Schema.Struct({ · 68 unmodified lines
7677 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
7778 specFormat: Schema.optional(Schema.String),
7879 family: Schema.optional(Schema.String),
80+ healthCheck: Schema.optional(HealthCheckSpec),
7981 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
8082 });
8183
packages/plugins/openapi/src/api/handlers.ts+1−0
@@ -66,6 +66,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 65 unmodified lines
6666 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
6767 specFormat: payload.specFormat,
6868 family: payload.family,
69+ healthCheck: payload.healthCheck,
6970 authenticationTemplate: payload.authenticationTemplate,
7071 });
7172 }),
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+29−15
@@ -5,10 +5,12 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines
55 import * as Option from "effect/Option";
66
77 import {
8+ AuthTemplateSlug,
89 IntegrationSlug,
910 type HealthCheckCandidate,
1011 type HealthCheckSpec,
1112 } from "@executor-js/sdk/shared";
13+import { useIntegrationPlugins } from "@executor-js/sdk/client";
1214 import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys";
1315 import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms";
1416 import {
@@ -43,7 +45,6 @@ import { · 28 unmodified lines
4345 } from "./auth-method-config";
4446 import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
4547 import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
46−import { openApiPresets } from "../sdk/presets";
4748 import type { SpecPreviewSummary } from "../sdk/preview";
4849 import { type Authentication } from "../sdk/types";
4950 import { resolveServerUrl } from "../sdk/openapi-utils";
@@ -96,10 +97,10 @@ export default function AddOpenApiSource(props: { · 46 unmodified lines
9697 initialPreset?: string;
9798 initialNamespace?: string;
9899 }) {
99− const initialPreset = useMemo(
100− () => openApiPresets.find((preset) => preset.id === props.initialPreset) ?? null,
101− [props.initialPreset],
102− );
100+ const integrationPlugins = useIntegrationPlugins();
101+ const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
102+ const openApiPresets = openApiPlugin?.presets;
103+ const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null;
103104 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
104105 const [analyzing, setAnalyzing] = useState(false);
105106 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
@@ -163,7 +164,7 @@ export default function AddOpenApiSource(props: { · 57 unmodified lines
163164 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
164165 : "";
165166 const previewPresetIcon =
166− openApiPresets.find(
167+ openApiPresets?.find(
167168 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
168169 )?.icon ?? null;
169170
@@ -181,15 +182,26 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
181182 // Register EVERY spec-detected auth method, not just a single selected one.
182183 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the
183184 // freshly-allocated `?? []` fallback arrays.
184− const authenticationTemplate: readonly Authentication[] = useMemo(
185− () =>
186− detectedAuthenticationTemplates(
187− preview?.headerPresets ?? [],
188− preview?.oauth2Presets ?? [],
189− resolvedBaseUrl,
190− ),
191− [preview, resolvedBaseUrl],
192− );
185+ const authenticationTemplate: readonly Authentication[] = useMemo(() => {
186+ if (initialPreset?.authTemplate) {
187+ return initialPreset.authTemplate.flatMap((template): readonly Authentication[] =>
188+ template.kind === "oauth2"
189+ ? [
190+ {
191+ ...template,
192+ slug: AuthTemplateSlug.make(template.slug),
193+ resource: template.resource ?? undefined,
194+ },
195+ ]
196+ : [],
197+ );
198+ }
199+ return detectedAuthenticationTemplates(
200+ preview?.headerPresets ?? [],
201+ preview?.oauth2Presets ?? [],
202+ resolvedBaseUrl,
203+ );
204+ }, [initialPreset?.authTemplate, preview, resolvedBaseUrl]);
193205
194206 // Editable auth methods, seeded from the spec-detected templates. The add flow
195207 // registers EVERY method (P6), so this is a LIST, preserving multi-method
@@ -307,6 +319,7 @@ export default function AddOpenApiSource(props: { · 111 unmodified lines
307319 baseUrl: resolvedBaseUrl,
308320 specFormat: initialPreset?.specFormat,
309321 family: initialPreset?.family,
322+ healthCheck: initialPreset?.healthCheck,
310323 // Always send the edited method list (even empty) when the user has
311324 // inspected a preview: an explicit [] means "no auth methods", while
312325 // OMITTING the field tells the server to derive defaults from the
@@ -330,6 +343,7 @@ export default function AddOpenApiSource(props: { · 17 unmodified lines
330343 resolvedBaseUrl,
331344 editedAuthenticationTemplate,
332345 initialPreset?.family,
346+ initialPreset?.healthCheck,
333347 initialPreset?.specFormat,
334348 ]);
335349
packages/plugins/openapi/src/react/plugin-client.tsx+7−5
@@ -12,9 +12,11 @@ · 11 unmodified lines
1212
1313 import { defineClientPlugin } from "@executor-js/sdk/client";
1414
15−import { openApiIntegrationPlugin } from "./source-plugin";
15+import { createOpenApiIntegrationPlugin, type OpenApiClientConfig } from "./source-plugin";
1616
17−export default defineClientPlugin({
18− id: "openapi" as const,
19− integrationPlugin: openApiIntegrationPlugin,
20−});
17+export default function createOpenApiClientPlugin(config?: OpenApiClientConfig) {
18+ return defineClientPlugin({
19+ id: "openapi" as const,
20+ integrationPlugin: createOpenApiIntegrationPlugin(config),
21+ });
22+}
packages/plugins/openapi/src/react/source-plugin.ts+12−4
@@ -1,21 +1,29 @@
11 import { lazy } from "react";
2−import type { IntegrationPlugin } from "@executor-js/sdk/client";
2+import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";
33 import { openApiPresets } from "../sdk/presets";
44
55 const importAdd = () => import("./AddOpenApiSource");
66 const importEditSheet = () => import("./UpdateSpecSection");
77 const importAccounts = () => import("./OpenApiAccountsPanel");
88
9−export const openApiIntegrationPlugin: IntegrationPlugin = {
9+export interface OpenApiClientConfig {
10+ readonly presets?: readonly IntegrationPreset[];
11+}
12+
13+export const createOpenApiIntegrationPlugin = (
14+ config?: OpenApiClientConfig,
15+): IntegrationPlugin => ({
1016 key: "openapi",
1117 label: "OpenAPI",
1218 add: lazy(importAdd),
1319 editSheet: lazy(importEditSheet),
1420 accounts: lazy(importAccounts),
15− presets: openApiPresets,
21+ presets: [...openApiPresets, ...(config?.presets ?? [])],
1622 preload: () => {
1723 void importAdd();
1824 void importEditSheet();
1925 void importAccounts();
2026 },
21−};
27+});
28+
29+export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/plugins/openapi/src/sdk/plugin.ts+16−4
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 IntegrationSlug,
1010 ToolResult,
1111 definePlugin,
12+ HealthCheckSpec,
1213 mergeAuthTemplates,
1314 sha256Hex,
1415 tool,
@@ -33,7 +34,7 @@ import { · 18 unmodified lines
3334 type SpecPreview,
3435 } from "./preview";
3536 import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
36−import { openApiPresets } from "./presets";
37+import { openApiPresets, type OpenApiPreset } from "./presets";
3738 import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";
3839 import {
3940 resolveSpecFormatAdapter,
@@ -87,6 +88,7 @@ export interface OpenApiSpecConfig { · 47 unmodified lines
8788 readonly queryParams?: Record<string, string>;
8889 readonly specFormat?: string;
8990 readonly family?: string;
91+ readonly healthCheck?: HealthCheckSpec;
9092 /** Auth methods a connection's value renders through - canonical
9193 * placements or the request-shaped authoring dialect. */
9294 readonly authenticationTemplate?: readonly AuthenticationInput[];
@@ -279,6 +281,7 @@ const AddSourceInputSchema = Schema.Struct({ · 186 unmodified lines
279281 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
280282 specFormat: Schema.optional(Schema.String),
281283 family: Schema.optional(Schema.String),
284+ healthCheck: Schema.optional(HealthCheckSpec),
282285 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
283286 });
284287
@@ -566,9 +569,12 @@ export const describeOpenApiAuthMethods = ( · 281 unmodified lines
566569
567570 export const describeOpenApiIntegrationDisplay = (
568571 record: IntegrationRecord,
569−): { readonly url?: string } => {
572+): { readonly url?: string; readonly family?: string } => {
570573 const config = decodeOpenApiIntegrationConfig(record.config);
571− return { url: config?.baseUrl ?? config?.sourceUrl };
574+ return {
575+ url: config?.baseUrl ?? config?.sourceUrl,
576+ ...(config?.family ? { family: config.family } : {}),
577+ };
572578 };
573579
574580 // ---------------------------------------------------------------------------
@@ -579,6 +585,7 @@ export interface OpenApiPluginOptions { · 4 unmodified lines
579585 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
580586 readonly invokeOptions?: InvokeOptions;
581587 readonly specFormats?: readonly SpecFormatAdapter[];
588+ readonly presets?: readonly OpenApiPreset[];
582589 }
583590
584591 export const openApiPlugin = definePlugin<
@@ -623,7 +630,8 @@ export const openApiPlugin = definePlugin< · 38 unmodified lines
623630 return {
624631 id: "openapi" as const,
625632 packageName: "@executor-js/plugin-openapi",
626− integrationPresets: openApiPresets.map((preset) => ({
633+ clientConfig: options?.presets ? { presets: options.presets } : undefined,
634+ integrationPresets: [...openApiPresets, ...(options?.presets ?? [])].map((preset) => ({
627635 id: preset.id,
628636 name: preset.name,
629637 summary: preset.summary,
@@ -814,6 +822,9 @@ export const openApiPlugin = definePlugin< · 184 unmodified lines
814822 canRemove: true,
815823 canRefresh: integrationConfig.sourceUrl != null,
816824 });
825+ if (config.healthCheck) {
826+ yield* ctx.core.integrations.setHealthCheck(slug, config.healthCheck);
827+ }
817828 if (compiled) {
818829 yield* ctx.storage.putOperations(
819830 resolvedSlug,
@@ -1100,6 +1111,7 @@ export const openApiPlugin = definePlugin< · 280 unmodified lines
11001111 queryParams: input.queryParams,
11011112 specFormat: input.specFormat,
11021113 family: input.family,
1114+ healthCheck: input.healthCheck,
11031115 authenticationTemplate: input.authenticationTemplate as
11041116 | readonly AuthenticationInput[]
11051117 | undefined,
packages/react/src/components/integration-favicon.test.tsx+22−0
@@ -224,6 +224,28 @@ describe("IntegrationFavicon", () => { · 223 unmodified lines
224224 ).toBe("https://example.com/stripe.png");
225225 });
226226
227+ it("prefers exact catalog defaultSlug icons for OpenAPI provider services", () => {
228+ expect(
229+ integrationPresetIconUrl({ id: "google_gmail", kind: "openapi", name: "Gmail" }, [
230+ {
231+ key: "openapi",
232+ label: "OpenAPI",
233+ add: () => null,
234+ edit: () => null,
235+ presets: [
236+ {
237+ id: "google-gmail",
238+ name: "Gmail",
239+ summary: "Messages.",
240+ defaultSlug: "google_gmail",
241+ icon: "https://example.com/gmail.png",
242+ },
243+ ],
244+ },
245+ ]),
246+ ).toBe("https://example.com/gmail.png");
247+ });
248+
227249 it("does not split generic words into brand matches", () => {
228250 expect(
229251 integrationPresetIconUrl(
packages/react/src/components/integration-favicon.tsx+3−0
@@ -157,6 +157,9 @@ export function integrationPresetIconUrl( · 156 unmodified lines
157157 const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;
158158 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
159159 const presets = plugin?.presets ?? [];
160+ const exactSlugIcon = presets.find((p) => p.defaultSlug === source.id)?.icon;
161+ if (exactSlugIcon) return exactSlugIcon;
162+
160163 const sourceUrl = normalizeUrl(source.url);
161164 const sourceGoogleService = googleApiServiceFromUrl(source.url);
162165 const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)];
packages/react/src/lib/integration-grouping.test.ts+80−0
@@ -0,0 +1,80 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { type Integration, IntegrationSlug } from "@executor-js/sdk/shared";
3+
4+import {
5+ groupIntegrations,
6+ integrationFamily,
7+ type IntegrationFamilyGroup,
8+} from "./integration-grouping";
9+
10+const integration = (slug: string, kind: string, name = slug, family?: string): Integration => ({
11+ slug: IntegrationSlug.make(slug),
12+ name,
13+ description: name,
14+ kind,
15+ canRemove: true,
16+ canRefresh: true,
17+ authMethods: [],
18+ ...(family ? { family } : {}),
19+});
20+
21+describe("groupIntegrations", () => {
22+ it("collapses multi-member config families under one umbrella", () => {
23+ const items = groupIntegrations([
24+ integration("google_calendar", "openapi", "Google Calendar", "google"),
25+ integration("google_gmail", "openapi", "Gmail", "google"),
26+ integration("google_drive", "openapi", "Google Drive", "google"),
27+ ]);
28+
29+ expect(items).toHaveLength(1);
30+ const group = items[0] as IntegrationFamilyGroup;
31+ expect(group.type).toBe("group");
32+ expect(group.family).toBe("google");
33+ expect(group.label).toBe("Google");
34+ expect(group.members.map((m) => String(m.slug))).toEqual([
35+ "google_calendar",
36+ "google_gmail",
37+ "google_drive",
38+ ]);
39+ });
40+
41+ it("leaves integrations without config family ungrouped", () => {
42+ const items = groupIntegrations([
43+ integration("stripe", "openapi", "Stripe"),
44+ integration("sentry", "mcp", "Sentry"),
45+ ]);
46+
47+ expect(items).toHaveLength(2);
48+ expect(items.map((item) => item.type)).toEqual(["single", "single"]);
49+ });
50+
51+ it("renders a lone family member flat", () => {
52+ const items = groupIntegrations([
53+ integration("google_calendar", "openapi", "Google Calendar", "google"),
54+ ]);
55+
56+ expect(items).toHaveLength(1);
57+ expect(items[0]?.type).toBe("single");
58+ });
59+
60+ it("places each group where its first member appeared", () => {
61+ const items = groupIntegrations([
62+ integration("stripe", "openapi", "Stripe"),
63+ integration("google_calendar", "openapi", "Google Calendar", "google"),
64+ integration("microsoft_mail", "openapi", "Outlook Mail", "microsoft"),
65+ integration("google_gmail", "openapi", "Gmail", "google"),
66+ integration("microsoft_calendar", "openapi", "Outlook Calendar", "microsoft"),
67+ integration("sentry", "mcp", "Sentry"),
68+ ]);
69+
70+ expect(
71+ items.map((item) => (item.type === "group" ? `group:${item.family}` : "single")),
72+ ).toEqual(["single", "group:google", "group:microsoft", "single"]);
73+ });
74+
75+ it("does not group from integration kind alone", () => {
76+ expect(
77+ integrationFamily(integration("google_calendar", "google", "Google Calendar")),
78+ ).toBeNull();
79+ });
80+});
packages/react/src/lib/integration-grouping.ts+69−0
@@ -0,0 +1,69 @@
1+import type { Integration } from "@executor-js/sdk/shared";
2+
3+export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
4+
5+const FAMILY_LABELS: Record<string, string> = {
6+ google: "Google",
7+ microsoft: "Microsoft",
8+};
9+
10+export const familyLabel = (family: string): string =>
11+ FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1);
12+
13+export const integrationFamily = (integration: Integration): string | null => {
14+ const family = integration.family?.trim();
15+ return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null;
16+};
17+
18+export interface IntegrationFamilyGroup {
19+ readonly type: "group";
20+ readonly family: string;
21+ readonly label: string;
22+ readonly members: readonly Integration[];
23+}
24+
25+export interface IntegrationSingle {
26+ readonly type: "single";
27+ readonly integration: Integration;
28+}
29+
30+export type IntegrationGridItem = IntegrationFamilyGroup | IntegrationSingle;
31+
32+export function groupIntegrations(
33+ integrations: readonly Integration[],
34+): readonly IntegrationGridItem[] {
35+ const familyCounts = new Map<string, number>();
36+ for (const integration of integrations) {
37+ const family = integrationFamily(integration);
38+ if (family) familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1);
39+ }
40+
41+ const items: IntegrationGridItem[] = [];
42+ const groupIndexByFamily = new Map<string, number>();
43+
44+ for (const integration of integrations) {
45+ const family = integrationFamily(integration);
46+ const grouped = family !== null && (familyCounts.get(family) ?? 0) > 1;
47+
48+ if (!grouped || family === null) {
49+ items.push({ type: "single", integration });
50+ continue;
51+ }
52+
53+ const existing = groupIndexByFamily.get(family);
54+ if (existing === undefined) {
55+ groupIndexByFamily.set(family, items.length);
56+ items.push({
57+ type: "group",
58+ family,
59+ label: familyLabel(family),
60+ members: [integration],
61+ });
62+ } else {
63+ const group = items[existing] as IntegrationFamilyGroup;
64+ items[existing] = { ...group, members: [...group.members, integration] };
65+ }
66+ }
67+
68+ return items;
69+}
packages/react/src/pages/integrations.tsx+100−42
@@ -1,4 +1,4 @@
1−import { Suspense, useCallback, useMemo, useState } from "react";
1+import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
44 import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -33,11 +33,14 @@ import { · 28 unmodified lines
3333 CardStackEntryDescription,
3434 CardStackEntryMedia,
3535 CardStackEntryTitle,
36+ CardStackHeader,
3637 } from "../components/card-stack";
3738 import {
39+ IntegrationFavicon,
3840 integrationInferredUrl,
3941 integrationPresetIconUrl,
4042 } from "../components/integration-favicon";
43+import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";
4144 import { IntegrationHealthSummary } from "../components/integration-health-summary";
4245 import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
4346 import { Skeleton } from "../components/skeleton";
@@ -349,7 +352,8 @@ function PresetGrid(props: { · 305 unmodified lines
349352 const q = (props.searchQuery ?? "").trim().toLowerCase();
350353 if (q.length === 0) return allPresets;
351354 return allPresets.filter(({ preset, pluginLabel }) => {
352− const corpus = `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase();
355+ const corpus =
356+ `${preset.name} ${preset.summary ?? ""} ${preset.family ?? ""} ${preset.specFormat ?? ""} ${pluginLabel}`.toLowerCase();
353357 return corpus.includes(q);
354358 });
355359 }, [allPresets, props.searchQuery]);
@@ -434,47 +438,101 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 78 unmodified lines
434438 return out;
435439 }, [integrationPlugins]);
436440
441+ const items = useMemo(() => groupIntegrations(props.integrations), [props.integrations]);
442+
443+ const renderEntry = (integration: Integration) => {
444+ const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind;
445+ const plugin = pluginByKind.get(pluginKey);
446+ const SummaryComponent = plugin?.summary;
447+ const slug = String(integration.slug);
448+ const name = integration.name || slug;
449+ return (
450+ <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>
451+ <Link
452+ to="/{-$orgSlug}/integrations/$namespace"
453+ params={{ namespace: slug }}
454+ data-testid={`integration-entry-${slug}`}
455+ >
456+ <IntegrationIconWithAccount
457+ icon={integrationPresetIconUrl(
458+ { id: slug, kind: integration.kind, name, url: integration.displayUrl },
459+ integrationPlugins,
460+ )}
461+ sourceId={slug}
462+ url={integration.displayUrl ?? integrationInferredUrl({ id: slug, name }) ?? undefined}
463+ />
464+ <CardStackEntryContent>
465+ <CardStackEntryTitle>{name}</CardStackEntryTitle>
466+ <CardStackEntryDescription>{slug}</CardStackEntryDescription>
467+ </CardStackEntryContent>
468+ <CardStackEntryActions>
469+ {SummaryComponent && (
470+ <Suspense fallback={null}>
471+ <SummaryComponent sourceId={slug} />
472+ </Suspense>
473+ )}
474+ <IntegrationHealthSummary integration={integration.slug} />
475+ </CardStackEntryActions>
476+ </Link>
477+ </CardStackEntry>
478+ );
479+ };
480+
481+ const rendered: ReactNode[] = [];
482+ let flatRun: Integration[] = [];
483+ const flushFlat = () => {
484+ if (flatRun.length === 0) return;
485+ const run = flatRun;
486+ flatRun = [];
487+ rendered.push(
488+ <CardStack key={`flat-${String(run[0]!.slug)}`} searchable>
489+ <CardStackContent>{run.map(renderEntry)}</CardStackContent>
490+ </CardStack>,
491+ );
492+ };
493+
494+ for (const item of items) {
495+ if (item.type === "single") {
496+ flatRun.push(item.integration);
497+ continue;
498+ }
499+ flushFlat();
500+ rendered.push(
501+ <IntegrationFamilyGroupCard
502+ key={`group-${item.family}`}
503+ group={item}
504+ plugin={pluginByKind.get("openapi")}
505+ renderEntry={renderEntry}
506+ />,
507+ );
508+ }
509+ flushFlat();
510+
511+ return <div className="space-y-3">{rendered}</div>;
512+}
513+
514+function IntegrationFamilyGroupCard(props: {
515+ group: IntegrationFamilyGroup;
516+ plugin: IntegrationPlugin | undefined;
517+ renderEntry: (integration: Integration) => ReactNode;
518+}) {
519+ const { group, plugin, renderEntry } = props;
520+ const headerIcon =
521+ plugin?.presets?.find((preset) => preset.family === group.family && preset.icon)?.icon ?? null;
437522 return (
438− <CardStack searchable>
439− <CardStackContent>
440− {props.integrations.map((integration) => {
441− const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind;
442− const plugin = pluginByKind.get(pluginKey);
443− const SummaryComponent = plugin?.summary;
444− const slug = String(integration.slug);
445− const name = integration.name || slug;
446− return (
447− <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>
448− <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}>
449− <IntegrationIconWithAccount
450− icon={integrationPresetIconUrl(
451− { id: slug, kind: integration.kind, name, url: integration.displayUrl },
452− integrationPlugins,
453− )}
454− sourceId={slug}
455− url={
456− integration.displayUrl ??
457− integrationInferredUrl({ id: slug, name }) ??
458− undefined
459− }
460− />
461− <CardStackEntryContent>
462− <CardStackEntryTitle>{name}</CardStackEntryTitle>
463− <CardStackEntryDescription>{slug}</CardStackEntryDescription>
464− </CardStackEntryContent>
465− <CardStackEntryActions>
466− {SummaryComponent && (
467− <Suspense fallback={null}>
468− <SummaryComponent sourceId={slug} />
469− </Suspense>
470− )}
471− <IntegrationHealthSummary integration={integration.slug} />
472− </CardStackEntryActions>
473− </Link>
474− </CardStackEntry>
475− );
476− })}
477− </CardStackContent>
523+ <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}>
524+ <CardStackHeader>
525+ <span className="flex min-w-0 items-center gap-2">
526+ <span className="flex size-5 shrink-0 items-center justify-center">
527+ <IntegrationFavicon icon={headerIcon} size={16} />
528+ </span>
529+ <span className="truncate">{group.label}</span>
530+ <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground">
531+ {group.members.length}
532+ </span>
533+ </span>
534+ </CardStackHeader>
535+ <CardStackContent>{group.members.map(renderEntry)}</CardStackContent>
478536 </CardStack>
479537 );
480538 }