Unified · Split
| @@ -1,7 +1,7 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; |
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; | 7 | import { 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 | ||
| 51 | export default defineExecutorConfig({ | 47 | export 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 | }), |
| @@ -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 | ||
| @@ -1,7 +1,7 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; |
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -19,9 +19,10 @@ import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; · 11 unmodified lines | |||
| 19 | export default defineExecutorConfig({ | 19 | export 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 }), |
| @@ -1,6 +1,6 @@ | |||
| 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 2 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 2 | import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; |
| 3 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 3 | import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; |
| 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 5 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 6 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 6 | import { 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 }), |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; |
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -21,18 +21,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines | |||
| 21 | 21 | ||
| 22 | interface SelfHostPluginDeps { | 22 | interface 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 | ||
| 30 | export default defineExecutorConfig({ | 26 | export 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 }), |
| @@ -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 | ); |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { defineExecutorConfig } from "@executor-js/sdk"; | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | 3 | import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; |
| 4 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 4 | import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; |
| 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | import { keychainPlugin } from "@executor-js/plugin-keychain"; | 7 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| @@ -26,9 +26,10 @@ interface LocalPluginDeps { · 18 unmodified lines | |||
| 26 | export default defineExecutorConfig({ | 26 | export 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 }), |
| @@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines | |||
| 5 | import { Effect } from "effect"; | 5 | import { Effect } from "effect"; |
| 6 | import { composePluginApi } from "@executor-js/api/server"; | 6 | import { composePluginApi } from "@executor-js/api/server"; |
| 7 | import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate"; | 7 | import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate"; |
| 8 | import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | 8 | import { |
| 9 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 9 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, |
| 10 | microsoftCatalog, | ||
| 11 | microsoftGraphAdapter, | ||
| 12 | } from "@executor-js/plugin-microsoft"; | ||
| 13 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 10 | import { | 14 | import { |
| 11 | AuthTemplateSlug, | 15 | AuthTemplateSlug, |
| 12 | ConnectionName, | 16 | ConnectionName, |
| @@ -17,7 +21,9 @@ import { · 4 unmodified lines | |||
| 17 | import { scenario } from "../src/scenario"; | 21 | import { scenario } from "../src/scenario"; |
| 18 | import { Api, Target } from "../src/services"; | 22 | import { Api, Target } from "../src/services"; |
| 19 | 23 | ||
| 20 | const api = composePluginApi([microsoftHttpPlugin()] as const); | 24 | const api = composePluginApi([ |
| 25 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 26 | ] as const); | ||
| 21 | 27 | ||
| 22 | type OAuthTemplateView = { | 28 | type 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 | |||
| 81 | scenario( | 87 | scenario( |
| 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 the | 90 | 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 | }), |
| @@ -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"; |
| 12 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 15 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 13 | import { | 16 | import { |
| 14 | AuthTemplateSlug, | 17 | AuthTemplateSlug, |
| 15 | ConnectionName, | 18 | ConnectionName, |
| @@ -20,7 +23,9 @@ import { · 4 unmodified lines | |||
| 20 | import { scenario } from "../src/scenario"; | 23 | import { scenario } from "../src/scenario"; |
| 21 | import { Api, Target } from "../src/services"; | 24 | import { Api, Target } from "../src/services"; |
| 22 | 25 | ||
| 23 | const api = composePluginApi([microsoftHttpPlugin()] as const); | 26 | const api = composePluginApi([ |
| 27 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 28 | ] as const); | ||
| 24 | 29 | ||
| 25 | type ToolView = { | 30 | type 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.microsoft | 160 | 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.oauth | 163 | yield* client.oauth |
| 176 | .removeClient({ | 164 | .removeClient({ |
| @@ -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"; |
| 11 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 14 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 12 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; | 15 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; |
| 13 | 16 | ||
| 14 | import { scenario } from "../src/scenario"; | 17 | import { scenario } from "../src/scenario"; |
| 15 | import { Api, Target } from "../src/services"; | 18 | import { Api, Target } from "../src/services"; |
| 16 | 19 | ||
| 17 | const api = composePluginApi([microsoftHttpPlugin()] as const); | 20 | const api = composePluginApi([ |
| 21 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 22 | ] as const); | ||
| 18 | 23 | ||
| 19 | type ToolView = { | 24 | type 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 and | 53 | // 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.microsoft | 123 | 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 | ); |
| @@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines | |||
| 25 | import { AccountHttpApi } from "@executor-js/api"; | 25 | import { AccountHttpApi } from "@executor-js/api"; |
| 26 | import { composePluginApi } from "@executor-js/api/server"; | 26 | import { composePluginApi } from "@executor-js/api/server"; |
| 27 | import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate"; | 27 | import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate"; |
| 28 | import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | 28 | import { |
| 29 | import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | 29 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, |
| 30 | microsoftCatalog, | ||
| 31 | microsoftGraphAdapter, | ||
| 32 | } from "@executor-js/plugin-microsoft"; | ||
| 33 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| 30 | import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; | 34 | import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; |
| 31 | 35 | ||
| 32 | import { scenario } from "../src/scenario"; | 36 | import { scenario } from "../src/scenario"; |
| 33 | import { Api, Browser, Mcp, Target } from "../src/services"; | 37 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 34 | import type { McpSession } from "../src/surfaces/mcp"; | 38 | import type { McpSession } from "../src/surfaces/mcp"; |
| 35 | 39 | ||
| 36 | const microsoftApi = composePluginApi([microsoftHttpPlugin()] as const); | 40 | const microsoftApi = composePluginApi([ |
| 41 | openApiHttpPlugin({ presets: microsoftCatalog, specFormats: [microsoftGraphAdapter] }), | ||
| 42 | ] as const); | ||
| 37 | 43 | ||
| 38 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | 44 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 39 | 45 | ||
| @@ -311,15 +317,7 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => · 271 unmodified lines | |||
| 311 | scenario( | 317 | scenario( |
| 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 handoff | 320 | 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 OAuth | 355 | // 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 | }), |
| @@ -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:*", |
| @@ -33,12 +33,13 @@ import { · 32 unmodified lines | |||
| 33 | } from "@executor-js/sdk"; | 33 | } from "@executor-js/sdk"; |
| 34 | 34 | ||
| 35 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; | 35 | import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; |
| 36 | import { googlePlugin } from "@executor-js/plugin-google"; | 36 | import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; |
| 37 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; | 37 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; |
| 38 | import { keychainPlugin } from "@executor-js/plugin-keychain"; | 38 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| 39 | import { mcpPlugin } from "@executor-js/plugin-mcp"; | 39 | import { mcpPlugin } from "@executor-js/plugin-mcp"; |
| 40 | import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; | 40 | import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; |
| 41 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi"; | 41 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi"; |
| 42 | import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; | ||
| 42 | import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; | 43 | import { 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), register | 79 | // 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 a | 89 | // workos-vault is a cloud-hosted credential provider. It would contribute a |
| 87 | // "workos-vault" provider if credentials were available. We skip it here | 90 | // "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 | // ------------------------------------------------------------------------- |
| @@ -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 | ||
| 20 | export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) => | 21 | export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) => |
| @@ -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 | ||
| 86 | const UpdateIntegrationPayload = Schema.Struct({ | 88 | const UpdateIntegrationPayload = Schema.Struct({ |
| @@ -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"; |
| 91 | import { | 92 | import { |
| @@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines | |||
| 522 | const rowToIntegration = ( | 523 | const 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 | ||
| 540 | const rowToIntegrationRecord = ( | 542 | const 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 read | 1769 | // 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 from | 1811 | // A scoped toolkit must not advertise providers it grants no tools from |
| 1807 | // (mirrors `connectionsList`). Static sources are system namespaces, not | 1812 | // (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 row | 1835 | return row |
| 1831 | ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)) | 1836 | ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)) |
| 1832 | : null; | 1837 | : null; |
| 1833 | }); | 1838 | }); |
| 1834 | 1839 | ||
| @@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines | |||
| 22 | export interface IntegrationDisplayDescriptor { | 22 | export 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 an | 29 | /** 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. The | 109 | /** Plugin-owned, opaque-to-core configuration stored on the integration row. The |
| @@ -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:", |
| @@ -1,142 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| 4 | import { | ||
| 5 | InternalError, | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationNotFoundError, | ||
| 8 | IntegrationSlug, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 10 | import { | ||
| 11 | OpenApiExtractionError, | ||
| 12 | OpenApiOAuthError, | ||
| 13 | OpenApiParseError, | ||
| 14 | } from "@executor-js/plugin-openapi"; | ||
| 15 | |||
| 16 | const DomainErrors = [ | ||
| 17 | InternalError, | ||
| 18 | OpenApiParseError, | ||
| 19 | OpenApiExtractionError, | ||
| 20 | OpenApiOAuthError, | ||
| 21 | IntegrationAlreadyExistsError, | ||
| 22 | ] as const; | ||
| 23 | |||
| 24 | const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); | ||
| 25 | |||
| 26 | const UpdateErrors = [ | ||
| 27 | InternalError, | ||
| 28 | OpenApiParseError, | ||
| 29 | OpenApiExtractionError, | ||
| 30 | OpenApiOAuthError, | ||
| 31 | IntegrationNotFound, | ||
| 32 | ] as const; | ||
| 33 | |||
| 34 | const SlugParams = { | ||
| 35 | slug: Schema.String, | ||
| 36 | }; | ||
| 37 | |||
| 38 | const OAuthTemplatePayload = Schema.Struct({ | ||
| 39 | slug: Schema.String, | ||
| 40 | kind: Schema.Literal("oauth2"), | ||
| 41 | authorizationUrl: Schema.String, | ||
| 42 | tokenUrl: Schema.String, | ||
| 43 | scopes: Schema.Array(Schema.String), | ||
| 44 | }); | ||
| 45 | |||
| 46 | const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); | ||
| 47 | const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); | ||
| 48 | |||
| 49 | const AddBundlePayload = Schema.Struct({ | ||
| 50 | urls: Schema.Array(Schema.String), | ||
| 51 | slug: Schema.optional(Schema.String), | ||
| 52 | name: Schema.optional(Schema.String), | ||
| 53 | description: Schema.optional(Schema.String), | ||
| 54 | baseUrl: Schema.optional(Schema.String), | ||
| 55 | }); | ||
| 56 | |||
| 57 | const AddBundleResponse = Schema.Struct({ | ||
| 58 | slug: IntegrationSlug, | ||
| 59 | toolCount: Schema.Number, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const UpdateBundlePayload = Schema.Struct({ | ||
| 63 | urls: Schema.optional(Schema.Array(Schema.String)), | ||
| 64 | }); | ||
| 65 | |||
| 66 | const UpdateBundleResponse = Schema.Struct({ | ||
| 67 | slug: IntegrationSlug, | ||
| 68 | toolCount: Schema.Number, | ||
| 69 | addedTools: Schema.Array(Schema.String), | ||
| 70 | removedTools: Schema.Array(Schema.String), | ||
| 71 | }); | ||
| 72 | |||
| 73 | const IntegrationView = Schema.Struct({ | ||
| 74 | slug: IntegrationSlug, | ||
| 75 | description: Schema.String, | ||
| 76 | kind: Schema.String, | ||
| 77 | canRemove: Schema.Boolean, | ||
| 78 | canRefresh: Schema.Boolean, | ||
| 79 | }); | ||
| 80 | |||
| 81 | const GoogleConfigView = Schema.Struct({ | ||
| 82 | googleDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)), | ||
| 83 | baseUrl: Schema.optional(Schema.String), | ||
| 84 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 85 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 86 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)), | ||
| 87 | }); | ||
| 88 | |||
| 89 | const ConfigurePayload = Schema.Struct({ | ||
| 90 | authenticationTemplate: Schema.Array(AuthenticationPayload), | ||
| 91 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), | ||
| 92 | }); | ||
| 93 | |||
| 94 | const ConfigureResponse = Schema.Struct({ | ||
| 95 | authenticationTemplate: Schema.Array(AuthenticationResponse), | ||
| 96 | }); | ||
| 97 | |||
| 98 | export const GoogleGroup = HttpApiGroup.make("google") | ||
| 99 | .add( | ||
| 100 | HttpApiEndpoint.post("addBundle", "/google/bundles", { | ||
| 101 | payload: AddBundlePayload, | ||
| 102 | success: AddBundleResponse, | ||
| 103 | error: DomainErrors, | ||
| 104 | }), | ||
| 105 | ) | ||
| 106 | .add( | ||
| 107 | HttpApiEndpoint.get("getIntegration", "/google/integrations/:slug", { | ||
| 108 | params: SlugParams, | ||
| 109 | success: Schema.NullOr(IntegrationView), | ||
| 110 | error: DomainErrors, | ||
| 111 | }), | ||
| 112 | ) | ||
| 113 | .add( | ||
| 114 | HttpApiEndpoint.get("getConfig", "/google/integrations/:slug/config", { | ||
| 115 | params: SlugParams, | ||
| 116 | success: Schema.NullOr(GoogleConfigView), | ||
| 117 | error: DomainErrors, | ||
| 118 | }), | ||
| 119 | ) | ||
| 120 | .add( | ||
| 121 | HttpApiEndpoint.post("configure", "/google/integrations/:slug/config", { | ||
| 122 | params: SlugParams, | ||
| 123 | payload: ConfigurePayload, | ||
| 124 | success: ConfigureResponse, | ||
| 125 | error: DomainErrors, | ||
| 126 | }), | ||
| 127 | ) | ||
| 128 | .add( | ||
| 129 | HttpApiEndpoint.post("updateBundle", "/google/integrations/:slug/bundle", { | ||
| 130 | params: SlugParams, | ||
| 131 | payload: UpdateBundlePayload, | ||
| 132 | success: UpdateBundleResponse, | ||
| 133 | error: UpdateErrors, | ||
| 134 | }), | ||
| 135 | ) | ||
| 136 | .add( | ||
| 137 | HttpApiEndpoint.delete("removeBundle", "/google/integrations/:slug", { | ||
| 138 | params: SlugParams, | ||
| 139 | success: Schema.Void, | ||
| 140 | error: DomainErrors, | ||
| 141 | }), | ||
| 142 | ); | ||
| @@ -1,105 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | ||
| 2 | import { Context, Effect } from "effect"; | ||
| 3 | |||
| 4 | import { addGroup, capture } from "@executor-js/api"; | ||
| 5 | import type { GooglePluginExtension } from "../sdk/plugin"; | ||
| 6 | import { GoogleGroup } from "./group"; | ||
| 7 | |||
| 8 | export class GoogleExtensionService extends Context.Service< | ||
| 9 | GoogleExtensionService, | ||
| 10 | GooglePluginExtension | ||
| 11 | >()("GoogleExtensionService") {} | ||
| 12 | |||
| 13 | const ExecutorApiWithGoogle = addGroup(GoogleGroup); | ||
| 14 | |||
| 15 | export const GoogleHandlers = HttpApiBuilder.group(ExecutorApiWithGoogle, "google", (handlers) => | ||
| 16 | handlers | ||
| 17 | .handle("addBundle", ({ payload }) => | ||
| 18 | capture( | ||
| 19 | Effect.gen(function* () { | ||
| 20 | const ext = yield* GoogleExtensionService; | ||
| 21 | return yield* ext.addBundle({ | ||
| 22 | urls: payload.urls, | ||
| 23 | slug: payload.slug, | ||
| 24 | name: payload.name, | ||
| 25 | description: payload.description, | ||
| 26 | baseUrl: payload.baseUrl, | ||
| 27 | }); | ||
| 28 | }), | ||
| 29 | ), | ||
| 30 | ) | ||
| 31 | .handle("getIntegration", ({ params }) => | ||
| 32 | capture( | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const ext = yield* GoogleExtensionService; | ||
| 35 | const integration = yield* ext.getIntegration(params.slug); | ||
| 36 | return integration | ||
| 37 | ? { | ||
| 38 | slug: integration.slug, | ||
| 39 | description: integration.description, | ||
| 40 | kind: integration.kind, | ||
| 41 | canRemove: integration.canRemove, | ||
| 42 | canRefresh: integration.canRefresh, | ||
| 43 | } | ||
| 44 | : null; | ||
| 45 | }), | ||
| 46 | ), | ||
| 47 | ) | ||
| 48 | .handle("getConfig", ({ params }) => | ||
| 49 | capture( | ||
| 50 | Effect.gen(function* () { | ||
| 51 | const ext = yield* GoogleExtensionService; | ||
| 52 | const config = yield* ext.getConfig(params.slug); | ||
| 53 | return config | ||
| 54 | ? { | ||
| 55 | googleDiscoveryUrls: config.googleDiscoveryUrls | ||
| 56 | ? [...config.googleDiscoveryUrls] | ||
| 57 | : undefined, | ||
| 58 | baseUrl: config.baseUrl, | ||
| 59 | headers: config.headers ? { ...config.headers } : undefined, | ||
| 60 | queryParams: config.queryParams ? { ...config.queryParams } : undefined, | ||
| 61 | authenticationTemplate: config.authenticationTemplate | ||
| 62 | ? [...config.authenticationTemplate] | ||
| 63 | : undefined, | ||
| 64 | } | ||
| 65 | : null; | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | ) | ||
| 69 | .handle("configure", ({ params, payload }) => | ||
| 70 | capture( | ||
| 71 | Effect.gen(function* () { | ||
| 72 | const ext = yield* GoogleExtensionService; | ||
| 73 | const authenticationTemplate = yield* ext.configure(params.slug, { | ||
| 74 | authenticationTemplate: payload.authenticationTemplate, | ||
| 75 | mode: payload.mode ?? "merge", | ||
| 76 | }); | ||
| 77 | return { authenticationTemplate: [...authenticationTemplate] }; | ||
| 78 | }), | ||
| 79 | ), | ||
| 80 | ) | ||
| 81 | .handle("updateBundle", ({ params, payload }) => | ||
| 82 | capture( | ||
| 83 | Effect.gen(function* () { | ||
| 84 | const ext = yield* GoogleExtensionService; | ||
| 85 | const result = yield* ext.updateBundle(params.slug, { | ||
| 86 | ...(payload.urls !== undefined ? { urls: payload.urls } : {}), | ||
| 87 | }); | ||
| 88 | return { | ||
| 89 | slug: result.slug, | ||
| 90 | toolCount: result.toolCount, | ||
| 91 | addedTools: [...result.addedTools], | ||
| 92 | removedTools: [...result.removedTools], | ||
| 93 | }; | ||
| 94 | }), | ||
| 95 | ), | ||
| 96 | ) | ||
| 97 | .handle("removeBundle", ({ params }) => | ||
| 98 | capture( | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const ext = yield* GoogleExtensionService; | ||
| 101 | yield* ext.removeBundle(params.slug); | ||
| 102 | }), | ||
| 103 | ), | ||
| 104 | ), | ||
| 105 | ); | ||
| @@ -1,15 +0,0 @@ | |||
| 1 | import { definePlugin } from "@executor-js/sdk/core"; | ||
| 2 | |||
| 3 | import { googlePlugin, type GooglePluginOptions } from "../sdk/plugin"; | ||
| 4 | import { GoogleGroup } from "./group"; | ||
| 5 | import { GoogleHandlers, GoogleExtensionService } from "./handlers"; | ||
| 6 | |||
| 7 | export { GoogleGroup } from "./group"; | ||
| 8 | export { GoogleHandlers, GoogleExtensionService } from "./handlers"; | ||
| 9 | |||
| 10 | export const googleHttpPlugin = definePlugin((options?: GooglePluginOptions) => ({ | ||
| 11 | ...googlePlugin(options), | ||
| 12 | routes: () => GoogleGroup, | ||
| 13 | handlers: () => GoogleHandlers, | ||
| 14 | extensionService: GoogleExtensionService, | ||
| 15 | })); | ||
| @@ -1 +1 @@ | |||
| 1 | export { googlePlugin } from "./sdk/plugin"; | 1 | export * from "./sdk/index"; |
| @@ -1,178 +0,0 @@ | |||
| 1 | import { useCallback, useMemo, useState } from "react"; | ||
| 2 | import { useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | |||
| 5 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 6 | import { | ||
| 7 | slugifyNamespace, | ||
| 8 | useIntegrationIdentity, | ||
| 9 | } from "@executor-js/react/plugins/integration-identity"; | ||
| 10 | import { Button } from "@executor-js/react/components/button"; | ||
| 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; | ||
| 12 | import { | ||
| 13 | addIntegrationErrorMessage, | ||
| 14 | FormErrorAlert, | ||
| 15 | SlugCollisionAlert, | ||
| 16 | useSlugAlreadyExists, | ||
| 17 | } from "@executor-js/react/lib/integration-add"; | ||
| 18 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; | ||
| 19 | |||
| 20 | import { addGoogleBundle } from "./atoms"; | ||
| 21 | import { GoogleProductPicker } from "./GoogleProductPicker"; | ||
| 22 | import { | ||
| 23 | GOOGLE_PHOTOS_PRESET_ID, | ||
| 24 | googleOpenApiPresets, | ||
| 25 | googlePhotosPresetIds, | ||
| 26 | type GoogleOpenApiPreset, | ||
| 27 | } from "../sdk/presets"; | ||
| 28 | |||
| 29 | const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; | ||
| 30 | |||
| 31 | const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set( | ||
| 32 | googleOpenApiPresets | ||
| 33 | .filter((preset: GoogleOpenApiPreset) => preset.featured) | ||
| 34 | .map((preset: GoogleOpenApiPreset) => preset.id), | ||
| 35 | ); | ||
| 36 | |||
| 37 | const googleBundleUrls = ( | ||
| 38 | selectedPresetIds: ReadonlySet<string>, | ||
| 39 | customUrls: readonly string[], | ||
| 40 | ): readonly string[] => { | ||
| 41 | const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) => | ||
| 42 | preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [], | ||
| 43 | ); | ||
| 44 | return [...new Set([...fromPresets, ...customUrls])]; | ||
| 45 | }; | ||
| 46 | |||
| 47 | export default function AddGoogleSource(props: { | ||
| 48 | onComplete: (slug?: string) => void; | ||
| 49 | onCancel: () => void; | ||
| 50 | initialPreset?: string; | ||
| 51 | initialNamespace?: string; | ||
| 52 | }) { | ||
| 53 | const isGooglePhotosPreset = props.initialPreset === GOOGLE_PHOTOS_PRESET_ID; | ||
| 54 | const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>( | ||
| 55 | isGooglePhotosPreset ? new Set(googlePhotosPresetIds) : googleBundleDefaultPresetIds, | ||
| 56 | ); | ||
| 57 | const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]); | ||
| 58 | const [baseUrl, setBaseUrl] = useState(""); | ||
| 59 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | ||
| 60 | const [adding, setAdding] = useState(false); | ||
| 61 | const [addError, setAddError] = useState<string | null>(null); | ||
| 62 | |||
| 63 | const identity = useIntegrationIdentity({ | ||
| 64 | fallbackName: isGooglePhotosPreset ? "Google Photos" : "Google", | ||
| 65 | fallbackNamespace: | ||
| 66 | props.initialNamespace ?? (isGooglePhotosPreset ? "google_photos" : "google"), | ||
| 67 | }); | ||
| 68 | |||
| 69 | const bundleDiscoveryUrls = useMemo( | ||
| 70 | () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls), | ||
| 71 | [selectedPresetIds, customDiscoveryUrls], | ||
| 72 | ); | ||
| 73 | |||
| 74 | const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { | ||
| 75 | setSelectedPresetIds((current: ReadonlySet<string>) => { | ||
| 76 | const next = new Set(current); | ||
| 77 | if (checked) next.add(presetId); | ||
| 78 | else next.delete(presetId); | ||
| 79 | return next; | ||
| 80 | }); | ||
| 81 | }, []); | ||
| 82 | |||
| 83 | const addCustomDiscoveryUrl = useCallback((url: string) => { | ||
| 84 | setCustomDiscoveryUrls((current: readonly string[]) => | ||
| 85 | current.includes(url) ? current : [...current, url], | ||
| 86 | ); | ||
| 87 | }, []); | ||
| 88 | |||
| 89 | const removeCustomDiscoveryUrl = useCallback((url: string) => { | ||
| 90 | setCustomDiscoveryUrls((current: readonly string[]) => | ||
| 91 | current.filter((entry: string) => entry !== url), | ||
| 92 | ); | ||
| 93 | }, []); | ||
| 94 | |||
| 95 | const doAdd = useAtomSet(addGoogleBundle, { mode: "promiseExit" }); | ||
| 96 | |||
| 97 | const resolvedSourceId = | ||
| 98 | slugifyNamespace(identity.namespace) || (isGooglePhotosPreset ? "google_photos" : "google"); | ||
| 99 | const resolvedDisplayName = | ||
| 100 | identity.name.trim() || (isGooglePhotosPreset ? "Google Photos" : "Google"); | ||
| 101 | const resolvedDescription = | ||
| 102 | descriptionDraft ?? | ||
| 103 | (isGooglePhotosPreset | ||
| 104 | ? "Google Photos albums, uploads, app-created media, and selected picker media." | ||
| 105 | : "Google APIs"); | ||
| 106 | const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); | ||
| 107 | const canAdd = bundleDiscoveryUrls.length > 0 && !slugAlreadyExists; | ||
| 108 | |||
| 109 | const handleAdd = async () => { | ||
| 110 | setAdding(true); | ||
| 111 | setAddError(null); | ||
| 112 | const exit = await doAdd({ | ||
| 113 | payload: { | ||
| 114 | urls: [...bundleDiscoveryUrls], | ||
| 115 | slug: resolvedSourceId, | ||
| 116 | name: resolvedDisplayName, | ||
| 117 | ...(resolvedDescription.trim().length > 0 | ||
| 118 | ? { description: resolvedDescription.trim() } | ||
| 119 | : {}), | ||
| 120 | ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}), | ||
| 121 | }, | ||
| 122 | reactivityKeys: integrationWriteKeys, | ||
| 123 | }); | ||
| 124 | if (Exit.isFailure(exit)) { | ||
| 125 | setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Google")); | ||
| 126 | setAdding(false); | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | props.onComplete(String(exit.value.slug)); | ||
| 130 | }; | ||
| 131 | |||
| 132 | return ( | ||
| 133 | <div className="flex flex-1 flex-col gap-6"> | ||
| 134 | <div> | ||
| 135 | <h1 className="text-xl font-semibold text-foreground">Add Google integration</h1> | ||
| 136 | <p className="mt-1 text-[13px] text-muted-foreground"> | ||
| 137 | Bundle Google APIs into one integration with a shared OAuth consent. | ||
| 138 | </p> | ||
| 139 | </div> | ||
| 140 | |||
| 141 | <GoogleProductPicker | ||
| 142 | selectedPresetIds={selectedPresetIds} | ||
| 143 | onToggle={toggleBundlePreset} | ||
| 144 | customUrls={customDiscoveryUrls} | ||
| 145 | onAddCustomUrl={addCustomDiscoveryUrl} | ||
| 146 | onRemoveCustomUrl={removeCustomDiscoveryUrl} | ||
| 147 | /> | ||
| 148 | |||
| 149 | <OpenApiSourceDetailsFields | ||
| 150 | title="Google" | ||
| 151 | subtitle={`${bundleDiscoveryUrls.length} Google API${ | ||
| 152 | bundleDiscoveryUrls.length !== 1 ? "s" : "" | ||
| 153 | } · one shared OAuth consent`} | ||
| 154 | identity={identity} | ||
| 155 | description={resolvedDescription} | ||
| 156 | onDescriptionChange={setDescriptionDraft} | ||
| 157 | baseUrl={baseUrl} | ||
| 158 | onBaseUrlChange={setBaseUrl} | ||
| 159 | baseUrlLabel="Base URL override (optional)" | ||
| 160 | faviconIcon={GOOGLE_BUNDLE_FAVICON} | ||
| 161 | faviconUrl={baseUrl} | ||
| 162 | /> | ||
| 163 | |||
| 164 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | ||
| 165 | |||
| 166 | {addError && <FormErrorAlert message={addError} />} | ||
| 167 | |||
| 168 | <FloatActions> | ||
| 169 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | ||
| 170 | Cancel | ||
| 171 | </Button> | ||
| 172 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | ||
| 173 | Connect Google | ||
| 174 | </Button> | ||
| 175 | </FloatActions> | ||
| 176 | </div> | ||
| 177 | ); | ||
| 178 | } | ||
| @@ -1,148 +0,0 @@ | |||
| 1 | import { useCallback, useMemo } from "react"; | ||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 5 | import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 6 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; | ||
| 7 | import { TriangleAlert } from "lucide-react"; | ||
| 8 | |||
| 9 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; | ||
| 10 | import { | ||
| 11 | HealthCheckEditor, | ||
| 12 | type HealthCheckLivePreview, | ||
| 13 | } from "@executor-js/react/components/health-check-editor"; | ||
| 14 | import { useOrganizationId } from "@executor-js/react/api/organization-context"; | ||
| 15 | import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner"; | ||
| 16 | import { Alert, AlertDescription, AlertTitle } from "@executor-js/react/components/alert"; | ||
| 17 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 18 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | ||
| 19 | import { | ||
| 20 | useCustomMethodActions, | ||
| 21 | type AuthMethodsCodec, | ||
| 22 | type ConfigureAuthMethods, | ||
| 23 | } from "@executor-js/react/lib/custom-auth-methods"; | ||
| 24 | import { | ||
| 25 | authMethodsFromConfig, | ||
| 26 | openApiWireAuthInput, | ||
| 27 | templateFromPlacements, | ||
| 28 | } from "@executor-js/plugin-openapi/react"; | ||
| 29 | import type { Authentication } from "@executor-js/plugin-openapi"; | ||
| 30 | |||
| 31 | import { googleConfigAtom, googleConfigure } from "./atoms"; | ||
| 32 | import { googleAudienceWarningsForUrls } from "../sdk/presets"; | ||
| 33 | |||
| 34 | const GOOGLE_AUDIENCE_WARNING: Readonly<Record<string, string>> = { | ||
| 35 | "workspace-admin": | ||
| 36 | "This connection includes Google Workspace admin APIs. Connecting requires a Workspace admin account; personal Gmail accounts cannot grant these scopes.", | ||
| 37 | "unsupported-user": | ||
| 38 | "This connection includes APIs that Google does not grant through standard user OAuth consent. Those tools may fail to authorize.", | ||
| 39 | }; | ||
| 40 | |||
| 41 | const NO_AUTH_METHOD: AuthMethod = { | ||
| 42 | id: "none", | ||
| 43 | label: "No authentication", | ||
| 44 | kind: "none", | ||
| 45 | source: "spec", | ||
| 46 | template: AuthTemplateSlug.make("none"), | ||
| 47 | placements: [], | ||
| 48 | }; | ||
| 49 | |||
| 50 | export default function GoogleAccountsPanel(props: { | ||
| 51 | readonly sourceId: string; | ||
| 52 | readonly integrationName: string; | ||
| 53 | readonly accountHandoff?: IntegrationAccountHandoff | null; | ||
| 54 | }) { | ||
| 55 | const { sourceId, integrationName, accountHandoff } = props; | ||
| 56 | const slug = IntegrationSlug.make(sourceId); | ||
| 57 | const configResult = useAtomValue(googleConfigAtom(slug)); | ||
| 58 | const doConfigure = useAtomSet(googleConfigure, { mode: "promiseExit" }); | ||
| 59 | |||
| 60 | const existingTemplate = useMemo<readonly Authentication[]>(() => { | ||
| 61 | if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; | ||
| 62 | return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[]; | ||
| 63 | }, [configResult]); | ||
| 64 | |||
| 65 | const methods = useMemo<readonly AuthMethod[]>(() => { | ||
| 66 | const declared = authMethodsFromConfig(existingTemplate); | ||
| 67 | return declared.length > 0 ? declared : [NO_AUTH_METHOD]; | ||
| 68 | }, [existingTemplate]); | ||
| 69 | |||
| 70 | const configure = useCallback<ConfigureAuthMethods<Authentication>>( | ||
| 71 | async (input) => { | ||
| 72 | const exit = await doConfigure({ | ||
| 73 | params: { slug }, | ||
| 74 | payload: { | ||
| 75 | authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput), | ||
| 76 | ...(input.mode ? { mode: input.mode } : {}), | ||
| 77 | }, | ||
| 78 | reactivityKeys: integrationWriteKeys, | ||
| 79 | }); | ||
| 80 | return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]); | ||
| 81 | }, | ||
| 82 | [doConfigure, slug], | ||
| 83 | ); | ||
| 84 | |||
| 85 | const codec = useMemo<AuthMethodsCodec<Authentication>>( | ||
| 86 | () => ({ | ||
| 87 | toAuthMethods: authMethodsFromConfig, | ||
| 88 | templatesFromPlacements: (placements: readonly Placement[]) => [ | ||
| 89 | templateFromPlacements(placements), | ||
| 90 | ], | ||
| 91 | slugOf: (template: Authentication) => String(template.slug), | ||
| 92 | }), | ||
| 93 | [], | ||
| 94 | ); | ||
| 95 | |||
| 96 | const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ | ||
| 97 | existing: existingTemplate, | ||
| 98 | codec, | ||
| 99 | configure, | ||
| 100 | }); | ||
| 101 | |||
| 102 | const audienceWarnings = useMemo<readonly string[]>(() => { | ||
| 103 | if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; | ||
| 104 | const urls = configResult.value.googleDiscoveryUrls ?? []; | ||
| 105 | return googleAudienceWarningsForUrls(urls).flatMap((audience: string) => { | ||
| 106 | const message = GOOGLE_AUDIENCE_WARNING[audience]; | ||
| 107 | return message ? [message] : []; | ||
| 108 | }); | ||
| 109 | }, [configResult]); | ||
| 110 | |||
| 111 | // Live-preview context for the health-check edit sheet (apiKey methods only; | ||
| 112 | // OAuth methods can't be previewed with a pasted key). | ||
| 113 | const organizationId = useOrganizationId(); | ||
| 114 | const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => { | ||
| 115 | const templates = methods | ||
| 116 | .filter((m) => m.kind === "apikey") | ||
| 117 | .map((m) => ({ template: m.template, label: m.label })); | ||
| 118 | if (templates.length === 0) return undefined; | ||
| 119 | return { owner: defaultConnectionOwnerForHost(organizationId), templates }; | ||
| 120 | }, [methods, organizationId]); | ||
| 121 | |||
| 122 | return ( | ||
| 123 | <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | ||
| 124 | {audienceWarnings.length > 0 && ( | ||
| 125 | <Alert variant="destructive"> | ||
| 126 | <TriangleAlert /> | ||
| 127 | <AlertTitle>Some Google APIs need special consent</AlertTitle> | ||
| 128 | <AlertDescription> | ||
| 129 | {audienceWarnings.map((message: string) => ( | ||
| 130 | <p key={message}>{message}</p> | ||
| 131 | ))} | ||
| 132 | </AlertDescription> | ||
| 133 | </Alert> | ||
| 134 | )} | ||
| 135 | <AccountsSection | ||
| 136 | integration={slug} | ||
| 137 | integrationName={integrationName} | ||
| 138 | methods={methods} | ||
| 139 | accountHandoff={accountHandoff} | ||
| 140 | createCustomMethod={createCustomMethod} | ||
| 141 | removeCustomMethod={removeCustomMethod} | ||
| 142 | /> | ||
| 143 | {/* Google auto-configures the People identity probe at add time; this is | ||
| 144 | where an admin sees, adjusts, or clears it. */} | ||
| 145 | <HealthCheckEditor integration={slug} livePreview={livePreview} /> | ||
| 146 | </div> | ||
| 147 | ); | ||
| 148 | } | ||
| @@ -1,363 +0,0 @@ | |||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | import { ChevronDownIcon, PlusIcon, TriangleAlert, XIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 5 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 6 | import { Button } from "@executor-js/react/components/button"; | ||
| 7 | import { Checkbox } from "@executor-js/react/components/checkbox"; | ||
| 8 | import { | ||
| 9 | Collapsible, | ||
| 10 | CollapsibleContent, | ||
| 11 | CollapsibleTrigger, | ||
| 12 | } from "@executor-js/react/components/collapsible"; | ||
| 13 | import { FieldLabel } from "@executor-js/react/components/field"; | ||
| 14 | import { Input } from "@executor-js/react/components/input"; | ||
| 15 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | googleOAuthConsentScopesForPreset, | ||
| 19 | googleOpenApiPresets, | ||
| 20 | type GoogleOpenApiOAuthAudience, | ||
| 21 | type GoogleOpenApiPreset, | ||
| 22 | } from "../sdk/presets"; | ||
| 23 | import { googleOAuthConsentBatches } from "../sdk/oauth-batches"; | ||
| 24 | import { isGoogleDiscoveryUrl } from "../sdk/discovery"; | ||
| 25 | |||
| 26 | // --------------------------------------------------------------------------- | ||
| 27 | // GoogleProductPicker - the "customize your Google connection" surface. | ||
| 28 | // | ||
| 29 | // A checkable card grid over `googleOpenApiPresets`, grouped/annotated by | ||
| 30 | // `oauthAudience`. The user picks which Google APIs to bundle into the single | ||
| 31 | // `google` integration; the parent turns the selected discovery URLs into a | ||
| 32 | // `{ kind: "googleDiscoveryBundle", urls }` add. A "View scopes" panel previews | ||
| 33 | // the unioned OAuth consent (via `googleOAuthConsentBatches`) BEFORE connecting, | ||
| 34 | // and a custom-URL escape hatch lets advanced users paste any Google Discovery | ||
| 35 | // document the preset list doesn't cover. | ||
| 36 | // --------------------------------------------------------------------------- | ||
| 37 | |||
| 38 | // Audience groups, ordered from least- to most-privileged. The warning tiers | ||
| 39 | // (`workspace-admin`, `unsupported-user`) carry a caution chip so the user sees | ||
| 40 | // the consent risk before selecting. | ||
| 41 | const AUDIENCE_ORDER: readonly GoogleOpenApiOAuthAudience[] = [ | ||
| 42 | "standard-user", | ||
| 43 | "advanced-user", | ||
| 44 | "workspace-admin", | ||
| 45 | "unsupported-user", | ||
| 46 | ]; | ||
| 47 | |||
| 48 | const AUDIENCE_LABEL: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = { | ||
| 49 | "standard-user": "Core Google services", | ||
| 50 | "advanced-user": "Advanced services", | ||
| 51 | "workspace-admin": "Workspace admin", | ||
| 52 | "unsupported-user": "Limited user consent", | ||
| 53 | }; | ||
| 54 | |||
| 55 | const AUDIENCE_DESCRIPTION: Readonly<Record<GoogleOpenApiOAuthAudience, string>> = { | ||
| 56 | "standard-user": "Connect with a normal Google account - one consent screen.", | ||
| 57 | "advanced-user": "Broader scopes that may need an unverified-app warning to be accepted.", | ||
| 58 | "workspace-admin": "Requires a Google Workspace admin account; not available on personal Gmail.", | ||
| 59 | "unsupported-user": "Google does not grant these scopes through standard user OAuth consent.", | ||
| 60 | }; | ||
| 61 | |||
| 62 | const audienceNeedsWarning = (audience: GoogleOpenApiOAuthAudience): boolean => | ||
| 63 | audience === "workspace-admin" || audience === "unsupported-user"; | ||
| 64 | |||
| 65 | type GoogleProductPickerProps = { | ||
| 66 | readonly selectedPresetIds: ReadonlySet<string>; | ||
| 67 | readonly onToggle: (presetId: string, checked: boolean) => void; | ||
| 68 | readonly customUrls: readonly string[]; | ||
| 69 | readonly onAddCustomUrl: (url: string) => void; | ||
| 70 | readonly onRemoveCustomUrl: (url: string) => void; | ||
| 71 | }; | ||
| 72 | |||
| 73 | const AudienceWarningChip = ({ audience }: { audience: GoogleOpenApiOAuthAudience }) => | ||
| 74 | audience === "workspace-admin" ? ( | ||
| 75 | <Badge variant="outline" className="shrink-0 border-border text-muted-foreground"> | ||
| 76 | <TriangleAlert className="size-3" /> | ||
| 77 | Admin only | ||
| 78 | </Badge> | ||
| 79 | ) : audience === "unsupported-user" ? ( | ||
| 80 | <Badge variant="outline" className="shrink-0 border-destructive/40 text-destructive"> | ||
| 81 | <TriangleAlert className="size-3" /> | ||
| 82 | Limited consent | ||
| 83 | </Badge> | ||
| 84 | ) : null; | ||
| 85 | |||
| 86 | // A Google API row - borderless and single-line, leaning on hover/selected | ||
| 87 | // fills instead of per-item card chrome (lightest separation that still reads). | ||
| 88 | // Name + truncated summary share one baseline for a dense, scannable two-column | ||
| 89 | // list; the audience warning chip trails on the right. | ||
| 90 | const ProductRow = ({ | ||
| 91 | preset, | ||
| 92 | checked, | ||
| 93 | onToggle, | ||
| 94 | }: { | ||
| 95 | readonly preset: GoogleOpenApiPreset; | ||
| 96 | readonly checked: boolean; | ||
| 97 | readonly onToggle: (checked: boolean) => void; | ||
| 98 | }) => ( | ||
| 99 | <FieldLabel | ||
| 100 | className={cn( | ||
| 101 | // `w-full` overrides FieldLabel's base `w-fit` (which would size the row to | ||
| 102 | // its content and overflow the column); `min-w-0` then lets the cell shrink | ||
| 103 | // to its track so the name/summary truncates instead of spilling over. | ||
| 104 | "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors", | ||
| 105 | checked ? "bg-primary/5" : "hover:bg-muted/40", | ||
| 106 | )} | ||
| 107 | > | ||
| 108 | <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} /> | ||
| 109 | <div className="shrink-0"> | ||
| 110 | <IntegrationFavicon icon={preset.icon} url={preset.url} size={16} /> | ||
| 111 | </div> | ||
| 112 | {/* One truncating line - the name + summary clip to the cell with an | ||
| 113 | ellipsis instead of overflowing into the neighbouring column. */} | ||
| 114 | <div className="min-w-0 flex-1 truncate text-sm"> | ||
| 115 | <span className="font-medium text-foreground">{preset.name}</span>{" "} | ||
| 116 | <span className="text-[11px] text-muted-foreground">{preset.summary}</span> | ||
| 117 | </div> | ||
| 118 | <AudienceWarningChip audience={preset.oauthAudience} /> | ||
| 119 | </FieldLabel> | ||
| 120 | ); | ||
| 121 | |||
| 122 | const CustomUrlEscapeHatch = ({ | ||
| 123 | customUrls, | ||
| 124 | onAddCustomUrl, | ||
| 125 | onRemoveCustomUrl, | ||
| 126 | }: { | ||
| 127 | readonly customUrls: readonly string[]; | ||
| 128 | readonly onAddCustomUrl: (url: string) => void; | ||
| 129 | readonly onRemoveCustomUrl: (url: string) => void; | ||
| 130 | }) => { | ||
| 131 | const [draft, setDraft] = useState(""); | ||
| 132 | const trimmed = draft.trim(); | ||
| 133 | const isValid = isGoogleDiscoveryUrl(trimmed); | ||
| 134 | const isDuplicate = customUrls.includes(trimmed); | ||
| 135 | |||
| 136 | const commit = () => { | ||
| 137 | if (!isValid || isDuplicate) return; | ||
| 138 | onAddCustomUrl(trimmed); | ||
| 139 | setDraft(""); | ||
| 140 | }; | ||
| 141 | |||
| 142 | return ( | ||
| 143 | <div className="space-y-2"> | ||
| 144 | <FieldLabel className="text-[11px] font-medium text-muted-foreground"> | ||
| 145 | Add a custom Google Discovery URL | ||
| 146 | </FieldLabel> | ||
| 147 | <div className="flex items-center gap-2"> | ||
| 148 | <Input | ||
| 149 | value={draft} | ||
| 150 | onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)} | ||
| 151 | onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => { | ||
| 152 | if (event.key === "Enter") { | ||
| 153 | event.preventDefault(); | ||
| 154 | commit(); | ||
| 155 | } | ||
| 156 | }} | ||
| 157 | placeholder="https://www.googleapis.com/discovery/v1/apis/<service>/<version>/rest" | ||
| 158 | className="font-mono text-[11px]" | ||
| 159 | /> | ||
| 160 | <Button | ||
| 161 | type="button" | ||
| 162 | variant="outline" | ||
| 163 | size="sm" | ||
| 164 | disabled={!isValid || isDuplicate} | ||
| 165 | onClick={commit} | ||
| 166 | > | ||
| 167 | <PlusIcon className="size-3.5" /> | ||
| 168 | Add | ||
| 169 | </Button> | ||
| 170 | </div> | ||
| 171 | {trimmed.length > 0 && !isValid ? ( | ||
| 172 | <p className="text-[11px] text-destructive"> | ||
| 173 | Enter a Google Discovery document URL (a *.googleapis.com discovery/$discovery endpoint). | ||
| 174 | </p> | ||
| 175 | ) : null} | ||
| 176 | {customUrls.length > 0 ? ( | ||
| 177 | <ul className="space-y-1"> | ||
| 178 | {customUrls.map((url: string) => ( | ||
| 179 | <li | ||
| 180 | key={url} | ||
| 181 | className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5" | ||
| 182 | > | ||
| 183 | <span className="truncate font-mono text-[11px] text-foreground">{url}</span> | ||
| 184 | <Button | ||
| 185 | type="button" | ||
| 186 | variant="ghost" | ||
| 187 | size="icon" | ||
| 188 | className="size-6 shrink-0" | ||
| 189 | onClick={() => onRemoveCustomUrl(url)} | ||
| 190 | aria-label={`Remove ${url}`} | ||
| 191 | > | ||
| 192 | <XIcon className="size-3.5" /> | ||
| 193 | </Button> | ||
| 194 | </li> | ||
| 195 | ))} | ||
| 196 | </ul> | ||
| 197 | ) : null} | ||
| 198 | </div> | ||
| 199 | ); | ||
| 200 | }; | ||
| 201 | |||
| 202 | export function GoogleProductPicker({ | ||
| 203 | selectedPresetIds, | ||
| 204 | onToggle, | ||
| 205 | customUrls, | ||
| 206 | onAddCustomUrl, | ||
| 207 | onRemoveCustomUrl, | ||
| 208 | }: GoogleProductPickerProps) { | ||
| 209 | const [scopesOpen, setScopesOpen] = useState(false); | ||
| 210 | |||
| 211 | const groups = useMemo( | ||
| 212 | () => | ||
| 213 | AUDIENCE_ORDER.flatMap((audience: GoogleOpenApiOAuthAudience) => { | ||
| 214 | const presets = googleOpenApiPresets.filter( | ||
| 215 | (preset: GoogleOpenApiPreset) => preset.oauthAudience === audience, | ||
| 216 | ); | ||
| 217 | return presets.length > 0 ? [{ audience, presets }] : []; | ||
| 218 | }), | ||
| 219 | [], | ||
| 220 | ); | ||
| 221 | |||
| 222 | // The "View scopes" preview unions the selected presets' representative | ||
| 223 | // consent scopes through `googleOAuthConsentBatches`, mirroring the unioned | ||
| 224 | // scopes the bundle converter ultimately stores on the integration. | ||
| 225 | const consentBatches = useMemo( | ||
| 226 | () => | ||
| 227 | googleOAuthConsentBatches( | ||
| 228 | googleOpenApiPresets | ||
| 229 | .filter((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id)) | ||
| 230 | .map((preset: GoogleOpenApiPreset) => ({ | ||
| 231 | id: preset.id, | ||
| 232 | name: preset.name, | ||
| 233 | oauthAudience: preset.oauthAudience, | ||
| 234 | scopes: googleOAuthConsentScopesForPreset(preset.id), | ||
| 235 | })), | ||
| 236 | ), | ||
| 237 | [selectedPresetIds], | ||
| 238 | ); | ||
| 239 | |||
| 240 | const selectedCount = selectedPresetIds.size + customUrls.length; | ||
| 241 | |||
| 242 | return ( | ||
| 243 | <section className="space-y-4"> | ||
| 244 | <div className="space-y-1"> | ||
| 245 | <FieldLabel>Customize your Google connection</FieldLabel> | ||
| 246 | <p className="text-[11px] text-muted-foreground"> | ||
| 247 | Pick the Google APIs to bundle into one connection. They share a single OAuth consent and | ||
| 248 | appear as merged tools under one Google integration. | ||
| 249 | </p> | ||
| 250 | </div> | ||
| 251 | |||
| 252 | {groups.map( | ||
| 253 | ({ | ||
| 254 | audience, | ||
| 255 | presets, | ||
| 256 | }: { | ||
| 257 | readonly audience: GoogleOpenApiOAuthAudience; | ||
| 258 | readonly presets: readonly GoogleOpenApiPreset[]; | ||
| 259 | }) => ( | ||
| 260 | <div key={audience} className="space-y-2"> | ||
| 261 | <div className="flex items-center gap-2"> | ||
| 262 | <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase"> | ||
| 263 | {AUDIENCE_LABEL[audience]} | ||
| 264 | </span> | ||
| 265 | {audienceNeedsWarning(audience) ? <AudienceWarningChip audience={audience} /> : null} | ||
| 266 | <Button | ||
| 267 | type="button" | ||
| 268 | variant="ghost" | ||
| 269 | size="sm" | ||
| 270 | className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground" | ||
| 271 | onClick={() => { | ||
| 272 | const allSelected = presets.every((preset: GoogleOpenApiPreset) => | ||
| 273 | selectedPresetIds.has(preset.id), | ||
| 274 | ); | ||
| 275 | presets.forEach((preset: GoogleOpenApiPreset) => | ||
| 276 | onToggle(preset.id, !allSelected), | ||
| 277 | ); | ||
| 278 | }} | ||
| 279 | > | ||
| 280 | {presets.every((preset: GoogleOpenApiPreset) => selectedPresetIds.has(preset.id)) | ||
| 281 | ? "Clear" | ||
| 282 | : "Select all"} | ||
| 283 | </Button> | ||
| 284 | </div> | ||
| 285 | <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p> | ||
| 286 | <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2"> | ||
| 287 | {presets.map((preset: GoogleOpenApiPreset) => ( | ||
| 288 | <ProductRow | ||
| 289 | key={preset.id} | ||
| 290 | preset={preset} | ||
| 291 | checked={selectedPresetIds.has(preset.id)} | ||
| 292 | onToggle={(checked: boolean) => onToggle(preset.id, checked)} | ||
| 293 | /> | ||
| 294 | ))} | ||
| 295 | </div> | ||
| 296 | </div> | ||
| 297 | ), | ||
| 298 | )} | ||
| 299 | |||
| 300 | <CustomUrlEscapeHatch | ||
| 301 | customUrls={customUrls} | ||
| 302 | onAddCustomUrl={onAddCustomUrl} | ||
| 303 | onRemoveCustomUrl={onRemoveCustomUrl} | ||
| 304 | /> | ||
| 305 | |||
| 306 | <div className="space-y-1.5 rounded-lg border border-border bg-muted/10 px-3 py-2.5"> | ||
| 307 | <div className="flex items-center gap-2"> | ||
| 308 | <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase"> | ||
| 309 | Authentication | ||
| 310 | </span> | ||
| 311 | <Badge variant="secondary">OAuth</Badge> | ||
| 312 | </div> | ||
| 313 | <p className="text-[11px] text-muted-foreground"> | ||
| 314 | The selected Google APIs share one OAuth consent. Review the scopes below, then connect a | ||
| 315 | Google account from the integration page after adding. | ||
| 316 | </p> | ||
| 317 | </div> | ||
| 318 | |||
| 319 | <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}> | ||
| 320 | <CollapsibleTrigger asChild> | ||
| 321 | <Button type="button" variant="outline" size="sm" disabled={consentBatches.length === 0}> | ||
| 322 | <ChevronDownIcon | ||
| 323 | className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")} | ||
| 324 | /> | ||
| 325 | View scopes | ||
| 326 | {selectedCount > 0 ? ( | ||
| 327 | <Badge variant="secondary" className="ml-1"> | ||
| 328 | {selectedCount} | ||
| 329 | </Badge> | ||
| 330 | ) : null} | ||
| 331 | </Button> | ||
| 332 | </CollapsibleTrigger> | ||
| 333 | <CollapsibleContent className="pt-3"> | ||
| 334 | {consentBatches.length === 0 ? ( | ||
| 335 | <p className="text-[11px] text-muted-foreground"> | ||
| 336 | Select at least one Google API to preview the OAuth consent. | ||
| 337 | </p> | ||
| 338 | ) : ( | ||
| 339 | <div className="space-y-3"> | ||
| 340 | {consentBatches.map((batch) => ( | ||
| 341 | <div key={batch.id} className="space-y-1.5"> | ||
| 342 | <span className="text-[11px] font-semibold text-foreground">{batch.label}</span> | ||
| 343 | <ul className="space-y-1"> | ||
| 344 | {batch.apiScopes.map((scope: string) => ( | ||
| 345 | <li | ||
| 346 | key={scope} | ||
| 347 | className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground" | ||
| 348 | > | ||
| 349 | {scope} | ||
| 350 | </li> | ||
| 351 | ))} | ||
| 352 | </ul> | ||
| 353 | </div> | ||
| 354 | ))} | ||
| 355 | </div> | ||
| 356 | )} | ||
| 357 | </CollapsibleContent> | ||
| 358 | </Collapsible> | ||
| 359 | </section> | ||
| 360 | ); | ||
| 361 | } | ||
| 362 | |||
| 363 | export default GoogleProductPicker; | ||
| @@ -1,30 +0,0 @@ | |||
| 1 | import type { IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | ||
| 4 | import { GoogleClient } from "./client"; | ||
| 5 | |||
| 6 | export const googleIntegrationAtom = (slug: IntegrationSlug) => | ||
| 7 | GoogleClient.query("google", "getIntegration", { | ||
| 8 | params: { slug }, | ||
| 9 | timeToLive: "15 seconds", | ||
| 10 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 11 | }); | ||
| 12 | |||
| 13 | export const googleConfigAtom = (slug: IntegrationSlug) => | ||
| 14 | GoogleClient.query("google", "getConfig", { | ||
| 15 | params: { slug }, | ||
| 16 | timeToLive: "15 seconds", | ||
| 17 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 18 | }); | ||
| 19 | |||
| 20 | export const addGoogleBundle = GoogleClient.mutation("google", "addBundle"); | ||
| 21 | |||
| 22 | export const updateGoogleBundle = GoogleClient.mutation("google", "updateBundle"); | ||
| 23 | |||
| 24 | export const removeGoogleBundle = GoogleClient.mutation("google", "removeBundle"); | ||
| 25 | |||
| 26 | export const googleConfigure = GoogleClient.mutation("google", "configure"); | ||
| 27 | |||
| 28 | export const googleIntegrationFamily = Atom.family(googleIntegrationAtom); | ||
| 29 | |||
| 30 | export const googleConfigFamily = Atom.family(googleConfigAtom); | ||
| @@ -1,13 +0,0 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | ||
| 2 | import { | ||
| 3 | getExecutorOrganizationHeaders, | ||
| 4 | getExecutorApiBaseUrl, | ||
| 5 | getExecutorServerAuthorizationHeader, | ||
| 6 | } from "@executor-js/react/api/server-connection"; | ||
| 7 | import { GoogleGroup } from "../api/group"; | ||
| 8 | |||
| 9 | export const GoogleClient = createPluginAtomClient(GoogleGroup, { | ||
| 10 | baseUrl: getExecutorApiBaseUrl, | ||
| 11 | authorizationHeader: getExecutorServerAuthorizationHeader, | ||
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 13 | }); | ||
| @@ -1,12 +0,0 @@ | |||
| 1 | export { googleIntegrationPlugin } from "./source-plugin"; | ||
| 2 | export { GoogleClient } from "./client"; | ||
| 3 | export { | ||
| 4 | addGoogleBundle, | ||
| 5 | googleConfigAtom, | ||
| 6 | googleConfigFamily, | ||
| 7 | googleConfigure, | ||
| 8 | googleIntegrationAtom, | ||
| 9 | googleIntegrationFamily, | ||
| 10 | removeGoogleBundle, | ||
| 11 | updateGoogleBundle, | ||
| 12 | } from "./atoms"; | ||
| @@ -1,8 +0,0 @@ | |||
| 1 | import { defineClientPlugin } from "@executor-js/sdk/client"; | ||
| 2 | |||
| 3 | import { googleIntegrationPlugin } from "./source-plugin"; | ||
| 4 | |||
| 5 | export default defineClientPlugin({ | ||
| 6 | id: "google" as const, | ||
| 7 | integrationPlugin: googleIntegrationPlugin, | ||
| 8 | }); | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | import { lazy } from "react"; | ||
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | ||
| 3 | import { googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset } from "../sdk/presets"; | ||
| 4 | |||
| 5 | const importAdd = () => import("./AddGoogleSource"); | ||
| 6 | const importAccounts = () => import("./GoogleAccountsPanel"); | ||
| 7 | |||
| 8 | export const googleIntegrationPlugin: IntegrationPlugin = { | ||
| 9 | key: "google", | ||
| 10 | label: "Google", | ||
| 11 | add: lazy(importAdd), | ||
| 12 | accounts: lazy(importAccounts), | ||
| 13 | presets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], | ||
| 14 | preload: () => { | ||
| 15 | void importAdd(); | ||
| 16 | void importAccounts(); | ||
| 17 | }, | ||
| 18 | }; | ||
| @@ -7,6 +7,7 @@ export { · 6 unmodified lines | |||
| 7 | } from "./discovery"; | 7 | } from "./discovery"; |
| 8 | export { | 8 | export { |
| 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"; |
| 30 | export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; | 32 | export { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| 31 | export { | ||
| 32 | googleOAuthConsentBatches, | ||
| 33 | type GoogleOAuthBatchInput, | ||
| 34 | type GoogleOAuthConsentBatch, | ||
| 35 | } from "./oauth-batches"; | ||
| 36 | export { | ||
| 37 | googlePlugin, | ||
| 38 | type GoogleBundleConfig, | ||
| 39 | type GoogleConfigureInput, | ||
| 40 | type GooglePluginExtension, | ||
| 41 | type GooglePluginOptions, | ||
| 42 | type GoogleUpdateInput, | ||
| 43 | type GoogleUpdateResult, | ||
| 44 | } from "./plugin"; | ||
| 45 | export { | 33 | export { |
| 46 | googleOpenApiOwnershipDataMigration, | 34 | googleOpenApiOwnershipDataMigration, |
| 47 | runSqliteGoogleOpenApiOwnershipMigration, | 35 | runSqliteGoogleOpenApiOwnershipMigration, |
| @@ -1,98 +0,0 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | ||
| 2 | |||
| 3 | import { googleOAuthConsentBatches } from "./oauth-batches"; | ||
| 4 | |||
| 5 | it("keeps core Google OAuth in one consent batch", () => { | ||
| 6 | expect( | ||
| 7 | googleOAuthConsentBatches([ | ||
| 8 | { | ||
| 9 | id: "google-gmail", | ||
| 10 | name: "Gmail", | ||
| 11 | oauthAudience: "standard-user", | ||
| 12 | scopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/gmail.send"], | ||
| 13 | }, | ||
| 14 | { | ||
| 15 | id: "google-calendar", | ||
| 16 | name: "Google Calendar", | ||
| 17 | oauthAudience: "standard-user", | ||
| 18 | scopes: [ | ||
| 19 | "https://www.googleapis.com/auth/calendar", | ||
| 20 | "https://www.googleapis.com/auth/calendar.readonly", | ||
| 21 | ], | ||
| 22 | }, | ||
| 23 | ]), | ||
| 24 | ).toEqual([ | ||
| 25 | { | ||
| 26 | id: "google-core", | ||
| 27 | label: "Core Google services", | ||
| 28 | apiScopes: ["https://mail.google.com/", "https://www.googleapis.com/auth/calendar"], | ||
| 29 | }, | ||
| 30 | ]); | ||
| 31 | }); | ||
| 32 | |||
| 33 | it("splits advanced Google OAuth into smaller known-good consent batches", () => { | ||
| 34 | expect( | ||
| 35 | googleOAuthConsentBatches([ | ||
| 36 | { | ||
| 37 | id: "google-youtube-data", | ||
| 38 | name: "YouTube Data", | ||
| 39 | oauthAudience: "advanced-user", | ||
| 40 | scopes: ["https://www.googleapis.com/auth/youtube"], | ||
| 41 | }, | ||
| 42 | { | ||
| 43 | id: "google-classroom", | ||
| 44 | name: "Google Classroom", | ||
| 45 | oauthAudience: "advanced-user", | ||
| 46 | scopes: ["https://www.googleapis.com/auth/classroom.courses"], | ||
| 47 | }, | ||
| 48 | { | ||
| 49 | id: "google-apps-script", | ||
| 50 | name: "Google Apps Script", | ||
| 51 | oauthAudience: "advanced-user", | ||
| 52 | scopes: [ | ||
| 53 | "https://www.googleapis.com/auth/script.projects", | ||
| 54 | "https://www.googleapis.com/auth/drive", | ||
| 55 | ], | ||
| 56 | }, | ||
| 57 | { | ||
| 58 | id: "google-bigquery", | ||
| 59 | name: "Google BigQuery", | ||
| 60 | oauthAudience: "advanced-user", | ||
| 61 | scopes: ["https://www.googleapis.com/auth/bigquery"], | ||
| 62 | }, | ||
| 63 | { | ||
| 64 | id: "google-cloud-resource-manager", | ||
| 65 | name: "Google Cloud Resource Manager", | ||
| 66 | oauthAudience: "advanced-user", | ||
| 67 | scopes: ["https://www.googleapis.com/auth/cloud-platform"], | ||
| 68 | }, | ||
| 69 | ]).map((batch) => ({ id: batch.id, label: batch.label, apiScopes: batch.apiScopes })), | ||
| 70 | ).toEqual([ | ||
| 71 | { | ||
| 72 | id: "google-youtube-data", | ||
| 73 | label: "YouTube Data", | ||
| 74 | apiScopes: ["https://www.googleapis.com/auth/youtube"], | ||
| 75 | }, | ||
| 76 | { | ||
| 77 | id: "google-classroom", | ||
| 78 | label: "Google Classroom", | ||
| 79 | apiScopes: ["https://www.googleapis.com/auth/classroom.courses"], | ||
| 80 | }, | ||
| 81 | { | ||
| 82 | id: "google-apps-script", | ||
| 83 | label: "Google Apps Script", | ||
| 84 | apiScopes: [ | ||
| 85 | "https://www.googleapis.com/auth/script.projects", | ||
| 86 | "https://www.googleapis.com/auth/drive", | ||
| 87 | ], | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | id: "google-cloud", | ||
| 91 | label: "Google Cloud services", | ||
| 92 | apiScopes: [ | ||
| 93 | "https://www.googleapis.com/auth/bigquery", | ||
| 94 | "https://www.googleapis.com/auth/cloud-platform", | ||
| 95 | ], | ||
| 96 | }, | ||
| 97 | ]); | ||
| 98 | }); | ||
| @@ -1,69 +0,0 @@ | |||
| 1 | import type { GoogleOpenApiOAuthAudience } from "./presets"; | ||
| 2 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; | ||
| 3 | |||
| 4 | export type GoogleOAuthBatchInput = { | ||
| 5 | readonly id: string; | ||
| 6 | readonly name: string; | ||
| 7 | readonly oauthAudience: GoogleOpenApiOAuthAudience; | ||
| 8 | readonly scopes: readonly string[]; | ||
| 9 | }; | ||
| 10 | |||
| 11 | export type GoogleOAuthConsentBatch = { | ||
| 12 | readonly id: string; | ||
| 13 | readonly label: string; | ||
| 14 | readonly apiScopes: readonly string[]; | ||
| 15 | }; | ||
| 16 | |||
| 17 | const GOOGLE_CLOUD_BATCH_IDS = new Set(["google-bigquery", "google-cloud-resource-manager"]); | ||
| 18 | |||
| 19 | export const googleOAuthConsentBatches = ( | ||
| 20 | items: readonly GoogleOAuthBatchInput[], | ||
| 21 | ): readonly GoogleOAuthConsentBatch[] => { | ||
| 22 | const standardScopes: string[] = []; | ||
| 23 | const cloudScopes: string[] = []; | ||
| 24 | const batches: GoogleOAuthConsentBatch[] = []; | ||
| 25 | |||
| 26 | for (const item of items) { | ||
| 27 | if (item.scopes.length === 0) continue; | ||
| 28 | if (item.oauthAudience === "standard-user") { | ||
| 29 | standardScopes.push(...item.scopes); | ||
| 30 | continue; | ||
| 31 | } | ||
| 32 | if (GOOGLE_CLOUD_BATCH_IDS.has(item.id)) { | ||
| 33 | cloudScopes.push(...item.scopes); | ||
| 34 | continue; | ||
| 35 | } | ||
| 36 | batches.push({ | ||
| 37 | id: item.id, | ||
| 38 | label: item.name, | ||
| 39 | apiScopes: item.scopes, | ||
| 40 | }); | ||
| 41 | } | ||
| 42 | |||
| 43 | const compactedStandardScopes = compactGoogleOAuthScopes(standardScopes); | ||
| 44 | const compactedCloudScopes = compactGoogleOAuthScopes(cloudScopes); | ||
| 45 | return [ | ||
| 46 | ...(compactedStandardScopes.length > 0 | ||
| 47 | ? [ | ||
| 48 | { | ||
| 49 | id: "google-core", | ||
| 50 | label: "Core Google services", | ||
| 51 | apiScopes: compactedStandardScopes, | ||
| 52 | }, | ||
| 53 | ] | ||
| 54 | : []), | ||
| 55 | ...batches.map((batch) => ({ | ||
| 56 | ...batch, | ||
| 57 | apiScopes: compactGoogleOAuthScopes(batch.apiScopes), | ||
| 58 | })), | ||
| 59 | ...(compactedCloudScopes.length > 0 | ||
| 60 | ? [ | ||
| 61 | { | ||
| 62 | id: "google-cloud", | ||
| 63 | label: "Google Cloud services", | ||
| 64 | apiScopes: compactedCloudScopes, | ||
| 65 | }, | ||
| 66 | ] | ||
| 67 | : []), | ||
| 68 | ].filter((batch) => batch.apiScopes.length > 0); | ||
| 69 | }; | ||
| @@ -1,799 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Google bundle add flow, "customize your Google connection". | ||
| 3 | // | ||
| 4 | // The product picker emits a URL list. The server fetches each Discovery | ||
| 5 | // document, merges them into ONE `google` | ||
| 6 | // integration spec, and stores the unioned `googleOAuth2` auth template. These | ||
| 7 | // tests exercise that path end-to-end against a stubbed Discovery host: | ||
| 8 | // - a 3-API bundle (calendar + gmail + drive) produces a single `google` | ||
| 9 | // integration whose merged tools carry NO name collisions (each method id | ||
| 10 | // is service-prefixed) even when two APIs share a generic method name; | ||
| 11 | // - the stored oauth template carries the UNION of every API's scopes. | ||
| 12 | // --------------------------------------------------------------------------- | ||
| 13 | |||
| 14 | import { describe, expect, it } from "@effect/vitest"; | ||
| 15 | import { Effect, Exit, Layer } from "effect"; | ||
| 16 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 17 | |||
| 18 | import { | ||
| 19 | ConnectionName, | ||
| 20 | IntegrationSlug, | ||
| 21 | createExecutor, | ||
| 22 | AuthTemplateSlug, | ||
| 23 | } from "@executor-js/sdk"; | ||
| 24 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 25 | |||
| 26 | import { defaultGoogleHealthCheck, googlePlugin } from "./plugin"; | ||
| 27 | import { googleOpenApiPresets, type GoogleOpenApiPreset } from "./presets"; | ||
| 28 | |||
| 29 | // --- Canned Discovery documents ------------------------------------------- | ||
| 30 | // Each carries one method. Calendar and Gmail BOTH expose a generic `list` | ||
| 31 | // method id segment, so a naive merge that keyed tools on the trailing method | ||
| 32 | // name would collide. The bundle converter keys on the full method id | ||
| 33 | // (`calendar.events.list`, `gmail.users.messages.list`, …), so they don't. | ||
| 34 | |||
| 35 | const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest"; | ||
| 36 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; | ||
| 37 | const SHEETS_URL = "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"; | ||
| 38 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; | ||
| 39 | const DOCS_URL = "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest"; | ||
| 40 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; | ||
| 41 | const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; | ||
| 42 | const PEOPLE_URL = "https://www.googleapis.com/discovery/v1/apis/people/v1/rest"; | ||
| 43 | const OAUTH2_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest"; | ||
| 44 | const OAUTH2_USERINFO_SCOPES = [ | ||
| 45 | "openid", | ||
| 46 | "https://www.googleapis.com/auth/userinfo.email", | ||
| 47 | "https://www.googleapis.com/auth/userinfo.profile", | ||
| 48 | ] as const; | ||
| 49 | |||
| 50 | const calendarDoc = { | ||
| 51 | name: "calendar", | ||
| 52 | version: "v3", | ||
| 53 | title: "Calendar API", | ||
| 54 | rootUrl: "https://www.googleapis.com/", | ||
| 55 | servicePath: "calendar/v3/", | ||
| 56 | auth: { | ||
| 57 | oauth2: { | ||
| 58 | scopes: { | ||
| 59 | "https://www.googleapis.com/auth/calendar": { description: "Manage calendars" }, | ||
| 60 | "https://www.googleapis.com/auth/calendar.readonly": { description: "Read calendars" }, | ||
| 61 | }, | ||
| 62 | }, | ||
| 63 | }, | ||
| 64 | resources: { | ||
| 65 | events: { | ||
| 66 | methods: { | ||
| 67 | list: { | ||
| 68 | id: "calendar.events.list", | ||
| 69 | httpMethod: "GET", | ||
| 70 | path: "calendars/{calendarId}/events", | ||
| 71 | scopes: ["https://www.googleapis.com/auth/calendar.readonly"], | ||
| 72 | parameters: { | ||
| 73 | calendarId: { location: "path", required: true, type: "string" }, | ||
| 74 | }, | ||
| 75 | }, | ||
| 76 | }, | ||
| 77 | }, | ||
| 78 | }, | ||
| 79 | schemas: { | ||
| 80 | Event: { id: "Event", type: "object", properties: { id: { type: "string" } } }, | ||
| 81 | }, | ||
| 82 | }; | ||
| 83 | |||
| 84 | const gmailDoc = { | ||
| 85 | name: "gmail", | ||
| 86 | version: "v1", | ||
| 87 | title: "Gmail API", | ||
| 88 | rootUrl: "https://gmail.googleapis.com/", | ||
| 89 | servicePath: "", | ||
| 90 | auth: { | ||
| 91 | oauth2: { | ||
| 92 | scopes: { | ||
| 93 | "https://mail.google.com/": { description: "Full Gmail access" }, | ||
| 94 | "https://www.googleapis.com/auth/gmail.readonly": { description: "Read Gmail" }, | ||
| 95 | }, | ||
| 96 | }, | ||
| 97 | }, | ||
| 98 | resources: { | ||
| 99 | users: { | ||
| 100 | resources: { | ||
| 101 | messages: { | ||
| 102 | methods: { | ||
| 103 | // Same trailing `list` as calendar.events.list - would collide on a | ||
| 104 | // naive merge; service-prefixed method id keeps them distinct. | ||
| 105 | list: { | ||
| 106 | id: "gmail.users.messages.list", | ||
| 107 | httpMethod: "GET", | ||
| 108 | path: "gmail/v1/users/{userId}/messages", | ||
| 109 | scopes: ["https://www.googleapis.com/auth/gmail.readonly"], | ||
| 110 | parameters: { | ||
| 111 | userId: { location: "path", required: true, type: "string" }, | ||
| 112 | }, | ||
| 113 | }, | ||
| 114 | }, | ||
| 115 | }, | ||
| 116 | }, | ||
| 117 | }, | ||
| 118 | }, | ||
| 119 | schemas: { | ||
| 120 | Message: { id: "Message", type: "object", properties: { id: { type: "string" } } }, | ||
| 121 | }, | ||
| 122 | }; | ||
| 123 | |||
| 124 | const driveDoc = { | ||
| 125 | name: "drive", | ||
| 126 | version: "v3", | ||
| 127 | title: "Drive API", | ||
| 128 | rootUrl: "https://www.googleapis.com/", | ||
| 129 | servicePath: "drive/v3/", | ||
| 130 | auth: { | ||
| 131 | oauth2: { | ||
| 132 | scopes: { | ||
| 133 | "https://www.googleapis.com/auth/drive": { description: "Manage Drive" }, | ||
| 134 | }, | ||
| 135 | }, | ||
| 136 | }, | ||
| 137 | resources: { | ||
| 138 | files: { | ||
| 139 | methods: { | ||
| 140 | // A third `list` - three generic method names across three APIs. | ||
| 141 | list: { | ||
| 142 | id: "drive.files.list", | ||
| 143 | httpMethod: "GET", | ||
| 144 | path: "files", | ||
| 145 | scopes: ["https://www.googleapis.com/auth/drive"], | ||
| 146 | parameters: {}, | ||
| 147 | }, | ||
| 148 | }, | ||
| 149 | }, | ||
| 150 | }, | ||
| 151 | schemas: { | ||
| 152 | File: { id: "File", type: "object", properties: { id: { type: "string" } } }, | ||
| 153 | }, | ||
| 154 | }; | ||
| 155 | |||
| 156 | const sheetsDoc = { | ||
| 157 | name: "sheets", | ||
| 158 | version: "v4", | ||
| 159 | title: "Google Sheets API", | ||
| 160 | rootUrl: "https://sheets.googleapis.com/", | ||
| 161 | servicePath: "v4/", | ||
| 162 | auth: { | ||
| 163 | oauth2: { | ||
| 164 | scopes: { | ||
| 165 | "https://www.googleapis.com/auth/spreadsheets": { description: "Manage spreadsheets" }, | ||
| 166 | }, | ||
| 167 | }, | ||
| 168 | }, | ||
| 169 | resources: { | ||
| 170 | spreadsheets: { | ||
| 171 | methods: { | ||
| 172 | get: { | ||
| 173 | id: "sheets.spreadsheets.get", | ||
| 174 | httpMethod: "GET", | ||
| 175 | path: "spreadsheets/{spreadsheetId}", | ||
| 176 | scopes: ["https://www.googleapis.com/auth/spreadsheets"], | ||
| 177 | parameters: { | ||
| 178 | spreadsheetId: { location: "path", required: true, type: "string" }, | ||
| 179 | }, | ||
| 180 | }, | ||
| 181 | }, | ||
| 182 | }, | ||
| 183 | }, | ||
| 184 | schemas: { | ||
| 185 | Spreadsheet: { id: "Spreadsheet", type: "object", properties: { id: { type: "string" } } }, | ||
| 186 | }, | ||
| 187 | }; | ||
| 188 | |||
| 189 | const docsDoc = { | ||
| 190 | name: "docs", | ||
| 191 | version: "v1", | ||
| 192 | title: "Google Docs API", | ||
| 193 | rootUrl: "https://docs.googleapis.com/", | ||
| 194 | servicePath: "v1/", | ||
| 195 | auth: { | ||
| 196 | oauth2: { | ||
| 197 | scopes: { | ||
| 198 | "https://www.googleapis.com/auth/documents": { description: "Manage documents" }, | ||
| 199 | }, | ||
| 200 | }, | ||
| 201 | }, | ||
| 202 | resources: { | ||
| 203 | documents: { | ||
| 204 | methods: { | ||
| 205 | get: { | ||
| 206 | id: "docs.documents.get", | ||
| 207 | httpMethod: "GET", | ||
| 208 | path: "documents/{documentId}", | ||
| 209 | scopes: ["https://www.googleapis.com/auth/documents"], | ||
| 210 | parameters: { | ||
| 211 | documentId: { location: "path", required: true, type: "string" }, | ||
| 212 | }, | ||
| 213 | }, | ||
| 214 | }, | ||
| 215 | }, | ||
| 216 | }, | ||
| 217 | schemas: { | ||
| 218 | Document: { id: "Document", type: "object", properties: { documentId: { type: "string" } } }, | ||
| 219 | }, | ||
| 220 | }; | ||
| 221 | |||
| 222 | const photosLibraryDoc = { | ||
| 223 | name: "photoslibrary", | ||
| 224 | version: "v1", | ||
| 225 | title: "Google Photos Library API", | ||
| 226 | rootUrl: "https://photoslibrary.googleapis.com/", | ||
| 227 | servicePath: "v1/", | ||
| 228 | auth: { | ||
| 229 | oauth2: { | ||
| 230 | scopes: { | ||
| 231 | "https://www.googleapis.com/auth/photoslibrary": { | ||
| 232 | description: "Manage the full Google Photos library", | ||
| 233 | }, | ||
| 234 | "https://www.googleapis.com/auth/photoslibrary.appendonly": { | ||
| 235 | description: "Upload to Google Photos", | ||
| 236 | }, | ||
| 237 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata": { | ||
| 238 | description: "Read app-created Google Photos media", | ||
| 239 | }, | ||
| 240 | }, | ||
| 241 | }, | ||
| 242 | }, | ||
| 243 | resources: { | ||
| 244 | albums: { | ||
| 245 | methods: { | ||
| 246 | list: { | ||
| 247 | id: "photoslibrary.albums.list", | ||
| 248 | httpMethod: "GET", | ||
| 249 | path: "albums", | ||
| 250 | scopes: ["https://www.googleapis.com/auth/photoslibrary"], | ||
| 251 | parameters: {}, | ||
| 252 | }, | ||
| 253 | }, | ||
| 254 | }, | ||
| 255 | mediaItems: { | ||
| 256 | methods: { | ||
| 257 | search: { | ||
| 258 | id: "photoslibrary.mediaItems.search", | ||
| 259 | httpMethod: "POST", | ||
| 260 | path: "mediaItems:search", | ||
| 261 | scopes: ["https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata"], | ||
| 262 | parameters: {}, | ||
| 263 | }, | ||
| 264 | }, | ||
| 265 | }, | ||
| 266 | }, | ||
| 267 | schemas: {}, | ||
| 268 | }; | ||
| 269 | |||
| 270 | const photosPickerDoc = { | ||
| 271 | name: "photospicker", | ||
| 272 | version: "v1", | ||
| 273 | title: "Google Photos Picker API", | ||
| 274 | rootUrl: "https://photospicker.googleapis.com/", | ||
| 275 | servicePath: "v1/", | ||
| 276 | resources: { | ||
| 277 | mediaItems: { | ||
| 278 | methods: { | ||
| 279 | list: { | ||
| 280 | id: "photospicker.mediaItems.list", | ||
| 281 | httpMethod: "GET", | ||
| 282 | path: "mediaItems", | ||
| 283 | parameters: {}, | ||
| 284 | }, | ||
| 285 | }, | ||
| 286 | }, | ||
| 287 | }, | ||
| 288 | schemas: {}, | ||
| 289 | }; | ||
| 290 | |||
| 291 | const peopleDoc = { | ||
| 292 | name: "people", | ||
| 293 | version: "v1", | ||
| 294 | title: "People API", | ||
| 295 | rootUrl: "https://people.googleapis.com/", | ||
| 296 | servicePath: "", | ||
| 297 | auth: { | ||
| 298 | oauth2: { | ||
| 299 | scopes: { | ||
| 300 | "https://www.googleapis.com/auth/userinfo.email": { description: "See your email" }, | ||
| 301 | }, | ||
| 302 | }, | ||
| 303 | }, | ||
| 304 | resources: { | ||
| 305 | people: { | ||
| 306 | methods: { | ||
| 307 | get: { | ||
| 308 | id: "people.people.get", | ||
| 309 | httpMethod: "GET", | ||
| 310 | path: "v1/{+resourceName}", | ||
| 311 | scopes: ["https://www.googleapis.com/auth/userinfo.email"], | ||
| 312 | parameters: { | ||
| 313 | resourceName: { location: "path", required: true, type: "string" }, | ||
| 314 | personFields: { location: "query", type: "string" }, | ||
| 315 | }, | ||
| 316 | response: { $ref: "Person" }, | ||
| 317 | }, | ||
| 318 | }, | ||
| 319 | }, | ||
| 320 | }, | ||
| 321 | schemas: { | ||
| 322 | Person: { | ||
| 323 | id: "Person", | ||
| 324 | type: "object", | ||
| 325 | properties: { | ||
| 326 | resourceName: { type: "string" }, | ||
| 327 | emailAddresses: { type: "array", items: { $ref: "EmailAddress" } }, | ||
| 328 | }, | ||
| 329 | }, | ||
| 330 | EmailAddress: { id: "EmailAddress", type: "object", properties: { value: { type: "string" } } }, | ||
| 331 | }, | ||
| 332 | }; | ||
| 333 | |||
| 334 | const oauth2Doc = { | ||
| 335 | name: "oauth2", | ||
| 336 | version: "v2", | ||
| 337 | title: "Google OAuth2 API", | ||
| 338 | rootUrl: "https://www.googleapis.com/", | ||
| 339 | servicePath: "", | ||
| 340 | auth: { | ||
| 341 | oauth2: { | ||
| 342 | scopes: { | ||
| 343 | openid: { description: "Associate you with your personal info on Google" }, | ||
| 344 | "https://www.googleapis.com/auth/userinfo.email": { | ||
| 345 | description: "See your primary Google Account email address", | ||
| 346 | }, | ||
| 347 | "https://www.googleapis.com/auth/userinfo.profile": { | ||
| 348 | description: "See your personal info", | ||
| 349 | }, | ||
| 350 | }, | ||
| 351 | }, | ||
| 352 | }, | ||
| 353 | methods: { | ||
| 354 | tokeninfo: { | ||
| 355 | id: "oauth2.tokeninfo", | ||
| 356 | httpMethod: "POST", | ||
| 357 | path: "oauth2/v2/tokeninfo", | ||
| 358 | response: { $ref: "Tokeninfo" }, | ||
| 359 | }, | ||
| 360 | }, | ||
| 361 | resources: { | ||
| 362 | userinfo: { | ||
| 363 | methods: { | ||
| 364 | get: { | ||
| 365 | id: "oauth2.userinfo.get", | ||
| 366 | httpMethod: "GET", | ||
| 367 | path: "oauth2/v2/userinfo", | ||
| 368 | scopes: OAUTH2_USERINFO_SCOPES, | ||
| 369 | response: { $ref: "Userinfo" }, | ||
| 370 | }, | ||
| 371 | }, | ||
| 372 | resources: { | ||
| 373 | v2: { | ||
| 374 | resources: { | ||
| 375 | me: { | ||
| 376 | methods: { | ||
| 377 | get: { | ||
| 378 | id: "oauth2.userinfo.v2.me.get", | ||
| 379 | httpMethod: "GET", | ||
| 380 | path: "userinfo/v2/me", | ||
| 381 | scopes: OAUTH2_USERINFO_SCOPES, | ||
| 382 | response: { $ref: "Userinfo" }, | ||
| 383 | }, | ||
| 384 | }, | ||
| 385 | }, | ||
| 386 | }, | ||
| 387 | }, | ||
| 388 | }, | ||
| 389 | }, | ||
| 390 | }, | ||
| 391 | schemas: { | ||
| 392 | Tokeninfo: { | ||
| 393 | id: "Tokeninfo", | ||
| 394 | type: "object", | ||
| 395 | properties: { | ||
| 396 | audience: { type: "string" }, | ||
| 397 | scope: { type: "string" }, | ||
| 398 | }, | ||
| 399 | }, | ||
| 400 | Userinfo: { | ||
| 401 | id: "Userinfo", | ||
| 402 | type: "object", | ||
| 403 | properties: { | ||
| 404 | email: { type: "string" }, | ||
| 405 | family_name: { type: "string" }, | ||
| 406 | gender: { type: "string" }, | ||
| 407 | given_name: { type: "string" }, | ||
| 408 | hd: { type: "string" }, | ||
| 409 | id: { type: "string" }, | ||
| 410 | link: { type: "string" }, | ||
| 411 | locale: { type: "string" }, | ||
| 412 | name: { type: "string" }, | ||
| 413 | picture: { type: "string" }, | ||
| 414 | verified_email: { type: "boolean" }, | ||
| 415 | }, | ||
| 416 | }, | ||
| 417 | }, | ||
| 418 | }; | ||
| 419 | |||
| 420 | const toJson = (value: unknown): string => JSON.stringify(value); | ||
| 421 | |||
| 422 | const DISCOVERY_BODIES: Readonly<Record<string, string>> = { | ||
| 423 | [CALENDAR_URL]: toJson(calendarDoc), | ||
| 424 | [GMAIL_URL]: toJson(gmailDoc), | ||
| 425 | [SHEETS_URL]: toJson(sheetsDoc), | ||
| 426 | [DRIVE_URL]: toJson(driveDoc), | ||
| 427 | [DOCS_URL]: toJson(docsDoc), | ||
| 428 | [PHOTOS_LIBRARY_URL]: toJson(photosLibraryDoc), | ||
| 429 | [PHOTOS_PICKER_URL]: toJson(photosPickerDoc), | ||
| 430 | [PEOPLE_URL]: toJson(peopleDoc), | ||
| 431 | [OAUTH2_URL]: toJson(oauth2Doc), | ||
| 432 | }; | ||
| 433 | |||
| 434 | // A stub HTTP client that serves the canned Discovery document for whichever | ||
| 435 | // URL the bundle converter fetches. Service-hosted Discovery URLs carry their | ||
| 436 | // version in the query string, so match the full URL before falling back to the | ||
| 437 | // path-only key used by central Discovery URLs. | ||
| 438 | const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 439 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { | ||
| 440 | const url = new URL(request.url); | ||
| 441 | const key = `${url.origin}${url.pathname}`; | ||
| 442 | const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; | ||
| 443 | return Effect.succeed( | ||
| 444 | HttpClientResponse.fromWeb( | ||
| 445 | request, | ||
| 446 | body === undefined | ||
| 447 | ? new Response("not found", { status: 404 }) | ||
| 448 | : new Response(body, { | ||
| 449 | status: 200, | ||
| 450 | headers: { "content-type": "application/json" }, | ||
| 451 | }), | ||
| 452 | ), | ||
| 453 | ); | ||
| 454 | }), | ||
| 455 | ); | ||
| 456 | |||
| 457 | const bundlePlugins = () => | ||
| 458 | [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; | ||
| 459 | |||
| 460 | describe("Google bundle add flow", () => { | ||
| 461 | it("SDK catalog includes the Google Photos focused preset", () => { | ||
| 462 | const presetIds = | ||
| 463 | googlePlugin({ httpClientLayer: discoveryHttpClientLayer }).integrationPresets?.map( | ||
| 464 | (preset) => preset.id, | ||
| 465 | ) ?? []; | ||
| 466 | |||
| 467 | expect(presetIds).toContain("google"); | ||
| 468 | expect(presetIds).toContain("google-photos"); | ||
| 469 | }); | ||
| 470 | |||
| 471 | it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () => | ||
| 472 | Effect.scoped( | ||
| 473 | Effect.gen(function* () { | ||
| 474 | let requests = 0; | ||
| 475 | const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 476 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 477 | Effect.sync(() => { | ||
| 478 | requests += 1; | ||
| 479 | return HttpClientResponse.fromWeb( | ||
| 480 | request, | ||
| 481 | new Response("unexpected request", { status: 500 }), | ||
| 482 | ); | ||
| 483 | }), | ||
| 484 | ), | ||
| 485 | ); | ||
| 486 | const executor = yield* createExecutor( | ||
| 487 | makeTestConfig({ | ||
| 488 | plugins: [ | ||
| 489 | googlePlugin({ httpClientLayer: blockedHttpClientLayer }), | ||
| 490 | memoryCredentialsPlugin(), | ||
| 491 | ], | ||
| 492 | }), | ||
| 493 | ); | ||
| 494 | |||
| 495 | const exit = yield* executor.google | ||
| 496 | .addBundle({ | ||
| 497 | urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"], | ||
| 498 | slug: "bad_google", | ||
| 499 | }) | ||
| 500 | .pipe(Effect.exit); | ||
| 501 | |||
| 502 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 503 | expect(requests).toBe(0); | ||
| 504 | }), | ||
| 505 | ), | ||
| 506 | ); | ||
| 507 | |||
| 508 | it.effect( | ||
| 509 | "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", | ||
| 510 | () => | ||
| 511 | Effect.scoped( | ||
| 512 | Effect.gen(function* () { | ||
| 513 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 514 | |||
| 515 | const result = yield* executor.google.addBundle({ | ||
| 516 | urls: [CALENDAR_URL, GMAIL_URL, DRIVE_URL], | ||
| 517 | slug: "google", | ||
| 518 | description: "Google", | ||
| 519 | }); | ||
| 520 | expect(String(result.slug)).toBe("google"); | ||
| 521 | |||
| 522 | // ONE integration, not three. | ||
| 523 | const integration = yield* executor.google.getIntegration("google"); | ||
| 524 | expect(integration?.slug).toBe(IntegrationSlug.make("google")); | ||
| 525 | |||
| 526 | // The stored oauth template carries the COMPACTED union of every API's | ||
| 527 | // scopes plus the hidden OAuth2 identity scopes that `oauth.start` | ||
| 528 | // requests. | ||
| 529 | // `calendar.readonly` collapses under `calendar`, and `gmail.readonly` | ||
| 530 | // collapses under `https://mail.google.com/`, so the requested consent | ||
| 531 | // is clean rather than the raw per-method union. | ||
| 532 | const config = yield* executor.google.getConfig("google"); | ||
| 533 | expect(config?.googleDiscoveryUrls).toEqual([ | ||
| 534 | CALENDAR_URL, | ||
| 535 | GMAIL_URL, | ||
| 536 | DRIVE_URL, | ||
| 537 | OAUTH2_URL, | ||
| 538 | ]); | ||
| 539 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 540 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 541 | [ | ||
| 542 | "email", | ||
| 543 | "https://mail.google.com/", | ||
| 544 | "https://www.googleapis.com/auth/calendar", | ||
| 545 | "https://www.googleapis.com/auth/drive", | ||
| 546 | "openid", | ||
| 547 | "profile", | ||
| 548 | ].sort(), | ||
| 549 | ); | ||
| 550 | |||
| 551 | // A connection stamps the merged tools; assert all three `list`s are | ||
| 552 | // present under distinct service-prefixed names (no collision). | ||
| 553 | yield* executor.connections.create({ | ||
| 554 | owner: "org", | ||
| 555 | name: ConnectionName.make("main"), | ||
| 556 | integration: IntegrationSlug.make("google"), | ||
| 557 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 558 | value: "token-xyz", | ||
| 559 | }); | ||
| 560 | |||
| 561 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 562 | expect(toolNames).toContain("calendar.events.list"); | ||
| 563 | expect(toolNames).toContain("gmail.users.messages.list"); | ||
| 564 | expect(toolNames).toContain("drive.files.list"); | ||
| 565 | |||
| 566 | // No duplicate tool names across the merged surface. | ||
| 567 | const googleTools = toolNames.filter((name) => name.endsWith(".list")); | ||
| 568 | expect(new Set(googleTools).size).toBe(googleTools.length); | ||
| 569 | expect(googleTools.length).toBe(3); | ||
| 570 | }), | ||
| 571 | ), | ||
| 572 | ); | ||
| 573 | |||
| 574 | it.effect("addBundle constrains Google Photos to the preset scopes and upload tool", () => | ||
| 575 | Effect.scoped( | ||
| 576 | Effect.gen(function* () { | ||
| 577 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 578 | |||
| 579 | yield* executor.google.addBundle({ | ||
| 580 | urls: [ | ||
| 581 | PHOTOS_LIBRARY_URL, | ||
| 582 | "https://photospicker.googleapis.com/$discovery/rest?version=v1", | ||
| 583 | ], | ||
| 584 | slug: "google_photos", | ||
| 585 | name: "Google Photos", | ||
| 586 | }); | ||
| 587 | |||
| 588 | const config = yield* executor.google.getConfig("google_photos"); | ||
| 589 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 590 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 591 | [ | ||
| 592 | "email", | ||
| 593 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 594 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 595 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", | ||
| 596 | "openid", | ||
| 597 | "profile", | ||
| 598 | ].sort(), | ||
| 599 | ); | ||
| 600 | |||
| 601 | yield* executor.connections.create({ | ||
| 602 | owner: "org", | ||
| 603 | name: ConnectionName.make("main"), | ||
| 604 | integration: IntegrationSlug.make("google_photos"), | ||
| 605 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 606 | value: "token-xyz", | ||
| 607 | }); | ||
| 608 | |||
| 609 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 610 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 611 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 612 | expect(toolNames).toContain("photospicker.mediaItems.list"); | ||
| 613 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 614 | }), | ||
| 615 | ), | ||
| 616 | ); | ||
| 617 | |||
| 618 | it.effect("addBundle keeps Google Photos scoped when combined with another API", () => | ||
| 619 | Effect.scoped( | ||
| 620 | Effect.gen(function* () { | ||
| 621 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 622 | |||
| 623 | yield* executor.google.addBundle({ | ||
| 624 | urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL, PHOTOS_PICKER_URL], | ||
| 625 | slug: "google_photos_calendar", | ||
| 626 | name: "Google Photos and Calendar", | ||
| 627 | }); | ||
| 628 | |||
| 629 | const config = yield* executor.google.getConfig("google_photos_calendar"); | ||
| 630 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 631 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 632 | [ | ||
| 633 | "email", | ||
| 634 | "https://www.googleapis.com/auth/calendar", | ||
| 635 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 636 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 637 | "https://www.googleapis.com/auth/photospicker.mediaitems.readonly", | ||
| 638 | "openid", | ||
| 639 | "profile", | ||
| 640 | ].sort(), | ||
| 641 | ); | ||
| 642 | |||
| 643 | yield* executor.connections.create({ | ||
| 644 | owner: "org", | ||
| 645 | name: ConnectionName.make("main"), | ||
| 646 | integration: IntegrationSlug.make("google_photos_calendar"), | ||
| 647 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 648 | value: "token-xyz", | ||
| 649 | }); | ||
| 650 | |||
| 651 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 652 | expect(toolNames).toContain("calendar.events.list"); | ||
| 653 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 654 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 655 | expect(toolNames).toContain("photospicker.mediaItems.list"); | ||
| 656 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 657 | }), | ||
| 658 | ), | ||
| 659 | ); | ||
| 660 | |||
| 661 | it.effect("addBundle scopes a partial Google Photos bundle when mixed with another API", () => | ||
| 662 | Effect.scoped( | ||
| 663 | Effect.gen(function* () { | ||
| 664 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 665 | |||
| 666 | yield* executor.google.addBundle({ | ||
| 667 | urls: [CALENDAR_URL, PHOTOS_LIBRARY_URL], | ||
| 668 | slug: "google_photos_library_calendar", | ||
| 669 | name: "Google Photos Library and Calendar", | ||
| 670 | }); | ||
| 671 | |||
| 672 | const config = yield* executor.google.getConfig("google_photos_library_calendar"); | ||
| 673 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 674 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 675 | [ | ||
| 676 | "email", | ||
| 677 | "https://www.googleapis.com/auth/calendar", | ||
| 678 | "https://www.googleapis.com/auth/photoslibrary.appendonly", | ||
| 679 | "https://www.googleapis.com/auth/photoslibrary.readonly.appcreateddata", | ||
| 680 | "openid", | ||
| 681 | "profile", | ||
| 682 | ].sort(), | ||
| 683 | ); | ||
| 684 | |||
| 685 | yield* executor.connections.create({ | ||
| 686 | owner: "org", | ||
| 687 | name: ConnectionName.make("main"), | ||
| 688 | integration: IntegrationSlug.make("google_photos_library_calendar"), | ||
| 689 | template: AuthTemplateSlug.make("googleOAuth2"), | ||
| 690 | value: "token-xyz", | ||
| 691 | }); | ||
| 692 | |||
| 693 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 694 | expect(toolNames).toContain("calendar.events.list"); | ||
| 695 | expect(toolNames).toContain("photoslibrary.mediaItems.upload"); | ||
| 696 | expect(toolNames).toContain("photoslibrary.mediaItems.search"); | ||
| 697 | expect(toolNames).not.toContain("photospicker.mediaItems.list"); | ||
| 698 | expect(toolNames).not.toContain("photoslibrary.albums.list"); | ||
| 699 | }), | ||
| 700 | ), | ||
| 701 | ); | ||
| 702 | }); | ||
| 703 | |||
| 704 | describe("Google health-check default", () => { | ||
| 705 | it.effect("default featured bundle auto-configures OAuth2 userinfo identity", () => | ||
| 706 | Effect.scoped( | ||
| 707 | Effect.gen(function* () { | ||
| 708 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 709 | const defaultUrls = googleOpenApiPresets | ||
| 710 | .filter((preset: GoogleOpenApiPreset) => preset.featured) | ||
| 711 | .flatMap((preset: GoogleOpenApiPreset) => (preset.url ? [preset.url] : [])); | ||
| 712 | |||
| 713 | expect(defaultUrls).toEqual([CALENDAR_URL, GMAIL_URL, SHEETS_URL, DRIVE_URL, DOCS_URL]); | ||
| 714 | |||
| 715 | yield* executor.google.addBundle({ | ||
| 716 | urls: defaultUrls, | ||
| 717 | slug: "google_default", | ||
| 718 | description: "Google", | ||
| 719 | }); | ||
| 720 | |||
| 721 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 722 | IntegrationSlug.make("google_default"), | ||
| 723 | ); | ||
| 724 | expect(stored?.operation, "the default check targets OAuth2 userinfo").toBe( | ||
| 725 | "oauth2.userinfo.get", | ||
| 726 | ); | ||
| 727 | expect(stored?.args, "userinfo needs no args").toBeUndefined(); | ||
| 728 | expect(stored?.identityField, "the default reads the account email").toBe("email"); | ||
| 729 | |||
| 730 | const config = yield* executor.google.getConfig("google_default"); | ||
| 731 | expect(config?.googleDiscoveryUrls).toEqual([...defaultUrls, OAUTH2_URL]); | ||
| 732 | const oauth = config?.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | ||
| 733 | expect(oauth?.kind === "oauth2" ? [...oauth.scopes].sort() : undefined).toEqual( | ||
| 734 | [ | ||
| 735 | "email", | ||
| 736 | "https://mail.google.com/", | ||
| 737 | "https://www.googleapis.com/auth/calendar", | ||
| 738 | "https://www.googleapis.com/auth/documents", | ||
| 739 | "https://www.googleapis.com/auth/drive", | ||
| 740 | "https://www.googleapis.com/auth/spreadsheets", | ||
| 741 | "openid", | ||
| 742 | "profile", | ||
| 743 | ].sort(), | ||
| 744 | ); | ||
| 745 | |||
| 746 | const candidates = yield* executor.integrations.healthCheck.candidates( | ||
| 747 | IntegrationSlug.make("google_default"), | ||
| 748 | ); | ||
| 749 | const userinfo = candidates.find((c) => c.operation === "oauth2.userinfo.get"); | ||
| 750 | expect(userinfo, "OAuth2 userinfo is a ranked candidate").toBeDefined(); | ||
| 751 | expect( | ||
| 752 | (userinfo?.responseFields ?? []).map((field) => field.path), | ||
| 753 | "the email identity field is projected from the response schema", | ||
| 754 | ).toContain("email"); | ||
| 755 | }), | ||
| 756 | ), | ||
| 757 | ); | ||
| 758 | |||
| 759 | it.effect("addBundle with People still prefers OAuth2 userinfo", () => | ||
| 760 | Effect.scoped( | ||
| 761 | Effect.gen(function* () { | ||
| 762 | const executor = yield* createExecutor(makeTestConfig({ plugins: bundlePlugins() })); | ||
| 763 | |||
| 764 | yield* executor.google.addBundle({ | ||
| 765 | urls: [PEOPLE_URL, CALENDAR_URL], | ||
| 766 | slug: "google_people", | ||
| 767 | description: "Google", | ||
| 768 | }); | ||
| 769 | |||
| 770 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 771 | IntegrationSlug.make("google_people"), | ||
| 772 | ); | ||
| 773 | expect(stored?.operation, "OAuth2 userinfo beats the People identity call").toBe( | ||
| 774 | "oauth2.userinfo.get", | ||
| 775 | ); | ||
| 776 | expect(stored?.args).toBeUndefined(); | ||
| 777 | expect(stored?.identityField).toBe("email"); | ||
| 778 | }), | ||
| 779 | ), | ||
| 780 | ); | ||
| 781 | |||
| 782 | it("falls back to People when OAuth2 userinfo is absent", () => { | ||
| 783 | expect( | ||
| 784 | defaultGoogleHealthCheck( | ||
| 785 | [PEOPLE_URL], | ||
| 786 | [ | ||
| 787 | { | ||
| 788 | toolPath: "people.people.get", | ||
| 789 | operation: { method: "get", pathTemplate: "/v1/{+resourceName}" }, | ||
| 790 | }, | ||
| 791 | ], | ||
| 792 | ), | ||
| 793 | ).toEqual({ | ||
| 794 | operation: "people.people.get", | ||
| 795 | args: { resourceName: "people/me", personFields: "names,emailAddresses" }, | ||
| 796 | identityField: "emailAddresses.0.value", | ||
| 797 | }); | ||
| 798 | }); | ||
| 799 | }); | ||
| @@ -1,469 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import type { Layer } from "effect"; | ||
| 3 | import { HttpClient } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationDetectionResult, | ||
| 8 | IntegrationNotFoundError, | ||
| 9 | IntegrationSlug, | ||
| 10 | definePlugin, | ||
| 11 | mergeAuthTemplates, | ||
| 12 | sha256Hex, | ||
| 13 | type AuthMethodDescriptor, | ||
| 14 | type HealthCheckSpec, | ||
| 15 | type Integration, | ||
| 16 | type IntegrationConfig, | ||
| 17 | type IntegrationRecord, | ||
| 18 | type PluginCtx, | ||
| 19 | } from "@executor-js/sdk/core"; | ||
| 20 | import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | ||
| 21 | import { | ||
| 22 | checkHealthOpenApi, | ||
| 23 | compileOpenApiSpec, | ||
| 24 | invokeOpenApiBackedTool, | ||
| 25 | listHealthCheckCandidatesOpenApi, | ||
| 26 | makeDefaultOpenapiStore, | ||
| 27 | normalizeOpenApiAuthInputs, | ||
| 28 | OpenApiParseError, | ||
| 29 | openApiStoredOperationsFromCompiled, | ||
| 30 | resolveOpenApiBackedAnnotations, | ||
| 31 | resolveOpenApiBackedTools, | ||
| 32 | type Authentication, | ||
| 33 | type AuthenticationInput, | ||
| 34 | type OpenapiStore, | ||
| 35 | } from "@executor-js/plugin-openapi"; | ||
| 36 | |||
| 37 | import { | ||
| 38 | convertGoogleDiscoveryBundleToOpenApi, | ||
| 39 | fetchGoogleDiscoveryDocument, | ||
| 40 | normalizeGoogleDiscoveryUrl, | ||
| 41 | } from "./discovery"; | ||
| 42 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; | ||
| 43 | import { | ||
| 44 | googleOAuthConsentScopesForPreset, | ||
| 45 | googleOpenApiBundlePreset, | ||
| 46 | googlePhotosOpenApiBundlePreset, | ||
| 47 | googlePhotosOpenApiPresets, | ||
| 48 | } from "./presets"; | ||
| 49 | |||
| 50 | const GOOGLE_OAUTH2_DISCOVERY_URL = "https://www.googleapis.com/discovery/v1/apis/oauth2/v2/rest"; | ||
| 51 | |||
| 52 | /** The default health check for a Google bundle: prefer the lightweight | ||
| 53 | * OAuth2 userinfo identity call, which every new Google bundle includes. | ||
| 54 | * Older bundles may only have People API, so keep that fallback. */ | ||
| 55 | export const defaultGoogleHealthCheck = ( | ||
| 56 | urls: readonly string[], | ||
| 57 | definitions: readonly { | ||
| 58 | readonly toolPath: string; | ||
| 59 | readonly operation: { readonly method: string; readonly pathTemplate: string }; | ||
| 60 | }[], | ||
| 61 | ): HealthCheckSpec | undefined => { | ||
| 62 | const userinfoGet = definitions.find( | ||
| 63 | (def) => | ||
| 64 | def.operation.method.toLowerCase() === "get" && | ||
| 65 | (def.toolPath === "oauth2.userinfo.get" || | ||
| 66 | def.operation.pathTemplate === "/oauth2/v2/userinfo"), | ||
| 67 | ); | ||
| 68 | if (userinfoGet) { | ||
| 69 | return { | ||
| 70 | operation: userinfoGet.toolPath, | ||
| 71 | identityField: "email", | ||
| 72 | }; | ||
| 73 | } | ||
| 74 | |||
| 75 | const hasPeopleApi = urls.some((url) => url.includes("/people/")); | ||
| 76 | if (!hasPeopleApi) return undefined; | ||
| 77 | const peopleGet = definitions.find( | ||
| 78 | (def) => | ||
| 79 | def.operation.method.toLowerCase() === "get" && | ||
| 80 | (def.toolPath === "people.people.get" || | ||
| 81 | def.operation.pathTemplate === "/v1/{+resourceName}"), | ||
| 82 | ); | ||
| 83 | return peopleGet | ||
| 84 | ? { | ||
| 85 | operation: peopleGet.toolPath, | ||
| 86 | args: { resourceName: "people/me", personFields: "names,emailAddresses" }, | ||
| 87 | identityField: "emailAddresses.0.value", | ||
| 88 | } | ||
| 89 | : undefined; | ||
| 90 | }; | ||
| 91 | |||
| 92 | export interface GoogleBundleConfig { | ||
| 93 | readonly urls: readonly string[]; | ||
| 94 | readonly slug?: string; | ||
| 95 | readonly name?: string; | ||
| 96 | readonly description?: string; | ||
| 97 | readonly baseUrl?: string; | ||
| 98 | } | ||
| 99 | |||
| 100 | export interface GoogleConfigureInput { | ||
| 101 | readonly authenticationTemplate: readonly AuthenticationInput[]; | ||
| 102 | readonly mode?: "merge" | "replace"; | ||
| 103 | } | ||
| 104 | |||
| 105 | export interface GoogleUpdateInput { | ||
| 106 | readonly urls?: readonly string[]; | ||
| 107 | } | ||
| 108 | |||
| 109 | export interface GoogleUpdateResult { | ||
| 110 | readonly slug: IntegrationSlug; | ||
| 111 | readonly toolCount: number; | ||
| 112 | readonly addedTools: readonly string[]; | ||
| 113 | readonly removedTools: readonly string[]; | ||
| 114 | } | ||
| 115 | |||
| 116 | export interface GooglePluginOptions { | ||
| 117 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | ||
| 118 | } | ||
| 119 | |||
| 120 | const DEFAULT_GOOGLE_SLUG = "google"; | ||
| 121 | |||
| 122 | const googlePhotosBundlePresetIdByUrl = new Map( | ||
| 123 | googlePhotosOpenApiPresets.flatMap((preset) => | ||
| 124 | preset.url ? [[normalizeGoogleDiscoveryUrl(preset.url) ?? preset.url, preset.id] as const] : [], | ||
| 125 | ), | ||
| 126 | ); | ||
| 127 | |||
| 128 | const googlePhotosBundleConsentScopes = ( | ||
| 129 | urls: readonly string[], | ||
| 130 | ): readonly string[] | undefined => { | ||
| 131 | const normalized = new Set(urls); | ||
| 132 | const presetIds = [...googlePhotosBundlePresetIdByUrl.entries()].flatMap(([url, presetId]) => | ||
| 133 | normalized.has(url) ? [presetId] : [], | ||
| 134 | ); | ||
| 135 | return presetIds.length > 0 | ||
| 136 | ? presetIds.flatMap((presetId) => googleOAuthConsentScopesForPreset(presetId)) | ||
| 137 | : undefined; | ||
| 138 | }; | ||
| 139 | |||
| 140 | const fetchGoogleBundleConversion = ( | ||
| 141 | urls: readonly string[], | ||
| 142 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | ||
| 143 | ) => | ||
| 144 | Effect.forEach( | ||
| 145 | urls, | ||
| 146 | (url) => | ||
| 147 | fetchGoogleDiscoveryDocument(url).pipe( | ||
| 148 | Effect.provide(httpClientLayer), | ||
| 149 | Effect.map((documentText) => ({ discoveryUrl: url, documentText })), | ||
| 150 | ), | ||
| 151 | { concurrency: 4 }, | ||
| 152 | ).pipe( | ||
| 153 | Effect.flatMap((documents) => { | ||
| 154 | const consentScopes = googlePhotosBundleConsentScopes(urls); | ||
| 155 | return convertGoogleDiscoveryBundleToOpenApi({ | ||
| 156 | documents, | ||
| 157 | ...(consentScopes ? { consentScopes } : {}), | ||
| 158 | }); | ||
| 159 | }), | ||
| 160 | ); | ||
| 161 | |||
| 162 | const uniqueUrls = (urls: readonly string[]): readonly string[] => [ | ||
| 163 | ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])), | ||
| 164 | ]; | ||
| 165 | |||
| 166 | const googleBundleUrlsWithIdentity = ( | ||
| 167 | urls: readonly string[], | ||
| 168 | ): Effect.Effect<readonly string[], OpenApiParseError> => | ||
| 169 | Effect.gen(function* () { | ||
| 170 | const normalized: string[] = []; | ||
| 171 | for (const url of urls) { | ||
| 172 | const discoveryUrl = normalizeGoogleDiscoveryUrl(url); | ||
| 173 | if (!discoveryUrl) { | ||
| 174 | return yield* new OpenApiParseError({ | ||
| 175 | message: | ||
| 176 | "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", | ||
| 177 | }); | ||
| 178 | } | ||
| 179 | normalized.push(discoveryUrl); | ||
| 180 | } | ||
| 181 | return uniqueUrls([...normalized, GOOGLE_OAUTH2_DISCOVERY_URL]); | ||
| 182 | }); | ||
| 183 | |||
| 184 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { | ||
| 185 | const config = decodeGoogleIntegrationConfig(record.config); | ||
| 186 | if (!config) return []; | ||
| 187 | return (config.authenticationTemplate ?? []).map( | ||
| 188 | (template: Authentication): AuthMethodDescriptor => { | ||
| 189 | if (template.kind === "oauth2") { | ||
| 190 | return { | ||
| 191 | id: String(template.slug), | ||
| 192 | label: "OAuth2", | ||
| 193 | kind: "oauth", | ||
| 194 | template: String(template.slug), | ||
| 195 | oauth: { | ||
| 196 | authorizationUrl: template.authorizationUrl, | ||
| 197 | tokenUrl: template.tokenUrl, | ||
| 198 | scopes: template.scopes, | ||
| 199 | }, | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | return describeApiKeyAuthMethod(template); | ||
| 203 | }, | ||
| 204 | ); | ||
| 205 | }; | ||
| 206 | |||
| 207 | const describeGoogleIntegrationDisplay = (record: IntegrationRecord): { readonly url?: string } => { | ||
| 208 | const config = decodeGoogleIntegrationConfig(record.config); | ||
| 209 | return { url: config?.baseUrl ?? config?.googleDiscoveryUrls?.[0] }; | ||
| 210 | }; | ||
| 211 | |||
| 212 | const makeGooglePluginExtension = ( | ||
| 213 | options: GooglePluginOptions | undefined, | ||
| 214 | ctx: PluginCtx<OpenapiStore>, | ||
| 215 | ) => { | ||
| 216 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 217 | |||
| 218 | const addBundle = (config: GoogleBundleConfig) => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | const urls = yield* googleBundleUrlsWithIdentity(config.urls); | ||
| 221 | const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer); | ||
| 222 | const compiled = yield* compileOpenApiSpec(conversion.specText); | ||
| 223 | const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_GOOGLE_SLUG); | ||
| 224 | |||
| 225 | const existing = yield* ctx.core.integrations.get(slug); | ||
| 226 | if (existing) { | ||
| 227 | return yield* new IntegrationAlreadyExistsError({ slug }); | ||
| 228 | } | ||
| 229 | |||
| 230 | const specHash = yield* sha256Hex(conversion.specText); | ||
| 231 | const integrationConfig: GoogleIntegrationConfig = { | ||
| 232 | specHash, | ||
| 233 | googleDiscoveryUrls: urls, | ||
| 234 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), | ||
| 235 | ...(conversion.authenticationTemplate | ||
| 236 | ? { authenticationTemplate: conversion.authenticationTemplate } | ||
| 237 | : {}), | ||
| 238 | }; | ||
| 239 | |||
| 240 | yield* ctx.storage.putSpec(specHash, conversion.specText); | ||
| 241 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | ||
| 242 | |||
| 243 | yield* ctx.transaction( | ||
| 244 | Effect.gen(function* () { | ||
| 245 | yield* ctx.core.integrations.register({ | ||
| 246 | slug, | ||
| 247 | name: config.name?.trim() || "Google", | ||
| 248 | description: config.description ?? "Google APIs", | ||
| 249 | config: integrationConfig satisfies GoogleIntegrationConfig as IntegrationConfig, | ||
| 250 | canRemove: true, | ||
| 251 | canRefresh: true, | ||
| 252 | }); | ||
| 253 | yield* ctx.storage.putOperations( | ||
| 254 | String(slug), | ||
| 255 | openApiStoredOperationsFromCompiled(String(slug), compiled), | ||
| 256 | ); | ||
| 257 | }), | ||
| 258 | ); | ||
| 259 | |||
| 260 | // Default the health check to the light OAuth2 userinfo identity call | ||
| 261 | // added to every new bundle. Older bundles without oauth2/v2 can still | ||
| 262 | // fall back to the People API identity operation. | ||
| 263 | const defaultHealthCheck = defaultGoogleHealthCheck(urls, compiled.definitions); | ||
| 264 | if (defaultHealthCheck) { | ||
| 265 | yield* ctx.core.integrations.setHealthCheck(slug, defaultHealthCheck); | ||
| 266 | } | ||
| 267 | |||
| 268 | return { slug, toolCount: compiled.definitions.length }; | ||
| 269 | }); | ||
| 270 | |||
| 271 | const updateBundle = (rawSlug: string, input?: GoogleUpdateInput) => | ||
| 272 | Effect.gen(function* () { | ||
| 273 | const slug = IntegrationSlug.make(rawSlug); | ||
| 274 | const record = yield* ctx.core.integrations.get(slug); | ||
| 275 | const current = record ? decodeGoogleIntegrationConfig(record.config) : null; | ||
| 276 | if (!record || !current) { | ||
| 277 | return yield* new IntegrationNotFoundError({ slug }); | ||
| 278 | } | ||
| 279 | |||
| 280 | const urls = yield* googleBundleUrlsWithIdentity( | ||
| 281 | input?.urls ?? current.googleDiscoveryUrls ?? [], | ||
| 282 | ); | ||
| 283 | const conversion = yield* fetchGoogleBundleConversion(urls, httpClientLayer); | ||
| 284 | const compiled = yield* compileOpenApiSpec(conversion.specText); | ||
| 285 | |||
| 286 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); | ||
| 287 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); | ||
| 288 | const nextNames = new Set(compiled.definitions.map((def) => def.toolPath)); | ||
| 289 | |||
| 290 | const specHash = yield* sha256Hex(conversion.specText); | ||
| 291 | yield* ctx.storage.putSpec(specHash, conversion.specText); | ||
| 292 | yield* ctx.storage.putDefs(specHash, JSON.stringify(compiled.hoistedDefs)); | ||
| 293 | |||
| 294 | const nextConfig: GoogleIntegrationConfig = { | ||
| 295 | ...current, | ||
| 296 | specHash, | ||
| 297 | googleDiscoveryUrls: urls, | ||
| 298 | }; | ||
| 299 | |||
| 300 | yield* ctx.transaction( | ||
| 301 | Effect.gen(function* () { | ||
| 302 | yield* ctx.core.integrations.update(slug, { | ||
| 303 | config: nextConfig satisfies GoogleIntegrationConfig as IntegrationConfig, | ||
| 304 | }); | ||
| 305 | yield* ctx.storage.putOperations( | ||
| 306 | rawSlug, | ||
| 307 | openApiStoredOperationsFromCompiled(rawSlug, compiled), | ||
| 308 | ); | ||
| 309 | }), | ||
| 310 | ); | ||
| 311 | |||
| 312 | const connections = yield* ctx.connections.list({ integration: slug }); | ||
| 313 | yield* Effect.forEach( | ||
| 314 | connections, | ||
| 315 | (connection) => | ||
| 316 | ctx.connections | ||
| 317 | .refresh({ | ||
| 318 | owner: connection.owner, | ||
| 319 | integration: connection.integration, | ||
| 320 | name: connection.name, | ||
| 321 | }) | ||
| 322 | .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))), | ||
| 323 | { discard: true }, | ||
| 324 | ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void)); | ||
| 325 | |||
| 326 | return { | ||
| 327 | slug, | ||
| 328 | toolCount: compiled.definitions.length, | ||
| 329 | addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), | ||
| 330 | removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), | ||
| 331 | }; | ||
| 332 | }); | ||
| 333 | |||
| 334 | return { | ||
| 335 | addBundle, | ||
| 336 | updateBundle, | ||
| 337 | removeBundle: (slug: string) => | ||
| 338 | ctx.transaction( | ||
| 339 | Effect.gen(function* () { | ||
| 340 | yield* ctx.storage.removeOperations(slug); | ||
| 341 | yield* ctx.core.integrations | ||
| 342 | .remove(IntegrationSlug.make(slug)) | ||
| 343 | .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); | ||
| 344 | }), | ||
| 345 | ), | ||
| 346 | getIntegration: (slug: string) => | ||
| 347 | ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe( | ||
| 348 | Effect.map((record) => | ||
| 349 | record | ||
| 350 | ? ({ | ||
| 351 | slug: record.slug, | ||
| 352 | description: record.description, | ||
| 353 | kind: record.kind, | ||
| 354 | canRemove: record.canRemove, | ||
| 355 | canRefresh: record.canRefresh, | ||
| 356 | } as Integration) | ||
| 357 | : null, | ||
| 358 | ), | ||
| 359 | ), | ||
| 360 | getConfig: (slug: string) => | ||
| 361 | ctx.core.integrations | ||
| 362 | .get(IntegrationSlug.make(slug)) | ||
| 363 | .pipe( | ||
| 364 | Effect.map((record) => (record ? decodeGoogleIntegrationConfig(record.config) : null)), | ||
| 365 | ), | ||
| 366 | configure: (slug: string, input: GoogleConfigureInput) => | ||
| 367 | ctx.transaction( | ||
| 368 | Effect.gen(function* () { | ||
| 369 | const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); | ||
| 370 | if (!record) return [] as readonly Authentication[]; | ||
| 371 | const current = decodeGoogleIntegrationConfig(record.config); | ||
| 372 | if (!current) return [] as readonly Authentication[]; | ||
| 373 | |||
| 374 | const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); | ||
| 375 | const merged = | ||
| 376 | input.mode === "replace" | ||
| 377 | ? incoming | ||
| 378 | : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); | ||
| 379 | |||
| 380 | const next: GoogleIntegrationConfig = { | ||
| 381 | ...current, | ||
| 382 | authenticationTemplate: merged, | ||
| 383 | }; | ||
| 384 | |||
| 385 | yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { | ||
| 386 | config: next satisfies GoogleIntegrationConfig as IntegrationConfig, | ||
| 387 | }); | ||
| 388 | |||
| 389 | return merged; | ||
| 390 | }), | ||
| 391 | ), | ||
| 392 | }; | ||
| 393 | }; | ||
| 394 | |||
| 395 | export type GooglePluginExtension = ReturnType<typeof makeGooglePluginExtension>; | ||
| 396 | |||
| 397 | export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ | ||
| 398 | id: "google" as const, | ||
| 399 | packageName: "@executor-js/plugin-google", | ||
| 400 | integrationPresets: [googleOpenApiBundlePreset, googlePhotosOpenApiBundlePreset], | ||
| 401 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), | ||
| 402 | |||
| 403 | extension: (ctx: PluginCtx<OpenapiStore>) => makeGooglePluginExtension(options, ctx), | ||
| 404 | |||
| 405 | describeAuthMethods: describeGoogleAuthMethods, | ||
| 406 | describeIntegrationDisplay: describeGoogleIntegrationDisplay, | ||
| 407 | |||
| 408 | resolveTools: ({ integration, config, storage }) => | ||
| 409 | resolveOpenApiBackedTools({ integration, config, storage }), | ||
| 410 | |||
| 411 | invokeTool: ({ ctx, toolRow, credential, args }) => { | ||
| 412 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 413 | return invokeOpenApiBackedTool({ | ||
| 414 | ctx, | ||
| 415 | toolRow, | ||
| 416 | credential, | ||
| 417 | args, | ||
| 418 | httpClientLayer, | ||
| 419 | }); | ||
| 420 | }, | ||
| 421 | |||
| 422 | resolveAnnotations: ({ ctx, integration, toolRows }) => | ||
| 423 | resolveOpenApiBackedAnnotations({ | ||
| 424 | ctx, | ||
| 425 | integration: String(integration), | ||
| 426 | toolRows, | ||
| 427 | }), | ||
| 428 | |||
| 429 | // Health checks reuse the OpenAPI backing (same store). The People API | ||
| 430 | // identity call is auto-defaulted at addBundle when present; core owns the | ||
| 431 | // stored spec, the user adjusts it via the editor. | ||
| 432 | listHealthCheckCandidates: (input) => | ||
| 433 | listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }), | ||
| 434 | checkHealth: (input) => | ||
| 435 | checkHealthOpenApi({ | ||
| 436 | ctx: input.ctx, | ||
| 437 | integration: input.integration, | ||
| 438 | credential: input.credential, | ||
| 439 | spec: input.spec, | ||
| 440 | httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer, | ||
| 441 | }), | ||
| 442 | |||
| 443 | removeConnection: () => Effect.void, | ||
| 444 | |||
| 445 | detect: ({ ctx, url }) => | ||
| 446 | Effect.gen(function* () { | ||
| 447 | const trimmed = url.trim(); | ||
| 448 | const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed); | ||
| 449 | if (!trimmed || !discoveryUrl) return null; | ||
| 450 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 451 | const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe( | ||
| 452 | Effect.provide(httpClientLayer), | ||
| 453 | Effect.flatMap((documentText) => | ||
| 454 | convertGoogleDiscoveryBundleToOpenApi({ | ||
| 455 | documents: [{ discoveryUrl, documentText }], | ||
| 456 | }), | ||
| 457 | ), | ||
| 458 | Effect.catch(() => Effect.succeed(null)), | ||
| 459 | ); | ||
| 460 | if (!conversion) return null; | ||
| 461 | return IntegrationDetectionResult.make({ | ||
| 462 | kind: "google", | ||
| 463 | confidence: "high", | ||
| 464 | endpoint: discoveryUrl, | ||
| 465 | name: conversion.title, | ||
| 466 | slug: DEFAULT_GOOGLE_SLUG, | ||
| 467 | }); | ||
| 468 | }), | ||
| 469 | })); | ||
| @@ -1,6 +1,30 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { expect, it } from "@effect/vitest"; |
| 2 | 2 | ||
| 3 | import { googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets"; | 3 | import { googleCatalog, googleOpenApiPresets, googleStandardUserOAuthPresets } from "./presets"; |
| 4 | |||
| 5 | const FROZEN_GOOGLE_SLUGS = [ | ||
| 6 | "google_calendar", | ||
| 7 | "google_gmail", | ||
| 8 | "google_sheets", | ||
| 9 | "google_drive", | ||
| 10 | "google_docs", | ||
| 11 | "google_slides", | ||
| 12 | "google_forms", | ||
| 13 | "google_tasks", | ||
| 14 | "google_people", | ||
| 15 | "google_photos_library", | ||
| 16 | "google_photos_picker", | ||
| 17 | "google_chat", | ||
| 18 | "google_keep", | ||
| 19 | "google_youtube_data", | ||
| 20 | "google_search_console", | ||
| 21 | "google_classroom", | ||
| 22 | "google_admin_directory", | ||
| 23 | "google_admin_reports", | ||
| 24 | "google_apps_script", | ||
| 25 | "google_bigquery", | ||
| 26 | "google_cloud_resource_manager", | ||
| 27 | ] as const; | ||
| 4 | 28 | ||
| 5 | it("keeps Select all limited to Google services that can use normal user OAuth", () => { | 29 | it("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 | |||
| 55 | it("exports one catalog row per Google service with stable slugs and per-service OAuth", () => { | ||
| 56 | expect(googleCatalog.map((preset) => preset.defaultSlug)).toEqual([...FROZEN_GOOGLE_SLUGS]); | ||
| 57 | for (const preset of googleCatalog) { | ||
| 58 | expect(preset.family).toBe("google"); | ||
| 59 | expect(preset.specFormat).toBe("google-discovery"); | ||
| 60 | expect(preset.defaultSlug).toBeTruthy(); | ||
| 61 | expect(preset.authTemplate).toHaveLength(1); | ||
| 62 | const oauthTemplates = (preset.authTemplate ?? []).filter( | ||
| 63 | (template) => template.kind === "oauth2", | ||
| 64 | ); | ||
| 65 | expect(oauthTemplates).toHaveLength(1); | ||
| 66 | expect(oauthTemplates[0]?.scopes).toEqual( | ||
| 67 | expect.arrayContaining(["openid", "email", "profile"]), | ||
| 68 | ); | ||
| 69 | } | ||
| 70 | }); | ||
| @@ -1,4 +1,6 @@ | |||
| 1 | import { normalizeGoogleDiscoveryUrl } from "./discovery"; | 1 | import { normalizeGoogleDiscoveryUrl } from "./discovery"; |
| 2 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; | ||
| 3 | import type { IntegrationPreset } from "@executor-js/sdk/core"; | ||
| 2 | 4 | ||
| 3 | export interface GooglePreset { | 5 | export 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 connecting | 240 | // 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 in | 241 | // (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 at | 242 | // each API's live Discovery document, which the add flow fetches lazily at |
| 241 | // `addBundle` time. To preview consent without N network round-trips, each preset | 243 | // provider add time. To preview consent without N network round-trips, each preset |
| 242 | // declares the broad top-level scope(s) a full integration grants. These flow | 244 | // declares the broad top-level scope(s) a full integration grants. These flow |
| 243 | // through `googleOAuthConsentBatches` (which compacts sub-scopes under their | 245 | // through `googleOAuthConsentBatches` (which compacts sub-scopes under their |
| 244 | // broad parent), so the previewed grant matches the unioned scopes the bundle | 246 | // 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 | |||
| 276 | export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => | 278 | export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => |
| 277 | googleOAuthConsentScopes[presetId] ?? []; | 279 | googleOAuthConsentScopes[presetId] ?? []; |
| 278 | 280 | ||
| 281 | export const googleServiceSlug = (presetId: string): string => presetId.replaceAll("-", "_"); | ||
| 282 | |||
| 283 | const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; | ||
| 284 | const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; | ||
| 285 | const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; | ||
| 286 | const GOOGLE_IDENTITY_SCOPES: readonly string[] = ["openid", "email", "profile"]; | ||
| 287 | const GOOGLE_USERINFO_HEALTH_CHECK = { | ||
| 288 | operation: "oauth2.userinfo.get", | ||
| 289 | identityField: "email", | ||
| 290 | } as const; | ||
| 291 | |||
| 292 | const googleCatalogAuthTemplate = (presetId: string) => [ | ||
| 293 | { | ||
| 294 | slug: GOOGLE_OAUTH_SECURITY_SCHEME, | ||
| 295 | kind: "oauth2" as const, | ||
| 296 | authorizationUrl: GOOGLE_OAUTH_AUTHORIZATION_URL, | ||
| 297 | tokenUrl: GOOGLE_OAUTH_TOKEN_URL, | ||
| 298 | scopes: compactGoogleOAuthScopes([ | ||
| 299 | ...GOOGLE_IDENTITY_SCOPES, | ||
| 300 | ...googleOAuthConsentScopesForPreset(presetId), | ||
| 301 | ]), | ||
| 302 | }, | ||
| 303 | ]; | ||
| 304 | |||
| 305 | export const googleCatalog: readonly IntegrationPreset[] = googleOpenApiPresets.map((preset) => ({ | ||
| 306 | id: preset.id, | ||
| 307 | name: preset.name, | ||
| 308 | summary: preset.summary, | ||
| 309 | ...(preset.url ? { url: preset.url } : {}), | ||
| 310 | ...(preset.icon ? { icon: preset.icon } : {}), | ||
| 311 | ...(preset.featured ? { featured: preset.featured } : {}), | ||
| 312 | family: "google", | ||
| 313 | specFormat: "google-discovery", | ||
| 314 | defaultSlug: googleServiceSlug(preset.id), | ||
| 315 | authTemplate: googleCatalogAuthTemplate(preset.id), | ||
| 316 | healthCheck: GOOGLE_USERINFO_HEALTH_CHECK, | ||
| 317 | })); | ||
| 318 | |||
| 279 | // --------------------------------------------------------------------------- | 319 | // --------------------------------------------------------------------------- |
| 280 | // Resolve a stored/normalized Discovery URL back to its preset, so a bundled | 320 | // 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. a | 321 | // `google` integration can surface each selected API's `oauthAudience` (e.g. a |
| @@ -1,122 +0,0 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // GoogleProductPicker "View scopes" data source. | ||
| 3 | // | ||
| 4 | // The picker previews the OAuth consent BEFORE connecting by feeding each | ||
| 5 | // selected preset's representative scopes through `googleOAuthConsentBatches`. | ||
| 6 | // These tests pin that bridge: the consent-scope map is complete and the | ||
| 7 | // previewed batches match what the user is about to grant (core services | ||
| 8 | // collapsed into one batch; admin/cloud services split out). | ||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | |||
| 11 | import { expect, it } from "@effect/vitest"; | ||
| 12 | |||
| 13 | import { | ||
| 14 | googleAudienceWarningsForUrls, | ||
| 15 | googleOAuthConsentScopes, | ||
| 16 | googleOAuthConsentScopesForPreset, | ||
| 17 | googleOpenApiPresets, | ||
| 18 | googlePresetForDiscoveryUrl, | ||
| 19 | type GoogleOpenApiPreset, | ||
| 20 | } from "./presets"; | ||
| 21 | import { googleOAuthConsentBatches } from "./oauth-batches"; | ||
| 22 | import { compactGoogleOAuthScopes } from "./oauth-scopes"; | ||
| 23 | |||
| 24 | const batchInputsFor = (presetIds: readonly string[]) => | ||
| 25 | googleOpenApiPresets | ||
| 26 | .filter((preset: GoogleOpenApiPreset) => presetIds.includes(preset.id)) | ||
| 27 | .map((preset: GoogleOpenApiPreset) => ({ | ||
| 28 | id: preset.id, | ||
| 29 | name: preset.name, | ||
| 30 | oauthAudience: preset.oauthAudience, | ||
| 31 | scopes: googleOAuthConsentScopesForPreset(preset.id), | ||
| 32 | })); | ||
| 33 | |||
| 34 | it("declares representative consent scopes for every Google preset", () => { | ||
| 35 | for (const preset of googleOpenApiPresets) { | ||
| 36 | expect(googleOAuthConsentScopesForPreset(preset.id).length).toBeGreaterThan(0); | ||
| 37 | } | ||
| 38 | // No stray keys for presets that no longer exist. | ||
| 39 | const presetIds = new Set(googleOpenApiPresets.map((preset) => preset.id)); | ||
| 40 | for (const key of Object.keys(googleOAuthConsentScopes)) { | ||
| 41 | expect(presetIds.has(key)).toBe(true); | ||
| 42 | } | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("previews the featured selection as a single core-services consent batch", () => { | ||
| 46 | const featuredIds = googleOpenApiPresets | ||
| 47 | .filter((preset: GoogleOpenApiPreset) => preset.featured) | ||
| 48 | .map((preset: GoogleOpenApiPreset) => preset.id); | ||
| 49 | |||
| 50 | const batches = googleOAuthConsentBatches(batchInputsFor(featuredIds)); | ||
| 51 | |||
| 52 | // The featured presets are all standard-user, so they collapse into one batch. | ||
| 53 | expect(batches.map((batch) => batch.id)).toEqual(["google-core"]); | ||
| 54 | expect(batches[0]?.apiScopes).toEqual([ | ||
| 55 | "https://www.googleapis.com/auth/calendar", | ||
| 56 | "https://mail.google.com/", | ||
| 57 | "https://www.googleapis.com/auth/spreadsheets", | ||
| 58 | "https://www.googleapis.com/auth/drive", | ||
| 59 | "https://www.googleapis.com/auth/documents", | ||
| 60 | ]); | ||
| 61 | }); | ||
| 62 | |||
| 63 | it("maps stored Discovery URLs back to presets and flags caution-tier audiences", () => { | ||
| 64 | // A normalized stored URL (trailing slash, sorted query) still resolves. | ||
| 65 | expect( | ||
| 66 | googlePresetForDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest/") | ||
| 67 | ?.id, | ||
| 68 | ).toBe("google-calendar"); | ||
| 69 | |||
| 70 | // A bundle of only standard/advanced APIs raises no audience warning. | ||
| 71 | expect( | ||
| 72 | googleAudienceWarningsForUrls([ | ||
| 73 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 74 | "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest", | ||
| 75 | ]), | ||
| 76 | ).toEqual([]); | ||
| 77 | |||
| 78 | // Admin-only (Chat) and unsupported-consent (Keep) APIs both flag. | ||
| 79 | expect( | ||
| 80 | [ | ||
| 81 | ...googleAudienceWarningsForUrls([ | ||
| 82 | "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | ||
| 83 | "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest", | ||
| 84 | "https://keep.googleapis.com/$discovery/rest?version=v1", | ||
| 85 | ]), | ||
| 86 | ].sort(), | ||
| 87 | ).toEqual(["unsupported-user", "workspace-admin"]); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("previews a consent set that matches what the bundle persists (compaction parity)", () => { | ||
| 91 | // The bundle converter runs the unioned Discovery scopes through | ||
| 92 | // `compactGoogleOAuthScopes` before persisting the auth template. The picker | ||
| 93 | // preview must agree: feeding every preview batch's scopes back through the | ||
| 94 | // same compaction is idempotent (already collapsed/filtered), so the previewed | ||
| 95 | // grant is exactly the persisted/requested grant. | ||
| 96 | const allPresetIds = googleOpenApiPresets.map((preset: GoogleOpenApiPreset) => preset.id); | ||
| 97 | const batches = googleOAuthConsentBatches(batchInputsFor(allPresetIds)); | ||
| 98 | for (const batch of batches) { | ||
| 99 | expect(compactGoogleOAuthScopes(batch.apiScopes)).toEqual([...batch.apiScopes]); | ||
| 100 | } | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("splits a mixed selection into core + per-service consent batches", () => { | ||
| 104 | const batches = googleOAuthConsentBatches( | ||
| 105 | batchInputsFor(["google-calendar", "google-youtube-data", "google-bigquery"]), | ||
| 106 | ); | ||
| 107 | |||
| 108 | expect(batches.map((batch) => ({ id: batch.id, apiScopes: batch.apiScopes }))).toEqual([ | ||
| 109 | { | ||
| 110 | id: "google-core", | ||
| 111 | apiScopes: ["https://www.googleapis.com/auth/calendar"], | ||
| 112 | }, | ||
| 113 | { | ||
| 114 | id: "google-youtube-data", | ||
| 115 | apiScopes: ["https://www.googleapis.com/auth/youtube"], | ||
| 116 | }, | ||
| 117 | { | ||
| 118 | id: "google-cloud", | ||
| 119 | apiScopes: ["https://www.googleapis.com/auth/bigquery"], | ||
| 120 | }, | ||
| 121 | ]); | ||
| 122 | }); | ||
| @@ -4,8 +4,10 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab · 3 unmodified lines | |||
| 4 | import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; | 4 | import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; |
| 5 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | 5 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 6 | import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; | 6 | import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; |
| 7 | import type { AuthenticationInput } from "@executor-js/plugin-openapi"; | ||
| 7 | 8 | ||
| 8 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; | 9 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| 10 | import { googleCatalog } from "./presets"; | ||
| 9 | 11 | ||
| 10 | const TASKS_URL = "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest"; | 12 | const 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 | |||
| 121 | it.effect( | ||
| 122 | "adds a Google catalog preset through OpenAPI with family, format, and default slug", | ||
| 123 | () => | ||
| 124 | Effect.gen(function* () { | ||
| 125 | const tasksPreset = googleCatalog.find((preset) => preset.defaultSlug === "google_tasks")!; | ||
| 126 | const authTemplate: readonly AuthenticationInput[] = (tasksPreset.authTemplate ?? []).flatMap( | ||
| 127 | (template) => (template.kind === "oauth2" ? [template] : []), | ||
| 128 | ); | ||
| 129 | const executor = yield* createExecutor( | ||
| 130 | makeTestConfig({ | ||
| 131 | plugins: [ | ||
| 132 | openApiPlugin({ | ||
| 133 | httpClientLayer: discoveryHttpClientLayer, | ||
| 134 | presets: [tasksPreset], | ||
| 135 | specFormats: [googleDiscoveryAdapter], | ||
| 136 | }), | ||
| 137 | memoryCredentialsPlugin(), | ||
| 138 | ], | ||
| 139 | }), | ||
| 140 | ); | ||
| 141 | |||
| 142 | const added = yield* executor.openapi.addSpec({ | ||
| 143 | spec: { kind: "url", url: tasksPreset.url! }, | ||
| 144 | slug: tasksPreset.defaultSlug, | ||
| 145 | specFormat: tasksPreset.specFormat, | ||
| 146 | family: tasksPreset.family, | ||
| 147 | authenticationTemplate: authTemplate, | ||
| 148 | }); | ||
| 149 | const config = yield* executor.openapi.getConfig("google_tasks"); | ||
| 150 | |||
| 151 | expect(String(added.slug)).toBe("google_tasks"); | ||
| 152 | expect(config?.family).toBe("google"); | ||
| 153 | expect(config?.specFormat).toBe("google-discovery"); | ||
| 154 | expect(config?.authenticationTemplate?.[0]?.kind).toBe("oauth2"); | ||
| 155 | const storedOAuthTemplates = (config?.authenticationTemplate ?? []).filter( | ||
| 156 | (template) => template.kind === "oauth2", | ||
| 157 | ); | ||
| 158 | expect(storedOAuthTemplates[0]?.scopes).toEqual( | ||
| 159 | expect.arrayContaining([ | ||
| 160 | "openid", | ||
| 161 | "email", | ||
| 162 | "profile", | ||
| 163 | "https://www.googleapis.com/auth/tasks", | ||
| 164 | ]), | ||
| 165 | ); | ||
| 166 | }), | ||
| 167 | ); | ||
| @@ -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, |
| @@ -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 | ||
| 26 | import { addGraphqlIntegrationOptimistic } from "./atoms"; | 26 | import { createGraphqlIntegrationOptimistic } from "./atoms"; |
| 27 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; | 27 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 28 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; | 28 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; |
| 29 | import type { GraphqlAuthMethodInput } from "../sdk/types"; | 29 | import 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 | ||
| @@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines | |||
| 33 | // Mutation atoms | 33 | // Mutation atoms |
| 34 | // --------------------------------------------------------------------------- | 34 | // --------------------------------------------------------------------------- |
| 35 | 35 | ||
| 36 | export const addGraphqlIntegration = GraphqlClient.mutation("graphql", "addIntegration"); | 36 | export 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`. |
| 39 | export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); | 39 | export 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 the | 41 | // Optimistically slot a pending row into the shared integration catalog so the |
| 42 | // integrations list reflects the new GraphQL integration before the server | 42 | // 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. |
| 44 | export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( | 44 | export 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 | ); |
| @@ -1,3 +1,3 @@ | |||
| 1 | export { graphqlIntegrationPlugin } from "./source-plugin"; | 1 | export { graphqlIntegrationPlugin } from "./source-plugin"; |
| 2 | export { GraphqlClient } from "./client"; | 2 | export { GraphqlClient } from "./client"; |
| 3 | export { addGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms"; | 3 | export { createGraphqlIntegration, graphqlIntegrationConfigAtom } from "./atoms"; |
| @@ -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:", |
| @@ -1,163 +0,0 @@ | |||
| 1 | import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | ||
| 4 | import { | ||
| 5 | InternalError, | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationNotFoundError, | ||
| 8 | IntegrationSlug, | ||
| 9 | } from "@executor-js/sdk/shared"; | ||
| 10 | import { | ||
| 11 | OpenApiExtractionError, | ||
| 12 | OpenApiOAuthError, | ||
| 13 | OpenApiParseError, | ||
| 14 | } from "@executor-js/plugin-openapi"; | ||
| 15 | |||
| 16 | const DomainErrors = [ | ||
| 17 | InternalError, | ||
| 18 | OpenApiParseError, | ||
| 19 | OpenApiExtractionError, | ||
| 20 | OpenApiOAuthError, | ||
| 21 | IntegrationAlreadyExistsError, | ||
| 22 | ] as const; | ||
| 23 | |||
| 24 | const IntegrationNotFound = IntegrationNotFoundError.annotate({ httpApiStatus: 404 }); | ||
| 25 | |||
| 26 | const UpdateErrors = [ | ||
| 27 | InternalError, | ||
| 28 | OpenApiParseError, | ||
| 29 | OpenApiExtractionError, | ||
| 30 | OpenApiOAuthError, | ||
| 31 | IntegrationNotFound, | ||
| 32 | ] as const; | ||
| 33 | |||
| 34 | const SlugParams = { | ||
| 35 | slug: Schema.String, | ||
| 36 | }; | ||
| 37 | |||
| 38 | const OAuthTemplatePayload = Schema.Struct({ | ||
| 39 | slug: Schema.String, | ||
| 40 | kind: Schema.Literal("oauth2"), | ||
| 41 | authorizationUrl: Schema.String, | ||
| 42 | tokenUrl: Schema.String, | ||
| 43 | scopes: Schema.Array(Schema.String), | ||
| 44 | }); | ||
| 45 | |||
| 46 | const AuthenticationPayload = Schema.Union([OAuthTemplatePayload, ApiKeyAuthTemplate]); | ||
| 47 | const AuthenticationResponse = Schema.Union([OAuthTemplatePayload, ApiKeyAuthMethod]); | ||
| 48 | |||
| 49 | const AddGraphPayload = Schema.Struct({ | ||
| 50 | presetIds: Schema.Array(Schema.String), | ||
| 51 | customScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 52 | slug: Schema.optional(Schema.String), | ||
| 53 | name: Schema.optional(Schema.String), | ||
| 54 | description: Schema.optional(Schema.String), | ||
| 55 | baseUrl: Schema.optional(Schema.String), | ||
| 56 | specUrl: Schema.optional(Schema.String), | ||
| 57 | authorizationUrl: Schema.optional(Schema.String), | ||
| 58 | tokenUrl: Schema.optional(Schema.String), | ||
| 59 | clientCredentialsTokenUrl: Schema.optional(Schema.String), | ||
| 60 | }); | ||
| 61 | |||
| 62 | const AddGraphResponse = Schema.Struct({ | ||
| 63 | slug: IntegrationSlug, | ||
| 64 | toolCount: Schema.Number, | ||
| 65 | }); | ||
| 66 | |||
| 67 | const UpdateGraphPayload = Schema.Struct({ | ||
| 68 | presetIds: Schema.optional(Schema.Array(Schema.String)), | ||
| 69 | customScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 70 | baseUrl: Schema.optional(Schema.String), | ||
| 71 | specUrl: Schema.optional(Schema.String), | ||
| 72 | authorizationUrl: Schema.optional(Schema.String), | ||
| 73 | tokenUrl: Schema.optional(Schema.String), | ||
| 74 | clientCredentialsTokenUrl: Schema.optional(Schema.String), | ||
| 75 | }); | ||
| 76 | |||
| 77 | const UpdateGraphResponse = Schema.Struct({ | ||
| 78 | slug: IntegrationSlug, | ||
| 79 | toolCount: Schema.Number, | ||
| 80 | addedTools: Schema.Array(Schema.String), | ||
| 81 | removedTools: Schema.Array(Schema.String), | ||
| 82 | }); | ||
| 83 | |||
| 84 | const IntegrationView = Schema.Struct({ | ||
| 85 | slug: IntegrationSlug, | ||
| 86 | description: Schema.String, | ||
| 87 | kind: Schema.String, | ||
| 88 | canRemove: Schema.Boolean, | ||
| 89 | canRefresh: Schema.Boolean, | ||
| 90 | }); | ||
| 91 | |||
| 92 | const MicrosoftConfigView = Schema.Struct({ | ||
| 93 | sourceUrl: Schema.optional(Schema.String), | ||
| 94 | microsoftGraphPresetIds: Schema.optional(Schema.Array(Schema.String)), | ||
| 95 | microsoftGraphCustomScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 96 | microsoftGraphScopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 97 | microsoftGraphExactPaths: Schema.optional(Schema.Array(Schema.String)), | ||
| 98 | microsoftGraphPathPrefixes: Schema.optional(Schema.Array(Schema.String)), | ||
| 99 | microsoftGraphTagPrefixes: Schema.optional(Schema.Array(Schema.String)), | ||
| 100 | microsoftGraphCoversFullGraph: Schema.optional(Schema.Boolean), | ||
| 101 | microsoftGraphAuthorizationUrl: Schema.optional(Schema.String), | ||
| 102 | microsoftGraphTokenUrl: Schema.optional(Schema.String), | ||
| 103 | microsoftGraphClientCredentialsTokenUrl: Schema.optional(Schema.String), | ||
| 104 | baseUrl: Schema.optional(Schema.String), | ||
| 105 | headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 106 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), | ||
| 107 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationResponse)), | ||
| 108 | }); | ||
| 109 | |||
| 110 | const ConfigurePayload = Schema.Struct({ | ||
| 111 | authenticationTemplate: Schema.Array(AuthenticationPayload), | ||
| 112 | mode: Schema.optional(Schema.Literals(["merge", "replace"])), | ||
| 113 | }); | ||
| 114 | |||
| 115 | const ConfigureResponse = Schema.Struct({ | ||
| 116 | authenticationTemplate: Schema.Array(AuthenticationResponse), | ||
| 117 | }); | ||
| 118 | |||
| 119 | export const MicrosoftGroup = HttpApiGroup.make("microsoft") | ||
| 120 | .add( | ||
| 121 | HttpApiEndpoint.post("addGraph", "/microsoft/graph", { | ||
| 122 | payload: AddGraphPayload, | ||
| 123 | success: AddGraphResponse, | ||
| 124 | error: DomainErrors, | ||
| 125 | }), | ||
| 126 | ) | ||
| 127 | .add( | ||
| 128 | HttpApiEndpoint.get("getIntegration", "/microsoft/integrations/:slug", { | ||
| 129 | params: SlugParams, | ||
| 130 | success: Schema.NullOr(IntegrationView), | ||
| 131 | error: DomainErrors, | ||
| 132 | }), | ||
| 133 | ) | ||
| 134 | .add( | ||
| 135 | HttpApiEndpoint.get("getConfig", "/microsoft/integrations/:slug/config", { | ||
| 136 | params: SlugParams, | ||
| 137 | success: Schema.NullOr(MicrosoftConfigView), | ||
| 138 | error: DomainErrors, | ||
| 139 | }), | ||
| 140 | ) | ||
| 141 | .add( | ||
| 142 | HttpApiEndpoint.post("configure", "/microsoft/integrations/:slug/config", { | ||
| 143 | params: SlugParams, | ||
| 144 | payload: ConfigurePayload, | ||
| 145 | success: ConfigureResponse, | ||
| 146 | error: DomainErrors, | ||
| 147 | }), | ||
| 148 | ) | ||
| 149 | .add( | ||
| 150 | HttpApiEndpoint.post("updateGraph", "/microsoft/integrations/:slug/graph", { | ||
| 151 | params: SlugParams, | ||
| 152 | payload: UpdateGraphPayload, | ||
| 153 | success: UpdateGraphResponse, | ||
| 154 | error: UpdateErrors, | ||
| 155 | }), | ||
| 156 | ) | ||
| 157 | .add( | ||
| 158 | HttpApiEndpoint.delete("removeGraph", "/microsoft/integrations/:slug", { | ||
| 159 | params: SlugParams, | ||
| 160 | success: Schema.Void, | ||
| 161 | error: DomainErrors, | ||
| 162 | }), | ||
| 163 | ); | ||
| @@ -1,144 +0,0 @@ | |||
| 1 | import { HttpApiBuilder } from "effect/unstable/httpapi"; | ||
| 2 | import { Context, Effect } from "effect"; | ||
| 3 | |||
| 4 | import { addGroup, capture } from "@executor-js/api"; | ||
| 5 | import type { MicrosoftPluginExtension } from "../sdk/plugin"; | ||
| 6 | import { MicrosoftGroup } from "./group"; | ||
| 7 | |||
| 8 | export class MicrosoftExtensionService extends Context.Service< | ||
| 9 | MicrosoftExtensionService, | ||
| 10 | MicrosoftPluginExtension | ||
| 11 | >()("MicrosoftExtensionService") {} | ||
| 12 | |||
| 13 | const ExecutorApiWithMicrosoft = addGroup(MicrosoftGroup); | ||
| 14 | |||
| 15 | export const MicrosoftHandlers = HttpApiBuilder.group( | ||
| 16 | ExecutorApiWithMicrosoft, | ||
| 17 | "microsoft", | ||
| 18 | (handlers) => | ||
| 19 | handlers | ||
| 20 | .handle("addGraph", ({ payload }) => | ||
| 21 | capture( | ||
| 22 | Effect.gen(function* () { | ||
| 23 | const ext = yield* MicrosoftExtensionService; | ||
| 24 | return yield* ext.addGraph({ | ||
| 25 | presetIds: payload.presetIds, | ||
| 26 | customScopes: payload.customScopes, | ||
| 27 | slug: payload.slug, | ||
| 28 | name: payload.name, | ||
| 29 | description: payload.description, | ||
| 30 | baseUrl: payload.baseUrl, | ||
| 31 | specUrl: payload.specUrl, | ||
| 32 | authorizationUrl: payload.authorizationUrl, | ||
| 33 | tokenUrl: payload.tokenUrl, | ||
| 34 | clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl, | ||
| 35 | }); | ||
| 36 | }), | ||
| 37 | ), | ||
| 38 | ) | ||
| 39 | .handle("getIntegration", ({ params }) => | ||
| 40 | capture( | ||
| 41 | Effect.gen(function* () { | ||
| 42 | const ext = yield* MicrosoftExtensionService; | ||
| 43 | const integration = yield* ext.getIntegration(params.slug); | ||
| 44 | return integration | ||
| 45 | ? { | ||
| 46 | slug: integration.slug, | ||
| 47 | description: integration.description, | ||
| 48 | kind: integration.kind, | ||
| 49 | canRemove: integration.canRemove, | ||
| 50 | canRefresh: integration.canRefresh, | ||
| 51 | } | ||
| 52 | : null; | ||
| 53 | }), | ||
| 54 | ), | ||
| 55 | ) | ||
| 56 | .handle("getConfig", ({ params }) => | ||
| 57 | capture( | ||
| 58 | Effect.gen(function* () { | ||
| 59 | const ext = yield* MicrosoftExtensionService; | ||
| 60 | const config = yield* ext.getConfig(params.slug); | ||
| 61 | return config | ||
| 62 | ? { | ||
| 63 | sourceUrl: config.sourceUrl, | ||
| 64 | microsoftGraphPresetIds: config.microsoftGraphPresetIds | ||
| 65 | ? [...config.microsoftGraphPresetIds] | ||
| 66 | : undefined, | ||
| 67 | microsoftGraphCustomScopes: config.microsoftGraphCustomScopes | ||
| 68 | ? [...config.microsoftGraphCustomScopes] | ||
| 69 | : undefined, | ||
| 70 | microsoftGraphScopes: config.microsoftGraphScopes | ||
| 71 | ? [...config.microsoftGraphScopes] | ||
| 72 | : undefined, | ||
| 73 | microsoftGraphExactPaths: config.microsoftGraphExactPaths | ||
| 74 | ? [...config.microsoftGraphExactPaths] | ||
| 75 | : undefined, | ||
| 76 | microsoftGraphPathPrefixes: config.microsoftGraphPathPrefixes | ||
| 77 | ? [...config.microsoftGraphPathPrefixes] | ||
| 78 | : undefined, | ||
| 79 | microsoftGraphTagPrefixes: config.microsoftGraphTagPrefixes | ||
| 80 | ? [...config.microsoftGraphTagPrefixes] | ||
| 81 | : undefined, | ||
| 82 | microsoftGraphCoversFullGraph: config.microsoftGraphCoversFullGraph, | ||
| 83 | microsoftGraphAuthorizationUrl: config.microsoftGraphAuthorizationUrl, | ||
| 84 | microsoftGraphTokenUrl: config.microsoftGraphTokenUrl, | ||
| 85 | microsoftGraphClientCredentialsTokenUrl: | ||
| 86 | config.microsoftGraphClientCredentialsTokenUrl, | ||
| 87 | baseUrl: config.baseUrl, | ||
| 88 | headers: config.headers ? { ...config.headers } : undefined, | ||
| 89 | queryParams: config.queryParams ? { ...config.queryParams } : undefined, | ||
| 90 | authenticationTemplate: config.authenticationTemplate | ||
| 91 | ? [...config.authenticationTemplate] | ||
| 92 | : undefined, | ||
| 93 | } | ||
| 94 | : null; | ||
| 95 | }), | ||
| 96 | ), | ||
| 97 | ) | ||
| 98 | .handle("configure", ({ params, payload }) => | ||
| 99 | capture( | ||
| 100 | Effect.gen(function* () { | ||
| 101 | const ext = yield* MicrosoftExtensionService; | ||
| 102 | const authenticationTemplate = yield* ext.configure(params.slug, { | ||
| 103 | authenticationTemplate: payload.authenticationTemplate, | ||
| 104 | mode: payload.mode ?? "merge", | ||
| 105 | }); | ||
| 106 | return { authenticationTemplate: [...authenticationTemplate] }; | ||
| 107 | }), | ||
| 108 | ), | ||
| 109 | ) | ||
| 110 | .handle("updateGraph", ({ params, payload }) => | ||
| 111 | capture( | ||
| 112 | Effect.gen(function* () { | ||
| 113 | const ext = yield* MicrosoftExtensionService; | ||
| 114 | const result = yield* ext.updateGraph(params.slug, { | ||
| 115 | ...(payload.presetIds !== undefined ? { presetIds: payload.presetIds } : {}), | ||
| 116 | ...(payload.customScopes !== undefined ? { customScopes: payload.customScopes } : {}), | ||
| 117 | ...(payload.baseUrl !== undefined ? { baseUrl: payload.baseUrl } : {}), | ||
| 118 | ...(payload.specUrl !== undefined ? { specUrl: payload.specUrl } : {}), | ||
| 119 | ...(payload.authorizationUrl !== undefined | ||
| 120 | ? { authorizationUrl: payload.authorizationUrl } | ||
| 121 | : {}), | ||
| 122 | ...(payload.tokenUrl !== undefined ? { tokenUrl: payload.tokenUrl } : {}), | ||
| 123 | ...(payload.clientCredentialsTokenUrl !== undefined | ||
| 124 | ? { clientCredentialsTokenUrl: payload.clientCredentialsTokenUrl } | ||
| 125 | : {}), | ||
| 126 | }); | ||
| 127 | return { | ||
| 128 | slug: result.slug, | ||
| 129 | toolCount: result.toolCount, | ||
| 130 | addedTools: [...result.addedTools], | ||
| 131 | removedTools: [...result.removedTools], | ||
| 132 | }; | ||
| 133 | }), | ||
| 134 | ), | ||
| 135 | ) | ||
| 136 | .handle("removeGraph", ({ params }) => | ||
| 137 | capture( | ||
| 138 | Effect.gen(function* () { | ||
| 139 | const ext = yield* MicrosoftExtensionService; | ||
| 140 | yield* ext.removeGraph(params.slug); | ||
| 141 | }), | ||
| 142 | ), | ||
| 143 | ), | ||
| 144 | ); | ||
| @@ -1,15 +0,0 @@ | |||
| 1 | import { definePlugin } from "@executor-js/sdk/core"; | ||
| 2 | |||
| 3 | import { microsoftPlugin, type MicrosoftPluginOptions } from "../sdk/plugin"; | ||
| 4 | import { MicrosoftGroup } from "./group"; | ||
| 5 | import { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers"; | ||
| 6 | |||
| 7 | export { MicrosoftGroup } from "./group"; | ||
| 8 | export { MicrosoftHandlers, MicrosoftExtensionService } from "./handlers"; | ||
| 9 | |||
| 10 | export const microsoftHttpPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({ | ||
| 11 | ...microsoftPlugin(options), | ||
| 12 | routes: () => MicrosoftGroup, | ||
| 13 | handlers: () => MicrosoftHandlers, | ||
| 14 | extensionService: MicrosoftExtensionService, | ||
| 15 | })); | ||
| @@ -1 +1 @@ | |||
| 1 | export { microsoftPlugin } from "./sdk/plugin"; | 1 | export * from "./sdk/index"; |
| @@ -1,155 +0,0 @@ | |||
| 1 | import { useCallback, useMemo, useState } from "react"; | ||
| 2 | import { useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | |||
| 5 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 6 | import { | ||
| 7 | slugifyNamespace, | ||
| 8 | useIntegrationIdentity, | ||
| 9 | } from "@executor-js/react/plugins/integration-identity"; | ||
| 10 | import { Button } from "@executor-js/react/components/button"; | ||
| 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; | ||
| 12 | import { | ||
| 13 | addIntegrationErrorMessage, | ||
| 14 | FormErrorAlert, | ||
| 15 | SlugCollisionAlert, | ||
| 16 | useSlugAlreadyExists, | ||
| 17 | } from "@executor-js/react/lib/integration-add"; | ||
| 18 | import { OpenApiSourceDetailsFields } from "@executor-js/plugin-openapi/react"; | ||
| 19 | |||
| 20 | import { addMicrosoftGraph } from "./atoms"; | ||
| 21 | import { MicrosoftScopePicker } from "./MicrosoftScopePicker"; | ||
| 22 | import { | ||
| 23 | MICROSOFT_GRAPH_BASE_URL, | ||
| 24 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 25 | microsoftGraphScopesForPresetIds, | ||
| 26 | } from "../sdk/presets"; | ||
| 27 | |||
| 28 | const MICROSOFT_FAVICON = "https://www.microsoft.com/favicon.ico"; | ||
| 29 | |||
| 30 | const defaultPresetIds: ReadonlySet<string> = new Set(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); | ||
| 31 | |||
| 32 | export default function AddMicrosoftSource(props: { | ||
| 33 | onComplete: (slug?: string) => void; | ||
| 34 | onCancel: () => void; | ||
| 35 | initialNamespace?: string; | ||
| 36 | }) { | ||
| 37 | const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>(defaultPresetIds); | ||
| 38 | const [customScopes, setCustomScopes] = useState<readonly string[]>([]); | ||
| 39 | const [baseUrl, setBaseUrl] = useState(""); | ||
| 40 | const [descriptionDraft, setDescriptionDraft] = useState<string | null>(null); | ||
| 41 | const [adding, setAdding] = useState(false); | ||
| 42 | const [addError, setAddError] = useState<string | null>(null); | ||
| 43 | |||
| 44 | const identity = useIntegrationIdentity({ | ||
| 45 | fallbackName: "Microsoft Graph", | ||
| 46 | fallbackNamespace: props.initialNamespace ?? "microsoft_graph", | ||
| 47 | }); | ||
| 48 | |||
| 49 | const selectedIds = useMemo(() => [...selectedPresetIds], [selectedPresetIds]); | ||
| 50 | const selectedScopes = useMemo( | ||
| 51 | () => microsoftGraphScopesForPresetIds(selectedIds, customScopes), | ||
| 52 | [selectedIds, customScopes], | ||
| 53 | ); | ||
| 54 | |||
| 55 | const togglePreset = useCallback((presetId: string, checked: boolean) => { | ||
| 56 | setSelectedPresetIds((current: ReadonlySet<string>) => { | ||
| 57 | const next = new Set(current); | ||
| 58 | if (checked) next.add(presetId); | ||
| 59 | else next.delete(presetId); | ||
| 60 | return next; | ||
| 61 | }); | ||
| 62 | }, []); | ||
| 63 | |||
| 64 | const addCustomScope = useCallback((scope: string) => { | ||
| 65 | setCustomScopes((current: readonly string[]) => | ||
| 66 | current.includes(scope) ? current : [...current, scope], | ||
| 67 | ); | ||
| 68 | }, []); | ||
| 69 | |||
| 70 | const removeCustomScope = useCallback((scope: string) => { | ||
| 71 | setCustomScopes((current: readonly string[]) => | ||
| 72 | current.filter((entry: string) => entry !== scope), | ||
| 73 | ); | ||
| 74 | }, []); | ||
| 75 | |||
| 76 | const doAdd = useAtomSet(addMicrosoftGraph, { mode: "promiseExit" }); | ||
| 77 | |||
| 78 | const resolvedSourceId = slugifyNamespace(identity.namespace) || "microsoft_graph"; | ||
| 79 | const resolvedDisplayName = identity.name.trim() || "Microsoft Graph"; | ||
| 80 | const resolvedDescription = descriptionDraft ?? "Selected Microsoft Graph workloads."; | ||
| 81 | const slugAlreadyExists = useSlugAlreadyExists(resolvedSourceId); | ||
| 82 | const canAdd = selectedIds.length > 0 && !slugAlreadyExists; | ||
| 83 | |||
| 84 | const handleAdd = async () => { | ||
| 85 | setAdding(true); | ||
| 86 | setAddError(null); | ||
| 87 | const exit = await doAdd({ | ||
| 88 | payload: { | ||
| 89 | presetIds: selectedIds, | ||
| 90 | customScopes: [...customScopes], | ||
| 91 | slug: resolvedSourceId, | ||
| 92 | name: resolvedDisplayName, | ||
| 93 | ...(resolvedDescription.trim().length > 0 | ||
| 94 | ? { description: resolvedDescription.trim() } | ||
| 95 | : {}), | ||
| 96 | ...(baseUrl.trim().length > 0 ? { baseUrl: baseUrl.trim() } : {}), | ||
| 97 | }, | ||
| 98 | reactivityKeys: integrationWriteKeys, | ||
| 99 | }); | ||
| 100 | if (Exit.isFailure(exit)) { | ||
| 101 | setAddError(addIntegrationErrorMessage(exit, resolvedSourceId, "Failed to add Microsoft")); | ||
| 102 | setAdding(false); | ||
| 103 | return; | ||
| 104 | } | ||
| 105 | props.onComplete(String(exit.value.slug)); | ||
| 106 | }; | ||
| 107 | |||
| 108 | return ( | ||
| 109 | <div className="flex flex-1 flex-col gap-6"> | ||
| 110 | <div> | ||
| 111 | <h1 className="text-xl font-semibold text-foreground">Add Microsoft integration</h1> | ||
| 112 | <p className="mt-1 text-[13px] text-muted-foreground"> | ||
| 113 | Pick Microsoft 365 workloads and connect them through one delegated OAuth consent. | ||
| 114 | </p> | ||
| 115 | </div> | ||
| 116 | |||
| 117 | <MicrosoftScopePicker | ||
| 118 | selectedPresetIds={selectedPresetIds} | ||
| 119 | onToggle={togglePreset} | ||
| 120 | customScopes={customScopes} | ||
| 121 | onAddCustomScope={addCustomScope} | ||
| 122 | onRemoveCustomScope={removeCustomScope} | ||
| 123 | /> | ||
| 124 | |||
| 125 | <OpenApiSourceDetailsFields | ||
| 126 | title="Microsoft Graph" | ||
| 127 | subtitle={`${selectedIds.length} operation group${ | ||
| 128 | selectedIds.length !== 1 ? "s" : "" | ||
| 129 | } with ${selectedScopes.length} preview scope${selectedScopes.length !== 1 ? "s" : ""}`} | ||
| 130 | identity={identity} | ||
| 131 | description={resolvedDescription} | ||
| 132 | onDescriptionChange={setDescriptionDraft} | ||
| 133 | baseUrl={baseUrl} | ||
| 134 | onBaseUrlChange={setBaseUrl} | ||
| 135 | baseUrlLabel="Base URL override (optional)" | ||
| 136 | baseUrlPlaceholder={MICROSOFT_GRAPH_BASE_URL} | ||
| 137 | faviconIcon={MICROSOFT_FAVICON} | ||
| 138 | faviconUrl={baseUrl || MICROSOFT_GRAPH_BASE_URL} | ||
| 139 | /> | ||
| 140 | |||
| 141 | {slugAlreadyExists && !adding && <SlugCollisionAlert slug={resolvedSourceId} />} | ||
| 142 | |||
| 143 | {addError && <FormErrorAlert message={addError} />} | ||
| 144 | |||
| 145 | <FloatActions> | ||
| 146 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | ||
| 147 | Cancel | ||
| 148 | </Button> | ||
| 149 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | ||
| 150 | Connect Microsoft | ||
| 151 | </Button> | ||
| 152 | </FloatActions> | ||
| 153 | </div> | ||
| 154 | ); | ||
| 155 | } | ||
| @@ -1,118 +0,0 @@ | |||
| 1 | import { useCallback, useMemo } from "react"; | ||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | ||
| 3 | import * as Exit from "effect/Exit"; | ||
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | ||
| 5 | import { AuthTemplateSlug, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 6 | import type { IntegrationAccountHandoff } from "@executor-js/sdk/client"; | ||
| 7 | |||
| 8 | import { AccountsSection } from "@executor-js/react/components/accounts-section"; | ||
| 9 | import { | ||
| 10 | HealthCheckEditor, | ||
| 11 | type HealthCheckLivePreview, | ||
| 12 | } from "@executor-js/react/components/health-check-editor"; | ||
| 13 | import { useOrganizationId } from "@executor-js/react/api/organization-context"; | ||
| 14 | import { defaultConnectionOwnerForHost } from "@executor-js/react/plugins/connection-owner"; | ||
| 15 | import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; | ||
| 16 | import type { AuthMethod, Placement } from "@executor-js/react/lib/auth-placements"; | ||
| 17 | import { | ||
| 18 | useCustomMethodActions, | ||
| 19 | type AuthMethodsCodec, | ||
| 20 | type ConfigureAuthMethods, | ||
| 21 | } from "@executor-js/react/lib/custom-auth-methods"; | ||
| 22 | import { | ||
| 23 | authMethodsFromConfig, | ||
| 24 | openApiWireAuthInput, | ||
| 25 | templateFromPlacements, | ||
| 26 | } from "@executor-js/plugin-openapi/react"; | ||
| 27 | import type { Authentication } from "@executor-js/plugin-openapi"; | ||
| 28 | |||
| 29 | import { microsoftConfigAtom, microsoftConfigure } from "./atoms"; | ||
| 30 | |||
| 31 | const NO_AUTH_METHOD: AuthMethod = { | ||
| 32 | id: "none", | ||
| 33 | label: "No authentication", | ||
| 34 | kind: "none", | ||
| 35 | source: "spec", | ||
| 36 | template: AuthTemplateSlug.make("none"), | ||
| 37 | placements: [], | ||
| 38 | }; | ||
| 39 | |||
| 40 | export default function MicrosoftAccountsPanel(props: { | ||
| 41 | readonly sourceId: string; | ||
| 42 | readonly integrationName: string; | ||
| 43 | readonly accountHandoff?: IntegrationAccountHandoff | null; | ||
| 44 | }) { | ||
| 45 | const { sourceId, integrationName, accountHandoff } = props; | ||
| 46 | const slug = IntegrationSlug.make(sourceId); | ||
| 47 | const configResult = useAtomValue(microsoftConfigAtom(slug)); | ||
| 48 | const doConfigure = useAtomSet(microsoftConfigure, { mode: "promiseExit" }); | ||
| 49 | |||
| 50 | const existingTemplate = useMemo<readonly Authentication[]>(() => { | ||
| 51 | if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; | ||
| 52 | return (configResult.value.authenticationTemplate ?? []) as readonly Authentication[]; | ||
| 53 | }, [configResult]); | ||
| 54 | |||
| 55 | const methods = useMemo<readonly AuthMethod[]>(() => { | ||
| 56 | const declared = authMethodsFromConfig(existingTemplate); | ||
| 57 | return declared.length > 0 ? declared : [NO_AUTH_METHOD]; | ||
| 58 | }, [existingTemplate]); | ||
| 59 | |||
| 60 | const configure = useCallback<ConfigureAuthMethods<Authentication>>( | ||
| 61 | async (input) => { | ||
| 62 | const exit = await doConfigure({ | ||
| 63 | params: { slug }, | ||
| 64 | payload: { | ||
| 65 | authenticationTemplate: input.authenticationTemplate.map(openApiWireAuthInput), | ||
| 66 | ...(input.mode ? { mode: input.mode } : {}), | ||
| 67 | }, | ||
| 68 | reactivityKeys: integrationWriteKeys, | ||
| 69 | }); | ||
| 70 | return Exit.map(exit, (result) => result.authenticationTemplate as readonly Authentication[]); | ||
| 71 | }, | ||
| 72 | [doConfigure, slug], | ||
| 73 | ); | ||
| 74 | |||
| 75 | const codec = useMemo<AuthMethodsCodec<Authentication>>( | ||
| 76 | () => ({ | ||
| 77 | toAuthMethods: authMethodsFromConfig, | ||
| 78 | templatesFromPlacements: (placements: readonly Placement[]) => [ | ||
| 79 | templateFromPlacements(placements), | ||
| 80 | ], | ||
| 81 | slugOf: (template: Authentication) => String(template.slug), | ||
| 82 | }), | ||
| 83 | [], | ||
| 84 | ); | ||
| 85 | |||
| 86 | const { createCustomMethod, removeCustomMethod } = useCustomMethodActions({ | ||
| 87 | existing: existingTemplate, | ||
| 88 | codec, | ||
| 89 | configure, | ||
| 90 | }); | ||
| 91 | |||
| 92 | // Live-preview context for the health-check edit sheet (apiKey methods only; | ||
| 93 | // OAuth methods can't be previewed with a pasted key). | ||
| 94 | const organizationId = useOrganizationId(); | ||
| 95 | const livePreview = useMemo<HealthCheckLivePreview | undefined>(() => { | ||
| 96 | const templates = methods | ||
| 97 | .filter((m) => m.kind === "apikey") | ||
| 98 | .map((m) => ({ template: m.template, label: m.label })); | ||
| 99 | if (templates.length === 0) return undefined; | ||
| 100 | return { owner: defaultConnectionOwnerForHost(organizationId), templates }; | ||
| 101 | }, [methods, organizationId]); | ||
| 102 | |||
| 103 | return ( | ||
| 104 | <div className="mx-auto max-w-3xl space-y-8 px-6 py-8"> | ||
| 105 | <AccountsSection | ||
| 106 | integration={slug} | ||
| 107 | integrationName={integrationName} | ||
| 108 | methods={methods} | ||
| 109 | accountHandoff={accountHandoff} | ||
| 110 | createCustomMethod={createCustomMethod} | ||
| 111 | removeCustomMethod={removeCustomMethod} | ||
| 112 | /> | ||
| 113 | {/* Graph auto-configures the /me identity probe at add time; this is | ||
| 114 | where an admin sees, adjusts, or clears it. */} | ||
| 115 | <HealthCheckEditor integration={slug} livePreview={livePreview} /> | ||
| 116 | </div> | ||
| 117 | ); | ||
| 118 | } | ||
| @@ -1,286 +0,0 @@ | |||
| 1 | import { useMemo, useState } from "react"; | ||
| 2 | import { ChevronDownIcon, PlusIcon, XIcon } from "lucide-react"; | ||
| 3 | |||
| 4 | import { cn } from "@executor-js/react/lib/utils"; | ||
| 5 | import { Badge } from "@executor-js/react/components/badge"; | ||
| 6 | import { Button } from "@executor-js/react/components/button"; | ||
| 7 | import { Checkbox } from "@executor-js/react/components/checkbox"; | ||
| 8 | import { | ||
| 9 | Collapsible, | ||
| 10 | CollapsibleContent, | ||
| 11 | CollapsibleTrigger, | ||
| 12 | } from "@executor-js/react/components/collapsible"; | ||
| 13 | import { FieldLabel } from "@executor-js/react/components/field"; | ||
| 14 | import { Input } from "@executor-js/react/components/input"; | ||
| 15 | import { IntegrationFavicon } from "@executor-js/react/components/integration-favicon"; | ||
| 16 | |||
| 17 | import { | ||
| 18 | microsoftGraphScopePresets, | ||
| 19 | microsoftGraphScopesForPresetIds, | ||
| 20 | type MicrosoftGraphScopeAudience, | ||
| 21 | type MicrosoftGraphScopePreset, | ||
| 22 | } from "../sdk/presets"; | ||
| 23 | |||
| 24 | type MicrosoftScopePickerProps = { | ||
| 25 | readonly selectedPresetIds: ReadonlySet<string>; | ||
| 26 | readonly onToggle: (presetId: string, checked: boolean) => void; | ||
| 27 | readonly customScopes: readonly string[]; | ||
| 28 | readonly onAddCustomScope: (scope: string) => void; | ||
| 29 | readonly onRemoveCustomScope: (scope: string) => void; | ||
| 30 | }; | ||
| 31 | |||
| 32 | const AUDIENCE_ORDER: readonly MicrosoftGraphScopeAudience[] = [ | ||
| 33 | "productivity", | ||
| 34 | "files-content", | ||
| 35 | "collaboration", | ||
| 36 | "directory-identity", | ||
| 37 | "admin-security", | ||
| 38 | "platform-business", | ||
| 39 | ]; | ||
| 40 | |||
| 41 | const AUDIENCE_LABEL: Readonly<Record<MicrosoftGraphScopeAudience, string>> = { | ||
| 42 | productivity: "Productivity", | ||
| 43 | "files-content": "Files and content", | ||
| 44 | collaboration: "Collaboration", | ||
| 45 | "directory-identity": "Directory and identity", | ||
| 46 | "admin-security": "Admin and security", | ||
| 47 | "platform-business": "Platform and business", | ||
| 48 | }; | ||
| 49 | |||
| 50 | const AUDIENCE_DESCRIPTION: Readonly<Record<MicrosoftGraphScopeAudience, string>> = { | ||
| 51 | productivity: "User-facing Microsoft 365 operations for the connected account.", | ||
| 52 | "files-content": "Drive, SharePoint, OneNote, workbook, and content APIs.", | ||
| 53 | collaboration: "Teams, chats, online meetings, calls, and teamwork APIs.", | ||
| 54 | "directory-identity": "Tenant directory, app, identity, policy, and governance APIs.", | ||
| 55 | "admin-security": "Admin, audit, security, compliance, device, and education APIs.", | ||
| 56 | "platform-business": "Search, connectors, extensions, print, places, and business services.", | ||
| 57 | }; | ||
| 58 | |||
| 59 | const ScopeRow = ({ | ||
| 60 | preset, | ||
| 61 | checked, | ||
| 62 | onToggle, | ||
| 63 | }: { | ||
| 64 | readonly preset: MicrosoftGraphScopePreset; | ||
| 65 | readonly checked: boolean; | ||
| 66 | readonly onToggle: (checked: boolean) => void; | ||
| 67 | }) => ( | ||
| 68 | <FieldLabel | ||
| 69 | className={cn( | ||
| 70 | "flex w-full min-w-0 cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 transition-colors", | ||
| 71 | checked ? "bg-primary/5" : "hover:bg-muted/40", | ||
| 72 | )} | ||
| 73 | > | ||
| 74 | <Checkbox checked={checked} onCheckedChange={(next) => onToggle(next === true)} /> | ||
| 75 | <div className="shrink-0"> | ||
| 76 | <IntegrationFavicon icon={preset.icon} size={16} /> | ||
| 77 | </div> | ||
| 78 | <div className="min-w-0 flex-1"> | ||
| 79 | <div className="flex min-w-0 items-center gap-2"> | ||
| 80 | <span className="truncate text-sm font-medium text-foreground">{preset.name}</span> | ||
| 81 | </div> | ||
| 82 | <p className="truncate text-[11px] text-muted-foreground">{preset.summary}</p> | ||
| 83 | </div> | ||
| 84 | </FieldLabel> | ||
| 85 | ); | ||
| 86 | |||
| 87 | const CustomScopeInput = ({ | ||
| 88 | customScopes, | ||
| 89 | onAddCustomScope, | ||
| 90 | onRemoveCustomScope, | ||
| 91 | }: { | ||
| 92 | readonly customScopes: readonly string[]; | ||
| 93 | readonly onAddCustomScope: (scope: string) => void; | ||
| 94 | readonly onRemoveCustomScope: (scope: string) => void; | ||
| 95 | }) => { | ||
| 96 | const [draft, setDraft] = useState(""); | ||
| 97 | const trimmed = draft.trim(); | ||
| 98 | const isValid = trimmed.length > 0 && !/\s/.test(trimmed); | ||
| 99 | const isDuplicate = customScopes.includes(trimmed); | ||
| 100 | |||
| 101 | const commit = () => { | ||
| 102 | if (!isValid || isDuplicate) return; | ||
| 103 | onAddCustomScope(trimmed); | ||
| 104 | setDraft(""); | ||
| 105 | }; | ||
| 106 | |||
| 107 | return ( | ||
| 108 | <div className="space-y-2"> | ||
| 109 | <FieldLabel className="text-[11px] font-medium text-muted-foreground"> | ||
| 110 | Add custom Graph scope | ||
| 111 | </FieldLabel> | ||
| 112 | <div className="flex items-center gap-2"> | ||
| 113 | <Input | ||
| 114 | value={draft} | ||
| 115 | onChange={(event: React.ChangeEvent<HTMLInputElement>) => setDraft(event.target.value)} | ||
| 116 | onKeyDown={(event: React.KeyboardEvent<HTMLInputElement>) => { | ||
| 117 | if (event.key === "Enter") { | ||
| 118 | event.preventDefault(); | ||
| 119 | commit(); | ||
| 120 | } | ||
| 121 | }} | ||
| 122 | placeholder="Sites.Read.All" | ||
| 123 | className="font-mono text-[11px]" | ||
| 124 | /> | ||
| 125 | <Button | ||
| 126 | type="button" | ||
| 127 | variant="outline" | ||
| 128 | size="sm" | ||
| 129 | disabled={!isValid || isDuplicate} | ||
| 130 | onClick={commit} | ||
| 131 | > | ||
| 132 | <PlusIcon className="size-3.5" /> | ||
| 133 | Add | ||
| 134 | </Button> | ||
| 135 | </div> | ||
| 136 | {trimmed.length > 0 && !isValid ? ( | ||
| 137 | <p className="text-[11px] text-destructive">Scopes cannot contain whitespace.</p> | ||
| 138 | ) : null} | ||
| 139 | {customScopes.length > 0 ? ( | ||
| 140 | <ul className="space-y-1"> | ||
| 141 | {customScopes.map((scope: string) => ( | ||
| 142 | <li | ||
| 143 | key={scope} | ||
| 144 | className="flex items-center justify-between gap-2 rounded-md border border-border bg-muted/20 px-2.5 py-1.5" | ||
| 145 | > | ||
| 146 | <span className="truncate font-mono text-[11px] text-foreground">{scope}</span> | ||
| 147 | <Button | ||
| 148 | type="button" | ||
| 149 | variant="ghost" | ||
| 150 | size="icon" | ||
| 151 | className="size-6 shrink-0" | ||
| 152 | onClick={() => onRemoveCustomScope(scope)} | ||
| 153 | aria-label={`Remove ${scope}`} | ||
| 154 | > | ||
| 155 | <XIcon className="size-3.5" /> | ||
| 156 | </Button> | ||
| 157 | </li> | ||
| 158 | ))} | ||
| 159 | </ul> | ||
| 160 | ) : null} | ||
| 161 | </div> | ||
| 162 | ); | ||
| 163 | }; | ||
| 164 | |||
| 165 | export function MicrosoftScopePicker({ | ||
| 166 | selectedPresetIds, | ||
| 167 | onToggle, | ||
| 168 | customScopes, | ||
| 169 | onAddCustomScope, | ||
| 170 | onRemoveCustomScope, | ||
| 171 | }: MicrosoftScopePickerProps) { | ||
| 172 | const [scopesOpen, setScopesOpen] = useState(false); | ||
| 173 | |||
| 174 | const groups = useMemo( | ||
| 175 | () => | ||
| 176 | AUDIENCE_ORDER.flatMap((audience: MicrosoftGraphScopeAudience) => { | ||
| 177 | const presets = microsoftGraphScopePresets.filter( | ||
| 178 | (preset: MicrosoftGraphScopePreset) => preset.audience === audience, | ||
| 179 | ); | ||
| 180 | return presets.length > 0 ? [{ audience, presets }] : []; | ||
| 181 | }), | ||
| 182 | [], | ||
| 183 | ); | ||
| 184 | |||
| 185 | const scopes = useMemo( | ||
| 186 | () => microsoftGraphScopesForPresetIds([...selectedPresetIds], customScopes), | ||
| 187 | [selectedPresetIds, customScopes], | ||
| 188 | ); | ||
| 189 | |||
| 190 | return ( | ||
| 191 | <section className="space-y-4"> | ||
| 192 | <div className="space-y-1"> | ||
| 193 | <FieldLabel>Customize Microsoft Graph</FieldLabel> | ||
| 194 | <p className="text-[11px] text-muted-foreground"> | ||
| 195 | Pick the operation groups to expose as tools. They share one Microsoft OAuth consent and | ||
| 196 | one account connection. | ||
| 197 | </p> | ||
| 198 | </div> | ||
| 199 | |||
| 200 | {groups.map( | ||
| 201 | ({ | ||
| 202 | audience, | ||
| 203 | presets, | ||
| 204 | }: { | ||
| 205 | readonly audience: MicrosoftGraphScopeAudience; | ||
| 206 | readonly presets: readonly MicrosoftGraphScopePreset[]; | ||
| 207 | }) => ( | ||
| 208 | <div key={audience} className="space-y-2"> | ||
| 209 | <div className="flex items-center gap-2"> | ||
| 210 | <span className="text-[11px] font-semibold tracking-wide text-foreground uppercase"> | ||
| 211 | {AUDIENCE_LABEL[audience]} | ||
| 212 | </span> | ||
| 213 | <Button | ||
| 214 | type="button" | ||
| 215 | variant="ghost" | ||
| 216 | size="sm" | ||
| 217 | className="ml-auto h-auto px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground hover:bg-transparent hover:text-foreground" | ||
| 218 | onClick={() => { | ||
| 219 | const allSelected = presets.every((preset: MicrosoftGraphScopePreset) => | ||
| 220 | selectedPresetIds.has(preset.id), | ||
| 221 | ); | ||
| 222 | presets.forEach((preset: MicrosoftGraphScopePreset) => | ||
| 223 | onToggle(preset.id, !allSelected), | ||
| 224 | ); | ||
| 225 | }} | ||
| 226 | > | ||
| 227 | {presets.every((preset: MicrosoftGraphScopePreset) => | ||
| 228 | selectedPresetIds.has(preset.id), | ||
| 229 | ) | ||
| 230 | ? "Clear" | ||
| 231 | : "Select all"} | ||
| 232 | </Button> | ||
| 233 | </div> | ||
| 234 | <p className="text-[11px] text-muted-foreground">{AUDIENCE_DESCRIPTION[audience]}</p> | ||
| 235 | <div className="grid grid-cols-1 gap-x-4 gap-y-0.5 sm:grid-cols-2"> | ||
| 236 | {presets.map((preset: MicrosoftGraphScopePreset) => ( | ||
| 237 | <ScopeRow | ||
| 238 | key={preset.id} | ||
| 239 | preset={preset} | ||
| 240 | checked={selectedPresetIds.has(preset.id)} | ||
| 241 | onToggle={(checked: boolean) => onToggle(preset.id, checked)} | ||
| 242 | /> | ||
| 243 | ))} | ||
| 244 | </div> | ||
| 245 | </div> | ||
| 246 | ), | ||
| 247 | )} | ||
| 248 | |||
| 249 | <CustomScopeInput | ||
| 250 | customScopes={customScopes} | ||
| 251 | onAddCustomScope={onAddCustomScope} | ||
| 252 | onRemoveCustomScope={onRemoveCustomScope} | ||
| 253 | /> | ||
| 254 | |||
| 255 | <Collapsible open={scopesOpen} onOpenChange={setScopesOpen}> | ||
| 256 | <CollapsibleTrigger asChild> | ||
| 257 | <Button type="button" variant="outline" size="sm" disabled={scopes.length === 0}> | ||
| 258 | <ChevronDownIcon | ||
| 259 | className={cn("size-3.5 transition-transform", scopesOpen ? "rotate-180" : "")} | ||
| 260 | /> | ||
| 261 | View scopes | ||
| 262 | {scopes.length > 0 ? ( | ||
| 263 | <Badge variant="secondary" className="ml-1"> | ||
| 264 | {scopes.length} | ||
| 265 | </Badge> | ||
| 266 | ) : null} | ||
| 267 | </Button> | ||
| 268 | </CollapsibleTrigger> | ||
| 269 | <CollapsibleContent className="pt-3"> | ||
| 270 | <ul className="space-y-1"> | ||
| 271 | {scopes.map((scope: string) => ( | ||
| 272 | <li | ||
| 273 | key={scope} | ||
| 274 | className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground" | ||
| 275 | > | ||
| 276 | {scope} | ||
| 277 | </li> | ||
| 278 | ))} | ||
| 279 | </ul> | ||
| 280 | </CollapsibleContent> | ||
| 281 | </Collapsible> | ||
| 282 | </section> | ||
| 283 | ); | ||
| 284 | } | ||
| 285 | |||
| 286 | export default MicrosoftScopePicker; | ||
| @@ -1,30 +0,0 @@ | |||
| 1 | import type { IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 2 | import * as Atom from "effect/unstable/reactivity/Atom"; | ||
| 3 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; | ||
| 4 | import { MicrosoftClient } from "./client"; | ||
| 5 | |||
| 6 | export const microsoftIntegrationAtom = (slug: IntegrationSlug) => | ||
| 7 | MicrosoftClient.query("microsoft", "getIntegration", { | ||
| 8 | params: { slug }, | ||
| 9 | timeToLive: "15 seconds", | ||
| 10 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 11 | }); | ||
| 12 | |||
| 13 | export const microsoftConfigAtom = (slug: IntegrationSlug) => | ||
| 14 | MicrosoftClient.query("microsoft", "getConfig", { | ||
| 15 | params: { slug }, | ||
| 16 | timeToLive: "15 seconds", | ||
| 17 | reactivityKeys: [ReactivityKey.integrations, ReactivityKey.tools], | ||
| 18 | }); | ||
| 19 | |||
| 20 | export const addMicrosoftGraph = MicrosoftClient.mutation("microsoft", "addGraph"); | ||
| 21 | |||
| 22 | export const updateMicrosoftGraph = MicrosoftClient.mutation("microsoft", "updateGraph"); | ||
| 23 | |||
| 24 | export const removeMicrosoftGraph = MicrosoftClient.mutation("microsoft", "removeGraph"); | ||
| 25 | |||
| 26 | export const microsoftConfigure = MicrosoftClient.mutation("microsoft", "configure"); | ||
| 27 | |||
| 28 | export const microsoftIntegrationFamily = Atom.family(microsoftIntegrationAtom); | ||
| 29 | |||
| 30 | export const microsoftConfigFamily = Atom.family(microsoftConfigAtom); | ||
| @@ -1,13 +0,0 @@ | |||
| 1 | import { createPluginAtomClient } from "@executor-js/sdk/client"; | ||
| 2 | import { | ||
| 3 | getExecutorOrganizationHeaders, | ||
| 4 | getExecutorApiBaseUrl, | ||
| 5 | getExecutorServerAuthorizationHeader, | ||
| 6 | } from "@executor-js/react/api/server-connection"; | ||
| 7 | import { MicrosoftGroup } from "../api/group"; | ||
| 8 | |||
| 9 | export const MicrosoftClient = createPluginAtomClient(MicrosoftGroup, { | ||
| 10 | baseUrl: getExecutorApiBaseUrl, | ||
| 11 | authorizationHeader: getExecutorServerAuthorizationHeader, | ||
| 12 | headers: getExecutorOrganizationHeaders, | ||
| 13 | }); | ||
| @@ -1,12 +0,0 @@ | |||
| 1 | export { microsoftIntegrationPlugin } from "./source-plugin"; | ||
| 2 | export { MicrosoftClient } from "./client"; | ||
| 3 | export { | ||
| 4 | addMicrosoftGraph, | ||
| 5 | microsoftConfigAtom, | ||
| 6 | microsoftConfigFamily, | ||
| 7 | microsoftConfigure, | ||
| 8 | microsoftIntegrationAtom, | ||
| 9 | microsoftIntegrationFamily, | ||
| 10 | removeMicrosoftGraph, | ||
| 11 | updateMicrosoftGraph, | ||
| 12 | } from "./atoms"; | ||
| @@ -1,8 +0,0 @@ | |||
| 1 | import { defineClientPlugin } from "@executor-js/sdk/client"; | ||
| 2 | |||
| 3 | import { microsoftIntegrationPlugin } from "./source-plugin"; | ||
| 4 | |||
| 5 | export default defineClientPlugin({ | ||
| 6 | id: "microsoft" as const, | ||
| 7 | integrationPlugin: microsoftIntegrationPlugin, | ||
| 8 | }); | ||
| @@ -1,18 +0,0 @@ | |||
| 1 | import { lazy } from "react"; | ||
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | ||
| 3 | import { microsoftGraphPreset } from "../sdk/presets"; | ||
| 4 | |||
| 5 | const importAdd = () => import("./AddMicrosoftSource"); | ||
| 6 | const importAccounts = () => import("./MicrosoftAccountsPanel"); | ||
| 7 | |||
| 8 | export const microsoftIntegrationPlugin: IntegrationPlugin = { | ||
| 9 | key: "microsoft", | ||
| 10 | label: "Microsoft", | ||
| 11 | add: lazy(importAdd), | ||
| 12 | accounts: lazy(importAccounts), | ||
| 13 | presets: [microsoftGraphPreset], | ||
| 14 | preload: () => { | ||
| 15 | void importAdd(); | ||
| 16 | void importAccounts(); | ||
| 17 | }, | ||
| 18 | }; | ||
| @@ -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"; |
| 39 | export { microsoftGraphAdapter } from "./spec-format-adapter"; | 41 | export { microsoftGraphAdapter } from "./spec-format-adapter"; |
| 40 | export { | ||
| 41 | microsoftPlugin, | ||
| 42 | type MicrosoftConfigureInput, | ||
| 43 | type MicrosoftGraphConfig, | ||
| 44 | type MicrosoftPluginExtension, | ||
| 45 | type MicrosoftPluginOptions, | ||
| 46 | type MicrosoftUpdateInput, | ||
| 47 | type MicrosoftUpdateResult, | ||
| 48 | } from "./plugin"; | ||
| @@ -1,692 +0,0 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { Effect, Exit, Layer } from "effect"; | ||
| 3 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | AuthTemplateSlug, | ||
| 7 | ConnectionName, | ||
| 8 | IntegrationSlug, | ||
| 9 | createExecutor, | ||
| 10 | } from "@executor-js/sdk"; | ||
| 11 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; | ||
| 12 | |||
| 13 | import { microsoftPlugin, type MicrosoftPluginOptions } from "./plugin"; | ||
| 14 | import { | ||
| 15 | MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 16 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 17 | MICROSOFT_GRAPH_ALL_PRESET_IDS, | ||
| 18 | MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES, | ||
| 19 | MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, | ||
| 20 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | ||
| 21 | MICROSOFT_GRAPH_OPENAPI_URL, | ||
| 22 | MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL, | ||
| 23 | } from "./presets"; | ||
| 24 | |||
| 25 | const graphFixture = ` | ||
| 26 | openapi: 3.0.4 | ||
| 27 | info: | ||
| 28 | title: Microsoft Graph Fixture | ||
| 29 | version: v1.0 | ||
| 30 | servers: | ||
| 31 | - url: https://graph.microsoft.com/v1.0 | ||
| 32 | paths: | ||
| 33 | /me: | ||
| 34 | get: | ||
| 35 | operationId: me.GetUser | ||
| 36 | summary: Get the signed-in user profile | ||
| 37 | security: | ||
| 38 | - azureAdDelegated: | ||
| 39 | - User.Read | ||
| 40 | responses: | ||
| 41 | "200": | ||
| 42 | description: OK | ||
| 43 | /me/messages: | ||
| 44 | get: | ||
| 45 | operationId: me.messages.ListMessages | ||
| 46 | security: | ||
| 47 | - azureAdDelegated: | ||
| 48 | - Mail.ReadWrite | ||
| 49 | responses: | ||
| 50 | "200": | ||
| 51 | description: OK | ||
| 52 | /me/events: | ||
| 53 | get: | ||
| 54 | operationId: me.events.ListEvents | ||
| 55 | security: | ||
| 56 | - azureAdDelegated: | ||
| 57 | - Calendars.ReadWrite | ||
| 58 | responses: | ||
| 59 | "200": | ||
| 60 | description: OK | ||
| 61 | /me/onenote/pages: | ||
| 62 | get: | ||
| 63 | operationId: me.onenote.pages.ListPages | ||
| 64 | security: | ||
| 65 | - azureAdDelegated: | ||
| 66 | - Notes.ReadWrite | ||
| 67 | responses: | ||
| 68 | "200": | ||
| 69 | description: OK | ||
| 70 | /sites: | ||
| 71 | get: | ||
| 72 | operationId: sites.ListSites | ||
| 73 | responses: | ||
| 74 | "200": | ||
| 75 | description: OK | ||
| 76 | components: | ||
| 77 | schemas: | ||
| 78 | user: | ||
| 79 | type: object | ||
| 80 | `; | ||
| 81 | |||
| 82 | const permissionsReferenceFixture = ` | ||
| 83 | ### User.Read | ||
| 84 | |||
| 85 | | Category | Application | Delegated | | ||
| 86 | |--|--|--| | ||
| 87 | | Identifier | - | e1fe6dd8-ba31-4d61-89e7-88639da4683d | | ||
| 88 | |||
| 89 | --- | ||
| 90 | |||
| 91 | ### Mail.ReadWrite | ||
| 92 | |||
| 93 | | Category | Application | Delegated | | ||
| 94 | |--|--|--| | ||
| 95 | | Identifier | e2a3a72e-5f79-4c64-b1b1-878b674786c9 | 024d486e-b451-40bb-833d-3e66d98c5c73 | | ||
| 96 | |||
| 97 | --- | ||
| 98 | |||
| 99 | ### Calendars.ReadWrite | ||
| 100 | |||
| 101 | | Category | Application | Delegated | | ||
| 102 | |--|--|--| | ||
| 103 | | Identifier | ef54d2bf-783f-4e0f-bca1-3210c0444d99 | 1ec239c2-d7c9-4623-a91a-a9775856bb36 | | ||
| 104 | |||
| 105 | --- | ||
| 106 | |||
| 107 | ### Notes.ReadWrite | ||
| 108 | |||
| 109 | | Category | Application | Delegated | | ||
| 110 | |--|--|--| | ||
| 111 | | Identifier | 085ca537-6565-41c2-aca7-db852babc212 | 615e82d5-1f7f-4f99-a456-0a0484a820d5 | | ||
| 112 | |||
| 113 | --- | ||
| 114 | |||
| 115 | ### AppCatalog.Read.All | ||
| 116 | |||
| 117 | | Category | Application | Delegated | | ||
| 118 | |--|--|--| | ||
| 119 | | Identifier | e12dae10-5a57-4817-b79d-dfbec5348930 | - | | ||
| 120 | `; | ||
| 121 | |||
| 122 | const EMULATOR_SPEC_URL = "https://microsoft.emulators.dev/_emulate/openapi"; | ||
| 123 | const EMULATOR_BASE_URL = "https://microsoft.emulators.dev"; | ||
| 124 | const LOCAL_EMULATOR_SPEC_URL = "http://localhost:4123/_emulate/openapi"; | ||
| 125 | const LOCAL_EMULATOR_BASE_URL = "http://localhost:4123"; | ||
| 126 | const emulatorGraphFixture = ` | ||
| 127 | openapi: 3.0.3 | ||
| 128 | info: | ||
| 129 | title: Microsoft Graph Emulator | ||
| 130 | version: 1.0.0 | ||
| 131 | servers: | ||
| 132 | - url: ${EMULATOR_BASE_URL} | ||
| 133 | paths: | ||
| 134 | /v1.0/users: | ||
| 135 | get: | ||
| 136 | operationId: graphUser_List | ||
| 137 | responses: | ||
| 138 | "200": | ||
| 139 | description: OK | ||
| 140 | /v1.0/me: | ||
| 141 | get: | ||
| 142 | operationId: graphUser_GetMyProfile | ||
| 143 | responses: | ||
| 144 | "200": | ||
| 145 | description: OK | ||
| 146 | components: | ||
| 147 | securitySchemes: | ||
| 148 | azureAdDelegated: | ||
| 149 | type: oauth2 | ||
| 150 | flows: | ||
| 151 | authorizationCode: | ||
| 152 | authorizationUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/authorize | ||
| 153 | tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token | ||
| 154 | scopes: | ||
| 155 | User.Read: User.Read | ||
| 156 | User.Read.All: User.Read.All | ||
| 157 | clientCredentials: | ||
| 158 | tokenUrl: ${EMULATOR_BASE_URL}/oauth2/v2.0/token | ||
| 159 | scopes: | ||
| 160 | https://graph.microsoft.com/.default: https://graph.microsoft.com/.default | ||
| 161 | `; | ||
| 162 | |||
| 163 | const localEmulatorGraphFixture = ` | ||
| 164 | openapi: 3.0.3 | ||
| 165 | info: | ||
| 166 | title: Microsoft Graph Local Emulator | ||
| 167 | version: 1.0.0 | ||
| 168 | servers: | ||
| 169 | - url: ${LOCAL_EMULATOR_BASE_URL} | ||
| 170 | paths: | ||
| 171 | /v1.0/users: | ||
| 172 | get: | ||
| 173 | operationId: graphUser_List | ||
| 174 | responses: | ||
| 175 | "200": | ||
| 176 | description: OK | ||
| 177 | components: | ||
| 178 | securitySchemes: | ||
| 179 | azureAdDelegated: | ||
| 180 | type: oauth2 | ||
| 181 | flows: | ||
| 182 | clientCredentials: | ||
| 183 | tokenUrl: ${LOCAL_EMULATOR_BASE_URL}/oauth2/v2.0/token | ||
| 184 | scopes: | ||
| 185 | https://graph.microsoft.com/.default: https://graph.microsoft.com/.default | ||
| 186 | `; | ||
| 187 | |||
| 188 | const graphHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 189 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 190 | Effect.succeed( | ||
| 191 | HttpClientResponse.fromWeb( | ||
| 192 | request, | ||
| 193 | new Response( | ||
| 194 | request.url === MICROSOFT_GRAPH_OPENAPI_URL | ||
| 195 | ? graphFixture | ||
| 196 | : request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL | ||
| 197 | ? permissionsReferenceFixture | ||
| 198 | : request.url === EMULATOR_SPEC_URL | ||
| 199 | ? emulatorGraphFixture | ||
| 200 | : request.url === LOCAL_EMULATOR_SPEC_URL | ||
| 201 | ? localEmulatorGraphFixture | ||
| 202 | : "not found", | ||
| 203 | { | ||
| 204 | status: | ||
| 205 | request.url === MICROSOFT_GRAPH_OPENAPI_URL || | ||
| 206 | request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL || | ||
| 207 | request.url === EMULATOR_SPEC_URL || | ||
| 208 | request.url === LOCAL_EMULATOR_SPEC_URL | ||
| 209 | ? 200 | ||
| 210 | : 404, | ||
| 211 | headers: { | ||
| 212 | "content-type": | ||
| 213 | request.url === MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL | ||
| 214 | ? "text/markdown" | ||
| 215 | : "application/yaml", | ||
| 216 | }, | ||
| 217 | }, | ||
| 218 | ), | ||
| 219 | ), | ||
| 220 | ), | ||
| 221 | ), | ||
| 222 | ); | ||
| 223 | |||
| 224 | const graphPlugins = (options?: Omit<MicrosoftPluginOptions, "httpClientLayer">) => | ||
| 225 | [ | ||
| 226 | microsoftPlugin({ httpClientLayer: graphHttpClientLayer, ...options }), | ||
| 227 | memoryCredentialsPlugin(), | ||
| 228 | ] as const; | ||
| 229 | |||
| 230 | describe("Microsoft Graph provider", () => { | ||
| 231 | it.effect("rejects non-Microsoft URL overrides before fetching the Graph spec", () => | ||
| 232 | Effect.scoped( | ||
| 233 | Effect.gen(function* () { | ||
| 234 | let requests = 0; | ||
| 235 | const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 236 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 237 | Effect.sync(() => { | ||
| 238 | requests += 1; | ||
| 239 | return HttpClientResponse.fromWeb( | ||
| 240 | request, | ||
| 241 | new Response("unexpected request", { status: 500 }), | ||
| 242 | ); | ||
| 243 | }), | ||
| 244 | ), | ||
| 245 | ); | ||
| 246 | const executor = yield* createExecutor( | ||
| 247 | makeTestConfig({ | ||
| 248 | plugins: [ | ||
| 249 | microsoftPlugin({ httpClientLayer: blockedHttpClientLayer }), | ||
| 250 | memoryCredentialsPlugin(), | ||
| 251 | ], | ||
| 252 | }), | ||
| 253 | ); | ||
| 254 | |||
| 255 | const exit = yield* executor.microsoft | ||
| 256 | .addGraph({ | ||
| 257 | slug: "bad_graph", | ||
| 258 | baseUrl: "https://attacker.example/v1.0", | ||
| 259 | specUrl: "https://attacker.example/openapi.yaml", | ||
| 260 | authorizationUrl: "https://attacker.example/oauth2/v2.0/authorize", | ||
| 261 | tokenUrl: "https://attacker.example/oauth2/v2.0/token", | ||
| 262 | clientCredentialsTokenUrl: "https://attacker.example/oauth2/v2.0/token", | ||
| 263 | }) | ||
| 264 | .pipe(Effect.exit); | ||
| 265 | |||
| 266 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 267 | expect(requests).toBe(0); | ||
| 268 | }), | ||
| 269 | ), | ||
| 270 | ); | ||
| 271 | |||
| 272 | it.effect("adds a selected Graph workload source with one OAuth template", () => | ||
| 273 | Effect.scoped( | ||
| 274 | Effect.gen(function* () { | ||
| 275 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 276 | |||
| 277 | const result = yield* executor.microsoft.addGraph({ | ||
| 278 | presetIds: ["profile", "mail"], | ||
| 279 | slug: "microsoft_graph", | ||
| 280 | description: "Microsoft Graph", | ||
| 281 | }); | ||
| 282 | |||
| 283 | expect(String(result.slug)).toBe("microsoft_graph"); | ||
| 284 | |||
| 285 | const config = yield* executor.microsoft.getConfig("microsoft_graph"); | ||
| 286 | expect(config?.microsoftGraphPresetIds).toEqual(["profile", "mail"]); | ||
| 287 | expect(config?.microsoftGraphCoversFullGraph).toBe(false); | ||
| 288 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 289 | "offline_access", | ||
| 290 | "User.Read", | ||
| 291 | "Mail.ReadWrite", | ||
| 292 | "Mail.Send", | ||
| 293 | "MailboxSettings.ReadWrite", | ||
| 294 | ]); | ||
| 295 | |||
| 296 | const oauthTemplates = config?.authenticationTemplate?.filter( | ||
| 297 | (entry) => entry.kind === "oauth2", | ||
| 298 | ); | ||
| 299 | const delegated = oauthTemplates?.find( | ||
| 300 | (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 301 | ); | ||
| 302 | const clientCredentials = oauthTemplates?.find( | ||
| 303 | (entry) => String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 304 | ); | ||
| 305 | expect(delegated?.kind === "oauth2" ? delegated.slug : undefined).toBe( | ||
| 306 | AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 307 | ); | ||
| 308 | expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([ | ||
| 309 | "offline_access", | ||
| 310 | "User.Read", | ||
| 311 | "Mail.ReadWrite", | ||
| 312 | "Mail.Send", | ||
| 313 | "MailboxSettings.ReadWrite", | ||
| 314 | ]); | ||
| 315 | expect(clientCredentials?.kind === "oauth2" ? clientCredentials.slug : undefined).toBe( | ||
| 316 | AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG), | ||
| 317 | ); | ||
| 318 | expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual( | ||
| 319 | [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], | ||
| 320 | ); | ||
| 321 | |||
| 322 | yield* executor.connections.create({ | ||
| 323 | owner: "org", | ||
| 324 | name: ConnectionName.make("main"), | ||
| 325 | integration: IntegrationSlug.make("microsoft_graph"), | ||
| 326 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 327 | value: "token-xyz", | ||
| 328 | }); | ||
| 329 | |||
| 330 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 331 | expect(toolNames).toContain("me.getUser"); | ||
| 332 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 333 | expect(toolNames).not.toContain("sites.listSites"); | ||
| 334 | }), | ||
| 335 | ), | ||
| 336 | ); | ||
| 337 | |||
| 338 | it.effect("adds common Microsoft Graph workloads by default", () => | ||
| 339 | Effect.scoped( | ||
| 340 | Effect.gen(function* () { | ||
| 341 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 342 | |||
| 343 | yield* executor.microsoft.addGraph({ | ||
| 344 | slug: "microsoft_graph_all", | ||
| 345 | description: "Microsoft Graph", | ||
| 346 | }); | ||
| 347 | |||
| 348 | const config = yield* executor.microsoft.getConfig("microsoft_graph_all"); | ||
| 349 | expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_DEFAULT_PRESET_IDS); | ||
| 350 | expect(config?.microsoftGraphCoversFullGraph).toBe(false); | ||
| 351 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 352 | "offline_access", | ||
| 353 | "User.Read", | ||
| 354 | "Mail.ReadWrite", | ||
| 355 | "Mail.Send", | ||
| 356 | "MailboxSettings.ReadWrite", | ||
| 357 | "Calendars.ReadWrite", | ||
| 358 | "Contacts.ReadWrite", | ||
| 359 | "People.Read.All", | ||
| 360 | "Tasks.ReadWrite", | ||
| 361 | "Files.ReadWrite.All", | ||
| 362 | "Sites.ReadWrite.All", | ||
| 363 | "Notes.ReadWrite", | ||
| 364 | "Chat.ReadWrite", | ||
| 365 | "Team.ReadBasic.All", | ||
| 366 | "Channel.ReadBasic.All", | ||
| 367 | "ChannelMessage.Read.All", | ||
| 368 | "ChannelMessage.Send", | ||
| 369 | "OnlineMeetings.ReadWrite", | ||
| 370 | ]); | ||
| 371 | |||
| 372 | const delegated = config?.authenticationTemplate?.find( | ||
| 373 | (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 374 | ); | ||
| 375 | expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual( | ||
| 376 | config?.microsoftGraphScopes, | ||
| 377 | ); | ||
| 378 | |||
| 379 | yield* executor.connections.create({ | ||
| 380 | owner: "org", | ||
| 381 | name: ConnectionName.make("all"), | ||
| 382 | integration: IntegrationSlug.make("microsoft_graph_all"), | ||
| 383 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 384 | value: "token-xyz", | ||
| 385 | }); | ||
| 386 | |||
| 387 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 388 | expect(toolNames).toContain("me.getUser"); | ||
| 389 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 390 | expect(toolNames).toContain("me.eventsListEvents"); | ||
| 391 | expect(toolNames).toContain("me.onenotePagesListPages"); | ||
| 392 | expect(toolNames).toContain("sites.listSites"); | ||
| 393 | }), | ||
| 394 | ), | ||
| 395 | ); | ||
| 396 | |||
| 397 | it.effect("uses the app registration default scope when every Graph workload is selected", () => | ||
| 398 | Effect.scoped( | ||
| 399 | Effect.gen(function* () { | ||
| 400 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 401 | |||
| 402 | yield* executor.microsoft.addGraph({ | ||
| 403 | presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | ||
| 404 | slug: "microsoft_graph_full", | ||
| 405 | description: "Microsoft Graph", | ||
| 406 | }); | ||
| 407 | |||
| 408 | const config = yield* executor.microsoft.getConfig("microsoft_graph_full"); | ||
| 409 | expect(config?.microsoftGraphPresetIds).toEqual(MICROSOFT_GRAPH_ALL_PRESET_IDS); | ||
| 410 | expect(config?.microsoftGraphCoversFullGraph).toBe(true); | ||
| 411 | expect(config?.microsoftGraphScopes).toEqual(MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES); | ||
| 412 | |||
| 413 | const delegated = config?.authenticationTemplate?.find( | ||
| 414 | (entry) => String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 415 | ); | ||
| 416 | expect(delegated?.kind === "oauth2" ? delegated.scopes : undefined).toEqual([ | ||
| 417 | ...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, | ||
| 418 | ]); | ||
| 419 | |||
| 420 | // Full-graph add routes through the streaming compile (the path the | ||
| 421 | // real 37MB spec takes): the source text is structurally split and each | ||
| 422 | // op's binding plus a `description` is persisted, alongside the | ||
| 423 | // content-addressed defs blob, never materializing the whole-document | ||
| 424 | // tree. Read the operations back through the live serve path to prove | ||
| 425 | // they landed in storage AND that the serve fast path rebuilds tools | ||
| 426 | // from the persisted bindings (no spec parse). | ||
| 427 | yield* executor.connections.create({ | ||
| 428 | owner: "org", | ||
| 429 | name: ConnectionName.make("full"), | ||
| 430 | integration: IntegrationSlug.make("microsoft_graph_full"), | ||
| 431 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 432 | value: "token-xyz", | ||
| 433 | }); | ||
| 434 | |||
| 435 | const tools = yield* executor.tools.list(); | ||
| 436 | const toolNames = tools.map((tool) => String(tool.name)); | ||
| 437 | expect(toolNames).toContain("me.getUser"); | ||
| 438 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 439 | expect(toolNames).toContain("sites.listSites"); | ||
| 440 | |||
| 441 | // The serve fast path must rebuild every tool's description from the | ||
| 442 | // persisted operation, not drop it. Each graph tool carries a non-empty | ||
| 443 | // description. | ||
| 444 | for (const tool of tools) { | ||
| 445 | expect(tool.description.length).toBeGreaterThan(0); | ||
| 446 | } | ||
| 447 | |||
| 448 | // `me.getUser`'s spec summary survives the add -> persist -> serve | ||
| 449 | // round-trip. The bare `${METHOD} ${path}` fallback inside the serve | ||
| 450 | // path would be "GET /me", so matching the summary proves the persisted | ||
| 451 | // `description` field is what's served. | ||
| 452 | const getUser = tools.find((tool) => String(tool.name) === "me.getUser"); | ||
| 453 | expect(getUser?.description).toBe("Get the signed-in user profile"); | ||
| 454 | |||
| 455 | // An op without a spec summary falls back to `${METHOD} ${path}`, also | ||
| 456 | // sourced from the persisted binding on the serve fast path. | ||
| 457 | const listSites = tools.find((tool) => String(tool.name) === "sites.listSites"); | ||
| 458 | expect(listSites?.description).toBe("GET /sites"); | ||
| 459 | }), | ||
| 460 | ), | ||
| 461 | ); | ||
| 462 | |||
| 463 | it.effect("uses explicit full Graph scopes when custom dynamic scopes are added", () => | ||
| 464 | Effect.scoped( | ||
| 465 | Effect.gen(function* () { | ||
| 466 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 467 | |||
| 468 | yield* executor.microsoft.addGraph({ | ||
| 469 | slug: "microsoft_graph_all_custom", | ||
| 470 | presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | ||
| 471 | customScopes: ["Custom.Scope"], | ||
| 472 | }); | ||
| 473 | |||
| 474 | const config = yield* executor.microsoft.getConfig("microsoft_graph_all_custom"); | ||
| 475 | expect(config?.microsoftGraphCoversFullGraph).toBe(true); | ||
| 476 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 477 | "offline_access", | ||
| 478 | "User.Read", | ||
| 479 | "Mail.ReadWrite", | ||
| 480 | "Calendars.ReadWrite", | ||
| 481 | "Notes.ReadWrite", | ||
| 482 | "Custom.Scope", | ||
| 483 | ]); | ||
| 484 | }), | ||
| 485 | ), | ||
| 486 | ); | ||
| 487 | |||
| 488 | it.effect("adds Microsoft Graph from the emulator spec with app-only OAuth endpoints", () => | ||
| 489 | Effect.scoped( | ||
| 490 | Effect.gen(function* () { | ||
| 491 | const executor = yield* createExecutor( | ||
| 492 | makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }), | ||
| 493 | ); | ||
| 494 | |||
| 495 | yield* executor.microsoft.addGraph({ | ||
| 496 | presetIds: ["users"], | ||
| 497 | slug: "microsoft_graph_emulated", | ||
| 498 | baseUrl: EMULATOR_BASE_URL, | ||
| 499 | specUrl: EMULATOR_SPEC_URL, | ||
| 500 | }); | ||
| 501 | |||
| 502 | const config = yield* executor.microsoft.getConfig("microsoft_graph_emulated"); | ||
| 503 | expect(config?.sourceUrl).toBe(EMULATOR_SPEC_URL); | ||
| 504 | expect(config?.baseUrl).toBe(EMULATOR_BASE_URL); | ||
| 505 | expect(config?.microsoftGraphAuthorizationUrl).toBe( | ||
| 506 | `${EMULATOR_BASE_URL}/oauth2/v2.0/authorize`, | ||
| 507 | ); | ||
| 508 | expect(config?.microsoftGraphTokenUrl).toBe(`${EMULATOR_BASE_URL}/oauth2/v2.0/token`); | ||
| 509 | expect(config?.microsoftGraphClientCredentialsTokenUrl).toBe( | ||
| 510 | `${EMULATOR_BASE_URL}/oauth2/v2.0/token`, | ||
| 511 | ); | ||
| 512 | |||
| 513 | const delegated = config?.authenticationTemplate?.find( | ||
| 514 | (entry) => entry.kind === "oauth2" && String(entry.slug) === MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 515 | ); | ||
| 516 | const clientCredentials = config?.authenticationTemplate?.find( | ||
| 517 | (entry) => | ||
| 518 | entry.kind === "oauth2" && | ||
| 519 | String(entry.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 520 | ); | ||
| 521 | expect(delegated?.kind === "oauth2" ? delegated.tokenUrl : undefined).toBe( | ||
| 522 | `${EMULATOR_BASE_URL}/oauth2/v2.0/token`, | ||
| 523 | ); | ||
| 524 | expect(clientCredentials?.kind === "oauth2" ? clientCredentials.scopes : undefined).toEqual( | ||
| 525 | [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], | ||
| 526 | ); | ||
| 527 | |||
| 528 | yield* executor.connections.create({ | ||
| 529 | owner: "org", | ||
| 530 | name: ConnectionName.make("machine"), | ||
| 531 | integration: IntegrationSlug.make("microsoft_graph_emulated"), | ||
| 532 | template: AuthTemplateSlug.make(MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG), | ||
| 533 | value: "token-xyz", | ||
| 534 | }); | ||
| 535 | |||
| 536 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 537 | expect(toolNames).toContain("users.graphUserList"); | ||
| 538 | }), | ||
| 539 | ), | ||
| 540 | ); | ||
| 541 | |||
| 542 | it.effect("accepts a loopback http emulator spec only when the override is enabled", () => | ||
| 543 | Effect.scoped( | ||
| 544 | Effect.gen(function* () { | ||
| 545 | const executor = yield* createExecutor( | ||
| 546 | makeTestConfig({ plugins: graphPlugins({ allowUnsafeUrlOverrides: true }) }), | ||
| 547 | ); | ||
| 548 | |||
| 549 | yield* executor.microsoft.addGraph({ | ||
| 550 | presetIds: ["users"], | ||
| 551 | slug: "microsoft_graph_local_emulated", | ||
| 552 | baseUrl: LOCAL_EMULATOR_BASE_URL, | ||
| 553 | specUrl: LOCAL_EMULATOR_SPEC_URL, | ||
| 554 | }); | ||
| 555 | |||
| 556 | const config = yield* executor.microsoft.getConfig("microsoft_graph_local_emulated"); | ||
| 557 | expect(config?.sourceUrl).toBe(LOCAL_EMULATOR_SPEC_URL); | ||
| 558 | expect(config?.baseUrl).toBe(LOCAL_EMULATOR_BASE_URL); | ||
| 559 | }), | ||
| 560 | ), | ||
| 561 | ); | ||
| 562 | |||
| 563 | it.effect("rejects a loopback http spec URL when the override is disabled", () => | ||
| 564 | Effect.scoped( | ||
| 565 | Effect.gen(function* () { | ||
| 566 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 567 | |||
| 568 | const exit = yield* executor.microsoft | ||
| 569 | .addGraph({ | ||
| 570 | slug: "microsoft_graph_local_disabled", | ||
| 571 | baseUrl: LOCAL_EMULATOR_BASE_URL, | ||
| 572 | specUrl: LOCAL_EMULATOR_SPEC_URL, | ||
| 573 | }) | ||
| 574 | .pipe(Effect.exit); | ||
| 575 | |||
| 576 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 577 | }), | ||
| 578 | ), | ||
| 579 | ); | ||
| 580 | |||
| 581 | it.effect("rejects a non-loopback http override even with allowUnsafeUrlOverrides", () => | ||
| 582 | Effect.scoped( | ||
| 583 | Effect.gen(function* () { | ||
| 584 | let requests = 0; | ||
| 585 | const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 586 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 587 | Effect.sync(() => { | ||
| 588 | requests += 1; | ||
| 589 | return HttpClientResponse.fromWeb( | ||
| 590 | request, | ||
| 591 | new Response("unexpected request", { status: 500 }), | ||
| 592 | ); | ||
| 593 | }), | ||
| 594 | ), | ||
| 595 | ); | ||
| 596 | const executor = yield* createExecutor( | ||
| 597 | makeTestConfig({ | ||
| 598 | plugins: [ | ||
| 599 | microsoftPlugin({ | ||
| 600 | httpClientLayer: blockedHttpClientLayer, | ||
| 601 | allowUnsafeUrlOverrides: true, | ||
| 602 | }), | ||
| 603 | memoryCredentialsPlugin(), | ||
| 604 | ], | ||
| 605 | }), | ||
| 606 | ); | ||
| 607 | |||
| 608 | const exit = yield* executor.microsoft | ||
| 609 | .addGraph({ | ||
| 610 | slug: "microsoft_graph_http_example", | ||
| 611 | baseUrl: "http://example.com/v1.0", | ||
| 612 | specUrl: "http://example.com/openapi.yaml", | ||
| 613 | }) | ||
| 614 | .pipe(Effect.exit); | ||
| 615 | |||
| 616 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 617 | expect(requests).toBe(0); | ||
| 618 | }), | ||
| 619 | ), | ||
| 620 | ); | ||
| 621 | }); | ||
| 622 | |||
| 623 | describe("Microsoft Graph health-check default", () => { | ||
| 624 | it.effect("addGraph without profile still auto-configures the /me identity check", () => | ||
| 625 | Effect.scoped( | ||
| 626 | Effect.gen(function* () { | ||
| 627 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 628 | |||
| 629 | yield* executor.microsoft.addGraph({ | ||
| 630 | presetIds: ["mail"], | ||
| 631 | slug: "microsoft_graph_mail_hc", | ||
| 632 | description: "Microsoft Graph", | ||
| 633 | }); | ||
| 634 | |||
| 635 | const config = yield* executor.microsoft.getConfig("microsoft_graph_mail_hc"); | ||
| 636 | expect(config?.microsoftGraphExactPaths).toEqual([]); | ||
| 637 | expect(config?.microsoftGraphScopes).toEqual([ | ||
| 638 | "offline_access", | ||
| 639 | "User.Read", | ||
| 640 | "Mail.ReadWrite", | ||
| 641 | "Mail.Send", | ||
| 642 | "MailboxSettings.ReadWrite", | ||
| 643 | ]); | ||
| 644 | |||
| 645 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 646 | IntegrationSlug.make("microsoft_graph_mail_hc"), | ||
| 647 | ); | ||
| 648 | expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser"); | ||
| 649 | expect(stored?.identityField, "the default reads the principal name").toBe( | ||
| 650 | "userPrincipalName", | ||
| 651 | ); | ||
| 652 | |||
| 653 | yield* executor.connections.create({ | ||
| 654 | owner: "org", | ||
| 655 | name: ConnectionName.make("mail"), | ||
| 656 | integration: IntegrationSlug.make("microsoft_graph_mail_hc"), | ||
| 657 | template: AuthTemplateSlug.make(MICROSOFT_AUTH_TEMPLATE_SLUG), | ||
| 658 | value: "token-xyz", | ||
| 659 | }); | ||
| 660 | |||
| 661 | const toolNames = (yield* executor.tools.list()).map((tool) => String(tool.name)); | ||
| 662 | expect(toolNames).toContain("me.getUser"); | ||
| 663 | expect(toolNames).toContain("me.messagesListMessages"); | ||
| 664 | expect(toolNames).not.toContain("me.eventsListEvents"); | ||
| 665 | }), | ||
| 666 | ), | ||
| 667 | ); | ||
| 668 | |||
| 669 | it.effect("addGraph with a /me workload auto-configures the identity health check", () => | ||
| 670 | Effect.scoped( | ||
| 671 | Effect.gen(function* () { | ||
| 672 | const executor = yield* createExecutor(makeTestConfig({ plugins: graphPlugins() })); | ||
| 673 | |||
| 674 | yield* executor.microsoft.addGraph({ | ||
| 675 | presetIds: ["profile", "mail"], | ||
| 676 | slug: "microsoft_graph_hc", | ||
| 677 | description: "Microsoft Graph", | ||
| 678 | }); | ||
| 679 | |||
| 680 | // GET /me is the canonical Graph identity endpoint: the default probe | ||
| 681 | // answers alive/expired + who-am-I with zero configuration. | ||
| 682 | const stored = yield* executor.integrations.healthCheck.get( | ||
| 683 | IntegrationSlug.make("microsoft_graph_hc"), | ||
| 684 | ); | ||
| 685 | expect(stored?.operation, "the default check targets GET /me").toBe("me.getUser"); | ||
| 686 | expect(stored?.identityField, "the default reads the principal name").toBe( | ||
| 687 | "userPrincipalName", | ||
| 688 | ); | ||
| 689 | }), | ||
| 690 | ), | ||
| 691 | ); | ||
| 692 | }); | ||
| @@ -1,436 +0,0 @@ | |||
| 1 | import { Effect } from "effect"; | ||
| 2 | import type { Layer } from "effect"; | ||
| 3 | import { HttpClient } from "effect/unstable/http"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | IntegrationAlreadyExistsError, | ||
| 7 | IntegrationDetectionResult, | ||
| 8 | IntegrationNotFoundError, | ||
| 9 | IntegrationSlug, | ||
| 10 | definePlugin, | ||
| 11 | mergeAuthTemplates, | ||
| 12 | sha256Hex, | ||
| 13 | type AuthMethodDescriptor, | ||
| 14 | type Integration, | ||
| 15 | type IntegrationConfig, | ||
| 16 | type IntegrationRecord, | ||
| 17 | type PluginCtx, | ||
| 18 | type StorageFailure, | ||
| 19 | } from "@executor-js/sdk/core"; | ||
| 20 | import { describeApiKeyAuthMethod } from "@executor-js/sdk/http-auth"; | ||
| 21 | import { | ||
| 22 | checkHealthOpenApi, | ||
| 23 | compileAndPersistOpenApiSpecStreaming, | ||
| 24 | listHealthCheckCandidatesOpenApi, | ||
| 25 | decodeOpenApiIntegrationConfig, | ||
| 26 | invokeOpenApiBackedTool, | ||
| 27 | makeDefaultOpenapiStore, | ||
| 28 | normalizeOpenApiAuthInputs, | ||
| 29 | OpenApiExtractionError, | ||
| 30 | resolveOpenApiBackedAnnotations, | ||
| 31 | resolveOpenApiBackedTools, | ||
| 32 | type Authentication, | ||
| 33 | type AuthenticationInput, | ||
| 34 | type OpenApiPersistResult, | ||
| 35 | type OpenapiStore, | ||
| 36 | } from "@executor-js/plugin-openapi"; | ||
| 37 | |||
| 38 | import { | ||
| 39 | buildMicrosoftGraphOpenApiSpec, | ||
| 40 | decodeMicrosoftGraphIntegrationConfig, | ||
| 41 | microsoftGraphKeepPathItem, | ||
| 42 | type MicrosoftGraphUrlPolicy, | ||
| 43 | type MicrosoftGraphIntegrationConfig, | ||
| 44 | type MicrosoftGraphSpecBuild, | ||
| 45 | } from "./graph"; | ||
| 46 | import { | ||
| 47 | MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 48 | MICROSOFT_GRAPH_BASE_URL, | ||
| 49 | microsoftGraphPreset, | ||
| 50 | } from "./presets"; | ||
| 51 | |||
| 52 | export interface MicrosoftGraphConfig { | ||
| 53 | readonly presetIds?: readonly string[]; | ||
| 54 | readonly customScopes?: readonly string[]; | ||
| 55 | readonly slug?: string; | ||
| 56 | readonly name?: string; | ||
| 57 | readonly description?: string; | ||
| 58 | readonly baseUrl?: string; | ||
| 59 | readonly specUrl?: string; | ||
| 60 | readonly authorizationUrl?: string; | ||
| 61 | readonly tokenUrl?: string; | ||
| 62 | readonly clientCredentialsTokenUrl?: string; | ||
| 63 | } | ||
| 64 | |||
| 65 | export interface MicrosoftConfigureInput { | ||
| 66 | readonly authenticationTemplate: readonly AuthenticationInput[]; | ||
| 67 | readonly mode?: "merge" | "replace"; | ||
| 68 | } | ||
| 69 | |||
| 70 | export interface MicrosoftUpdateInput { | ||
| 71 | readonly presetIds?: readonly string[]; | ||
| 72 | readonly customScopes?: readonly string[]; | ||
| 73 | readonly baseUrl?: string; | ||
| 74 | readonly specUrl?: string; | ||
| 75 | readonly authorizationUrl?: string; | ||
| 76 | readonly tokenUrl?: string; | ||
| 77 | readonly clientCredentialsTokenUrl?: string; | ||
| 78 | } | ||
| 79 | |||
| 80 | export interface MicrosoftUpdateResult { | ||
| 81 | readonly slug: IntegrationSlug; | ||
| 82 | readonly toolCount: number; | ||
| 83 | readonly addedTools: readonly string[]; | ||
| 84 | readonly removedTools: readonly string[]; | ||
| 85 | } | ||
| 86 | |||
| 87 | export interface MicrosoftPluginOptions { | ||
| 88 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | ||
| 89 | /** | ||
| 90 | * Allows `addGraph` to point spec/base/OAuth URLs at a trusted https host | ||
| 91 | * other than the pinned Microsoft Graph endpoints, or at plain http on | ||
| 92 | * loopback (local Graph emulators). Off by default; hosts wire this to | ||
| 93 | * their own local-network dev posture (e.g. `allowLocalNetwork`), never on | ||
| 94 | * in production. | ||
| 95 | */ | ||
| 96 | readonly allowUnsafeUrlOverrides?: boolean; | ||
| 97 | } | ||
| 98 | |||
| 99 | const DEFAULT_MICROSOFT_SLUG = "microsoft_graph"; | ||
| 100 | |||
| 101 | const describeMicrosoftAuthMethods = ( | ||
| 102 | record: IntegrationRecord, | ||
| 103 | ): readonly AuthMethodDescriptor[] => { | ||
| 104 | const config = decodeOpenApiIntegrationConfig(record.config); | ||
| 105 | if (!config) return []; | ||
| 106 | return (config.authenticationTemplate ?? []).map( | ||
| 107 | (template: Authentication): AuthMethodDescriptor => { | ||
| 108 | if (template.kind === "oauth2") { | ||
| 109 | const machineFlow = | ||
| 110 | String(template.slug) === MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG; | ||
| 111 | return { | ||
| 112 | id: String(template.slug), | ||
| 113 | label: machineFlow ? "Microsoft OAuth (client credentials)" : "Microsoft OAuth", | ||
| 114 | kind: "oauth", | ||
| 115 | template: String(template.slug), | ||
| 116 | oauth: { | ||
| 117 | authorizationUrl: template.authorizationUrl, | ||
| 118 | tokenUrl: template.tokenUrl, | ||
| 119 | scopes: template.scopes, | ||
| 120 | }, | ||
| 121 | }; | ||
| 122 | } | ||
| 123 | return describeApiKeyAuthMethod(template); | ||
| 124 | }, | ||
| 125 | ); | ||
| 126 | }; | ||
| 127 | |||
| 128 | const describeMicrosoftIntegrationDisplay = ( | ||
| 129 | record: IntegrationRecord, | ||
| 130 | ): { readonly url?: string } => { | ||
| 131 | const config = decodeMicrosoftGraphIntegrationConfig(record.config); | ||
| 132 | return { url: config?.baseUrl ?? MICROSOFT_GRAPH_BASE_URL }; | ||
| 133 | }; | ||
| 134 | |||
| 135 | const makeMicrosoftPluginExtension = ( | ||
| 136 | ctx: PluginCtx<OpenapiStore>, | ||
| 137 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, | ||
| 138 | urlPolicy?: MicrosoftGraphUrlPolicy, | ||
| 139 | ) => { | ||
| 140 | const persistGraphOperations = ( | ||
| 141 | graph: MicrosoftGraphSpecBuild, | ||
| 142 | integration: string, | ||
| 143 | specHash: string, | ||
| 144 | ): Effect.Effect<OpenApiPersistResult, OpenApiExtractionError | StorageFailure> => | ||
| 145 | // Stream the (full 37MB) source straight to persisted bindings + a | ||
| 146 | // content-addressed defs blob, never materializing the whole-document tree | ||
| 147 | // that OOMs the 128MB Workers isolate. `keepPathItem` applies the Microsoft | ||
| 148 | // Graph scope selection per path-item during the stream; a full-graph | ||
| 149 | // selection returns `undefined` (keep everything). | ||
| 150 | compileAndPersistOpenApiSpecStreaming({ | ||
| 151 | specText: graph.specText, | ||
| 152 | integration, | ||
| 153 | storage: ctx.storage, | ||
| 154 | specHash, | ||
| 155 | keepPathItem: microsoftGraphKeepPathItem(graph), | ||
| 156 | }); | ||
| 157 | |||
| 158 | const addGraph = (config: MicrosoftGraphConfig) => | ||
| 159 | Effect.gen(function* () { | ||
| 160 | const graph = yield* buildMicrosoftGraphOpenApiSpec(config, httpClientLayer, urlPolicy); | ||
| 161 | const slug = IntegrationSlug.make(config.slug?.trim() || DEFAULT_MICROSOFT_SLUG); | ||
| 162 | |||
| 163 | const existing = yield* ctx.core.integrations.get(slug); | ||
| 164 | if (existing) { | ||
| 165 | return yield* new IntegrationAlreadyExistsError({ slug }); | ||
| 166 | } | ||
| 167 | |||
| 168 | const specHash = yield* sha256Hex(graph.specText); | ||
| 169 | const integrationConfig: MicrosoftGraphIntegrationConfig = { | ||
| 170 | specHash, | ||
| 171 | sourceUrl: graph.specUrl, | ||
| 172 | microsoftGraphPresetIds: graph.presetIds, | ||
| 173 | microsoftGraphCustomScopes: graph.customScopes, | ||
| 174 | microsoftGraphScopes: graph.scopes, | ||
| 175 | microsoftGraphExactPaths: graph.exactPaths, | ||
| 176 | microsoftGraphPathPrefixes: graph.pathPrefixes, | ||
| 177 | microsoftGraphTagPrefixes: graph.tagPrefixes, | ||
| 178 | microsoftGraphCoversFullGraph: graph.coversFullGraph, | ||
| 179 | microsoftGraphAuthorizationUrl: graph.authorizationUrl, | ||
| 180 | microsoftGraphTokenUrl: graph.tokenUrl, | ||
| 181 | microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl, | ||
| 182 | authenticationTemplate: graph.authenticationTemplate, | ||
| 183 | ...(config.baseUrl ? { baseUrl: config.baseUrl } : {}), | ||
| 184 | }; | ||
| 185 | |||
| 186 | yield* ctx.storage.putSpec(specHash, graph.specText); | ||
| 187 | |||
| 188 | const persisted = yield* ctx.transaction( | ||
| 189 | Effect.gen(function* () { | ||
| 190 | yield* ctx.core.integrations.register({ | ||
| 191 | slug, | ||
| 192 | name: config.name?.trim() || "Microsoft Graph", | ||
| 193 | description: config.description ?? "Selected Microsoft Graph workloads.", | ||
| 194 | config: | ||
| 195 | integrationConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig, | ||
| 196 | canRemove: true, | ||
| 197 | canRefresh: true, | ||
| 198 | }); | ||
| 199 | return yield* persistGraphOperations(graph, String(slug), specHash); | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | |||
| 203 | // Default the health check to `GET /me` (the canonical Graph identity | ||
| 204 | // endpoint) when the selected workloads include it, so connections report | ||
| 205 | // alive/expired + who-am-I out of the box. The operations were streamed | ||
| 206 | // to storage above, so find the binding there. Best-effort: a Graph | ||
| 207 | // selection without /me just leaves the check unconfigured (the editor | ||
| 208 | // remains available). | ||
| 209 | const meOperation = (yield* ctx.storage.listOperations(String(slug))).find( | ||
| 210 | (op) => op.binding.method.toLowerCase() === "get" && op.binding.pathTemplate === "/me", | ||
| 211 | ); | ||
| 212 | if (meOperation) { | ||
| 213 | yield* ctx.core.integrations.setHealthCheck(slug, { | ||
| 214 | operation: meOperation.toolName, | ||
| 215 | identityField: "userPrincipalName", | ||
| 216 | }); | ||
| 217 | } | ||
| 218 | |||
| 219 | return { slug, toolCount: persisted.toolCount }; | ||
| 220 | }); | ||
| 221 | |||
| 222 | const updateGraph = (rawSlug: string, input?: MicrosoftUpdateInput) => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | const slug = IntegrationSlug.make(rawSlug); | ||
| 225 | const record = yield* ctx.core.integrations.get(slug); | ||
| 226 | const current = record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null; | ||
| 227 | if (!record || !current) { | ||
| 228 | return yield* new IntegrationNotFoundError({ slug }); | ||
| 229 | } | ||
| 230 | |||
| 231 | const graph = yield* buildMicrosoftGraphOpenApiSpec( | ||
| 232 | { | ||
| 233 | presetIds: input?.presetIds ?? current.microsoftGraphPresetIds, | ||
| 234 | customScopes: input?.customScopes ?? current.microsoftGraphCustomScopes, | ||
| 235 | baseUrl: input?.baseUrl ?? current.baseUrl, | ||
| 236 | specUrl: input?.specUrl ?? current.sourceUrl, | ||
| 237 | authorizationUrl: input?.authorizationUrl ?? current.microsoftGraphAuthorizationUrl, | ||
| 238 | tokenUrl: input?.tokenUrl ?? current.microsoftGraphTokenUrl, | ||
| 239 | clientCredentialsTokenUrl: | ||
| 240 | input?.clientCredentialsTokenUrl ?? current.microsoftGraphClientCredentialsTokenUrl, | ||
| 241 | }, | ||
| 242 | httpClientLayer, | ||
| 243 | urlPolicy, | ||
| 244 | ); | ||
| 245 | const previousOperations = yield* ctx.storage.listOperations(rawSlug); | ||
| 246 | const previousNames = new Set(previousOperations.map((op) => op.toolName)); | ||
| 247 | |||
| 248 | const specHash = yield* sha256Hex(graph.specText); | ||
| 249 | yield* ctx.storage.putSpec(specHash, graph.specText); | ||
| 250 | |||
| 251 | const nextConfig: MicrosoftGraphIntegrationConfig = { | ||
| 252 | ...current, | ||
| 253 | specHash, | ||
| 254 | sourceUrl: graph.specUrl, | ||
| 255 | microsoftGraphPresetIds: graph.presetIds, | ||
| 256 | microsoftGraphCustomScopes: graph.customScopes, | ||
| 257 | microsoftGraphScopes: graph.scopes, | ||
| 258 | microsoftGraphExactPaths: graph.exactPaths, | ||
| 259 | microsoftGraphPathPrefixes: graph.pathPrefixes, | ||
| 260 | microsoftGraphTagPrefixes: graph.tagPrefixes, | ||
| 261 | microsoftGraphCoversFullGraph: graph.coversFullGraph, | ||
| 262 | microsoftGraphAuthorizationUrl: graph.authorizationUrl, | ||
| 263 | microsoftGraphTokenUrl: graph.tokenUrl, | ||
| 264 | microsoftGraphClientCredentialsTokenUrl: graph.clientCredentialsTokenUrl, | ||
| 265 | authenticationTemplate: graph.authenticationTemplate, | ||
| 266 | ...(input?.baseUrl ? { baseUrl: input.baseUrl } : {}), | ||
| 267 | }; | ||
| 268 | |||
| 269 | const persisted = yield* ctx.transaction( | ||
| 270 | Effect.gen(function* () { | ||
| 271 | yield* ctx.core.integrations.update(slug, { | ||
| 272 | config: nextConfig satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig, | ||
| 273 | }); | ||
| 274 | return yield* persistGraphOperations(graph, rawSlug, specHash); | ||
| 275 | }), | ||
| 276 | ); | ||
| 277 | const nextNames = new Set(persisted.toolNames); | ||
| 278 | |||
| 279 | const connections = yield* ctx.connections.list({ integration: slug }); | ||
| 280 | yield* Effect.forEach( | ||
| 281 | connections, | ||
| 282 | (connection) => | ||
| 283 | ctx.connections | ||
| 284 | .refresh({ | ||
| 285 | owner: connection.owner, | ||
| 286 | integration: connection.integration, | ||
| 287 | name: connection.name, | ||
| 288 | }) | ||
| 289 | .pipe(Effect.catchTag("ConnectionNotFoundError", () => Effect.succeed([]))), | ||
| 290 | { discard: true }, | ||
| 291 | ).pipe(Effect.catchTag("IntegrationNotFoundError", () => Effect.void)); | ||
| 292 | |||
| 293 | return { | ||
| 294 | slug, | ||
| 295 | toolCount: persisted.toolCount, | ||
| 296 | addedTools: [...nextNames].filter((name) => !previousNames.has(name)).sort(), | ||
| 297 | removedTools: [...previousNames].filter((name) => !nextNames.has(name)).sort(), | ||
| 298 | }; | ||
| 299 | }); | ||
| 300 | |||
| 301 | return { | ||
| 302 | addGraph, | ||
| 303 | updateGraph, | ||
| 304 | removeGraph: (slug: string) => | ||
| 305 | ctx.transaction( | ||
| 306 | Effect.gen(function* () { | ||
| 307 | yield* ctx.storage.removeOperations(slug); | ||
| 308 | yield* ctx.core.integrations | ||
| 309 | .remove(IntegrationSlug.make(slug)) | ||
| 310 | .pipe(Effect.catchTag("IntegrationRemovalNotAllowedError", () => Effect.void)); | ||
| 311 | }), | ||
| 312 | ), | ||
| 313 | getIntegration: (slug: string) => | ||
| 314 | ctx.core.integrations.get(IntegrationSlug.make(slug)).pipe( | ||
| 315 | Effect.map((record) => | ||
| 316 | record | ||
| 317 | ? ({ | ||
| 318 | slug: record.slug, | ||
| 319 | description: record.description, | ||
| 320 | kind: record.kind, | ||
| 321 | canRemove: record.canRemove, | ||
| 322 | canRefresh: record.canRefresh, | ||
| 323 | } as Integration) | ||
| 324 | : null, | ||
| 325 | ), | ||
| 326 | ), | ||
| 327 | getConfig: (slug: string) => | ||
| 328 | ctx.core.integrations | ||
| 329 | .get(IntegrationSlug.make(slug)) | ||
| 330 | .pipe( | ||
| 331 | Effect.map((record) => | ||
| 332 | record ? decodeMicrosoftGraphIntegrationConfig(record.config) : null, | ||
| 333 | ), | ||
| 334 | ), | ||
| 335 | configure: (slug: string, input: MicrosoftConfigureInput) => | ||
| 336 | ctx.transaction( | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const record = yield* ctx.core.integrations.get(IntegrationSlug.make(slug)); | ||
| 339 | if (!record) return [] as readonly Authentication[]; | ||
| 340 | const current = decodeMicrosoftGraphIntegrationConfig(record.config); | ||
| 341 | if (!current) return [] as readonly Authentication[]; | ||
| 342 | |||
| 343 | const incoming = normalizeOpenApiAuthInputs(input.authenticationTemplate); | ||
| 344 | const merged = | ||
| 345 | input.mode === "replace" | ||
| 346 | ? incoming | ||
| 347 | : mergeAuthTemplates(current.authenticationTemplate ?? [], incoming); | ||
| 348 | |||
| 349 | const next: MicrosoftGraphIntegrationConfig = { | ||
| 350 | ...current, | ||
| 351 | authenticationTemplate: merged, | ||
| 352 | }; | ||
| 353 | |||
| 354 | yield* ctx.core.integrations.update(IntegrationSlug.make(slug), { | ||
| 355 | config: next satisfies MicrosoftGraphIntegrationConfig as IntegrationConfig, | ||
| 356 | }); | ||
| 357 | |||
| 358 | return merged; | ||
| 359 | }), | ||
| 360 | ), | ||
| 361 | }; | ||
| 362 | }; | ||
| 363 | |||
| 364 | export type MicrosoftPluginExtension = ReturnType<typeof makeMicrosoftPluginExtension>; | ||
| 365 | |||
| 366 | export const microsoftPlugin = definePlugin((options?: MicrosoftPluginOptions) => ({ | ||
| 367 | id: "microsoft" as const, | ||
| 368 | packageName: "@executor-js/plugin-microsoft", | ||
| 369 | integrationPresets: [microsoftGraphPreset], | ||
| 370 | storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), | ||
| 371 | |||
| 372 | extension: (ctx) => | ||
| 373 | makeMicrosoftPluginExtension(ctx, options?.httpClientLayer ?? ctx.httpClientLayer, { | ||
| 374 | allowUnsafeUrlOverrides: options?.allowUnsafeUrlOverrides === true, | ||
| 375 | }), | ||
| 376 | |||
| 377 | describeAuthMethods: describeMicrosoftAuthMethods, | ||
| 378 | describeIntegrationDisplay: describeMicrosoftIntegrationDisplay, | ||
| 379 | |||
| 380 | resolveTools: ({ integration, config, storage }) => | ||
| 381 | resolveOpenApiBackedTools({ integration, config, storage }), | ||
| 382 | |||
| 383 | invokeTool: ({ ctx, toolRow, credential, args }) => { | ||
| 384 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | ||
| 385 | return invokeOpenApiBackedTool({ | ||
| 386 | ctx, | ||
| 387 | toolRow, | ||
| 388 | credential, | ||
| 389 | args, | ||
| 390 | httpClientLayer, | ||
| 391 | }); | ||
| 392 | }, | ||
| 393 | |||
| 394 | resolveAnnotations: ({ ctx, integration, toolRows }) => | ||
| 395 | resolveOpenApiBackedAnnotations({ | ||
| 396 | ctx, | ||
| 397 | integration: String(integration), | ||
| 398 | toolRows, | ||
| 399 | }), | ||
| 400 | |||
| 401 | // Health checks reuse the OpenAPI backing (same store). GET /me is | ||
| 402 | // auto-defaulted at addGraph when present; core owns the stored spec. | ||
| 403 | listHealthCheckCandidates: (input) => | ||
| 404 | listHealthCheckCandidatesOpenApi({ ctx: input.ctx, integration: input.integration }), | ||
| 405 | checkHealth: (input) => | ||
| 406 | checkHealthOpenApi({ | ||
| 407 | ctx: input.ctx, | ||
| 408 | integration: input.integration, | ||
| 409 | credential: input.credential, | ||
| 410 | spec: input.spec, | ||
| 411 | httpClientLayer: options?.httpClientLayer ?? input.ctx.httpClientLayer, | ||
| 412 | }), | ||
| 413 | |||
| 414 | removeConnection: () => Effect.void, | ||
| 415 | |||
| 416 | detect: ({ url }) => | ||
| 417 | Effect.sync(() => { | ||
| 418 | const trimmed = url.trim(); | ||
| 419 | if (!URL.canParse(trimmed)) return null; | ||
| 420 | const parsed = new URL(trimmed); | ||
| 421 | const host = parsed.hostname.toLowerCase(); | ||
| 422 | const isGraph = | ||
| 423 | host === "graph.microsoft.com" || | ||
| 424 | (host === "learn.microsoft.com" && parsed.pathname.startsWith("/graph/")) || | ||
| 425 | (host === "raw.githubusercontent.com" && | ||
| 426 | parsed.pathname.includes("/microsoftgraph/msgraph-metadata/")); | ||
| 427 | if (!isGraph) return null; | ||
| 428 | return IntegrationDetectionResult.make({ | ||
| 429 | kind: "microsoft", | ||
| 430 | confidence: "high", | ||
| 431 | endpoint: trimmed, | ||
| 432 | name: "Microsoft Graph", | ||
| 433 | slug: DEFAULT_MICROSOFT_SLUG, | ||
| 434 | }); | ||
| 435 | }), | ||
| 436 | })); | ||
| @@ -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 | ||
| 16 | const FROZEN_MICROSOFT_SLUGS = [ | ||
| 17 | "microsoft_profile", | ||
| 18 | "microsoft_me_surface", | ||
| 19 | "microsoft_mail", | ||
| 20 | "microsoft_calendar", | ||
| 21 | "microsoft_contacts", | ||
| 22 | "microsoft_tasks", | ||
| 23 | "microsoft_planner", | ||
| 24 | "microsoft_files", | ||
| 25 | "microsoft_excel", | ||
| 26 | "microsoft_sites", | ||
| 27 | "microsoft_onenote", | ||
| 28 | "microsoft_teams_chat", | ||
| 29 | "microsoft_teams_channels", | ||
| 30 | "microsoft_meetings_calls", | ||
| 31 | "microsoft_users", | ||
| 32 | "microsoft_groups", | ||
| 33 | "microsoft_directory", | ||
| 34 | "microsoft_applications", | ||
| 35 | "microsoft_identity", | ||
| 36 | "microsoft_admin_reports", | ||
| 37 | "microsoft_security_compliance", | ||
| 38 | "microsoft_devices", | ||
| 39 | "microsoft_education", | ||
| 40 | "microsoft_search", | ||
| 41 | "microsoft_external_connections", | ||
| 42 | "microsoft_solutions", | ||
| 43 | "microsoft_platform_services", | ||
| 44 | ] as const; | ||
| 45 | |||
| 15 | describe("Microsoft Graph scope presets", () => { | 46 | describe("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 | }); |
| @@ -1,3 +1,5 @@ | |||
| 1 | import type { IntegrationPreset } from "@executor-js/sdk/core"; | ||
| 2 | |||
| 1 | export interface MicrosoftGraphPreset { | 3 | export 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 | |||
| 528 | export const microsoftServiceSlug = (presetId: string): string => | ||
| 529 | `microsoft_${presetId.replaceAll("-", "_")}`; | ||
| 530 | |||
| 531 | const microsoftGraphCatalogUrl = (presetId: string): string => | ||
| 532 | `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${encodeURIComponent(presetId)}`; | ||
| 533 | |||
| 534 | const microsoftGraphCatalogAuthTemplate = (preset: MicrosoftGraphScopePreset) => [ | ||
| 535 | { | ||
| 536 | slug: MICROSOFT_AUTH_TEMPLATE_SLUG, | ||
| 537 | kind: "oauth2" as const, | ||
| 538 | authorizationUrl: MICROSOFT_AUTHORIZATION_URL, | ||
| 539 | tokenUrl: MICROSOFT_TOKEN_URL, | ||
| 540 | scopes: microsoftGraphScopesForPresetIds([preset.id]), | ||
| 541 | }, | ||
| 542 | { | ||
| 543 | slug: MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | ||
| 544 | kind: "oauth2" as const, | ||
| 545 | authorizationUrl: MICROSOFT_AUTHORIZATION_URL, | ||
| 546 | tokenUrl: MICROSOFT_TOKEN_URL, | ||
| 547 | scopes: [...MICROSOFT_GRAPH_CLIENT_CREDENTIALS_SCOPES], | ||
| 548 | }, | ||
| 549 | ]; | ||
| 550 | |||
| 551 | export const microsoftCatalog: readonly IntegrationPreset[] = microsoftGraphScopePresets.map( | ||
| 552 | (preset) => ({ | ||
| 553 | id: `microsoft-${preset.id}`, | ||
| 554 | name: preset.name, | ||
| 555 | summary: preset.summary, | ||
| 556 | url: microsoftGraphCatalogUrl(preset.id), | ||
| 557 | ...(preset.icon ? { icon: preset.icon } : {}), | ||
| 558 | ...(preset.featured ? { featured: preset.featured } : {}), | ||
| 559 | family: "microsoft", | ||
| 560 | specFormat: "microsoft-graph", | ||
| 561 | defaultSlug: microsoftServiceSlug(preset.id), | ||
| 562 | authTemplate: microsoftGraphCatalogAuthTemplate(preset), | ||
| 563 | ...(preset.id === "profile" | ||
| 564 | ? { healthCheck: { operation: "me.GetUser", identityField: "mail" } } | ||
| 565 | : {}), | ||
| 566 | }), | ||
| 567 | ); | ||
| @@ -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 | |||
| 79 | it.effect("uses catalog URL fragments to select one Graph workload", () => | ||
| 80 | Effect.gen(function* () { | ||
| 81 | const converted = yield* microsoftGraphAdapter.fetch({ | ||
| 82 | urls: [`${MICROSOFT_GRAPH_OPENAPI_URL}#preset=profile`], | ||
| 83 | httpClientLayer: graphHttpClientLayer, | ||
| 84 | }); | ||
| 85 | const keepPathItem = converted.keepPathItem!; | ||
| 86 | |||
| 87 | expect(keepPathItem("/me", { get: { operationId: "me.GetUser" } })).toEqual({ | ||
| 88 | get: { operationId: "me.GetUser" }, | ||
| 89 | }); | ||
| 90 | expect(keepPathItem("/irrelevant", { get: { operationId: "irrelevant.Get" } })).toBeNull(); | ||
| 91 | }), | ||
| 92 | ); | ||
| @@ -3,13 +3,26 @@ import type { SpecFormatAdapter } from "@executor-js/plugin-openapi"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; | 4 | import { buildMicrosoftGraphOpenApiSpec, microsoftGraphKeepPathItem } from "./graph"; |
| 5 | 5 | ||
| 6 | const graphCatalogSelection = ( | ||
| 7 | rawUrl: string | undefined, | ||
| 8 | ): { readonly specUrl?: string; readonly presetIds?: readonly string[] } => { | ||
| 9 | if (!rawUrl || !URL.canParse(rawUrl)) return rawUrl ? { specUrl: rawUrl } : {}; | ||
| 10 | const parsed = new URL(rawUrl); | ||
| 11 | const preset = parsed.hash.startsWith("#preset=") | ||
| 12 | ? decodeURIComponent(parsed.hash.slice("#preset=".length)) | ||
| 13 | : ""; | ||
| 14 | parsed.hash = ""; | ||
| 15 | return { | ||
| 16 | specUrl: parsed.toString(), | ||
| 17 | ...(preset.length > 0 ? { presetIds: [preset] } : {}), | ||
| 18 | }; | ||
| 19 | }; | ||
| 20 | |||
| 6 | export const microsoftGraphAdapter: SpecFormatAdapter = { | 21 | export 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) => ({ |
| @@ -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, |
| @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; · 1 unmodified line | |||
| 2 | import { Schema } from "effect"; | 2 | import { Schema } from "effect"; |
| 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; | 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; |
| 4 | import { | 4 | import { |
| 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 | ||
| @@ -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 | }), |
| @@ -5,10 +5,12 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines | |||
| 5 | import * as Option from "effect/Option"; | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 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"; |
| 13 | import { useIntegrationPlugins } from "@executor-js/sdk/client"; | ||
| 12 | import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 14 | import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; | 15 | import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; |
| 14 | import { | 16 | import { |
| @@ -43,7 +45,6 @@ import { · 28 unmodified lines | |||
| 43 | } from "./auth-method-config"; | 45 | } from "./auth-method-config"; |
| 44 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; | 46 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; |
| 45 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; | 47 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 46 | import { openApiPresets } from "../sdk/presets"; | ||
| 47 | import type { SpecPreviewSummary } from "../sdk/preview"; | 48 | import type { SpecPreviewSummary } from "../sdk/preview"; |
| 48 | import { type Authentication } from "../sdk/types"; | 49 | import { type Authentication } from "../sdk/types"; |
| 49 | import { resolveServerUrl } from "../sdk/openapi-utils"; | 50 | import { 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 the | 183 | // 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 flow | 206 | // Editable auth methods, seeded from the spec-detected templates. The add flow |
| 195 | // registers EVERY method (P6), so this is a LIST, preserving multi-method | 207 | // 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 has | 323 | // Always send the edited method list (even empty) when the user has |
| 311 | // inspected a preview: an explicit [] means "no auth methods", while | 324 | // inspected a preview: an explicit [] means "no auth methods", while |
| 312 | // OMITTING the field tells the server to derive defaults from the | 325 | // 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 | ||
| @@ -12,9 +12,11 @@ · 11 unmodified lines | |||
| 12 | 12 | ||
| 13 | import { defineClientPlugin } from "@executor-js/sdk/client"; | 13 | import { defineClientPlugin } from "@executor-js/sdk/client"; |
| 14 | 14 | ||
| 15 | import { openApiIntegrationPlugin } from "./source-plugin"; | 15 | import { createOpenApiIntegrationPlugin, type OpenApiClientConfig } from "./source-plugin"; |
| 16 | 16 | ||
| 17 | export default defineClientPlugin({ | 17 | export 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 | } | ||
| @@ -1,21 +1,29 @@ | |||
| 1 | import { lazy } from "react"; | 1 | import { lazy } from "react"; |
| 2 | import type { IntegrationPlugin } from "@executor-js/sdk/client"; | 2 | import type { IntegrationPlugin, IntegrationPreset } from "@executor-js/sdk/client"; |
| 3 | import { openApiPresets } from "../sdk/presets"; | 3 | import { openApiPresets } from "../sdk/presets"; |
| 4 | 4 | ||
| 5 | const importAdd = () => import("./AddOpenApiSource"); | 5 | const importAdd = () => import("./AddOpenApiSource"); |
| 6 | const importEditSheet = () => import("./UpdateSpecSection"); | 6 | const importEditSheet = () => import("./UpdateSpecSection"); |
| 7 | const importAccounts = () => import("./OpenApiAccountsPanel"); | 7 | const importAccounts = () => import("./OpenApiAccountsPanel"); |
| 8 | 8 | ||
| 9 | export const openApiIntegrationPlugin: IntegrationPlugin = { | 9 | export interface OpenApiClientConfig { |
| 10 | readonly presets?: readonly IntegrationPreset[]; | ||
| 11 | } | ||
| 12 | |||
| 13 | export const createOpenApiIntegrationPlugin = ( | ||
| 14 | config?: OpenApiClientConfig, | ||
| 15 | ): IntegrationPlugin => ({ | ||
| 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 | |||
| 29 | export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); | ||
| @@ -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"; |
| 35 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; | 36 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; |
| 36 | import { openApiPresets } from "./presets"; | 37 | import { openApiPresets, type OpenApiPreset } from "./presets"; |
| 37 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; | 38 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; |
| 38 | import { | 39 | import { |
| 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 - canonical | 92 | /** 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 | ||
| 567 | export const describeOpenApiIntegrationDisplay = ( | 570 | export 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 | ||
| 584 | export const openApiPlugin = definePlugin< | 591 | export 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 as | 1115 | authenticationTemplate: input.authenticationTemplate as |
| 1104 | | readonly AuthenticationInput[] | 1116 | | readonly AuthenticationInput[] |
| 1105 | | undefined, | 1117 | | undefined, |
| @@ -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( |
| @@ -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)]; |
| @@ -0,0 +1,80 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { type Integration, IntegrationSlug } from "@executor-js/sdk/shared"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | groupIntegrations, | ||
| 6 | integrationFamily, | ||
| 7 | type IntegrationFamilyGroup, | ||
| 8 | } from "./integration-grouping"; | ||
| 9 | |||
| 10 | const integration = (slug: string, kind: string, name = slug, family?: string): Integration => ({ | ||
| 11 | slug: IntegrationSlug.make(slug), | ||
| 12 | name, | ||
| 13 | description: name, | ||
| 14 | kind, | ||
| 15 | canRemove: true, | ||
| 16 | canRefresh: true, | ||
| 17 | authMethods: [], | ||
| 18 | ...(family ? { family } : {}), | ||
| 19 | }); | ||
| 20 | |||
| 21 | describe("groupIntegrations", () => { | ||
| 22 | it("collapses multi-member config families under one umbrella", () => { | ||
| 23 | const items = groupIntegrations([ | ||
| 24 | integration("google_calendar", "openapi", "Google Calendar", "google"), | ||
| 25 | integration("google_gmail", "openapi", "Gmail", "google"), | ||
| 26 | integration("google_drive", "openapi", "Google Drive", "google"), | ||
| 27 | ]); | ||
| 28 | |||
| 29 | expect(items).toHaveLength(1); | ||
| 30 | const group = items[0] as IntegrationFamilyGroup; | ||
| 31 | expect(group.type).toBe("group"); | ||
| 32 | expect(group.family).toBe("google"); | ||
| 33 | expect(group.label).toBe("Google"); | ||
| 34 | expect(group.members.map((m) => String(m.slug))).toEqual([ | ||
| 35 | "google_calendar", | ||
| 36 | "google_gmail", | ||
| 37 | "google_drive", | ||
| 38 | ]); | ||
| 39 | }); | ||
| 40 | |||
| 41 | it("leaves integrations without config family ungrouped", () => { | ||
| 42 | const items = groupIntegrations([ | ||
| 43 | integration("stripe", "openapi", "Stripe"), | ||
| 44 | integration("sentry", "mcp", "Sentry"), | ||
| 45 | ]); | ||
| 46 | |||
| 47 | expect(items).toHaveLength(2); | ||
| 48 | expect(items.map((item) => item.type)).toEqual(["single", "single"]); | ||
| 49 | }); | ||
| 50 | |||
| 51 | it("renders a lone family member flat", () => { | ||
| 52 | const items = groupIntegrations([ | ||
| 53 | integration("google_calendar", "openapi", "Google Calendar", "google"), | ||
| 54 | ]); | ||
| 55 | |||
| 56 | expect(items).toHaveLength(1); | ||
| 57 | expect(items[0]?.type).toBe("single"); | ||
| 58 | }); | ||
| 59 | |||
| 60 | it("places each group where its first member appeared", () => { | ||
| 61 | const items = groupIntegrations([ | ||
| 62 | integration("stripe", "openapi", "Stripe"), | ||
| 63 | integration("google_calendar", "openapi", "Google Calendar", "google"), | ||
| 64 | integration("microsoft_mail", "openapi", "Outlook Mail", "microsoft"), | ||
| 65 | integration("google_gmail", "openapi", "Gmail", "google"), | ||
| 66 | integration("microsoft_calendar", "openapi", "Outlook Calendar", "microsoft"), | ||
| 67 | integration("sentry", "mcp", "Sentry"), | ||
| 68 | ]); | ||
| 69 | |||
| 70 | expect( | ||
| 71 | items.map((item) => (item.type === "group" ? `group:${item.family}` : "single")), | ||
| 72 | ).toEqual(["single", "group:google", "group:microsoft", "single"]); | ||
| 73 | }); | ||
| 74 | |||
| 75 | it("does not group from integration kind alone", () => { | ||
| 76 | expect( | ||
| 77 | integrationFamily(integration("google_calendar", "google", "Google Calendar")), | ||
| 78 | ).toBeNull(); | ||
| 79 | }); | ||
| 80 | }); | ||
| @@ -0,0 +1,69 @@ | |||
| 1 | import type { Integration } from "@executor-js/sdk/shared"; | ||
| 2 | |||
| 3 | export const MULTI_SERVICE_FAMILIES: ReadonlySet<string> = new Set(["google", "microsoft"]); | ||
| 4 | |||
| 5 | const FAMILY_LABELS: Record<string, string> = { | ||
| 6 | google: "Google", | ||
| 7 | microsoft: "Microsoft", | ||
| 8 | }; | ||
| 9 | |||
| 10 | export const familyLabel = (family: string): string => | ||
| 11 | FAMILY_LABELS[family] ?? family.charAt(0).toUpperCase() + family.slice(1); | ||
| 12 | |||
| 13 | export const integrationFamily = (integration: Integration): string | null => { | ||
| 14 | const family = integration.family?.trim(); | ||
| 15 | return family && MULTI_SERVICE_FAMILIES.has(family) ? family : null; | ||
| 16 | }; | ||
| 17 | |||
| 18 | export interface IntegrationFamilyGroup { | ||
| 19 | readonly type: "group"; | ||
| 20 | readonly family: string; | ||
| 21 | readonly label: string; | ||
| 22 | readonly members: readonly Integration[]; | ||
| 23 | } | ||
| 24 | |||
| 25 | export interface IntegrationSingle { | ||
| 26 | readonly type: "single"; | ||
| 27 | readonly integration: Integration; | ||
| 28 | } | ||
| 29 | |||
| 30 | export type IntegrationGridItem = IntegrationFamilyGroup | IntegrationSingle; | ||
| 31 | |||
| 32 | export function groupIntegrations( | ||
| 33 | integrations: readonly Integration[], | ||
| 34 | ): readonly IntegrationGridItem[] { | ||
| 35 | const familyCounts = new Map<string, number>(); | ||
| 36 | for (const integration of integrations) { | ||
| 37 | const family = integrationFamily(integration); | ||
| 38 | if (family) familyCounts.set(family, (familyCounts.get(family) ?? 0) + 1); | ||
| 39 | } | ||
| 40 | |||
| 41 | const items: IntegrationGridItem[] = []; | ||
| 42 | const groupIndexByFamily = new Map<string, number>(); | ||
| 43 | |||
| 44 | for (const integration of integrations) { | ||
| 45 | const family = integrationFamily(integration); | ||
| 46 | const grouped = family !== null && (familyCounts.get(family) ?? 0) > 1; | ||
| 47 | |||
| 48 | if (!grouped || family === null) { | ||
| 49 | items.push({ type: "single", integration }); | ||
| 50 | continue; | ||
| 51 | } | ||
| 52 | |||
| 53 | const existing = groupIndexByFamily.get(family); | ||
| 54 | if (existing === undefined) { | ||
| 55 | groupIndexByFamily.set(family, items.length); | ||
| 56 | items.push({ | ||
| 57 | type: "group", | ||
| 58 | family, | ||
| 59 | label: familyLabel(family), | ||
| 60 | members: [integration], | ||
| 61 | }); | ||
| 62 | } else { | ||
| 63 | const group = items[existing] as IntegrationFamilyGroup; | ||
| 64 | items[existing] = { ...group, members: [...group.members, integration] }; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | return items; | ||
| 69 | } | ||
| @@ -1,4 +1,4 @@ | |||
| 1 | import { Suspense, useCallback, useMemo, useState } from "react"; | 1 | import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react"; |
| 2 | import { Link, useNavigate } from "@tanstack/react-router"; | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; | 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * 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"; |
| 37 | import { | 38 | import { |
| 39 | IntegrationFavicon, | ||
| 38 | integrationInferredUrl, | 40 | integrationInferredUrl, |
| 39 | integrationPresetIconUrl, | 41 | integrationPresetIconUrl, |
| 40 | } from "../components/integration-favicon"; | 42 | } from "../components/integration-favicon"; |
| 43 | import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; | ||
| 41 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; | 44 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; |
| 42 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; | 45 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; |
| 43 | import { Skeleton } from "../components/skeleton"; | 46 | import { 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 | |||
| 514 | function IntegrationFamilyGroupCard(props: { | ||
| 515 | group: IntegrationFamilyGroup; | ||
| 516 | plugin: IntegrationPlugin | undefined; | ||
| 517 | renderEntry: (integration: Integration) => ReactNode; | ||
| 518 | }) { | ||
| 519 | const { group, plugin, renderEntry } = props; | ||
| 520 | const headerIcon = | ||
| 521 | plugin?.presets?.find((preset) => preset.family === group.family && preset.icon)?.icon ?? null; | ||
| 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 | <IntegrationIconWithAccount | 534 | </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 | } |