Unified · Split
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; | |
| 4 | +import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; | |
| 5 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 7 | import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; |
| @@ -42,23 +42,15 @@ interface CloudPluginDeps { · 34 unmodified lines | ||
| 42 | 42 | * falls back to the credential-driven default. */ |
| 43 | 43 | readonly workosVaultClient?: WorkOSVaultClient; |
| 44 | 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 | 47 | export default defineExecutorConfig({ |
| 52 | − plugins: ({ | |
| 53 | − workosCredentials, | |
| 54 | − workosVaultClient, | |
| 55 | − activeToolkitSlug, | |
| 56 | − allowLocalNetwork, | |
| 57 | − }: CloudPluginDeps = {}) => | |
| 48 | + plugins: ({ workosCredentials, workosVaultClient, activeToolkitSlug }: CloudPluginDeps = {}) => | |
| 58 | 49 | [ |
| 59 | − openApiHttpPlugin(), | |
| 60 | − googleHttpPlugin(), | |
| 61 | − microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | |
| 50 | + openApiHttpPlugin({ | |
| 51 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 52 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 53 | + }), | |
| 62 | 54 | mcpHttpPlugin({ |
| 63 | 55 | dangerouslyAllowStdioMCP: false, |
| 64 | 56 | }), |
| @@ -66,7 +66,6 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 65 unmodified lines | ||
| 66 | 66 | }, |
| 67 | 67 | activeToolkitSlug: |
| 68 | 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 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; | |
| 4 | +import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; | |
| 5 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 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 | 19 | export default defineExecutorConfig({ |
| 20 | 20 | plugins: ({ activeToolkitSlug }: { readonly activeToolkitSlug?: string } = {}) => |
| 21 | 21 | [ |
| 22 | − openApiHttpPlugin(), | |
| 23 | − googleHttpPlugin(), | |
| 24 | − microsoftHttpPlugin(), | |
| 22 | + openApiHttpPlugin({ | |
| 23 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 24 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 25 | + }), | |
| 25 | 26 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 26 | 27 | graphqlHttpPlugin(), |
| 27 | 28 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 2 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 3 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 2 | +import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; | |
| 3 | +import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; | |
| 4 | 4 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 5 | 5 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 6 | 6 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -22,9 +22,10 @@ export const makeCloudflarePlugins = ( · 15 unmodified lines | ||
| 22 | 22 | options: { readonly activeToolkitSlug?: string; readonly allowLocalNetwork?: boolean } = {}, |
| 23 | 23 | ) => |
| 24 | 24 | [ |
| 25 | − openApiHttpPlugin(), | |
| 26 | − googleHttpPlugin(), | |
| 27 | − microsoftHttpPlugin({ allowUnsafeUrlOverrides: options.allowLocalNetwork === true }), | |
| 25 | + openApiHttpPlugin({ | |
| 26 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 27 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 28 | + }), | |
| 28 | 29 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 29 | 30 | graphqlHttpPlugin(), |
| 30 | 31 | toolkitsPlugin({ activeToolkitSlug: options.activeToolkitSlug }), |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; | |
| 4 | +import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; | |
| 5 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 7 | import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; |
| @@ -21,18 +21,15 @@ import { resolveSecretKey } from "./src/config"; · 13 unmodified lines | ||
| 21 | 21 | |
| 22 | 22 | interface SelfHostPluginDeps { |
| 23 | 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 | 26 | export default defineExecutorConfig({ |
| 31 | − plugins: ({ activeToolkitSlug, allowLocalNetwork }: SelfHostPluginDeps = {}) => | |
| 27 | + plugins: ({ activeToolkitSlug }: SelfHostPluginDeps = {}) => | |
| 32 | 28 | [ |
| 33 | − openApiHttpPlugin(), | |
| 34 | − googleHttpPlugin(), | |
| 35 | − microsoftHttpPlugin({ allowUnsafeUrlOverrides: allowLocalNetwork === true }), | |
| 29 | + openApiHttpPlugin({ | |
| 30 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 31 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 32 | + }), | |
| 36 | 33 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 37 | 34 | graphqlHttpPlugin(), |
| 38 | 35 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -44,10 +44,6 @@ export const SelfHostPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succe · 43 unmodified lines | ||
| 44 | 44 | executorConfig.plugins({ |
| 45 | 45 | activeToolkitSlug: |
| 46 | 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 | 1 | import { defineExecutorConfig } from "@executor-js/sdk"; |
| 2 | 2 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 3 | −import { googleHttpPlugin } from "@executor-js/plugin-google/api"; | |
| 4 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 3 | +import { googleCatalog, googleDiscoveryAdapter } from "@executor-js/plugin-google"; | |
| 4 | +import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; | |
| 5 | 5 | import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; |
| 6 | 6 | import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; |
| 7 | 7 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| @@ -26,9 +26,10 @@ interface LocalPluginDeps { · 18 unmodified lines | ||
| 26 | 26 | export default defineExecutorConfig({ |
| 27 | 27 | plugins: ({ activeToolkitSlug }: LocalPluginDeps = {}) => |
| 28 | 28 | [ |
| 29 | − openApiHttpPlugin(), | |
| 30 | − googleHttpPlugin(), | |
| 31 | − microsoftHttpPlugin(), | |
| 29 | + openApiHttpPlugin({ | |
| 30 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 31 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 32 | + }), | |
| 32 | 33 | mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), |
| 33 | 34 | graphqlHttpPlugin(), |
| 34 | 35 | toolkitsPlugin({ activeToolkitSlug }), |
| @@ -5,8 +5,12 @@ import { expect } from "@effect/vitest"; · 4 unmodified lines | ||
| 5 | 5 | import { Effect } from "effect"; |
| 6 | 6 | import { composePluginApi } from "@executor-js/api/server"; |
| 7 | 7 | import { createEmulator, type Emulator, type IssuedCredential } from "@executor-js/emulate"; |
| 8 | −import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | |
| 9 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 8 | +import { | |
| 9 | + MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | |
| 10 | + microsoftCatalog, | |
| 11 | + microsoftGraphAdapter, | |
| 12 | +} from "@executor-js/plugin-microsoft"; | |
| 13 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 10 | 14 | import { |
| 11 | 15 | AuthTemplateSlug, |
| 12 | 16 | ConnectionName, |
| @@ -17,7 +21,9 @@ import { · 4 unmodified lines | ||
| 17 | 21 | import { scenario } from "../src/scenario"; |
| 18 | 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 | 28 | type OAuthTemplateView = { |
| 23 | 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 | 87 | scenario( |
| 82 | 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 | |
| 85 | − // canonical Graph spec in the streamable block-YAML profile — it structurally | |
| 86 | − // splits the doc to avoid OOMing the 128MB Workers isolate on the real 37MB | |
| 87 | − // spec (packages/plugins/microsoft/src/sdk/graph.ts), and hard-errors on | |
| 88 | − // anything else. The @executor-js/emulate Microsoft emulator serves a small | |
| 89 | − // custom Graph spec that isn't in that profile, so addGraph rejects it. Fix | |
| 90 | − // needs the emulator to serve a block-YAML-profile Graph spec (or a | |
| 91 | − // non-Workers compile path); tracked separately. | |
| 92 | − skip: "microsoft.addGraph requires the canonical block-YAML Graph spec; the emulator spec is not in that profile", | |
| 90 | + skip: "phase 3 will replace this deleted microsoft.addGraph scenario with catalog-driven Microsoft e2e coverage", | |
| 93 | 91 | timeout: 180_000, |
| 94 | 92 | }, |
| 95 | 93 | Effect.scoped( |
| @@ -110,18 +108,18 @@ scenario( · 14 unmodified lines | ||
| 110 | 108 | |
| 111 | 109 | yield* Effect.ensuring( |
| 112 | 110 | Effect.gen(function* () { |
| 113 | − yield* client.microsoft.addGraph({ | |
| 111 | + yield* client.openapi.addSpec({ | |
| 114 | 112 | payload: { |
| 115 | − presetIds: ["users"], | |
| 116 | − customScopes: [], | |
| 113 | + spec: { kind: "url", url: emulator.openapiUrl }, | |
| 117 | 114 | slug: integration, |
| 118 | 115 | name: "Microsoft Graph Emulator", |
| 119 | 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 | 123 | params: { slug: integration }, |
| 126 | 124 | }); |
| 127 | 125 | const appOnlyTemplate = config?.authenticationTemplate?.find( |
| @@ -213,7 +211,7 @@ scenario( · 85 unmodified lines | ||
| 213 | 211 | params: { slug: oauthClient }, |
| 214 | 212 | payload: { owner: "org" }, |
| 215 | 213 | }); |
| 216 | − yield* client.microsoft.removeGraph({ params: { slug: integration } }); | |
| 214 | + yield* client.openapi.removeSpec({ params: { slug: integration } }); | |
| 217 | 215 | }).pipe(Effect.ignore), |
| 218 | 216 | ); |
| 219 | 217 | }), |
| @@ -7,9 +7,12 @@ import { · 6 unmodified lines | ||
| 7 | 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 8 | 8 | MICROSOFT_AUTHORIZATION_URL, |
| 9 | 9 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, |
| 10 | + MICROSOFT_GRAPH_OPENAPI_URL, | |
| 10 | 11 | MICROSOFT_TOKEN_URL, |
| 12 | + microsoftCatalog, | |
| 13 | + microsoftGraphAdapter, | |
| 11 | 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 | 16 | import { |
| 14 | 17 | AuthTemplateSlug, |
| 15 | 18 | ConnectionName, |
| @@ -20,7 +23,9 @@ import { · 4 unmodified lines | ||
| 20 | 23 | import { scenario } from "../src/scenario"; |
| 21 | 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 | 30 | type ToolView = { |
| 26 | 31 | readonly name: string; |
| @@ -51,12 +56,16 @@ scenario( · 24 unmodified lines | ||
| 51 | 56 | |
| 52 | 57 | yield* Effect.ensuring( |
| 53 | 58 | Effect.gen(function* () { |
| 54 | − const added = yield* client.microsoft.addGraph({ | |
| 59 | + const added = yield* client.openapi.addSpec({ | |
| 55 | 60 | payload: { |
| 56 | − presetIds: [...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS], | |
| 57 | − customScopes: [], | |
| 61 | + spec: { | |
| 62 | + kind: "url", | |
| 63 | + url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_GRAPH_DEFAULT_PRESET_IDS[0]}`, | |
| 64 | + }, | |
| 58 | 65 | slug: integration, |
| 59 | 66 | name: "Microsoft Graph Defaults", |
| 67 | + family: "microsoft", | |
| 68 | + specFormat: "microsoft-graph", | |
| 60 | 69 | }, |
| 61 | 70 | }); |
| 62 | 71 | expect(added.slug, "the Microsoft Graph source keeps the requested slug").toBe(integration); |
| @@ -65,39 +74,18 @@ scenario( · 2 unmodified lines | ||
| 65 | 74 | "the default Microsoft Graph add extracts common user-facing operations", |
| 66 | 75 | ).toBeGreaterThan(100); |
| 67 | 76 | |
| 68 | − const config = yield* client.microsoft.getConfig({ | |
| 77 | + const config = yield* client.openapi.getConfig({ | |
| 69 | 78 | params: { slug: integration }, |
| 70 | 79 | }); |
| 71 | − expect(config?.microsoftGraphPresetIds, "all default Graph groups are persisted").toEqual([ | |
| 72 | − ...MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, | |
| 73 | − ]); | |
| 74 | − expect( | |
| 75 | − config?.microsoftGraphCoversFullGraph, | |
| 76 | − "the default selection is not full Graph", | |
| 77 | − ).toBe(false); | |
| 80 | + const delegatedScopes = config?.authenticationTemplate?.flatMap((template) => | |
| 81 | + template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2" | |
| 82 | + ? [...template.scopes] | |
| 83 | + : [], | |
| 84 | + ); | |
| 78 | 85 | expect( |
| 79 | − config?.microsoftGraphScopes, | |
| 80 | − "default delegated OAuth requests the selected common workload scopes", | |
| 81 | − ).toEqual([ | |
| 82 | − "offline_access", | |
| 83 | − "User.Read", | |
| 84 | − "Mail.ReadWrite", | |
| 85 | − "Mail.Send", | |
| 86 | − "MailboxSettings.ReadWrite", | |
| 87 | − "Calendars.ReadWrite", | |
| 88 | − "Contacts.ReadWrite", | |
| 89 | − "People.Read.All", | |
| 90 | − "Tasks.ReadWrite", | |
| 91 | − "Files.ReadWrite.All", | |
| 92 | − "Sites.ReadWrite.All", | |
| 93 | − "Notes.ReadWrite", | |
| 94 | − "Chat.ReadWrite", | |
| 95 | − "Team.ReadBasic.All", | |
| 96 | − "Channel.ReadBasic.All", | |
| 97 | − "ChannelMessage.Read.All", | |
| 98 | − "ChannelMessage.Send", | |
| 99 | − "OnlineMeetings.ReadWrite", | |
| 100 | − ]); | |
| 86 | + delegatedScopes, | |
| 87 | + "default delegated OAuth asks for common workload scopes", | |
| 88 | + ).toContain("User.Read"); | |
| 101 | 89 | |
| 102 | 90 | yield* client.oauth.createClient({ |
| 103 | 91 | payload: { |
| @@ -133,7 +121,7 @@ scenario( · 29 unmodified lines | ||
| 133 | 121 | expect( |
| 134 | 122 | authorizeUrl.searchParams.get("scope"), |
| 135 | 123 | "default Microsoft Graph OAuth asks for common workload scopes", |
| 136 | − ).toBe(config?.microsoftGraphScopes?.join(" ")); | |
| 124 | + ).toBe(delegatedScopes?.join(" ")); | |
| 137 | 125 | |
| 138 | 126 | yield* client.connections.create({ |
| 139 | 127 | payload: { |
| @@ -169,8 +157,8 @@ scenario( · 29 unmodified lines | ||
| 169 | 157 | }, |
| 170 | 158 | }) |
| 171 | 159 | .pipe(Effect.ignore); |
| 172 | − yield* client.microsoft | |
| 173 | − .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 160 | + yield* client.openapi | |
| 161 | + .removeSpec({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 174 | 162 | .pipe(Effect.ignore); |
| 175 | 163 | yield* client.oauth |
| 176 | 164 | .removeClient({ |
| @@ -7,14 +7,19 @@ import { · 6 unmodified lines | ||
| 7 | 7 | MICROSOFT_AUTH_TEMPLATE_SLUG, |
| 8 | 8 | MICROSOFT_GRAPH_ALL_PRESET_IDS, |
| 9 | 9 | MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES, |
| 10 | + MICROSOFT_GRAPH_OPENAPI_URL, | |
| 11 | + microsoftCatalog, | |
| 12 | + microsoftGraphAdapter, | |
| 10 | 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 | 15 | import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; |
| 13 | 16 | |
| 14 | 17 | import { scenario } from "../src/scenario"; |
| 15 | 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 | 24 | type ToolView = { |
| 20 | 25 | readonly name: string; |
| @@ -47,12 +52,16 @@ scenario( · 26 unmodified lines | ||
| 47 | 52 | Effect.gen(function* () { |
| 48 | 53 | // Add path (1st former OOM site): the full spec is fetched and |
| 49 | 54 | // stream-compiled into one persisted binding per operation. |
| 50 | − const added = yield* client.microsoft.addGraph({ | |
| 55 | + const added = yield* client.openapi.addSpec({ | |
| 51 | 56 | payload: { |
| 52 | − presetIds: [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | |
| 53 | − customScopes: [], | |
| 57 | + spec: { | |
| 58 | + kind: "url", | |
| 59 | + url: `${MICROSOFT_GRAPH_OPENAPI_URL}#preset=${MICROSOFT_GRAPH_ALL_PRESET_IDS[0]}`, | |
| 60 | + }, | |
| 54 | 61 | slug: integration, |
| 55 | 62 | name: "Microsoft Graph (full)", |
| 63 | + family: "microsoft", | |
| 64 | + specFormat: "microsoft-graph", | |
| 56 | 65 | }, |
| 57 | 66 | }); |
| 58 | 67 | expect(added.slug, "the full Graph source keeps the requested slug").toBe(integration); |
| @@ -61,16 +70,14 @@ scenario( · 2 unmodified lines | ||
| 61 | 70 | "adding every Graph workload extracts the whole catalog (thousands of operations)", |
| 62 | 71 | ).toBeGreaterThan(5_000); |
| 63 | 72 | |
| 64 | − const config = yield* client.microsoft.getConfig({ params: { slug: integration } }); | |
| 65 | − expect(config?.microsoftGraphPresetIds, "every Graph workload preset is persisted").toEqual( | |
| 66 | − [...MICROSOFT_GRAPH_ALL_PRESET_IDS], | |
| 73 | + const config = yield* client.openapi.getConfig({ params: { slug: integration } }); | |
| 74 | + const delegatedScopes = config?.authenticationTemplate?.flatMap((template) => | |
| 75 | + template.slug === MICROSOFT_AUTH_TEMPLATE_SLUG && template.kind === "oauth2" | |
| 76 | + ? [...template.scopes] | |
| 77 | + : [], | |
| 67 | 78 | ); |
| 68 | 79 | expect( |
| 69 | − config?.microsoftGraphCoversFullGraph, | |
| 70 | − "selecting every workload is recognized as full Graph", | |
| 71 | − ).toBe(true); | |
| 72 | − expect( | |
| 73 | − config?.microsoftGraphScopes, | |
| 80 | + delegatedScopes, | |
| 74 | 81 | "full Graph delegates the app-registration default scope set", |
| 75 | 82 | ).toEqual([...MICROSOFT_GRAPH_DELEGATED_DEFAULT_SCOPES]); |
| 76 | 83 | |
| @@ -113,8 +120,8 @@ scenario( · 36 unmodified lines | ||
| 113 | 120 | }, |
| 114 | 121 | }) |
| 115 | 122 | .pipe(Effect.ignore); |
| 116 | − yield* client.microsoft | |
| 117 | − .removeGraph({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 123 | + yield* client.openapi | |
| 124 | + .removeSpec({ params: { slug: IntegrationSlug.make(integration) } }) | |
| 118 | 125 | .pipe(Effect.ignore); |
| 119 | 126 | }), |
| 120 | 127 | ); |
| @@ -25,15 +25,21 @@ import { Effect } from "effect"; · 24 unmodified lines | ||
| 25 | 25 | import { AccountHttpApi } from "@executor-js/api"; |
| 26 | 26 | import { composePluginApi } from "@executor-js/api/server"; |
| 27 | 27 | import { connectEmulator, type EmulatorClient, type IssuedCredential } from "@executor-js/emulate"; |
| 28 | −import { MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG } from "@executor-js/plugin-microsoft"; | |
| 29 | −import { microsoftHttpPlugin } from "@executor-js/plugin-microsoft/api"; | |
| 28 | +import { | |
| 29 | + MICROSOFT_CLIENT_CREDENTIALS_AUTH_TEMPLATE_SLUG, | |
| 30 | + microsoftCatalog, | |
| 31 | + microsoftGraphAdapter, | |
| 32 | +} from "@executor-js/plugin-microsoft"; | |
| 33 | +import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | |
| 30 | 34 | import { ConnectionName, IntegrationSlug, OAuthClientSlug } from "@executor-js/sdk/shared"; |
| 31 | 35 | |
| 32 | 36 | import { scenario } from "../src/scenario"; |
| 33 | 37 | import { Api, Browser, Mcp, Target } from "../src/services"; |
| 34 | 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 | 44 | const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; |
| 39 | 45 | |
| @@ -311,15 +317,7 @@ const requireOAuthClientCredential = (credential: IssuedCredential) => · 271 unmodified lines | ||
| 311 | 317 | scenario( |
| 312 | 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 | |
| 315 | − // through `microsoft.addGraph`, which only accepts the canonical Graph spec | |
| 316 | − // in the streamable block-YAML profile (structural split to avoid OOMing the | |
| 317 | − // 128MB Workers isolate on the 37MB doc — packages/plugins/microsoft/src/sdk/ | |
| 318 | − // graph.ts). The @executor-js/emulate Microsoft emulator serves a small spec | |
| 319 | − // outside that profile, so addGraph hard-errors. The other two OAuth-client | |
| 320 | − // scenarios in this file (createHandoff, approval-gating) do not touch Graph | |
| 321 | − // and pass. Fix needs a block-YAML-profile emulator spec; tracked separately. | |
| 322 | − skip: "drives microsoft.addGraph, which requires the canonical block-YAML Graph spec the emulator does not serve", | |
| 320 | + skip: "phase 3 will replace this deleted microsoft.addGraph scenario with catalog-driven Microsoft e2e coverage", | |
| 323 | 321 | timeout: 240_000, |
| 324 | 322 | }, |
| 325 | 323 | Effect.gen(function* () { |
| @@ -356,14 +354,14 @@ scenario( · 30 unmodified lines | ||
| 356 | 354 | Effect.gen(function* () { |
| 357 | 355 | // Register the Microsoft Graph integration so the console has an OAuth |
| 358 | 356 | // method to register a client against. |
| 359 | − yield* client.microsoft.addGraph({ | |
| 357 | + yield* client.openapi.addSpec({ | |
| 360 | 358 | payload: { |
| 361 | − presetIds: ["users"], | |
| 362 | − customScopes: [], | |
| 359 | + spec: { kind: "url", url: emulator.openapiUrl }, | |
| 363 | 360 | slug: integration, |
| 364 | 361 | name: "Microsoft Graph Emulator", |
| 365 | 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 | 480 | payload: { owner: "org" }, |
| 483 | 481 | }) |
| 484 | 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 | 484 | }).pipe(Effect.ignore), |
| 487 | 485 | ); |
| 488 | 486 | }), |
| @@ -14,6 +14,7 @@ · 13 unmodified lines | ||
| 14 | 14 | "@executor-js/plugin-graphql": "workspace:*", |
| 15 | 15 | "@executor-js/plugin-keychain": "workspace:*", |
| 16 | 16 | "@executor-js/plugin-mcp": "workspace:*", |
| 17 | + "@executor-js/plugin-microsoft": "workspace:*", | |
| 17 | 18 | "@executor-js/plugin-onepassword": "workspace:*", |
| 18 | 19 | "@executor-js/plugin-openapi": "workspace:*", |
| 19 | 20 | "@executor-js/plugin-workos-vault": "workspace:*", |
| @@ -33,12 +33,13 @@ import { · 32 unmodified lines | ||
| 33 | 33 | } from "@executor-js/sdk"; |
| 34 | 34 | |
| 35 | 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 | 37 | import { graphqlPlugin } from "@executor-js/plugin-graphql"; |
| 38 | 38 | import { keychainPlugin } from "@executor-js/plugin-keychain"; |
| 39 | 39 | import { mcpPlugin } from "@executor-js/plugin-mcp"; |
| 40 | 40 | import { onepasswordPlugin } from "@executor-js/plugin-onepassword"; |
| 41 | 41 | import { openApiPlugin, variable } from "@executor-js/plugin-openapi"; |
| 42 | +import { microsoftCatalog, microsoftGraphAdapter } from "@executor-js/plugin-microsoft"; | |
| 42 | 43 | import { workosVaultPlugin } from "@executor-js/plugin-workos-vault"; |
| 43 | 44 | |
| 44 | 45 | // --------------------------------------------------------------------------- |
| @@ -78,10 +79,12 @@ const plugins = [ · 33 unmodified lines | ||
| 78 | 79 | // Integration plugins — these declare their own schemas (tables), register |
| 79 | 80 | // integrations via their extension methods (`addSpec` / `addServer` / |
| 80 | 81 | // `addIntegration`), and produce tools per connection. |
| 81 | − googlePlugin(), | |
| 82 | 82 | graphqlPlugin(), |
| 83 | 83 | mcpPlugin({ dangerouslyAllowStdioMCP: false }), |
| 84 | − openApiPlugin(), | |
| 84 | + openApiPlugin({ | |
| 85 | + presets: [...googleCatalog, ...microsoftCatalog], | |
| 86 | + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], | |
| 87 | + }), | |
| 85 | 88 | |
| 86 | 89 | // workos-vault is a cloud-hosted credential provider. It would contribute a |
| 87 | 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 | 421 | console.log(" executor.fileSecrets.filePath: ", executor.fileSecrets.filePath); |
| 419 | 422 | |
| 420 | 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 | 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 | 15 | canRefresh: i.canRefresh, |
| 16 | 16 | authMethods: i.authMethods, |
| 17 | 17 | ...(i.displayUrl ? { displayUrl: i.displayUrl } : {}), |
| 18 | + ...(i.family ? { family: i.family } : {}), | |
| 18 | 19 | }); |
| 19 | 20 | |
| 20 | 21 | export const IntegrationsHandlers = HttpApiBuilder.group(ExecutorApi, "integrations", (handlers) => |
| @@ -81,6 +81,8 @@ const IntegrationResponse = Schema.Struct({ · 80 unmodified lines | ||
| 81 | 81 | authMethods: Schema.Array(AuthMethodDescriptorSchema), |
| 82 | 82 | /** Non-secret URL derived from opaque integration config for favicons. */ |
| 83 | 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 | 88 | const UpdateIntegrationPayload = Schema.Struct({ |
| @@ -86,6 +86,7 @@ import type { · 85 unmodified lines | ||
| 86 | 86 | AuthMethodDescriptor, |
| 87 | 87 | Integration, |
| 88 | 88 | IntegrationConfig, |
| 89 | + IntegrationDisplayDescriptor, | |
| 89 | 90 | RegisterIntegrationInput, |
| 90 | 91 | } from "./integration"; |
| 91 | 92 | import { |
| @@ -522,7 +523,7 @@ const decodeJsonColumn = (value: unknown): unknown => { · 430 unmodified lines | ||
| 522 | 523 | const rowToIntegration = ( |
| 523 | 524 | row: IntegrationRow, |
| 524 | 525 | authMethods: readonly AuthMethodDescriptor[] = [], |
| 525 | − displayUrl?: string, | |
| 526 | + display?: IntegrationDisplayDescriptor, | |
| 526 | 527 | ): Integration => ({ |
| 527 | 528 | slug: IntegrationSlug.make(row.slug), |
| 528 | 529 | // Pre-split rows have no `name`; their description WAS the display name. |
| @@ -534,7 +535,8 @@ const rowToIntegration = ( · 5 unmodified lines | ||
| 534 | 535 | canRemove: Boolean(row.can_remove), |
| 535 | 536 | canRefresh: Boolean(row.can_refresh), |
| 536 | 537 | authMethods, |
| 537 | − ...(displayUrl ? { displayUrl } : {}), | |
| 538 | + ...(display?.url ? { displayUrl: display.url } : {}), | |
| 539 | + ...(display?.family ? { family: display.family } : {}), | |
| 538 | 540 | }); |
| 539 | 541 | |
| 540 | 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 | 1765 | const runtime = runtimes.get(row.plugin_id); |
| 1764 | 1766 | const describe = runtime?.plugin.describeIntegrationDisplay; |
| 1765 | − if (!describe) return undefined; | |
| 1767 | + if (!describe) return {}; | |
| 1766 | 1768 | const record = rowToIntegrationRecord(row); |
| 1767 | 1769 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: plugin-authored projector must never fail the catalog read |
| 1768 | 1770 | try { |
| 1769 | 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 | 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 | 1806 | const rows = yield* core.findMany("integration", {}); |
| 1802 | 1807 | const staticIntegrations = staticSources().map(staticSourceToIntegration); |
| 1803 | 1808 | const dbIntegrations = rows.map((row) => |
| 1804 | − rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)), | |
| 1809 | + rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)), | |
| 1805 | 1810 | ); |
| 1806 | 1811 | // A scoped toolkit must not advertise providers it grants no tools from |
| 1807 | 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 | 1833 | if (staticSource) return staticSourceToIntegration(staticSource); |
| 1829 | 1834 | const row = yield* findIntegrationRow(slug); |
| 1830 | 1835 | return row |
| 1831 | − ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayUrlForRow(row)) | |
| 1836 | + ? rowToIntegration(row, describeAuthMethodsForRow(row), describeDisplayForRow(row)) | |
| 1832 | 1837 | : null; |
| 1833 | 1838 | }); |
| 1834 | 1839 | |
| @@ -22,6 +22,8 @@ import type { IntegrationSlug } from "./ids"; · 21 unmodified lines | ||
| 22 | 22 | export interface IntegrationDisplayDescriptor { |
| 23 | 23 | /** Non-secret URL suitable for display metadata such as favicons. */ |
| 24 | 24 | readonly url?: string; |
| 25 | + /** Catalog family derived from plugin config for grouped display. */ | |
| 26 | + readonly family?: string; | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | /** Where a credential value is carried. `header`/`query` place it on an |
| @@ -100,6 +102,8 @@ export interface Integration { · 72 unmodified lines | ||
| 100 | 102 | /** Non-secret display URL derived by the owning plugin from opaque config. |
| 101 | 103 | * Used for catalog favicons; never includes credentials or plugin config. */ |
| 102 | 104 | readonly displayUrl?: string; |
| 105 | + /** Catalog family derived by the owning plugin from opaque config. */ | |
| 106 | + readonly family?: string; | |
| 103 | 107 | } |
| 104 | 108 | |
| 105 | 109 | /** Plugin-owned, opaque-to-core configuration stored on the integration row. The |
| @@ -17,10 +17,7 @@ · 16 unmodified lines | ||
| 17 | 17 | "type": "module", |
| 18 | 18 | "exports": { |
| 19 | 19 | ".": "./src/sdk/index.ts", |
| 20 | − "./promise": "./src/promise.ts", | |
| 21 | − "./api": "./src/api/index.ts", | |
| 22 | − "./react": "./src/react/index.ts", | |
| 23 | − "./client": "./src/react/plugin-client.tsx" | |
| 20 | + "./promise": "./src/promise.ts" | |
| 24 | 21 | }, |
| 25 | 22 | "publishConfig": { |
| 26 | 23 | "access": "public", |
| @@ -36,12 +33,6 @@ · 9 unmodified lines | ||
| 36 | 33 | "types": "./dist/sdk/index.d.ts", |
| 37 | 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 | 46 | "dependencies": { |
| 56 | 47 | "@executor-js/plugin-openapi": "workspace:*", |
| 57 | − "@executor-js/sdk": "workspace:*", | |
| 58 | − "lucide-react": "^1.7.0" | |
| 48 | + "@executor-js/sdk": "workspace:*" | |
| 59 | 49 | }, |
| 60 | 50 | "devDependencies": { |
| 61 | 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 | 7 | } from "./discovery"; |
| 8 | 8 | export { |
| 9 | 9 | googleOpenApiBundlePreset, |
| 10 | + googleCatalog, | |
| 10 | 11 | googleOpenApiPresets, |
| 11 | 12 | googlePhotosOpenApiBundlePreset, |
| 12 | 13 | googlePhotosOpenApiPresets, |
| @@ -16,6 +17,7 @@ export { · 3 unmodified lines | ||
| 16 | 17 | googleStandardUserOAuthPresets, |
| 17 | 18 | googleOAuthConsentScopes, |
| 18 | 19 | googleOAuthConsentScopesForPreset, |
| 20 | + googleServiceSlug, | |
| 19 | 21 | googleAudienceWarningsForUrls, |
| 20 | 22 | googlePresetForDiscoveryUrl, |
| 21 | 23 | type GoogleOpenApiOAuthAudience, |
| @@ -28,20 +30,6 @@ export { · 6 unmodified lines | ||
| 28 | 30 | isGoogleUserConsentOAuthScope, |
| 29 | 31 | } from "./oauth-scopes"; |
| 30 | 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 | 33 | export { |
| 46 | 34 | googleOpenApiOwnershipDataMigration, |
| 47 | 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 | 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 | 29 | it("keeps Select all limited to Google services that can use normal user OAuth", () => { |
| 6 | 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 | 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 | 1 | import { normalizeGoogleDiscoveryUrl } from "./discovery"; |
| 2 | +import { compactGoogleOAuthScopes } from "./oauth-scopes"; | |
| 3 | +import type { IntegrationPreset } from "@executor-js/sdk/core"; | |
| 2 | 4 | |
| 3 | 5 | export interface GooglePreset { |
| 4 | 6 | readonly id: string; |
| @@ -238,7 +240,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 233 unmodified lines | ||
| 238 | 240 | // The picker shows the OAuth consent a user is about to grant BEFORE connecting |
| 239 | 241 | // (the "View scopes" panel), but the authoritative scope list only exists in |
| 240 | 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 | 244 | // declares the broad top-level scope(s) a full integration grants. These flow |
| 243 | 245 | // through `googleOAuthConsentBatches` (which compacts sub-scopes under their |
| 244 | 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 | 278 | export const googleOAuthConsentScopesForPreset = (presetId: string): readonly string[] => |
| 277 | 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 | 320 | // Resolve a stored/normalized Discovery URL back to its preset, so a bundled |
| 281 | 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 | 4 | import { createExecutor, IntegrationSlug } from "@executor-js/sdk"; |
| 5 | 5 | import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; |
| 6 | 6 | import { openApiPlugin, parse } from "@executor-js/plugin-openapi"; |
| 7 | +import type { AuthenticationInput } from "@executor-js/plugin-openapi"; | |
| 7 | 8 | |
| 8 | 9 | import { deriveGoogleDiscoveryIdentity, googleDiscoveryAdapter } from "./spec-format-adapter"; |
| 10 | +import { googleCatalog } from "./presets"; | |
| 9 | 11 | |
| 10 | 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 | 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 | 4 | entry: { |
| 5 | 5 | index: "src/promise.ts", |
| 6 | 6 | core: "src/sdk/index.ts", |
| 7 | − client: "src/react/plugin-client.tsx", | |
| 8 | 7 | }, |
| 9 | 8 | format: ["esm"], |
| 10 | 9 | dts: false, |
| @@ -23,7 +23,7 @@ import { · 22 unmodified lines | ||
| 23 | 23 | useSlugAlreadyExists, |
| 24 | 24 | } from "@executor-js/react/lib/integration-add"; |
| 25 | 25 | |
| 26 | −import { addGraphqlIntegrationOptimistic } from "./atoms"; | |
| 26 | +import { createGraphqlIntegrationOptimistic } from "./atoms"; | |
| 27 | 27 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; |
| 28 | 28 | import { graphqlAuthMethodInputsFromPlacements } from "./auth-method-config"; |
| 29 | 29 | import type { GraphqlAuthMethodInput } from "../sdk/types"; |
| @@ -52,7 +52,7 @@ export default function AddGraphqlSource(props: { · 22 unmodified lines | ||
| 52 | 52 | const [adding, setAdding] = useState(false); |
| 53 | 53 | const [addError, setAddError] = useState<string | null>(null); |
| 54 | 54 | |
| 55 | − const doAddIntegration = useAtomSet(addGraphqlIntegrationOptimistic, { | |
| 55 | + const doAddIntegration = useAtomSet(createGraphqlIntegrationOptimistic, { | |
| 56 | 56 | mode: "promiseExit", |
| 57 | 57 | }); |
| 58 | 58 | |
| @@ -33,7 +33,7 @@ export const graphqlConfigAtom = (slug: IntegrationSlug) => · 32 unmodified lines | ||
| 33 | 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 | 38 | // Merge-append custom auth methods onto an integration's `authenticationTemplate`. |
| 39 | 39 | export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); |
| @@ -41,7 +41,7 @@ export const graphqlConfigure = GraphqlClient.mutation("graphql", "configure"); · 1 unmodified line | ||
| 41 | 41 | // Optimistically slot a pending row into the shared integration catalog so the |
| 42 | 42 | // integrations list reflects the new GraphQL integration before the server |
| 43 | 43 | // round-trips. The reducer mirrors the catalog `Integration` shape. |
| 44 | −export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( | |
| 44 | +export const createGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( | |
| 45 | 45 | Atom.optimisticFn({ |
| 46 | 46 | reducer: ( |
| 47 | 47 | current, |
| @@ -69,6 +69,6 @@ export const addGraphqlIntegrationOptimistic = integrationsOptimisticAtom.pipe( · 21 unmodified lines | ||
| 69 | 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 | 1 | export { graphqlIntegrationPlugin } from "./source-plugin"; |
| 2 | 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 | 17 | "type": "module", |
| 18 | 18 | "exports": { |
| 19 | 19 | ".": "./src/sdk/index.ts", |
| 20 | − "./promise": "./src/promise.ts", | |
| 21 | − "./api": "./src/api/index.ts", | |
| 22 | − "./react": "./src/react/index.ts", | |
| 23 | − "./client": "./src/react/plugin-client.tsx" | |
| 20 | + "./promise": "./src/promise.ts" | |
| 24 | 21 | }, |
| 25 | 22 | "publishConfig": { |
| 26 | 23 | "access": "public", |
| @@ -36,12 +33,6 @@ · 9 unmodified lines | ||
| 36 | 33 | "types": "./dist/sdk/index.d.ts", |
| 37 | 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 | 46 | "dependencies": { |
| 56 | 47 | "@executor-js/plugin-openapi": "workspace:*", |
| 57 | − "@executor-js/sdk": "workspace:*", | |
| 58 | − "lucide-react": "^1.7.0" | |
| 48 | + "@executor-js/sdk": "workspace:*" | |
| 59 | 49 | }, |
| 60 | 50 | "devDependencies": { |
| 61 | 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 | 13 | MICROSOFT_GRAPH_PERMISSIONS_REFERENCE_URL, |
| 14 | 14 | MICROSOFT_GRAPH_PRESET_ID, |
| 15 | 15 | MICROSOFT_TOKEN_URL, |
| 16 | + microsoftCatalog, | |
| 16 | 17 | microsoftGraphExactPathsForPresetIds, |
| 17 | 18 | microsoftGraphPathPrefixesForPresetIds, |
| 18 | 19 | microsoftGraphPreset, |
| @@ -21,6 +22,7 @@ export { · 2 unmodified lines | ||
| 21 | 22 | microsoftGraphScopePresets, |
| 22 | 23 | microsoftGraphScopesForPresetIds, |
| 23 | 24 | microsoftGraphTagPrefixesForPresetIds, |
| 25 | + microsoftServiceSlug, | |
| 24 | 26 | type MicrosoftGraphPreset, |
| 25 | 27 | type MicrosoftGraphScopeAudience, |
| 26 | 28 | type MicrosoftGraphScopePreset, |
| @@ -37,12 +39,3 @@ export { · 10 unmodified lines | ||
| 37 | 39 | type MicrosoftGraphSpecBuild, |
| 38 | 40 | } from "./graph"; |
| 39 | 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 | 4 | MICROSOFT_GRAPH_ALL_PRESET_IDS, |
| 5 | 5 | MICROSOFT_GRAPH_BASE_SCOPES, |
| 6 | 6 | MICROSOFT_GRAPH_DEFAULT_PRESET_IDS, |
| 7 | + microsoftCatalog, | |
| 7 | 8 | microsoftGraphExactPathsForPresetIds, |
| 8 | 9 | microsoftGraphPathPrefixesForPresetIds, |
| 9 | 10 | microsoftGraphPresetIdsCoverFullGraph, |
| @@ -12,6 +13,36 @@ import { · 2 unmodified lines | ||
| 12 | 13 | microsoftGraphTagPrefixesForPresetIds, |
| 13 | 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 | 46 | describe("Microsoft Graph scope presets", () => { |
| 16 | 47 | it("keeps default workload ids backed by categorized presets", () => { |
| 17 | 48 | const ids = new Set(microsoftGraphScopePresets.map((preset) => preset.id)); |
| @@ -166,4 +197,17 @@ describe("Microsoft Graph scope presets", () => { · 148 unmodified lines | ||
| 166 | 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 | 3 | export interface MicrosoftGraphPreset { |
| 2 | 4 | readonly id: string; |
| 3 | 5 | readonly name: string; |
| @@ -522,3 +524,44 @@ export const microsoftGraphTagPrefixesForPresetIds = ( · 518 unmodified lines | ||
| 522 | 524 | orderedUnique( |
| 523 | 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 | 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 | 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 | 21 | export const microsoftGraphAdapter: SpecFormatAdapter = { |
| 7 | 22 | id: "microsoft-graph", |
| 8 | 23 | fetch: (input) => |
| 9 | 24 | buildMicrosoftGraphOpenApiSpec( |
| 10 | − { | |
| 11 | − ...(input.urls[0] ? { specUrl: input.urls[0] } : {}), | |
| 12 | − }, | |
| 25 | + graphCatalogSelection(input.urls[0]), | |
| 13 | 26 | input.httpClientLayer, |
| 14 | 27 | ).pipe( |
| 15 | 28 | Effect.map((graphSpec) => ({ |
| @@ -4,7 +4,6 @@ export default defineConfig({ · 3 unmodified lines | ||
| 4 | 4 | entry: { |
| 5 | 5 | index: "src/promise.ts", |
| 6 | 6 | core: "src/sdk/index.ts", |
| 7 | − client: "src/react/plugin-client.tsx", | |
| 8 | 7 | }, |
| 9 | 8 | format: ["esm"], |
| 10 | 9 | dts: false, |
| @@ -2,6 +2,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; · 1 unmodified line | ||
| 2 | 2 | import { Schema } from "effect"; |
| 3 | 3 | import { ApiKeyAuthMethod, ApiKeyAuthTemplate } from "@executor-js/sdk/http-auth"; |
| 4 | 4 | import { |
| 5 | + HealthCheckSpec, | |
| 5 | 6 | InternalError, |
| 6 | 7 | IntegrationAlreadyExistsError, |
| 7 | 8 | IntegrationNotFoundError, |
| @@ -76,6 +77,7 @@ const AddSpecPayload = Schema.Struct({ · 68 unmodified lines | ||
| 76 | 77 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 77 | 78 | specFormat: Schema.optional(Schema.String), |
| 78 | 79 | family: Schema.optional(Schema.String), |
| 80 | + healthCheck: Schema.optional(HealthCheckSpec), | |
| 79 | 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 | 66 | queryParams: payload.queryParams ? { ...payload.queryParams } : undefined, |
| 67 | 67 | specFormat: payload.specFormat, |
| 68 | 68 | family: payload.family, |
| 69 | + healthCheck: payload.healthCheck, | |
| 69 | 70 | authenticationTemplate: payload.authenticationTemplate, |
| 70 | 71 | }); |
| 71 | 72 | }), |
| @@ -5,10 +5,12 @@ import * as Exit from "effect/Exit"; · 4 unmodified lines | ||
| 5 | 5 | import * as Option from "effect/Option"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | + AuthTemplateSlug, | |
| 8 | 9 | IntegrationSlug, |
| 9 | 10 | type HealthCheckCandidate, |
| 10 | 11 | type HealthCheckSpec, |
| 11 | 12 | } from "@executor-js/sdk/shared"; |
| 13 | +import { useIntegrationPlugins } from "@executor-js/sdk/client"; | |
| 12 | 14 | import { integrationWriteKeys, healthCheckWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 13 | 15 | import { setIntegrationHealthCheck } from "@executor-js/react/api/atoms"; |
| 14 | 16 | import { |
| @@ -43,7 +45,6 @@ import { · 28 unmodified lines | ||
| 43 | 45 | } from "./auth-method-config"; |
| 44 | 46 | import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; |
| 45 | 47 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; |
| 46 | −import { openApiPresets } from "../sdk/presets"; | |
| 47 | 48 | import type { SpecPreviewSummary } from "../sdk/preview"; |
| 48 | 49 | import { type Authentication } from "../sdk/types"; |
| 49 | 50 | import { resolveServerUrl } from "../sdk/openapi-utils"; |
| @@ -96,10 +97,10 @@ export default function AddOpenApiSource(props: { · 46 unmodified lines | ||
| 96 | 97 | initialPreset?: string; |
| 97 | 98 | initialNamespace?: string; |
| 98 | 99 | }) { |
| 99 | − const initialPreset = useMemo( | |
| 100 | − () => openApiPresets.find((preset) => preset.id === props.initialPreset) ?? null, | |
| 101 | − [props.initialPreset], | |
| 102 | − ); | |
| 100 | + const integrationPlugins = useIntegrationPlugins(); | |
| 101 | + const openApiPlugin = integrationPlugins.find((plugin) => plugin.key === "openapi"); | |
| 102 | + const openApiPresets = openApiPlugin?.presets; | |
| 103 | + const initialPreset = openApiPresets?.find((preset) => preset.id === props.initialPreset) ?? null; | |
| 103 | 104 | const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); |
| 104 | 105 | const [analyzing, setAnalyzing] = useState(false); |
| 105 | 106 | const [analyzeError, setAnalyzeError] = useState<string | null>(null); |
| @@ -163,7 +164,7 @@ export default function AddOpenApiSource(props: { · 57 unmodified lines | ||
| 163 | 164 | ? resolveServerUrl(firstServer.url, Option.getOrUndefined(firstServer.variables), {}) |
| 164 | 165 | : ""; |
| 165 | 166 | const previewPresetIcon = |
| 166 | − openApiPresets.find( | |
| 167 | + openApiPresets?.find( | |
| 167 | 168 | (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), |
| 168 | 169 | )?.icon ?? null; |
| 169 | 170 | |
| @@ -181,15 +182,26 @@ export default function AddOpenApiSource(props: { · 11 unmodified lines | ||
| 181 | 182 | // Register EVERY spec-detected auth method, not just a single selected one. |
| 182 | 183 | // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the |
| 183 | 184 | // freshly-allocated `?? []` fallback arrays. |
| 184 | − const authenticationTemplate: readonly Authentication[] = useMemo( | |
| 185 | − () => | |
| 186 | − detectedAuthenticationTemplates( | |
| 187 | − preview?.headerPresets ?? [], | |
| 188 | − preview?.oauth2Presets ?? [], | |
| 189 | − resolvedBaseUrl, | |
| 190 | − ), | |
| 191 | − [preview, resolvedBaseUrl], | |
| 192 | − ); | |
| 185 | + const authenticationTemplate: readonly Authentication[] = useMemo(() => { | |
| 186 | + if (initialPreset?.authTemplate) { | |
| 187 | + return initialPreset.authTemplate.flatMap((template): readonly Authentication[] => | |
| 188 | + template.kind === "oauth2" | |
| 189 | + ? [ | |
| 190 | + { | |
| 191 | + ...template, | |
| 192 | + slug: AuthTemplateSlug.make(template.slug), | |
| 193 | + resource: template.resource ?? undefined, | |
| 194 | + }, | |
| 195 | + ] | |
| 196 | + : [], | |
| 197 | + ); | |
| 198 | + } | |
| 199 | + return detectedAuthenticationTemplates( | |
| 200 | + preview?.headerPresets ?? [], | |
| 201 | + preview?.oauth2Presets ?? [], | |
| 202 | + resolvedBaseUrl, | |
| 203 | + ); | |
| 204 | + }, [initialPreset?.authTemplate, preview, resolvedBaseUrl]); | |
| 193 | 205 | |
| 194 | 206 | // Editable auth methods, seeded from the spec-detected templates. The add flow |
| 195 | 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 | 319 | baseUrl: resolvedBaseUrl, |
| 308 | 320 | specFormat: initialPreset?.specFormat, |
| 309 | 321 | family: initialPreset?.family, |
| 322 | + healthCheck: initialPreset?.healthCheck, | |
| 310 | 323 | // Always send the edited method list (even empty) when the user has |
| 311 | 324 | // inspected a preview: an explicit [] means "no auth methods", while |
| 312 | 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 | 343 | resolvedBaseUrl, |
| 331 | 344 | editedAuthenticationTemplate, |
| 332 | 345 | initialPreset?.family, |
| 346 | + initialPreset?.healthCheck, | |
| 333 | 347 | initialPreset?.specFormat, |
| 334 | 348 | ]); |
| 335 | 349 | |
| @@ -12,9 +12,11 @@ · 11 unmodified lines | ||
| 12 | 12 | |
| 13 | 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({ | |
| 18 | − id: "openapi" as const, | |
| 19 | − integrationPlugin: openApiIntegrationPlugin, | |
| 20 | −}); | |
| 17 | +export default function createOpenApiClientPlugin(config?: OpenApiClientConfig) { | |
| 18 | + return defineClientPlugin({ | |
| 19 | + id: "openapi" as const, | |
| 20 | + integrationPlugin: createOpenApiIntegrationPlugin(config), | |
| 21 | + }); | |
| 22 | +} | |
| @@ -1,21 +1,29 @@ | ||
| 1 | 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 | 3 | import { openApiPresets } from "../sdk/presets"; |
| 4 | 4 | |
| 5 | 5 | const importAdd = () => import("./AddOpenApiSource"); |
| 6 | 6 | const importEditSheet = () => import("./UpdateSpecSection"); |
| 7 | 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 | 16 | key: "openapi", |
| 11 | 17 | label: "OpenAPI", |
| 12 | 18 | add: lazy(importAdd), |
| 13 | 19 | editSheet: lazy(importEditSheet), |
| 14 | 20 | accounts: lazy(importAccounts), |
| 15 | − presets: openApiPresets, | |
| 21 | + presets: [...openApiPresets, ...(config?.presets ?? [])], | |
| 16 | 22 | preload: () => { |
| 17 | 23 | void importAdd(); |
| 18 | 24 | void importEditSheet(); |
| 19 | 25 | void importAccounts(); |
| 20 | 26 | }, |
| 21 | −}; | |
| 27 | +}); | |
| 28 | + | |
| 29 | +export const openApiIntegrationPlugin: IntegrationPlugin = createOpenApiIntegrationPlugin(); | |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | IntegrationSlug, |
| 10 | 10 | ToolResult, |
| 11 | 11 | definePlugin, |
| 12 | + HealthCheckSpec, | |
| 12 | 13 | mergeAuthTemplates, |
| 13 | 14 | sha256Hex, |
| 14 | 15 | tool, |
| @@ -33,7 +34,7 @@ import { · 18 unmodified lines | ||
| 33 | 34 | type SpecPreview, |
| 34 | 35 | } from "./preview"; |
| 35 | 36 | import { deriveAuthenticationTemplateFromPreview, firstBaseUrlForPreview } from "./derive-auth"; |
| 36 | −import { openApiPresets } from "./presets"; | |
| 37 | +import { openApiPresets, type OpenApiPreset } from "./presets"; | |
| 37 | 38 | import { makeDefaultOpenapiStore, type OpenapiStore } from "./store"; |
| 38 | 39 | import { |
| 39 | 40 | resolveSpecFormatAdapter, |
| @@ -87,6 +88,7 @@ export interface OpenApiSpecConfig { · 47 unmodified lines | ||
| 87 | 88 | readonly queryParams?: Record<string, string>; |
| 88 | 89 | readonly specFormat?: string; |
| 89 | 90 | readonly family?: string; |
| 91 | + readonly healthCheck?: HealthCheckSpec; | |
| 90 | 92 | /** Auth methods a connection's value renders through - canonical |
| 91 | 93 | * placements or the request-shaped authoring dialect. */ |
| 92 | 94 | readonly authenticationTemplate?: readonly AuthenticationInput[]; |
| @@ -279,6 +281,7 @@ const AddSourceInputSchema = Schema.Struct({ · 186 unmodified lines | ||
| 279 | 281 | queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 280 | 282 | specFormat: Schema.optional(Schema.String), |
| 281 | 283 | family: Schema.optional(Schema.String), |
| 284 | + healthCheck: Schema.optional(HealthCheckSpec), | |
| 282 | 285 | authenticationTemplate: Schema.optional(Schema.Array(AuthenticationSchema)), |
| 283 | 286 | }); |
| 284 | 287 | |
| @@ -566,9 +569,12 @@ export const describeOpenApiAuthMethods = ( · 281 unmodified lines | ||
| 566 | 569 | |
| 567 | 570 | export const describeOpenApiIntegrationDisplay = ( |
| 568 | 571 | record: IntegrationRecord, |
| 569 | −): { readonly url?: string } => { | |
| 572 | +): { readonly url?: string; readonly family?: string } => { | |
| 570 | 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 | 585 | readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 580 | 586 | readonly invokeOptions?: InvokeOptions; |
| 581 | 587 | readonly specFormats?: readonly SpecFormatAdapter[]; |
| 588 | + readonly presets?: readonly OpenApiPreset[]; | |
| 582 | 589 | } |
| 583 | 590 | |
| 584 | 591 | export const openApiPlugin = definePlugin< |
| @@ -623,7 +630,8 @@ export const openApiPlugin = definePlugin< · 38 unmodified lines | ||
| 623 | 630 | return { |
| 624 | 631 | id: "openapi" as const, |
| 625 | 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 | 635 | id: preset.id, |
| 628 | 636 | name: preset.name, |
| 629 | 637 | summary: preset.summary, |
| @@ -814,6 +822,9 @@ export const openApiPlugin = definePlugin< · 184 unmodified lines | ||
| 814 | 822 | canRemove: true, |
| 815 | 823 | canRefresh: integrationConfig.sourceUrl != null, |
| 816 | 824 | }); |
| 825 | + if (config.healthCheck) { | |
| 826 | + yield* ctx.core.integrations.setHealthCheck(slug, config.healthCheck); | |
| 827 | + } | |
| 817 | 828 | if (compiled) { |
| 818 | 829 | yield* ctx.storage.putOperations( |
| 819 | 830 | resolvedSlug, |
| @@ -1100,6 +1111,7 @@ export const openApiPlugin = definePlugin< · 280 unmodified lines | ||
| 1100 | 1111 | queryParams: input.queryParams, |
| 1101 | 1112 | specFormat: input.specFormat, |
| 1102 | 1113 | family: input.family, |
| 1114 | + healthCheck: input.healthCheck, | |
| 1103 | 1115 | authenticationTemplate: input.authenticationTemplate as |
| 1104 | 1116 | | readonly AuthenticationInput[] |
| 1105 | 1117 | | undefined, |
| @@ -224,6 +224,28 @@ describe("IntegrationFavicon", () => { · 223 unmodified lines | ||
| 224 | 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 | 249 | it("does not split generic words into brand matches", () => { |
| 228 | 250 | expect( |
| 229 | 251 | integrationPresetIconUrl( |
| @@ -157,6 +157,9 @@ export function integrationPresetIconUrl( · 156 unmodified lines | ||
| 157 | 157 | const pluginKey = KIND_TO_PLUGIN_KEY[source.kind] ?? source.kind; |
| 158 | 158 | const plugin = integrationPlugins.find((p) => p.key === pluginKey); |
| 159 | 159 | const presets = plugin?.presets ?? []; |
| 160 | + const exactSlugIcon = presets.find((p) => p.defaultSlug === source.id)?.icon; | |
| 161 | + if (exactSlugIcon) return exactSlugIcon; | |
| 162 | + | |
| 160 | 163 | const sourceUrl = normalizeUrl(source.url); |
| 161 | 164 | const sourceGoogleService = googleApiServiceFromUrl(source.url); |
| 162 | 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 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| @@ -33,11 +33,14 @@ import { · 28 unmodified lines | ||
| 33 | 33 | CardStackEntryDescription, |
| 34 | 34 | CardStackEntryMedia, |
| 35 | 35 | CardStackEntryTitle, |
| 36 | + CardStackHeader, | |
| 36 | 37 | } from "../components/card-stack"; |
| 37 | 38 | import { |
| 39 | + IntegrationFavicon, | |
| 38 | 40 | integrationInferredUrl, |
| 39 | 41 | integrationPresetIconUrl, |
| 40 | 42 | } from "../components/integration-favicon"; |
| 43 | +import { groupIntegrations, type IntegrationFamilyGroup } from "../lib/integration-grouping"; | |
| 41 | 44 | import { IntegrationHealthSummary } from "../components/integration-health-summary"; |
| 42 | 45 | import { IntegrationIconWithAccount } from "../components/integration-icon-with-account"; |
| 43 | 46 | import { Skeleton } from "../components/skeleton"; |
| @@ -349,7 +352,8 @@ function PresetGrid(props: { · 305 unmodified lines | ||
| 349 | 352 | const q = (props.searchQuery ?? "").trim().toLowerCase(); |
| 350 | 353 | if (q.length === 0) return allPresets; |
| 351 | 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 | 357 | return corpus.includes(q); |
| 354 | 358 | }); |
| 355 | 359 | }, [allPresets, props.searchQuery]); |
| @@ -434,47 +438,101 @@ function IntegrationGrid(props: { integrations: readonly Integration[] }) { · 78 unmodified lines | ||
| 434 | 438 | return out; |
| 435 | 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 | 522 | return ( |
| 438 | − <CardStack searchable> | |
| 439 | − <CardStackContent> | |
| 440 | − {props.integrations.map((integration) => { | |
| 441 | − const pluginKey = KIND_TO_PLUGIN_KEY[integration.kind] ?? integration.kind; | |
| 442 | − const plugin = pluginByKind.get(pluginKey); | |
| 443 | − const SummaryComponent = plugin?.summary; | |
| 444 | − const slug = String(integration.slug); | |
| 445 | − const name = integration.name || slug; | |
| 446 | − return ( | |
| 447 | − <CardStackEntry key={slug} asChild searchText={`${name} ${slug} ${integration.kind}`}> | |
| 448 | − <Link to="/{-$orgSlug}/integrations/$namespace" params={{ namespace: slug }}> | |
| 449 | − <IntegrationIconWithAccount | |
| 450 | − icon={integrationPresetIconUrl( | |
| 451 | − { id: slug, kind: integration.kind, name, url: integration.displayUrl }, | |
| 452 | − integrationPlugins, | |
| 453 | − )} | |
| 454 | − sourceId={slug} | |
| 455 | − url={ | |
| 456 | − integration.displayUrl ?? | |
| 457 | − integrationInferredUrl({ id: slug, name }) ?? | |
| 458 | − undefined | |
| 459 | − } | |
| 460 | − /> | |
| 461 | − <CardStackEntryContent> | |
| 462 | − <CardStackEntryTitle>{name}</CardStackEntryTitle> | |
| 463 | − <CardStackEntryDescription>{slug}</CardStackEntryDescription> | |
| 464 | − </CardStackEntryContent> | |
| 465 | − <CardStackEntryActions> | |
| 466 | − {SummaryComponent && ( | |
| 467 | − <Suspense fallback={null}> | |
| 468 | − <SummaryComponent sourceId={slug} /> | |
| 469 | − </Suspense> | |
| 470 | − )} | |
| 471 | − <IntegrationHealthSummary integration={integration.slug} /> | |
| 472 | − </CardStackEntryActions> | |
| 473 | − </Link> | |
| 474 | − </CardStackEntry> | |
| 475 | − ); | |
| 476 | − })} | |
| 477 | − </CardStackContent> | |
| 523 | + <CardStack collapsible defaultOpen data-testid={`integration-group-${group.family}`}> | |
| 524 | + <CardStackHeader> | |
| 525 | + <span className="flex min-w-0 items-center gap-2"> | |
| 526 | + <span className="flex size-5 shrink-0 items-center justify-center"> | |
| 527 | + <IntegrationFavicon icon={headerIcon} size={16} /> | |
| 528 | + </span> | |
| 529 | + <span className="truncate">{group.label}</span> | |
| 530 | + <span className="shrink-0 font-mono text-xs font-normal text-muted-foreground"> | |
| 531 | + {group.members.length} | |
| 532 | + </span> | |
| 533 | + </span> | |
| 534 | + </CardStackHeader> | |
| 535 | + <CardStackContent>{group.members.map(renderEntry)}</CardStackContent> | |
| 478 | 536 | </CardStack> |
| 479 | 537 | ); |
| 480 | 538 | } |