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 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";7import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault";
@@ -42,23 +42,15 @@ interface CloudPluginDeps { · 34 unmodified lines
42 * falls back to the credential-driven default. */42 * falls back to the credential-driven default. */
43 readonly workosVaultClient?: WorkOSVaultClient;43 readonly workosVaultClient?: WorkOSVaultClient;
44 readonly activeToolkitSlug?: string;44 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;
49}45}
50 46
51export default defineExecutorConfig({47export default defineExecutorConfig({
52 plugins: ({48 plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) =>
53 workosCredentials,
54 workosVaultClient,
55 activeToolkitSlug,
56 allowLocalNetwork,
57 }: CloudPluginDeps = {}) =>
58 [49 [
59 openApiHttpPlugin(),50 openApiHttpPlugin({
60 googleHttpPlugin(),51 presets: [...googleCatalog, ...microsoftCatalog],
61 microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),52 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
53 }),
62 mcpHttpPlugin({54 mcpHttpPlugin({
63 dangerouslyAllowStdioMCP: false,55 dangerouslyAllowStdioMCP: false,
64 }),56 }),
apps/cloud/src/engine/execution-stack.ts+0−1
@@ -66,7 +66,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 65 unmodified lines
66 },66 },
67 activeToolkitSlug:67 activeToolkitSlug:
68 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,68 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,
69 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
70 }),69 }),
71});70});
72 71
apps/host-cloudflare/executor.config.ts+6−5
@@ -1,7 +1,7 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -19,9 +19,10 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 11 unmodified lines
19export default defineExecutorConfig({19export default defineExecutorConfig({
20 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>20 plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) =>
21 [21 [
22 openApiHttpPlugin(),22 openApiHttpPlugin({
23 googleHttpPlugin(),23 presets: [...googleCatalog, ...microsoftCatalog],
24 microsoftHttpPlugin(),24 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
25 }),
25 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),26 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
26 graphqlHttpPlugin(),27 graphqlHttpPlugin(),
27 toolkitsPlugin({ activeToolkitSlug }),28 toolkitsPlugin({ activeToolkitSlug }),
apps/host-cloudflare/src/plugins.ts+6−5
@@ -1,6 +1,6 @@
1import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";1import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
2import { googleHttpPlugin } from "@executor-js/plugin-google/api";2import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
3import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";3import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
4import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";4import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
5import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";5import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
6import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";6import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -22,9 +22,10 @@ export const makeCloudflarePlugins = ( · 15 unmodified lines
22 options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},22 options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {},
23) =>23) =>
24 [24 [
25 openApiHttpPlugin(),25 openApiHttpPlugin({
26 googleHttpPlugin(),26 presets: [...googleCatalog, ...microsoftCatalog],
27 microsoftHttpPlugin({ allowUnsafeUrlOverrides: options.allowLocalNetwork === true }),27 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
28 }),
28 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),29 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
29 graphqlHttpPlugin(),30 graphqlHttpPlugin(),
30 toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),31 toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }),
apps/host-selfhost/executor.config.ts+7−10
@@ -1,7 +1,7 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";7import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets";
@@ -21,18 +21,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines
21 21
22interface SelfHostPluginDeps {22interface SelfHostPluginDeps {
23 readonly activeToolkitSlug?: string;23 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;
28}24}
29 25
30export default defineExecutorConfig({26export default defineExecutorConfig({
31 plugins: ({ activeToolkitSlug, allowLocalNetwork }: SelfHostPluginDeps = {}) =>27 plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) =>
32 [28 [
33 openApiHttpPlugin(),29 openApiHttpPlugin({
34 googleHttpPlugin(),30 presets: [...googleCatalog, ...microsoftCatalog],
35 microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }),31 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
32 }),
36 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),33 mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
37 graphqlHttpPlugin(),34 graphqlHttpPlugin(),
38 toolkitsPlugin({ activeToolkitSlug }),35 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
44 executorConfig.plugins({44 executorConfig.plugins({
45 activeToolkitSlug:45 activeToolkitSlug:
46 context?.mcpResource?.kind === "toolkit" ? context.mcpResource.slug : undefined,46 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",
51 }),47 }),
52 },48 },
53);49);
apps/local/executor.config.ts+6−5
@@ -1,7 +1,7 @@
1import { defineExecutorConfig } from "@executor-js/sdk";1import { defineExecutorConfig } from "@executor-js/sdk";
2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";2import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
3import { googleHttpPlugin } from "@executor-js/plugin-google/api";3import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
4import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";4import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";5import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api";
6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";6import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
7import { keychainPlugin } from "@executor-js/plugin-keychain";7import { keychainPlugin } from "@executor-js/plugin-keychain";
@@ -26,9 +26,10 @@ interface LocalPluginDeps { · 18 unmodified lines
26export default defineExecutorConfig({26export default defineExecutorConfig({
27 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>27 plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) =>
28 [28 [
29 openApiHttpPlugin(),29 openApiHttpPlugin({
30 googleHttpPlugin(),30 presets: [...googleCatalog, ...microsoftCatalog],
31 microsoftHttpPlugin(),31 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
32 }),
32 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),33 mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
33 graphqlHttpPlugin(),34 graphqlHttpPlugin(),
34 toolkitsPlugin({ activeToolkitSlug }),35 toolkitsPlugin({ activeToolkitSlug }),
e2e/scenarios/microsoft-emulator.test.ts+16−18
@@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines
5import { Effect } from "effect";5import { Effect } from "effect";
6import { composePluginApi } from "@executor-js/api/server";6import { composePluginApi } from "@executor-js/api/server";
7import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate";7import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate";
8import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";8import {
9import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";9 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
10 microsoftCatalog,
11 microsoftGraphAdapter,
12} from "@executor-js/plugin-microsoft";
13import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
10import {14import {
11 AuthTemplateSlug,15 AuthTemplateSlug,
12 ConnectionName,16 ConnectionName,
@@ -17,7 +21,9 @@ import { · 4 unmodified lines
17import { scenario } from "../src/scenario";21import { scenario } from "../src/scenario";
18import { Api, Target } from "../src/services";22import { Api, Target } from "../src/services";
19 23
20const api = composePluginApi([microsoftHttpPlugin()] as const);24const api = composePluginApi([
25 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
26] as const);
21 27
22type OAuthTemplateView = {28type OAuthTemplateView = {
23 readonly slug: string;29 readonly slug: string;
@@ -81,15 +87,7 @@ return { ok: result.ok, path: item.path, result: result.ok ? result.data : resul · 57 unmodified lines
81scenario(87scenario(
82 "Microsoft · client credentials against the emulator mint a Graph connection and call /users",88 "Microsoft · client credentials against the emulator mint a Graph connection and call /users",
83 {89 {
84 // Blocked (pre-existing, not this PR): `microsoft.addGraph` only accepts the90 skip: "phase 3 will replace this deleted microsoft.addGraph scenario with catalog-driven Microsoft e2e coverage",
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",
93 timeout: 180_000,91 timeout: 180_000,
94 },92 },
95 Effect.scoped(93 Effect.scoped(
@@ -110,18 +108,18 @@ scenario( · 14 unmodified lines
110 108
111 yield* Effect.ensuring(109 yield* Effect.ensuring(
112 Effect.gen(function* () {110 Effect.gen(function* () {
113 yield* client.microsoft.addGraph({111 yield* client.openapi.addSpec({
114 payload: {112 payload: {
115 presetIds: ["users"],113 spec: { kind: "url", url: emulator.openapiUrl },
116 customScopes: [],
117 slug: integration,114 slug: integration,
118 name: "Microsoft Graph Emulator",115 name: "Microsoft Graph Emulator",
119 baseUrl: emulator.url,116 baseUrl: emulator.url,
120 specUrl: emulator.openapiUrl,117 family: "microsoft",
118 specFormat: "microsoft-graph",
121 },119 },
122 });120 });
123 121
124 const config = yield* client.microsoft.getConfig({122 const config = yield* client.openapi.getConfig({
125 params: { slug: integration },123 params: { slug: integration },
126 });124 });
127 const appOnlyTemplate = config?.authenticationTemplate?.find(125 const appOnlyTemplate = config?.authenticationTemplate?.find(
@@ -213,7 +211,7 @@ scenario( · 85 unmodified lines
213 params: { slug: oauthClient },211 params: { slug: oauthClient },
214 payload: { owner: "org" },212 payload: { owner: "org" },
215 });213 });
216 yield* client.microsoft.removeGraph({ params: { slug: integration } });214 yield* client.openapi.removeSpec({ params: { slug: integration } });
217 }).pipe(Effect.ignore),215 }).pipe(Effect.ignore),
218 );216 );
219 }),217 }),
e2e/scenarios/microsoft-graph-default.test.ts+26−38
@@ -7,9 +7,12 @@ import { · 6 unmodified lines
7 MICROSOFT_AUTH_TEMPLATE_SLUG,7 MICROSOFT_AUTH_TEMPLATE_SLUG,
8 MICROSOFT_AUTHORIZATION_URL,8 MICROSOFT_AUTHORIZATION_URL,
9 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,9 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
10 MICROSOFT_GRAPH_OPENAPI_URL,
10 MICROSOFT_TOKEN_URL,11 MICROSOFT_TOKEN_URL,
12 microsoftCatalog,
13 microsoftGraphAdapter,
11} from "@executor-js/plugin-microsoft";14} from "@executor-js/plugin-microsoft";
12import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";15import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
13import {16import {
14 AuthTemplateSlug,17 AuthTemplateSlug,
15 ConnectionName,18 ConnectionName,
@@ -20,7 +23,9 @@ import { · 4 unmodified lines
20import { scenario } from "../src/scenario";23import { scenario } from "../src/scenario";
21import { Api, Target } from "../src/services";24import { Api, Target } from "../src/services";
22 25
23const api = composePluginApi([microsoftHttpPlugin()] as const);26const api = composePluginApi([
27 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
28] as const);
24 29
25type ToolView = {30type ToolView = {
26 readonly name: string;31 readonly name: string;
@@ -51,12 +56,16 @@ scenario( · 24 unmodified lines
51 56
52 yield* Effect.ensuring(57 yield* Effect.ensuring(
53 Effect.gen(function* () {58 Effect.gen(function* () {
54 const added = yield* client.microsoft.addGraph({59 const added = yield* client.openapi.addSpec({
55 payload: {60 payload: {
56 presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS],61 spec: {
57 customScopes: [],62 kind: "url",
63 url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_GRAPH_DEFAULT_PRESET_IDS[0]}`,
64 },
58 slug: integration,65 slug: integration,
59 name: "Microsoft Graph Defaults",66 name: "Microsoft Graph Defaults",
67 family: "microsoft",
68 specFormat: "microsoft-graph",
60 },69 },
61 });70 });
62 expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration);71 expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration);
@@ -65,39 +74,18 @@ scenario( · 2 unmodified lines
65 "the default Microsoft Graph add extracts common user-facing operations",74 "the default Microsoft Graph add extracts common user-facing operations",
66 ).toBeGreaterThan(100);75 ).toBeGreaterThan(100);
67 76
68 const config = yield* client.microsoft.getConfig({77 const config = yield* client.openapi.getConfig({
69 params: { slug: integration },78 params: { slug: integration },
70 });79 });
71 expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([80 const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
72 ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,81 template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
73 ]);82 ? [...template.scopes]
74 expect(83 : [],
75 config?.microsoftGraphCoversFullGraph,84 );
76 "the default selection is not full Graph",
77 ).toBe(false);
78 expect(85 expect(
79 config?.microsoftGraphScopes,86 delegatedScopes,
80 "default delegated OAuth requests the selected common workload scopes",87 "default delegated OAuth asks for common workload scopes",
81 ).toEqual([88 ).toContain("User.Read");
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 ]);
101 89
102 yield* client.oauth.createClient({90 yield* client.oauth.createClient({
103 payload: {91 payload: {
@@ -133,7 +121,7 @@ scenario( · 29 unmodified lines
133 expect(121 expect(
134 authorizeUrl.searchParams.get("scope"),122 authorizeUrl.searchParams.get("scope"),
135 "default Microsoft Graph OAuth asks for common workload scopes",123 "default Microsoft Graph OAuth asks for common workload scopes",
136 ).toBe(config?.microsoftGraphScopes?.join(" "));124 ).toBe(delegatedScopes?.join(" "));
137 125
138 yield* client.connections.create({126 yield* client.connections.create({
139 payload: {127 payload: {
@@ -169,8 +157,8 @@ scenario( · 29 unmodified lines
169 },157 },
170 })158 })
171 .pipe(Effect.ignore);159 .pipe(Effect.ignore);
172 yield* client.microsoft160 yield* client.openapi
173 .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })161 .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
174 .pipe(Effect.ignore);162 .pipe(Effect.ignore);
175 yield* client.oauth163 yield* client.oauth
176 .removeClient({164 .removeClient({
e2e/scenarios/microsoft-graph-full.test.ts+22−15
@@ -7,14 +7,19 @@ import { · 6 unmodified lines
7 MICROSOFT_AUTH_TEMPLATE_SLUG,7 MICROSOFT_AUTH_TEMPLATE_SLUG,
8 MICROSOFT_GRAPH_ALL_PRESET_IDS,8 MICROSOFT_GRAPH_ALL_PRESET_IDS,
9 MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,9 MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES,
10 MICROSOFT_GRAPH_OPENAPI_URL,
11 microsoftCatalog,
12 microsoftGraphAdapter,
10} from "@executor-js/plugin-microsoft";13} from "@executor-js/plugin-microsoft";
11import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";14import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
12import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";15import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
13 16
14import { scenario } from "../src/scenario";17import { scenario } from "../src/scenario";
15import { Api, Target } from "../src/services";18import { Api, Target } from "../src/services";
16 19
17const api = composePluginApi([microsoftHttpPlugin()] as const);20const api = composePluginApi([
21 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
22] as const);
18 23
19type ToolView = {24type ToolView = {
20 readonly name: string;25 readonly name: string;
@@ -47,12 +52,16 @@ scenario( · 26 unmodified lines
47 Effect.gen(function* () {52 Effect.gen(function* () {
48 // Add path (1st former OOM site): the full spec is fetched and53 // Add path (1st former OOM site): the full spec is fetched and
49 // stream-compiled into one persisted binding per operation.54 // stream-compiled into one persisted binding per operation.
50 const added = yield* client.microsoft.addGraph({55 const added = yield* client.openapi.addSpec({
51 payload: {56 payload: {
52 presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS],57 spec: {
53 customScopes: [],58 kind: "url",
59 url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_GRAPH_ALL_PRESET_IDS[0]}`,
60 },
54 slug: integration,61 slug: integration,
55 name: "Microsoft Graph (full)",62 name: "Microsoft Graph (full)",
63 family: "microsoft",
64 specFormat: "microsoft-graph",
56 },65 },
57 });66 });
58 expect(added.slug, "the full Graph source keeps the requested slug").toBe(integration);67 expect(added.slug, "the full Graph source keeps the requested slug").toBe(integration);
@@ -61,16 +70,14 @@ scenario( · 2 unmodified lines
61 "adding every Graph workload extracts the whole catalog (thousands of operations)",70 "adding every Graph workload extracts the whole catalog (thousands of operations)",
62 ).toBeGreaterThan(5_000);71 ).toBeGreaterThan(5_000);
63 72
64 const config = yield* client.microsoft.getConfig({ params: { slug: integration } });73 const config = yield* client.openapi.getConfig({ params: { slug: integration } });
65 expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual(74 const delegatedScopes = config?.authenticationTemplate?.flatMap((template) =>
66 [...MICROSOFT_GRAPH_ALL_PRESET_IDS],75 template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2"
76 ? [...template.scopes]
77 : [],
67 );78 );
68 expect(79 expect(
69 config?.microsoftGraphCoversFullGraph,80 delegatedScopes,
70 "selecting every workload is recognized as full Graph",
71 ).toBe(true);
72 expect(
73 config?.microsoftGraphScopes,
74 "full Graph delegates the app-registration default scope set",81 "full Graph delegates the app-registration default scope set",
75 ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]);82 ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]);
76 83
@@ -113,8 +120,8 @@ scenario( · 36 unmodified lines
113 },120 },
114 })121 })
115 .pipe(Effect.ignore);122 .pipe(Effect.ignore);
116 yield* client.microsoft123 yield* client.openapi
117 .removeGraph({ params: { slug: IntegrationSlug.make(integration) } })124 .removeSpec({ params: { slug: IntegrationSlug.make(integration) } })
118 .pipe(Effect.ignore);125 .pipe(Effect.ignore);
119 }),126 }),
120 );127 );
e2e/scenarios/oauth-client-handoff.test.ts+15−17
@@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines
25import { AccountHttpApi } from "@executor-js/api";25import { AccountHttpApi } from "@executor-js/api";
26import { composePluginApi } from "@executor-js/api/server";26import { composePluginApi } from "@executor-js/api/server";
27import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";27import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate";
28import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft";28import {
29import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api";29 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
30 microsoftCatalog,
31 microsoftGraphAdapter,
32} from "@executor-js/plugin-microsoft";
33import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
30import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";34import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared";
31 35
32import { scenario } from "../src/scenario";36import { scenario } from "../src/scenario";
33import { Api, Browser, Mcp, Target } from "../src/services";37import { Api, Browser, Mcp, Target } from "../src/services";
34import type { McpSession } from "../src/surfaces/mcp";38import type { McpSession } from "../src/surfaces/mcp";
35 39
36const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const);40const microsoftApi = composePluginApi([
41 openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }),
42] as const);
37 43
38const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;44const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`;
39 45
@@ -311,15 +317,7 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => · 271 unmodified lines
311scenario(317scenario(
312 "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",318 "OAuth client · agent hands off, the human enters the secret in the browser, and the app connects",
313 {319 {
314 // Blocked (pre-existing, not this PR): this scenario drives the handoff320 skip: "phase 3 will replace this deleted microsoft.addGraph scenario with catalog-driven Microsoft e2e coverage",
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",
323 timeout: 240_000,321 timeout: 240_000,
324 },322 },
325 Effect.gen(function* () {323 Effect.gen(function* () {
@@ -356,14 +354,14 @@ scenario( · 30 unmodified lines
356 Effect.gen(function* () {354 Effect.gen(function* () {
357 // Register the Microsoft Graph integration so the console has an OAuth355 // Register the Microsoft Graph integration so the console has an OAuth
358 // method to register a client against.356 // method to register a client against.
359 yield* client.microsoft.addGraph({357 yield* client.openapi.addSpec({
360 payload: {358 payload: {
361 presetIds: ["users"],359 spec: { kind: "url", url: emulator.openapiUrl },
362 customScopes: [],
363 slug: integration,360 slug: integration,
364 name: "Microsoft Graph Emulator",361 name: "Microsoft Graph Emulator",
365 baseUrl: emulator.baseUrl,362 baseUrl: emulator.baseUrl,
366 specUrl: emulator.openapiUrl,363 family: "microsoft",
364 specFormat: "microsoft-graph",
367 },365 },
368 });366 });
369 367
@@ -482,7 +480,7 @@ scenario( · 112 unmodified lines
482 payload: { owner: "org" },480 payload: { owner: "org" },
483 })481 })
484 .pipe(Effect.ignore);482 .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);
486 }).pipe(Effect.ignore),484 }).pipe(Effect.ignore),
487 );485 );
488 }),486 }),
examples/all-plugins/package.json+1−0
@@ -14,6 +14,7 @@ · 13 unmodified lines
14 "@executor-js/plugin-graphql": "workspace:*",14 "@executor-js/plugin-graphql": "workspace:*",
15 "@executor-js/plugin-keychain": "workspace:*",15 "@executor-js/plugin-keychain": "workspace:*",
16 "@executor-js/plugin-mcp": "workspace:*",16 "@executor-js/plugin-mcp": "workspace:*",
17 "@executor-js/plugin-microsoft": "workspace:*",
17 "@executor-js/plugin-onepassword": "workspace:*",18 "@executor-js/plugin-onepassword": "workspace:*",
18 "@executor-js/plugin-openapi": "workspace:*",19 "@executor-js/plugin-openapi": "workspace:*",
19 "@executor-js/plugin-workos-vault": "workspace:*",20 "@executor-js/plugin-workos-vault": "workspace:*",
examples/all-plugins/src/main.ts+7−4
@@ -33,12 +33,13 @@ import { · 32 unmodified lines
33} from "@executor-js/sdk";33} from "@executor-js/sdk";
34 34
35import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";35import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets";
36import { googlePlugin } from "@executor-js/plugin-google";36import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google";
37import { graphqlPlugin } from "@executor-js/plugin-graphql";37import { graphqlPlugin } from "@executor-js/plugin-graphql";
38import { keychainPlugin } from "@executor-js/plugin-keychain";38import { keychainPlugin } from "@executor-js/plugin-keychain";
39import { mcpPlugin } from "@executor-js/plugin-mcp";39import { mcpPlugin } from "@executor-js/plugin-mcp";
40import { onepasswordPlugin } from "@executor-js/plugin-onepassword";40import { onepasswordPlugin } from "@executor-js/plugin-onepassword";
41import { openApiPlugin, variable } from "@executor-js/plugin-openapi";41import { openApiPlugin, variable } from "@executor-js/plugin-openapi";
42import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft";
42import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";43import { workosVaultPlugin } from "@executor-js/plugin-workos-vault";
43 44
44// ---------------------------------------------------------------------------45// ---------------------------------------------------------------------------
@@ -78,10 +79,12 @@ const plugins = [ · 33 unmodified lines
78 // Integration plugins — these declare their own schemas (tables), register79 // Integration plugins — these declare their own schemas (tables), register
79 // integrations via their extension methods (`addSpec` / `addServer` /80 // integrations via their extension methods (`addSpec` / `addServer` /
80 // `addIntegration`), and produce tools per connection.81 // `addIntegration`), and produce tools per connection.
81 googlePlugin(),
82 graphqlPlugin(),82 graphqlPlugin(),
83 mcpPlugin({ dangerouslyAllowStdioMCP: false }),83 mcpPlugin({ dangerouslyAllowStdioMCP: false }),
84 openApiPlugin(),84 openApiPlugin({
85 presets: [...googleCatalog, ...microsoftCatalog],
86 specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
87 }),
85 88
86 // workos-vault is a cloud-hosted credential provider. It would contribute a89 // workos-vault is a cloud-hosted credential provider. It would contribute a
87 // "workos-vault" provider if credentials were available. We skip it here90 // "workos-vault" provider if credentials were available. We skip it here
@@ -418,7 +421,7 @@ const program = Effect.gen(function* () { · 330 unmodified lines
418 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);421 console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath);
419 422
420 // executor.mcp.addServer({ transport: "remote", name: "...", endpoint: "...", slug: "..." });423 // 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".
422 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });425 // executor.onepassword.configure({ auth: { kind: "desktop-app", accountName: "..." }, vaultId: "..." });
423 426
424 // -------------------------------------------------------------------------427 // -------------------------------------------------------------------------
packages/core/api/src/handlers/integrations.ts+1−0
@@ -15,6 +15,7 @@ const toResponse = (i: Integration) => ({ · 14 unmodified lines
15 canRefresh: i.canRefresh,15 canRefresh: i.canRefresh,
16 authMethods: i.authMethods,16 authMethods: i.authMethods,
17 ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}),17 ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}),
18 ...(i.family ? { family: i.family } : {}),
18});19});
19 20
20export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) =>21export 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
81 authMethods: Schema.Array(AuthMethodDescriptorSchema),81 authMethods: Schema.Array(AuthMethodDescriptorSchema),
82 /** Non-secret URL derived from opaque integration config for favicons. */82 /** Non-secret URL derived from opaque integration config for favicons. */
83 displayUrl: Schema.optional(Schema.String),83 displayUrl: Schema.optional(Schema.String),
84 /** Catalog family derived from opaque integration config for grouped display. */
85 family: Schema.optional(Schema.String),
84});86});
85 87
86const UpdateIntegrationPayload = Schema.Struct({88const UpdateIntegrationPayload = Schema.Struct({
packages/core/sdk/src/executor.ts+13−8
@@ -86,6 +86,7 @@ import type { · 85 unmodified lines
86 AuthMethodDescriptor,86 AuthMethodDescriptor,
87 Integration,87 Integration,
88 IntegrationConfig,88 IntegrationConfig,
89 IntegrationDisplayDescriptor,
89 RegisterIntegrationInput,90 RegisterIntegrationInput,
90} from "./integration";91} from "./integration";
91import {92import {
@@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines
522const rowToIntegration = (523const rowToIntegration = (
523 row: IntegrationRow,524 row: IntegrationRow,
524 authMethods: readonly AuthMethodDescriptor[] = [],525 authMethods: readonly AuthMethodDescriptor[] = [],
525 displayUrl?: string,526 display?: IntegrationDisplayDescriptor,
526): Integration => ({527): Integration => ({
527 slug: IntegrationSlug.make(row.slug),528 slug: IntegrationSlug.make(row.slug),
528 // Pre-split rows have no `name`; their description WAS the display name.529 // Pre-split rows have no `name`; their description WAS the display name.
@@ -534,7 +535,8 @@ const rowToIntegration = ( · 5 unmodified lines
534 canRemove: Boolean(row.can_remove),535 canRemove: Boolean(row.can_remove),
535 canRefresh: Boolean(row.can_refresh),536 canRefresh: Boolean(row.can_refresh),
536 authMethods,537 authMethods,
537 ...(displayUrl ? { displayUrl } : {}),538 ...(display?.url ? { displayUrl: display.url } : {}),
539 ...(display?.family ? { family: display.family } : {}),
538});540});
539 541
540const rowToIntegrationRecord = (542const rowToIntegrationRecord = (
@@ -1759,17 +1761,20 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 1218 unmodified lines
1759 }1761 }
1760 };1762 };
1761 1763
1762 const describeDisplayUrlForRow = (row: IntegrationRow): string | undefined => {1764 const describeDisplayForRow = (row: IntegrationRow): IntegrationDisplayDescriptor => {
1763 const runtime = runtimes.get(row.plugin_id);1765 const runtime = runtimes.get(row.plugin_id);
1764 const describe = runtime?.plugin.describeIntegrationDisplay;1766 const describe = runtime?.plugin.describeIntegrationDisplay;
1765 if (!describe) return undefined;1767 if (!describe) return {};
1766 const record = rowToIntegrationRecord(row);1768 const record = rowToIntegrationRecord(row);
1767 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read1769 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read
1768 try {1770 try {
1769 const display = describe(record);1771 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 };
1771 } catch {1776 } catch {
1772 return undefined;1777 return {};
1773 }1778 }
1774 };1779 };
1775 1780
@@ -1801,7 +1806,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 25 unmodified lines
1801 const rows = yield* core.findMany("integration", {});1806 const rows = yield* core.findMany("integration", {});
1802 const staticIntegrations = staticSources().map(staticSourceToIntegration);1807 const staticIntegrations = staticSources().map(staticSourceToIntegration);
1803 const dbIntegrations = rows.map((row) =>1808 const dbIntegrations = rows.map((row) =>
1804 rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)),1809 rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)),
1805 );1810 );
1806 // A scoped toolkit must not advertise providers it grants no tools from1811 // A scoped toolkit must not advertise providers it grants no tools from
1807 // (mirrors `connectionsList`). Static sources are system namespaces, not1812 // (mirrors `connectionsList`). Static sources are system namespaces, not
@@ -1828,7 +1833,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea · 20 unmodified lines
1828 if (staticSource) return staticSourceToIntegration(staticSource);1833 if (staticSource) return staticSourceToIntegration(staticSource);
1829 const row = yield* findIntegrationRow(slug);1834 const row = yield* findIntegrationRow(slug);
1830 return row1835 return row
1831 ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row))1836 ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row))
1832 : null;1837 : null;
1833 });1838 });
1834 1839
packages/core/sdk/src/integration.ts+4−0
@@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines
22export interface IntegrationDisplayDescriptor {22export interface IntegrationDisplayDescriptor {
23 /** Non-secret URL suitable for display metadata such as favicons. */23 /** Non-secret URL suitable for display metadata such as favicons. */
24 readonly url?: string;24 readonly url?: string;
25 /** Catalog family derived from plugin config for grouped display. */
26 readonly family?: string;
25}27}
26 28
27/** Where a credential value is carried. `header`/`query` place it on an29/** Where a credential value is carried. `header`/`query` place it on an
@@ -100,6 +102,8 @@ export interface Integration { · 72 unmodified lines
100 /** Non-secret display URL derived by the owning plugin from opaque config.102 /** Non-secret display URL derived by the owning plugin from opaque config.
101 * Used for catalog favicons; never includes credentials or plugin config. */103 * Used for catalog favicons; never includes credentials or plugin config. */
102 readonly displayUrl?: string;104 readonly displayUrl?: string;
105 /** Catalog family derived by the owning plugin from opaque config. */
106 readonly family?: string;
103}107}
104 108
105/** Plugin-owned, opaque-to-core configuration stored on the integration row. The109/** 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
17 "type": "module",17 "type": "module",
18 "exports": {18 "exports": {
19 ".": "./src/sdk/index.ts",19 ".": "./src/sdk/index.ts",
20 "./promise": "./src/promise.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"
24 },21 },
25 "publishConfig": {22 "publishConfig": {
26 "access": "public",23 "access": "public",
@@ -36,12 +33,6 @@ · 9 unmodified lines
36 "types": "./dist/sdk/index.d.ts",33 "types": "./dist/sdk/index.d.ts",
37 "default": "./dist/core.js"34 "default": "./dist/core.js"
38 }35 }
39 },
40 "./client": {
41 "import": {
42 "types": "./dist/react/plugin-client.d.ts",
43 "default": "./dist/client.js"
44 }
45 }36 }
46 }37 }
47 },38 },
@@ -54,8 +45,7 @@ · 6 unmodified lines
54 },45 },
55 "dependencies": {46 "dependencies": {
56 "@executor-js/plugin-openapi": "workspace:*",47 "@executor-js/plugin-openapi": "workspace:*",
57 "@executor-js/sdk": "workspace:*",48 "@executor-js/sdk": "workspace:*"
58 "lucide-react": "^1.7.0"
59 },49 },
60 "devDependencies": {50 "devDependencies": {
61 "@effect/atom-react": "catalog:",51 "@effect/atom-react": "catalog:",
packages/plugins/google/src/api/group.ts+0−142
@@ -1,142 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4import {
5 InternalError,
6 IntegrationAlreadyExistsError,
7 IntegrationNotFoundError,
8 IntegrationSlug,
9} from "@executor-js/sdk/shared";
10import {
11 OpenApiExtractionError,
12 OpenApiOAuthError,
13 OpenApiParseError,
14} from "@executor-js/plugin-openapi";
15
16const DomainErrors = [
17 InternalError,
18 OpenApiParseError,
19 OpenApiExtractionError,
20 OpenApiOAuthError,
21 IntegrationAlreadyExistsError,
22] as const;
23
24const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25
26const UpdateErrors = [
27 InternalError,
28 OpenApiParseError,
29 OpenApiExtractionError,
30 OpenApiOAuthError,
31 IntegrationNotFound,
32] as const;
33
34const SlugParams = {
35 slug: Schema.String,
36};
37
38const 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
46const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48
49const 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
57const AddBundleResponse = Schema.Struct({
58 slug: IntegrationSlug,
59 toolCount: Schema.Number,
60});
61
62const UpdateBundlePayload = Schema.Struct({
63 urls: Schema.optional(Schema.Array(Schema.String)),
64});
65
66const UpdateBundleResponse = Schema.Struct({
67 slug: IntegrationSlug,
68 toolCount: Schema.Number,
69 addedTools: Schema.Array(Schema.String),
70 removedTools: Schema.Array(Schema.String),
71});
72
73const IntegrationView = Schema.Struct({
74 slug: IntegrationSlug,
75 description: Schema.String,
76 kind: Schema.String,
77 canRemove: Schema.Boolean,
78 canRefresh: Schema.Boolean,
79});
80
81const 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
89const ConfigurePayload = Schema.Struct({
90 authenticationTemplate: Schema.Array(AuthenticationPayload),
91 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
92});
93
94const ConfigureResponse = Schema.Struct({
95 authenticationTemplate: Schema.Array(AuthenticationResponse),
96});
97
98export 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 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { Context, Effect } from "effect";
3
4import { addGroup, capture } from "@executor-js/api";
5import type { GooglePluginExtension } from "../sdk/plugin";
6import { GoogleGroup } from "./group";
7
8export class GoogleExtensionService extends Context.Service<
9 GoogleExtensionService,
10 GooglePluginExtension
11>()("GoogleExtensionService") {}
12
13const ExecutorApiWithGoogle = addGroup(GoogleGroup);
14
15export 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 @@
1import { definePlugin } from "@executor-js/sdk/core";
2
3import { googlePlugin, type GooglePluginOptions } from "../sdk/plugin";
4import { GoogleGroup } from "./group";
5import { GoogleHandlers, GoogleExtensionService } from "./handlers";
6
7export { GoogleGroup } from "./group";
8export { GoogleHandlers, GoogleExtensionService } from "./handlers";
9
10export 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 @@
1export { googlePlugin } from "./sdk/plugin";1export * from "./sdk/index";
packages/plugins/google/src/react/AddGoogleSource.tsx+0−178
@@ -1,178 +0,0 @@
1import { useCallback, useMemo, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4
5import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import {
7 slugifyNamespace,
8 useIntegrationIdentity,
9} from "@executor-js/react/plugins/integration-identity";
10import { Button } from "@executor-js/react/components/button";
11import { FloatActions } from "@executor-js/react/components/float-actions";
12import {
13 addIntegrationErrorMessage,
14 FormErrorAlert,
15 SlugCollisionAlert,
16 useSlugAlreadyExists,
17} from "@executor-js/react/lib/integration-add";
18import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19
20import { addGoogleBundle } from "./atoms";
21import { GoogleProductPicker } from "./GoogleProductPicker";
22import {
23 GOOGLE_PHOTOS_PRESET_ID,
24 googleOpenApiPresets,
25 googlePhotosPresetIds,
26 type GoogleOpenApiPreset,
27} from "../sdk/presets";
28
29const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg";
30
31const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set(
32 googleOpenApiPresets
33 .filter((preset: GoogleOpenApiPreset) => preset.featured)
34 .map((preset: GoogleOpenApiPreset) => preset.id),
35);
36
37const 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
47export 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 @@
1import { useCallback, useMemo } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7import { TriangleAlert } from "lucide-react";
8
9import { AccountsSection } from "@executor-js/react/components/accounts-section";
10import {
11 HealthCheckEditor,
12 type HealthCheckLivePreview,
13} from "@executor-js/react/components/health-check-editor";
14import { useOrganizationId } from "@executor-js/react/api/organization-context";
15import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
16import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert";
17import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
18import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
19import {
20 useCustomMethodActions,
21 type AuthMethodsCodec,
22 type ConfigureAuthMethods,
23} from "@executor-js/react/lib/custom-auth-methods";
24import {
25 authMethodsFromConfig,
26 openApiWireAuthInput,
27 templateFromPlacements,
28} from "@executor-js/plugin-openapi/react";
29import type { Authentication } from "@executor-js/plugin-openapi";
30
31import { googleConfigAtom, googleConfigure } from "./atoms";
32import { googleAudienceWarningsForUrls } from "../sdk/presets";
33
34const 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
41const 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
50export 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 @@
1import { useMemo, useState } from "react";
2import { ChevronDownIcon, PlusIcon, TriangleAlert, XIcon } from "lucide-react";
3
4import { cn } from "@executor-js/react/lib/utils";
5import { Badge } from "@executor-js/react/components/badge";
6import { Button } from "@executor-js/react/components/button";
7import { Checkbox } from "@executor-js/react/components/checkbox";
8import {
9 Collapsible,
10 CollapsibleContent,
11 CollapsibleTrigger,
12} from "@executor-js/react/components/collapsible";
13import { FieldLabel } from "@executor-js/react/components/field";
14import { Input } from "@executor-js/react/components/input";
15import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16
17import {
18 googleOAuthConsentScopesForPreset,
19 googleOpenApiPresets,
20 type GoogleOpenApiOAuthAudience,
21 type GoogleOpenApiPreset,
22} from "../sdk/presets";
23import { googleOAuthConsentBatches } from "../sdk/oauth-batches";
24import { 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.
41const AUDIENCE_ORDER: readonly GoogleOpenApiOAuthAudience[] = [
42 "standard-user",
43 "advanced-user",
44 "workspace-admin",
45 "unsupported-user",
46];
47
48const 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
55const 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
62const audienceNeedsWarning = (audience: GoogleOpenApiOAuthAudience): boolean =>
63 audience === "workspace-admin" || audience === "unsupported-user";
64
65type 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
73const 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.
90const 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
122const 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
202export 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
363export default GoogleProductPicker;
packages/plugins/google/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1import type { IntegrationSlug } from "@executor-js/sdk/shared";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4import { GoogleClient } from "./client";
5
6export const googleIntegrationAtom = (slug: IntegrationSlug) =>
7 GoogleClient.query("google", "getIntegration", {
8 params: { slug },
9 timeToLive: "15 seconds",
10 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11 });
12
13export const googleConfigAtom = (slug: IntegrationSlug) =>
14 GoogleClient.query("google", "getConfig", {
15 params: { slug },
16 timeToLive: "15 seconds",
17 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18 });
19
20export const addGoogleBundle = GoogleClient.mutation("google", "addBundle");
21
22export const updateGoogleBundle = GoogleClient.mutation("google", "updateBundle");
23
24export const removeGoogleBundle = GoogleClient.mutation("google", "removeBundle");
25
26export const googleConfigure = GoogleClient.mutation("google", "configure");
27
28export const googleIntegrationFamily = Atom.family(googleIntegrationAtom);
29
30export const googleConfigFamily = Atom.family(googleConfigAtom);
packages/plugins/google/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1import { createPluginAtomClient } from "@executor-js/sdk/client";
2import {
3 getExecutorOrganizationHeaders,
4 getExecutorApiBaseUrl,
5 getExecutorServerAuthorizationHeader,
6} from "@executor-js/react/api/server-connection";
7import { GoogleGroup } from "../api/group";
8
9export 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 @@
1export { googleIntegrationPlugin } from "./source-plugin";
2export { GoogleClient } from "./client";
3export {
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 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { googleIntegrationPlugin } from "./source-plugin";
4
5export 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 @@
1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets";
4
5const importAdd = () => import("./AddGoogleSource");
6const importAccounts = () => import("./GoogleAccountsPanel");
7
8export 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
7} from "./discovery";7} from "./discovery";
8export {8export {
9 googleOpenApiBundlePreset,9 googleOpenApiBundlePreset,
10 googleCatalog,
10 googleOpenApiPresets,11 googleOpenApiPresets,
11 googlePhotosOpenApiBundlePreset,12 googlePhotosOpenApiBundlePreset,
12 googlePhotosOpenApiPresets,13 googlePhotosOpenApiPresets,
@@ -16,6 +17,7 @@ export { · 3 unmodified lines
16 googleStandardUserOAuthPresets,17 googleStandardUserOAuthPresets,
17 googleOAuthConsentScopes,18 googleOAuthConsentScopes,
18 googleOAuthConsentScopesForPreset,19 googleOAuthConsentScopesForPreset,
20 googleServiceSlug,
19 googleAudienceWarningsForUrls,21 googleAudienceWarningsForUrls,
20 googlePresetForDiscoveryUrl,22 googlePresetForDiscoveryUrl,
21 type GoogleOpenApiOAuthAudience,23 type GoogleOpenApiOAuthAudience,
@@ -28,20 +30,6 @@ export { · 6 unmodified lines
28 isGoogleUserConsentOAuthScope,30 isGoogleUserConsentOAuthScope,
29} from "./oauth-scopes";31} from "./oauth-scopes";
30export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";32export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
31export {
32 googleOAuthConsentBatches,
33 type GoogleOAuthBatchInput,
34 type GoogleOAuthConsentBatch,
35} from "./oauth-batches";
36export {
37 googlePlugin,
38 type GoogleBundleConfig,
39 type GoogleConfigureInput,
40 type GooglePluginExtension,
41 type GooglePluginOptions,
42 type GoogleUpdateInput,
43 type GoogleUpdateResult,
44} from "./plugin";
45export {33export {
46 googleOpenApiOwnershipDataMigration,34 googleOpenApiOwnershipDataMigration,
47 runSqliteGoogleOpenApiOwnershipMigration,35 runSqliteGoogleOpenApiOwnershipMigration,
packages/plugins/google/src/sdk/oauth-batches.test.ts+0−98
@@ -1,98 +0,0 @@
1import { expect, it } from "@effect/vitest";
2
3import { googleOAuthConsentBatches } from "./oauth-batches";
4
5it("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
33it("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 @@
1import type { GoogleOpenApiOAuthAudience } from "./presets";
2import { compactGoogleOAuthScopes } from "./oauth-scopes";
3
4export type GoogleOAuthBatchInput = {
5 readonly id: string;
6 readonly name: string;
7 readonly oauthAudience: GoogleOpenApiOAuthAudience;
8 readonly scopes: readonly string[];
9};
10
11export type GoogleOAuthConsentBatch = {
12 readonly id: string;
13 readonly label: string;
14 readonly apiScopes: readonly string[];
15};
16
17const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]);
18
19export 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
14import { describe, expect, it } from "@effect/vitest";
15import { Effect, Exit, Layer } from "effect";
16import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
17
18import {
19 ConnectionName,
20 IntegrationSlug,
21 createExecutor,
22 AuthTemplateSlug,
23} from "@executor-js/sdk";
24import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
25
26import { defaultGoogleHealthCheck, googlePlugin } from "./plugin";
27import { 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
35const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest";
36const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest";
37const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest";
38const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest";
39const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest";
40const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest";
41const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1";
42const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest";
43const OAUTH2_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest";
44const 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
50const 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
84const 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
124const 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
156const 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
189const 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
222const 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
270const 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
291const 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
334const 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
420const toJson = (value: unknown): string => JSON.stringify(value);
421
422const 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.
438const 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
457const bundlePlugins = () =>
458 [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const;
459
460describe("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
704describe("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 @@
1import { Effect } from "effect";
2import type { Layer } from "effect";
3import { HttpClient } from "effect/unstable/http";
4
5import {
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";
20import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21import {
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
37import {
38 convertGoogleDiscoveryBundleToOpenApi,
39 fetchGoogleDiscoveryDocument,
40 normalizeGoogleDiscoveryUrl,
41} from "./discovery";
42import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config";
43import {
44 googleOAuthConsentScopesForPreset,
45 googleOpenApiBundlePreset,
46 googlePhotosOpenApiBundlePreset,
47 googlePhotosOpenApiPresets,
48} from "./presets";
49
50const 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. */
55export 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
92export 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
100export interface GoogleConfigureInput {
101 readonly authenticationTemplate: readonly AuthenticationInput[];
102 readonly mode?: "merge" | "replace";
103}
104
105export interface GoogleUpdateInput {
106 readonly urls?: readonly string[];
107}
108
109export interface GoogleUpdateResult {
110 readonly slug: IntegrationSlug;
111 readonly toolCount: number;
112 readonly addedTools: readonly string[];
113 readonly removedTools: readonly string[];
114}
115
116export interface GooglePluginOptions {
117 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
118}
119
120const DEFAULT_GOOGLE_SLUG = "google";
121
122const googlePhotosBundlePresetIdByUrl = new Map(
123 googlePhotosOpenApiPresets.flatMap((preset) =>
124 preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [],
125 ),
126);
127
128const 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
140const 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
162const uniqueUrls = (urls: readonly string[]): readonly string[] => [
163 ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])),
164];
165
166const 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
184const 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
207const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => {
208 const config = decodeGoogleIntegrationConfig(record.config);
209 return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] };
210};
211
212const 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
395export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>;
396
397export 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 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2 2
3import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";3import { googleCatalog, googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets";
4
5const 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;
4 28
5it("keeps Select all limited to Google services that can use normal user OAuth", () => {29it("keeps Select all limited to Google services that can use normal user OAuth", () => {
6 const standardIds = new Set(googleStandardUserOAuthPresets.map((preset) => preset.id));30 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
27 })),51 })),
28 ).toMatchSnapshot();52 ).toMatchSnapshot();
29});53});
54
55it("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 @@
1import { normalizeGoogleDiscoveryUrl } from "./discovery";1import { normalizeGoogleDiscoveryUrl } from "./discovery";
2import { compactGoogleOAuthScopes } from "./oauth-scopes";
3import type { IntegrationPreset } from "@executor-js/sdk/core";
2 4
3export interface GooglePreset {5export interface GooglePreset {
4 readonly id: string;6 readonly id: string;
@@ -238,7 +240,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 233 unmodified lines
238// The picker shows the OAuth consent a user is about to grant BEFORE connecting240// The picker shows the OAuth consent a user is about to grant BEFORE connecting
239// (the "View scopes" panel), but the authoritative scope list only exists in241// (the "View scopes" panel), but the authoritative scope list only exists in
240// each API's live Discovery document, which the add flow fetches lazily at242// 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 preset243// provider add time. To preview consent without N network round-trips, each preset
242// declares the broad top-level scope(s) a full integration grants. These flow244// declares the broad top-level scope(s) a full integration grants. These flow
243// through `googleOAuthConsentBatches` (which compacts sub-scopes under their245// through `googleOAuthConsentBatches` (which compacts sub-scopes under their
244// broad parent), so the previewed grant matches the unioned scopes the bundle246// 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
276export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>278export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] =>
277 googleOAuthConsentScopes[presetId] ?? [];279 googleOAuthConsentScopes[presetId] ?? [];
278 280
281export const googleServiceSlug = (presetId: string): string => presetId.replaceAll("-", "_");
282
283const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth";
284const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token";
285const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2";
286const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"];
287const GOOGLE_USERINFO_HEALTH_CHECK = {
288 operation: "oauth2.userinfo.get",
289 identityField: "email",
290} as const;
291
292const 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
305export 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
279// ---------------------------------------------------------------------------319// ---------------------------------------------------------------------------
280// Resolve a stored/normalized Discovery URL back to its preset, so a bundled320// Resolve a stored/normalized Discovery URL back to its preset, so a bundled
281// `google` integration can surface each selected API's `oauthAudience` (e.g. a321// `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
11import { expect, it } from "@effect/vitest";
12
13import {
14 googleAudienceWarningsForUrls,
15 googleOAuthConsentScopes,
16 googleOAuthConsentScopesForPreset,
17 googleOpenApiPresets,
18 googlePresetForDiscoveryUrl,
19 type GoogleOpenApiPreset,
20} from "./presets";
21import { googleOAuthConsentBatches } from "./oauth-batches";
22import { compactGoogleOAuthScopes } from "./oauth-scopes";
23
24const 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
34it("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
45it("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
63it("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
90it("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
103it("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
4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";4import { createExecutor, IntegrationSlug } from "@executor-js/sdk";
5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";5import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";6import { openApiPlugin, parse } from "@executor-js/plugin-openapi";
7import type { AuthenticationInput } from "@executor-js/plugin-openapi";
7 8
8import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";9import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter";
10import { googleCatalog } from "./presets";
9 11
10const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";12const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest";
11 13
@@ -115,3 +117,51 @@ it.effect("adds a Google Discovery URL through the OpenAPI plugin with derived i · 103 unmodified lines
115 expect(added.toolCount).toBe(1);117 expect(added.toolCount).toBe(1);
116 }),118 }),
117);119);
120
121it.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
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
8 },7 },
9 format: ["esm"],8 format: ["esm"],
10 dts: false,9 dts: false,
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−2
@@ -23,7 +23,7 @@ import { · 22 unmodified lines
23 useSlugAlreadyExists,23 useSlugAlreadyExists,
24} from "@executor-js/react/lib/integration-add";24} from "@executor-js/react/lib/integration-add";
25 25
26import { addGraphqlIntegrationOptimistic } from "./atoms";26import { createGraphqlIntegrationOptimistic } from "./atoms";
27import { GraphqlSourceFields } from "./GraphqlSourceFields";27import { GraphqlSourceFields } from "./GraphqlSourceFields";
28import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";28import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config";
29import type { GraphqlAuthMethodInput } from "../sdk/types";29import type { GraphqlAuthMethodInput } from "../sdk/types";
@@ -52,7 +52,7 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines
52 const [adding, setAdding] = useState(false);52 const [adding, setAdding] = useState(false);
53 const [addError, setAddError] = useState<string | null>(null);53 const [addError, setAddError] = useState<string | null>(null);
54 54
55 const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, {55 const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, {
56 mode: "promiseExit",56 mode: "promiseExit",
57 });57 });
58 58
packages/plugins/graphql/src/react/atoms.ts+3−3
@@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines
33// Mutation atoms33// Mutation atoms
34// ---------------------------------------------------------------------------34// ---------------------------------------------------------------------------
35 35
36export const addGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");36export const createGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration");
37 37
38// Merge-append custom auth methods onto an integration's `authenticationTemplate`.38// Merge-append custom auth methods onto an integration's `authenticationTemplate`.
39export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure");39export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure");
@@ -41,7 +41,7 @@ export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); · 1 unmodified line
41// Optimistically slot a pending row into the shared integration catalog so the41// Optimistically slot a pending row into the shared integration catalog so the
42// integrations list reflects the new GraphQL integration before the server42// integrations list reflects the new GraphQL integration before the server
43// round-trips. The reducer mirrors the catalog `Integration` shape.43// round-trips. The reducer mirrors the catalog `Integration` shape.
44export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(44export const createGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe(
45 Atom.optimisticFn({45 Atom.optimisticFn({
46 reducer: (46 reducer: (
47 current,47 current,
@@ -69,6 +69,6 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 21 unmodified lines
69 a.name.localeCompare(b.name),69 a.name.localeCompare(b.name),
70 );70 );
71 }),71 }),
72 fn: addGraphqlIntegration,72 fn: createGraphqlIntegration,
73 }),73 }),
74);74);
packages/plugins/graphql/src/react/index.ts+1−1
@@ -1,3 +1,3 @@
1export { graphqlIntegrationPlugin } from "./source-plugin";1export { graphqlIntegrationPlugin } from "./source-plugin";
2export { GraphqlClient } from "./client";2export { GraphqlClient } from "./client";
3export { addGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms";3export { createGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms";
packages/plugins/microsoft/package.json+2−12
@@ -17,10 +17,7 @@ · 16 unmodified lines
17 "type": "module",17 "type": "module",
18 "exports": {18 "exports": {
19 ".": "./src/sdk/index.ts",19 ".": "./src/sdk/index.ts",
20 "./promise": "./src/promise.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"
24 },21 },
25 "publishConfig": {22 "publishConfig": {
26 "access": "public",23 "access": "public",
@@ -36,12 +33,6 @@ · 9 unmodified lines
36 "types": "./dist/sdk/index.d.ts",33 "types": "./dist/sdk/index.d.ts",
37 "default": "./dist/core.js"34 "default": "./dist/core.js"
38 }35 }
39 },
40 "./client": {
41 "import": {
42 "types": "./dist/react/plugin-client.d.ts",
43 "default": "./dist/client.js"
44 }
45 }36 }
46 }37 }
47 },38 },
@@ -54,8 +45,7 @@ · 6 unmodified lines
54 },45 },
55 "dependencies": {46 "dependencies": {
56 "@executor-js/plugin-openapi": "workspace:*",47 "@executor-js/plugin-openapi": "workspace:*",
57 "@executor-js/sdk": "workspace:*",48 "@executor-js/sdk": "workspace:*"
58 "lucide-react": "^1.7.0"
59 },49 },
60 "devDependencies": {50 "devDependencies": {
61 "@effect/atom-react": "catalog:",51 "@effect/atom-react": "catalog:",
packages/plugins/microsoft/src/api/group.ts+0−163
@@ -1,163 +0,0 @@
1import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi";
2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4import {
5 InternalError,
6 IntegrationAlreadyExistsError,
7 IntegrationNotFoundError,
8 IntegrationSlug,
9} from "@executor-js/sdk/shared";
10import {
11 OpenApiExtractionError,
12 OpenApiOAuthError,
13 OpenApiParseError,
14} from "@executor-js/plugin-openapi";
15
16const DomainErrors = [
17 InternalError,
18 OpenApiParseError,
19 OpenApiExtractionError,
20 OpenApiOAuthError,
21 IntegrationAlreadyExistsError,
22] as const;
23
24const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 });
25
26const UpdateErrors = [
27 InternalError,
28 OpenApiParseError,
29 OpenApiExtractionError,
30 OpenApiOAuthError,
31 IntegrationNotFound,
32] as const;
33
34const SlugParams = {
35 slug: Schema.String,
36};
37
38const 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
46const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]);
47const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]);
48
49const 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
62const AddGraphResponse = Schema.Struct({
63 slug: IntegrationSlug,
64 toolCount: Schema.Number,
65});
66
67const 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
77const UpdateGraphResponse = Schema.Struct({
78 slug: IntegrationSlug,
79 toolCount: Schema.Number,
80 addedTools: Schema.Array(Schema.String),
81 removedTools: Schema.Array(Schema.String),
82});
83
84const IntegrationView = Schema.Struct({
85 slug: IntegrationSlug,
86 description: Schema.String,
87 kind: Schema.String,
88 canRemove: Schema.Boolean,
89 canRefresh: Schema.Boolean,
90});
91
92const 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
110const ConfigurePayload = Schema.Struct({
111 authenticationTemplate: Schema.Array(AuthenticationPayload),
112 mode: Schema.optional(Schema.Literals(["merge", "replace"])),
113});
114
115const ConfigureResponse = Schema.Struct({
116 authenticationTemplate: Schema.Array(AuthenticationResponse),
117});
118
119export 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 @@
1import { HttpApiBuilder } from "effect/unstable/httpapi";
2import { Context, Effect } from "effect";
3
4import { addGroup, capture } from "@executor-js/api";
5import type { MicrosoftPluginExtension } from "../sdk/plugin";
6import { MicrosoftGroup } from "./group";
7
8export class MicrosoftExtensionService extends Context.Service<
9 MicrosoftExtensionService,
10 MicrosoftPluginExtension
11>()("MicrosoftExtensionService") {}
12
13const ExecutorApiWithMicrosoft = addGroup(MicrosoftGroup);
14
15export 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 @@
1import { definePlugin } from "@executor-js/sdk/core";
2
3import { microsoftPlugin, type MicrosoftPluginOptions } from "../sdk/plugin";
4import { MicrosoftGroup } from "./group";
5import { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
6
7export { MicrosoftGroup } from "./group";
8export { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers";
9
10export 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 @@
1export { microsoftPlugin } from "./sdk/plugin";1export * from "./sdk/index";
packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx+0−155
@@ -1,155 +0,0 @@
1import { useCallback, useMemo, useState } from "react";
2import { useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4
5import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
6import {
7 slugifyNamespace,
8 useIntegrationIdentity,
9} from "@executor-js/react/plugins/integration-identity";
10import { Button } from "@executor-js/react/components/button";
11import { FloatActions } from "@executor-js/react/components/float-actions";
12import {
13 addIntegrationErrorMessage,
14 FormErrorAlert,
15 SlugCollisionAlert,
16 useSlugAlreadyExists,
17} from "@executor-js/react/lib/integration-add";
18import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react";
19
20import { addMicrosoftGraph } from "./atoms";
21import { MicrosoftScopePicker } from "./MicrosoftScopePicker";
22import {
23 MICROSOFT_GRAPH_BASE_URL,
24 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
25 microsoftGraphScopesForPresetIds,
26} from "../sdk/presets";
27
28const MICROSOFT_FAVICON = "https://www.microsoft.com/favicon.ico";
29
30const defaultPresetIds: ReadonlySet<string> = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS);
31
32export 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 @@
1import { useCallback, useMemo } from "react";
2import { useAtomValue, useAtomSet } from "@effect/atom-react";
3import * as Exit from "effect/Exit";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
5import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared";
6import type { IntegrationAccountHandoff } from "@executor-js/sdk/client";
7
8import { AccountsSection } from "@executor-js/react/components/accounts-section";
9import {
10 HealthCheckEditor,
11 type HealthCheckLivePreview,
12} from "@executor-js/react/components/health-check-editor";
13import { useOrganizationId } from "@executor-js/react/api/organization-context";
14import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner";
15import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";
16import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements";
17import {
18 useCustomMethodActions,
19 type AuthMethodsCodec,
20 type ConfigureAuthMethods,
21} from "@executor-js/react/lib/custom-auth-methods";
22import {
23 authMethodsFromConfig,
24 openApiWireAuthInput,
25 templateFromPlacements,
26} from "@executor-js/plugin-openapi/react";
27import type { Authentication } from "@executor-js/plugin-openapi";
28
29import { microsoftConfigAtom, microsoftConfigure } from "./atoms";
30
31const 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
40export 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 @@
1import { useMemo, useState } from "react";
2import { ChevronDownIcon, PlusIcon, XIcon } from "lucide-react";
3
4import { cn } from "@executor-js/react/lib/utils";
5import { Badge } from "@executor-js/react/components/badge";
6import { Button } from "@executor-js/react/components/button";
7import { Checkbox } from "@executor-js/react/components/checkbox";
8import {
9 Collapsible,
10 CollapsibleContent,
11 CollapsibleTrigger,
12} from "@executor-js/react/components/collapsible";
13import { FieldLabel } from "@executor-js/react/components/field";
14import { Input } from "@executor-js/react/components/input";
15import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon";
16
17import {
18 microsoftGraphScopePresets,
19 microsoftGraphScopesForPresetIds,
20 type MicrosoftGraphScopeAudience,
21 type MicrosoftGraphScopePreset,
22} from "../sdk/presets";
23
24type 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
32const AUDIENCE_ORDER: readonly MicrosoftGraphScopeAudience[] = [
33 "productivity",
34 "files-content",
35 "collaboration",
36 "directory-identity",
37 "admin-security",
38 "platform-business",
39];
40
41const 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
50const 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
59const 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
87const 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
165export 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
286export default MicrosoftScopePicker;
packages/plugins/microsoft/src/react/atoms.ts+0−30
@@ -1,30 +0,0 @@
1import type { IntegrationSlug } from "@executor-js/sdk/shared";
2import * as Atom from "effect/unstable/reactivity/Atom";
3import { ReactivityKey } from "@executor-js/react/api/reactivity-keys";
4import { MicrosoftClient } from "./client";
5
6export const microsoftIntegrationAtom = (slug: IntegrationSlug) =>
7 MicrosoftClient.query("microsoft", "getIntegration", {
8 params: { slug },
9 timeToLive: "15 seconds",
10 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
11 });
12
13export const microsoftConfigAtom = (slug: IntegrationSlug) =>
14 MicrosoftClient.query("microsoft", "getConfig", {
15 params: { slug },
16 timeToLive: "15 seconds",
17 reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools],
18 });
19
20export const addMicrosoftGraph = MicrosoftClient.mutation("microsoft", "addGraph");
21
22export const updateMicrosoftGraph = MicrosoftClient.mutation("microsoft", "updateGraph");
23
24export const removeMicrosoftGraph = MicrosoftClient.mutation("microsoft", "removeGraph");
25
26export const microsoftConfigure = MicrosoftClient.mutation("microsoft", "configure");
27
28export const microsoftIntegrationFamily = Atom.family(microsoftIntegrationAtom);
29
30export const microsoftConfigFamily = Atom.family(microsoftConfigAtom);
packages/plugins/microsoft/src/react/client.ts+0−13
@@ -1,13 +0,0 @@
1import { createPluginAtomClient } from "@executor-js/sdk/client";
2import {
3 getExecutorOrganizationHeaders,
4 getExecutorApiBaseUrl,
5 getExecutorServerAuthorizationHeader,
6} from "@executor-js/react/api/server-connection";
7import { MicrosoftGroup } from "../api/group";
8
9export 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 @@
1export { microsoftIntegrationPlugin } from "./source-plugin";
2export { MicrosoftClient } from "./client";
3export {
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 @@
1import { defineClientPlugin } from "@executor-js/sdk/client";
2
3import { microsoftIntegrationPlugin } from "./source-plugin";
4
5export 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 @@
1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";
3import { microsoftGraphPreset } from "../sdk/presets";
4
5const importAdd = () => import("./AddMicrosoftSource");
6const importAccounts = () => import("./MicrosoftAccountsPanel");
7
8export 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
13 MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,13 MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL,
14 MICROSOFT_GRAPH_PRESET_ID,14 MICROSOFT_GRAPH_PRESET_ID,
15 MICROSOFT_TOKEN_URL,15 MICROSOFT_TOKEN_URL,
16 microsoftCatalog,
16 microsoftGraphExactPathsForPresetIds,17 microsoftGraphExactPathsForPresetIds,
17 microsoftGraphPathPrefixesForPresetIds,18 microsoftGraphPathPrefixesForPresetIds,
18 microsoftGraphPreset,19 microsoftGraphPreset,
@@ -21,6 +22,7 @@ export { · 2 unmodified lines
21 microsoftGraphScopePresets,22 microsoftGraphScopePresets,
22 microsoftGraphScopesForPresetIds,23 microsoftGraphScopesForPresetIds,
23 microsoftGraphTagPrefixesForPresetIds,24 microsoftGraphTagPrefixesForPresetIds,
25 microsoftServiceSlug,
24 type MicrosoftGraphPreset,26 type MicrosoftGraphPreset,
25 type MicrosoftGraphScopeAudience,27 type MicrosoftGraphScopeAudience,
26 type MicrosoftGraphScopePreset,28 type MicrosoftGraphScopePreset,
@@ -37,12 +39,3 @@ export { · 10 unmodified lines
37 type MicrosoftGraphSpecBuild,39 type MicrosoftGraphSpecBuild,
38} from "./graph";40} from "./graph";
39export { microsoftGraphAdapter } from "./spec-format-adapter";41export { microsoftGraphAdapter } from "./spec-format-adapter";
40export {
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 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Exit, Layer } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
5import {
6 AuthTemplateSlug,
7 ConnectionName,
8 IntegrationSlug,
9 createExecutor,
10} from "@executor-js/sdk";
11import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
12
13import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin";
14import {
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
25const graphFixture = `
26openapi: 3.0.4
27info:
28 title: Microsoft Graph Fixture
29 version: v1.0
30servers:
31 - url: https://graph.microsoft.com/v1.0
32paths:
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
76components:
77 schemas:
78 user:
79 type: object
80`;
81
82const 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
122const EMULATOR_SPEC_URL = "https://microsoft.emulators.dev/_emulate/openapi";
123const EMULATOR_BASE_URL = "https://microsoft.emulators.dev";
124const LOCAL_EMULATOR_SPEC_URL = "http://localhost:4123/_emulate/openapi";
125const LOCAL_EMULATOR_BASE_URL = "http://localhost:4123";
126const emulatorGraphFixture = `
127openapi: 3.0.3
128info:
129 title: Microsoft Graph Emulator
130 version: 1.0.0
131servers:
132 - url: ${EMULATOR_BASE_URL}
133paths:
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
146components:
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
163const localEmulatorGraphFixture = `
164openapi: 3.0.3
165info:
166 title: Microsoft Graph Local Emulator
167 version: 1.0.0
168servers:
169 - url: ${LOCAL_EMULATOR_BASE_URL}
170paths:
171 /v1.0/users:
172 get:
173 operationId: graphUser_List
174 responses:
175 "200":
176 description: OK
177components:
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
188const 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
224const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) =>
225 [
226 microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }),
227 memoryCredentialsPlugin(),
228 ] as const;
229
230describe("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
623describe("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 @@
1import { Effect } from "effect";
2import type { Layer } from "effect";
3import { HttpClient } from "effect/unstable/http";
4
5import {
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";
20import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth";
21import {
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
38import {
39 buildMicrosoftGraphOpenApiSpec,
40 decodeMicrosoftGraphIntegrationConfig,
41 microsoftGraphKeepPathItem,
42 type MicrosoftGraphUrlPolicy,
43 type MicrosoftGraphIntegrationConfig,
44 type MicrosoftGraphSpecBuild,
45} from "./graph";
46import {
47 MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG,
48 MICROSOFT_GRAPH_BASE_URL,
49 microsoftGraphPreset,
50} from "./presets";
51
52export 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
65export interface MicrosoftConfigureInput {
66 readonly authenticationTemplate: readonly AuthenticationInput[];
67 readonly mode?: "merge" | "replace";
68}
69
70export 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
80export interface MicrosoftUpdateResult {
81 readonly slug: IntegrationSlug;
82 readonly toolCount: number;
83 readonly addedTools: readonly string[];
84 readonly removedTools: readonly string[];
85}
86
87export 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
99const DEFAULT_MICROSOFT_SLUG = "microsoft_graph";
100
101const 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
128const 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
135const 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
364export type MicrosoftPluginExtension = ReturnType<typeof makeMicrosoftPluginExtension>;
365
366export 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
4 MICROSOFT_GRAPH_ALL_PRESET_IDS,4 MICROSOFT_GRAPH_ALL_PRESET_IDS,
5 MICROSOFT_GRAPH_BASE_SCOPES,5 MICROSOFT_GRAPH_BASE_SCOPES,
6 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,6 MICROSOFT_GRAPH_DEFAULT_PRESET_IDS,
7 microsoftCatalog,
7 microsoftGraphExactPathsForPresetIds,8 microsoftGraphExactPathsForPresetIds,
8 microsoftGraphPathPrefixesForPresetIds,9 microsoftGraphPathPrefixesForPresetIds,
9 microsoftGraphPresetIdsCoverFullGraph,10 microsoftGraphPresetIdsCoverFullGraph,
@@ -12,6 +13,36 @@ import { · 2 unmodified lines
12 microsoftGraphTagPrefixesForPresetIds,13 microsoftGraphTagPrefixesForPresetIds,
13} from "./presets";14} from "./presets";
14 15
16const 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
15describe("Microsoft Graph scope presets", () => {46describe("Microsoft Graph scope presets", () => {
16 it("keeps default workload ids backed by categorized presets", () => {47 it("keeps default workload ids backed by categorized presets", () => {
17 const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id));48 const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id));
@@ -166,4 +197,17 @@ describe("Microsoft Graph scope presets", () => { · 148 unmodified lines
166 expect(preset.icon).toMatch(/^https:\/\/svgl\.app\/library\/.+\.svg$/);197 expect(preset.icon).toMatch(/^https:\/\/svgl\.app\/library\/.+\.svg$/);
167 }198 }
168 });199 });
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 });
169});213});
packages/plugins/microsoft/src/sdk/presets.ts+43−0
@@ -1,3 +1,5 @@
1import type { IntegrationPreset } from "@executor-js/sdk/core";
2
1export interface MicrosoftGraphPreset {3export interface MicrosoftGraphPreset {
2 readonly id: string;4 readonly id: string;
3 readonly name: string;5 readonly name: string;
@@ -522,3 +524,44 @@ export const microsoftGraphTagPrefixesForPresetIds = ( · 518 unmodified lines
522 orderedUnique(524 orderedUnique(
523 [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []),525 [...presetIds].flatMap((presetId) => microsoftGraphPresetForId(presetId)?.tagPrefixes ?? []),
524 );526 );
527
528export const microsoftServiceSlug = (presetId: string): string =>
529 `microsoft_${presetId.replaceAll("-", "_")}`;
530
531const microsoftGraphCatalogUrl = (presetId: string): string =>
532 `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${encodeURIComponent(presetId)}`;
533
534const 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
551export 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
75 expect(structure!.pathItems.length).toBe(2);75 expect(structure!.pathItems.length).toBe(2);
76 }),76 }),
77);77);
78
79it.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
3 3
4import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";4import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph";
5 5
6const 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
6export const microsoftGraphAdapter: SpecFormatAdapter = {21export const microsoftGraphAdapter: SpecFormatAdapter = {
7 id: "microsoft-graph",22 id: "microsoft-graph",
8 fetch: (input) =>23 fetch: (input) =>
9 buildMicrosoftGraphOpenApiSpec(24 buildMicrosoftGraphOpenApiSpec(
10 {25 graphCatalogSelection(input.urls[0]),
11 ...(input.urls[0] ? { specUrl: input.urls[0] } : {}),
12 },
13 input.httpClientLayer,26 input.httpClientLayer,
14 ).pipe(27 ).pipe(
15 Effect.map((graphSpec) => ({28 Effect.map((graphSpec) => ({
packages/plugins/microsoft/tsup.config.ts+0−1
@@ -4,7 +4,6 @@ export default defineConfig({ · 3 unmodified lines
4 entry: {4 entry: {
5 index: "src/promise.ts",5 index: "src/promise.ts",
6 core: "src/sdk/index.ts",6 core: "src/sdk/index.ts",
7 client: "src/react/plugin-client.tsx",
8 },7 },
9 format: ["esm"],8 format: ["esm"],
10 dts: false,9 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
2import { Schema } from "effect";2import { Schema } from "effect";
3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";3import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth";
4import {4import {
5 HealthCheckSpec,
5 InternalError,6 InternalError,
6 IntegrationAlreadyExistsError,7 IntegrationAlreadyExistsError,
7 IntegrationNotFoundError,8 IntegrationNotFoundError,
@@ -76,6 +77,7 @@ const AddSpecPayload = Schema.Struct({ · 68 unmodified lines
76 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),77 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
77 specFormat: Schema.optional(Schema.String),78 specFormat: Schema.optional(Schema.String),
78 family: Schema.optional(Schema.String),79 family: Schema.optional(Schema.String),
80 healthCheck: Schema.optional(HealthCheckSpec),
79 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),81 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationPayload)),
80});82});
81 83
packages/plugins/openapi/src/api/handlers.ts+1−0
@@ -66,6 +66,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope · 65 unmodified lines
66 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,66 queryParams: payload.queryParams ? { ...payload.queryParams } : undefined,
67 specFormat: payload.specFormat,67 specFormat: payload.specFormat,
68 family: payload.family,68 family: payload.family,
69 healthCheck: payload.healthCheck,
69 authenticationTemplate: payload.authenticationTemplate,70 authenticationTemplate: payload.authenticationTemplate,
70 });71 });
71 }),72 }),
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+29−15
@@ -5,10 +5,12 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines
5import * as Option from "effect/Option";5import * as Option from "effect/Option";
6 6
7import {7import {
8 AuthTemplateSlug,
8 IntegrationSlug,9 IntegrationSlug,
9 type HealthCheckCandidate,10 type HealthCheckCandidate,
10 type HealthCheckSpec,11 type HealthCheckSpec,
11} from "@executor-js/sdk/shared";12} from "@executor-js/sdk/shared";
13import { useIntegrationPlugins } from "@executor-js/sdk/client";
12import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys";14import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys";
13import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms";15import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms";
14import {16import {
@@ -43,7 +45,6 @@ import { · 28 unmodified lines
43} from "./auth-method-config";45} from "./auth-method-config";
44import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";46import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";
45import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";47import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";
46import { openApiPresets } from "../sdk/presets";
47import type { SpecPreviewSummary } from "../sdk/preview";48import type { SpecPreviewSummary } from "../sdk/preview";
48import { type Authentication } from "../sdk/types";49import { type Authentication } from "../sdk/types";
49import { resolveServerUrl } from "../sdk/openapi-utils";50import { resolveServerUrl } from "../sdk/openapi-utils";
@@ -96,10 +97,10 @@ export default function AddOpenApiSource(props: { · 46 unmodified lines
96 initialPreset?: string;97 initialPreset?: string;
97 initialNamespace?: string;98 initialNamespace?: string;
98}) {99}) {
99 const initialPreset = useMemo(100 const integrationPlugins = useIntegrationPlugins();
100 () => openApiPresets.find((preset) => preset.id === props.initialPreset) ?? null,101 const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi");
101 [props.initialPreset],102 const openApiPresets = openApiPlugin?.presets;
102 );103 const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null;
103 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");104 const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");
104 const [analyzing, setAnalyzing] = useState(false);105 const [analyzing, setAnalyzing] = useState(false);
105 const [analyzeError, setAnalyzeError] = useState<string | null>(null);106 const [analyzeError, setAnalyzeError] = useState<string | null>(null);
@@ -163,7 +164,7 @@ export default function AddOpenApiSource(props: { · 57 unmodified lines
163 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})164 ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {})
164 : "";165 : "";
165 const previewPresetIcon =166 const previewPresetIcon =
166 openApiPresets.find(167 openApiPresets?.find(
167 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),168 (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl),
168 )?.icon ?? null;169 )?.icon ?? null;
169 170
@@ -181,15 +182,26 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines
181 // Register EVERY spec-detected auth method, not just a single selected one.182 // Register EVERY spec-detected auth method, not just a single selected one.
182 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the183 // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the
183 // freshly-allocated `?? []` fallback arrays.184 // freshly-allocated `?? []` fallback arrays.
184 const authenticationTemplate: readonly Authentication[] = useMemo(185 const authenticationTemplate: readonly Authentication[] = useMemo(() => {
185 () =>186 if (initialPreset?.authTemplate) {
186 detectedAuthenticationTemplates(187 return initialPreset.authTemplate.flatMap((template): readonly Authentication[] =>
187 preview?.headerPresets ?? [],188 template.kind === "oauth2"
188 preview?.oauth2Presets ?? [],189 ? [
189 resolvedBaseUrl,190 {
190 ),191 ...template,
191 [preview, resolvedBaseUrl],192 slug: AuthTemplateSlug.make(template.slug),
192 );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]);
193 205
194 // Editable auth methods, seeded from the spec-detected templates. The add flow206 // Editable auth methods, seeded from the spec-detected templates. The add flow
195 // registers EVERY method (P6), so this is a LIST, preserving multi-method207 // registers EVERY method (P6), so this is a LIST, preserving multi-method
@@ -307,6 +319,7 @@ export default function AddOpenApiSource(props: { · 111 unmodified lines
307 baseUrl: resolvedBaseUrl,319 baseUrl: resolvedBaseUrl,
308 specFormat: initialPreset?.specFormat,320 specFormat: initialPreset?.specFormat,
309 family: initialPreset?.family,321 family: initialPreset?.family,
322 healthCheck: initialPreset?.healthCheck,
310 // Always send the edited method list (even empty) when the user has323 // Always send the edited method list (even empty) when the user has
311 // inspected a preview: an explicit [] means "no auth methods", while324 // inspected a preview: an explicit [] means "no auth methods", while
312 // OMITTING the field tells the server to derive defaults from the325 // OMITTING the field tells the server to derive defaults from the
@@ -330,6 +343,7 @@ export default function AddOpenApiSource(props: { · 17 unmodified lines
330 resolvedBaseUrl,343 resolvedBaseUrl,
331 editedAuthenticationTemplate,344 editedAuthenticationTemplate,
332 initialPreset?.family,345 initialPreset?.family,
346 initialPreset?.healthCheck,
333 initialPreset?.specFormat,347 initialPreset?.specFormat,
334 ]);348 ]);
335 349
packages/plugins/openapi/src/react/plugin-client.tsx+7−5
@@ -12,9 +12,11 @@ · 11 unmodified lines
12 12
13import { defineClientPlugin } from "@executor-js/sdk/client";13import { defineClientPlugin } from "@executor-js/sdk/client";
14 14
15import { openApiIntegrationPlugin } from "./source-plugin";15import { createOpenApiIntegrationPlugin, type OpenApiClientConfig } from "./source-plugin";
16 16
17export default defineClientPlugin({17export default function createOpenApiClientPlugin(config?: OpenApiClientConfig) {
18 id: "openapi" as const,18 return defineClientPlugin({
19 integrationPlugin: openApiIntegrationPlugin,19 id: "openapi" as const,
20});20 integrationPlugin: createOpenApiIntegrationPlugin(config),
21 });
22}
packages/plugins/openapi/src/react/source-plugin.ts+12−4
@@ -1,21 +1,29 @@
1import { lazy } from "react";1import { lazy } from "react";
2import type { IntegrationPlugin } from "@executor-js/sdk/client";2import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client";
3import { openApiPresets } from "../sdk/presets";3import { openApiPresets } from "../sdk/presets";
4 4
5const importAdd = () => import("./AddOpenApiSource");5const importAdd = () => import("./AddOpenApiSource");
6const importEditSheet = () => import("./UpdateSpecSection");6const importEditSheet = () => import("./UpdateSpecSection");
7const importAccounts = () => import("./OpenApiAccountsPanel");7const importAccounts = () => import("./OpenApiAccountsPanel");
8 8
9export const openApiIntegrationPlugin: IntegrationPlugin = {9export interface OpenApiClientConfig {
10 readonly presets?: readonly IntegrationPreset[];
11}
12
13export const createOpenApiIntegrationPlugin = (
14 config?: OpenApiClientConfig,
15): IntegrationPlugin => ({
10 key: "openapi",16 key: "openapi",
11 label: "OpenAPI",17 label: "OpenAPI",
12 add: lazy(importAdd),18 add: lazy(importAdd),
13 editSheet: lazy(importEditSheet),19 editSheet: lazy(importEditSheet),
14 accounts: lazy(importAccounts),20 accounts: lazy(importAccounts),
15 presets: openApiPresets,21 presets: [...openApiPresets, ...(config?.presets ?? [])],
16 preload: () => {22 preload: () => {
17 void importAdd();23 void importAdd();
18 void importEditSheet();24 void importEditSheet();
19 void importAccounts();25 void importAccounts();
20 },26 },
21};27});
28
29export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin();
packages/plugins/openapi/src/sdk/plugin.ts+16−4
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 IntegrationSlug,9 IntegrationSlug,
10 ToolResult,10 ToolResult,
11 definePlugin,11 definePlugin,
12 HealthCheckSpec,
12 mergeAuthTemplates,13 mergeAuthTemplates,
13 sha256Hex,14 sha256Hex,
14 tool,15 tool,
@@ -33,7 +34,7 @@ import { · 18 unmodified lines
33 type SpecPreview,34 type SpecPreview,
34} from "./preview";35} from "./preview";
35import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";36import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth";
36import { openApiPresets } from "./presets";37import { openApiPresets, type OpenApiPreset } from "./presets";
37import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";38import { makeDefaultOpenapiStore, type OpenapiStore } from "./store";
38import {39import {
39 resolveSpecFormatAdapter,40 resolveSpecFormatAdapter,
@@ -87,6 +88,7 @@ export interface OpenApiSpecConfig { · 47 unmodified lines
87 readonly queryParams?: Record<string, string>;88 readonly queryParams?: Record<string, string>;
88 readonly specFormat?: string;89 readonly specFormat?: string;
89 readonly family?: string;90 readonly family?: string;
91 readonly healthCheck?: HealthCheckSpec;
90 /** Auth methods a connection's value renders through - canonical92 /** Auth methods a connection's value renders through - canonical
91 * placements or the request-shaped authoring dialect. */93 * placements or the request-shaped authoring dialect. */
92 readonly authenticationTemplate?: readonly AuthenticationInput[];94 readonly authenticationTemplate?: readonly AuthenticationInput[];
@@ -279,6 +281,7 @@ const AddSourceInputSchema = Schema.Struct({ · 186 unmodified lines
279 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),281 queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)),
280 specFormat: Schema.optional(Schema.String),282 specFormat: Schema.optional(Schema.String),
281 family: Schema.optional(Schema.String),283 family: Schema.optional(Schema.String),
284 healthCheck: Schema.optional(HealthCheckSpec),
282 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),285 authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)),
283});286});
284 287
@@ -566,9 +569,12 @@ export const describeOpenApiAuthMethods = ( · 281 unmodified lines
566 569
567export const describeOpenApiIntegrationDisplay = (570export const describeOpenApiIntegrationDisplay = (
568 record: IntegrationRecord,571 record: IntegrationRecord,
569): { readonly url?: string } => {572): { readonly url?: string; readonly family?: string } => {
570 const config = decodeOpenApiIntegrationConfig(record.config);573 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 };
572};578};
573 579
574// ---------------------------------------------------------------------------580// ---------------------------------------------------------------------------
@@ -579,6 +585,7 @@ export interface OpenApiPluginOptions { · 4 unmodified lines
579 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;585 readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
580 readonly invokeOptions?: InvokeOptions;586 readonly invokeOptions?: InvokeOptions;
581 readonly specFormats?: readonly SpecFormatAdapter[];587 readonly specFormats?: readonly SpecFormatAdapter[];
588 readonly presets?: readonly OpenApiPreset[];
582}589}
583 590
584export const openApiPlugin = definePlugin<591export const openApiPlugin = definePlugin<
@@ -623,7 +630,8 @@ export const openApiPlugin = definePlugin< · 38 unmodified lines
623 return {630 return {
624 id: "openapi" as const,631 id: "openapi" as const,
625 packageName: "@executor-js/plugin-openapi",632 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) => ({
627 id: preset.id,635 id: preset.id,
628 name: preset.name,636 name: preset.name,
629 summary: preset.summary,637 summary: preset.summary,
@@ -814,6 +822,9 @@ export const openApiPlugin = definePlugin< · 184 unmodified lines
814 canRemove: true,822 canRemove: true,
815 canRefresh: integrationConfig.sourceUrl != null,823 canRefresh: integrationConfig.sourceUrl != null,
816 });824 });
825 if (config.healthCheck) {
826 yield* ctx.core.integrations.setHealthCheck(slug, config.healthCheck);
827 }
817 if (compiled) {828 if (compiled) {
818 yield* ctx.storage.putOperations(829 yield* ctx.storage.putOperations(
819 resolvedSlug,830 resolvedSlug,
@@ -1100,6 +1111,7 @@ export const openApiPlugin = definePlugin< · 280 unmodified lines
1100 queryParams: input.queryParams,1111 queryParams: input.queryParams,
1101 specFormat: input.specFormat,1112 specFormat: input.specFormat,
1102 family: input.family,1113 family: input.family,
1114 healthCheck: input.healthCheck,
1103 authenticationTemplate: input.authenticationTemplate as1115 authenticationTemplate: input.authenticationTemplate as
1104 | readonly AuthenticationInput[]1116 | readonly AuthenticationInput[]
1105 | undefined,1117 | undefined,
packages/react/src/components/integration-favicon.test.tsx+22−0
@@ -224,6 +224,28 @@ describe("IntegrationFavicon", () => { · 223 unmodified lines
224 ).toBe("https://example.com/stripe.png");224 ).toBe("https://example.com/stripe.png");
225 });225 });
226 226
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
227 it("does not split generic words into brand matches", () => {249 it("does not split generic words into brand matches", () => {
228 expect(250 expect(
229 integrationPresetIconUrl(251 integrationPresetIconUrl(
packages/react/src/components/integration-favicon.tsx+3−0
@@ -157,6 +157,9 @@ export function integrationPresetIconUrl( · 156 unmodified lines
157 const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;157 const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind;
158 const plugin = integrationPlugins.find((p) => p.key === pluginKey);158 const plugin = integrationPlugins.find((p) => p.key === pluginKey);
159 const presets = plugin?.presets ?? [];159 const presets = plugin?.presets ?? [];
160 const exactSlugIcon = presets.find((p) => p.defaultSlug === source.id)?.icon;
161 if (exactSlugIcon) return exactSlugIcon;
162
160 const sourceUrl = normalizeUrl(source.url);163 const sourceUrl = normalizeUrl(source.url);
161 const sourceGoogleService = googleApiServiceFromUrl(source.url);164 const sourceGoogleService = googleApiServiceFromUrl(source.url);
162 const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)];165 const sourceTokens = [...tokenVariants(source.id), ...tokenVariants(source.name)];
packages/react/src/lib/integration-grouping.test.ts+80−0
@@ -0,0 +1,80 @@
1import { describe, expect, it } from "@effect/vitest";
2import { type Integration, IntegrationSlug } from "@executor-js/sdk/shared";
3
4import {
5 groupIntegrations,
6 integrationFamily,
7 type IntegrationFamilyGroup,
8} from "./integration-grouping";
9
10const 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
21describe("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 @@
1import type { Integration } from "@executor-js/sdk/shared";
2
3export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]);
4
5const FAMILY_LABELS: Record<string, string> = {
6 google: "Google",
7 microsoft: "Microsoft",
8};
9
10export const familyLabel = (family: string): string =>
11 FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1);
12
13export const integrationFamily = (integration: Integration): string | null => {
14 const family = integration.family?.trim();
15 return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null;
16};
17
18export interface IntegrationFamilyGroup {
19 readonly type: "group";
20 readonly family: string;
21 readonly label: string;
22 readonly members: readonly Integration[];
23}
24
25export interface IntegrationSingle {
26 readonly type: "single";
27 readonly integration: Integration;
28}
29
30export type IntegrationGridItem = IntegrationFamilyGroup | IntegrationSingle;
31
32export 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 @@
1import { Suspense, useCallback, useMemo, useState } from "react";1import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";3import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
@@ -33,11 +33,14 @@ import { · 28 unmodified lines
33 CardStackEntryDescription,33 CardStackEntryDescription,
34 CardStackEntryMedia,34 CardStackEntryMedia,
35 CardStackEntryTitle,35 CardStackEntryTitle,
36 CardStackHeader,
36} from "../components/card-stack";37} from "../components/card-stack";
37import {38import {
39 IntegrationFavicon,
38 integrationInferredUrl,40 integrationInferredUrl,
39 integrationPresetIconUrl,41 integrationPresetIconUrl,
40} from "../components/integration-favicon";42} from "../components/integration-favicon";
43import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping";
41import { IntegrationHealthSummary } from "../components/integration-health-summary";44import { IntegrationHealthSummary } from "../components/integration-health-summary";
42import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";45import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
43import { Skeleton } from "../components/skeleton";46import { Skeleton } from "../components/skeleton";
@@ -349,7 +352,8 @@ function PresetGrid(props: { · 305 unmodified lines
349 const q = (props.searchQuery ?? "").trim().toLowerCase();352 const q = (props.searchQuery ?? "").trim().toLowerCase();
350 if (q.length === 0) return allPresets;353 if (q.length === 0) return allPresets;
351 return allPresets.filter(({ preset, pluginLabel }) => {354 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();
353 return corpus.includes(q);357 return corpus.includes(q);
354 });358 });
355 }, [allPresets, props.searchQuery]);359 }, [allPresets, props.searchQuery]);
@@ -434,47 +438,101 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 78 unmodified lines
434 return out;438 return out;
435 }, [integrationPlugins]);439 }, [integrationPlugins]);
436 440
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
514function 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;
437 return (522 return (
438 <CardStack searchable>523 <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}>
439 <CardStackContent>524 <CardStackHeader>
440 {props.integrations.map((integration) => {525 <span className="flex min-w-0 items-center gap-2">
441 const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind;526 <span className="flex size-5 shrink-0 items-center justify-center">
442 const plugin = pluginByKind.get(pluginKey);527 <IntegrationFavicon icon={headerIcon} size={16} />
443 const SummaryComponent = plugin?.summary;528 </span>
444 const slug = String(integration.slug);529 <span className="truncate">{group.label}</span>
445 const name = integration.name || slug;530 <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground">
446 return (531 {group.members.length}
447 <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}>532 </span>
448 <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}>533 </span>
449 <IntegrationIconWithAccount534 </CardStackHeader>
450 icon={integrationPresetIconUrl(535 <CardStackContent>{group.members.map(renderEntry)}</CardStackContent>
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>
478 </CardStack>536 </CardStack>
479 );537 );
480}538}