Unified · Split
| @@ -5,6 +5,8 @@ import { buildToolTypeScriptPreview } from "@executor-js/sdk/core"; · 4 unmodified lines | |||
| 5 | import { | 5 | import { |
| 6 | convertGoogleDiscoveryBundleToOpenApi, | 6 | convertGoogleDiscoveryBundleToOpenApi, |
| 7 | convertGoogleDiscoveryToOpenApi, | 7 | convertGoogleDiscoveryToOpenApi, |
| 8 | isGoogleDiscoveryUrl, | ||
| 9 | normalizeGoogleDiscoveryUrl, | ||
| 8 | } from "./discovery"; | 10 | } from "./discovery"; |
| 9 | import { extract, parse } from "@executor-js/plugin-openapi"; | 11 | import { extract, parse } from "@executor-js/plugin-openapi"; |
| 10 | 12 | ||
| @@ -44,6 +46,31 @@ const ConvertedSpec = Schema.Struct({ · 33 unmodified lines | |||
| 44 | 46 | ||
| 45 | const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); | 47 | const decodeConvertedSpec = Schema.decodeUnknownSync(Schema.fromJsonString(ConvertedSpec)); |
| 46 | 48 | ||
| 49 | it("accepts only supported HTTPS Google Discovery endpoints", () => { | ||
| 50 | expect( | ||
| 51 | normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"), | ||
| 52 | ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"); | ||
| 53 | expect( | ||
| 54 | normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), | ||
| 55 | ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); | ||
| 56 | |||
| 57 | expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( | ||
| 58 | true, | ||
| 59 | ); | ||
| 60 | expect(isGoogleDiscoveryUrl("https://evilgoogleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( | ||
| 61 | false, | ||
| 62 | ); | ||
| 63 | expect(isGoogleDiscoveryUrl("http://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( | ||
| 64 | false, | ||
| 65 | ); | ||
| 66 | expect( | ||
| 67 | isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest?next=x"), | ||
| 68 | ).toBe(false); | ||
| 69 | expect( | ||
| 70 | isGoogleDiscoveryUrl("https://token@www.googleapis.com/discovery/v1/apis/gmail/v1/rest"), | ||
| 71 | ).toBe(false); | ||
| 72 | }); | ||
| 73 | |||
| 47 | const normalizeOpenApiRefsForPreview = (node: unknown): unknown => { | 74 | const normalizeOpenApiRefsForPreview = (node: unknown): unknown => { |
| 48 | if (node == null || typeof node !== "object") return node; | 75 | if (node == null || typeof node !== "object") return node; |
| 49 | if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview); | 76 | if (Array.isArray(node)) return node.map(normalizeOpenApiRefsForPreview); |
| @@ -231,38 +231,76 @@ const decodeDiscoveryMethod = Schema.decodeUnknownSync(DiscoveryMethod); · 230 unmodified lines | |||
| 231 | const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); | 231 | const decodeDiscoveryResource = Schema.decodeUnknownSync(DiscoveryResource); |
| 232 | const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); | 232 | const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); |
| 233 | 233 | ||
| 234 | const normalizeDiscoveryUrl = (discoveryUrl: string): string => { | 234 | const DISCOVERY_SERVICE_PATH_RE = |
| 235 | const trimmed = discoveryUrl.trim(); | 235 | /^\/discovery\/v1\/apis\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/rest\/?$/; |
| 236 | if (!URL.canParse(trimmed)) return trimmed; | 236 | const DISCOVERY_VERSION_RE = /^[A-Za-z0-9._-]+$/; |
| 237 | const parsed = new URL(trimmed); | 237 | |
| 238 | if (parsed.pathname !== "/$discovery/rest") return trimmed; | 238 | const serviceFromGoogleApisHost = (host: string): string | null => { |
| 239 | const version = parsed.searchParams.get("version")?.trim(); | 239 | if (!host.endsWith(".googleapis.com")) return null; |
| 240 | if (!version) return trimmed; | ||
| 241 | const host = parsed.hostname.toLowerCase(); | ||
| 242 | if (!host.endsWith(".googleapis.com")) return trimmed; | ||
| 243 | const rawService = host.slice(0, -".googleapis.com".length); | 240 | const rawService = host.slice(0, -".googleapis.com".length); |
| 241 | if (!rawService || rawService.includes(".")) return null; | ||
| 244 | const service = | 242 | const service = |
| 245 | rawService === "calendar-json" | 243 | rawService === "calendar-json" |
| 246 | ? "calendar" | 244 | ? "calendar" |
| 247 | : rawService.endsWith("-json") | 245 | : rawService.endsWith("-json") |
| 248 | ? rawService.slice(0, -5) | 246 | ? rawService.slice(0, -5) |
| 249 | : rawService; | 247 | : rawService; |
| 250 | return service ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : trimmed; | 248 | return /^[a-z0-9][a-z0-9-]*$/.test(service) ? service : null; |
| 251 | }; | 249 | }; |
| 252 | 250 | ||
| 253 | export const isGoogleDiscoveryUrl = (url: string): boolean => { | 251 | export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null => { |
| 254 | const trimmed = url.trim(); | 252 | const trimmed = discoveryUrl.trim(); |
| 255 | if (!URL.canParse(trimmed)) return false; | 253 | if (!URL.canParse(trimmed)) return null; |
| 256 | const parsed = new URL(trimmed); | 254 | const parsed = new URL(trimmed); |
| 255 | if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash) { | ||
| 256 | return null; | ||
| 257 | } | ||
| 258 | |||
| 257 | const host = parsed.hostname.toLowerCase(); | 259 | const host = parsed.hostname.toLowerCase(); |
| 258 | if (!host.endsWith("googleapis.com")) return false; | 260 | if (host === "www.googleapis.com") { |
| 259 | return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); | 261 | if (parsed.search) return null; |
| 262 | const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE); | ||
| 263 | const service = match?.[1]; | ||
| 264 | const version = match?.[2]; | ||
| 265 | return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null; | ||
| 266 | } | ||
| 267 | |||
| 268 | const service = serviceFromGoogleApisHost(host); | ||
| 269 | if (!service || !["/$discovery/rest", "/$discovery/rest/"].includes(parsed.pathname)) { | ||
| 270 | return null; | ||
| 271 | } | ||
| 272 | const keys = [...parsed.searchParams.keys()]; | ||
| 273 | const version = parsed.searchParams.get("version")?.trim(); | ||
| 274 | if ( | ||
| 275 | keys.length !== 1 || | ||
| 276 | keys[0] !== "version" || | ||
| 277 | !version || | ||
| 278 | !DISCOVERY_VERSION_RE.test(version) | ||
| 279 | ) { | ||
| 280 | return null; | ||
| 281 | } | ||
| 282 | return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; | ||
| 283 | }; | ||
| 284 | |||
| 285 | const normalizeDiscoveryUrl = (discoveryUrl: string): string => { | ||
| 286 | return normalizeGoogleDiscoveryUrl(discoveryUrl) ?? discoveryUrl.trim(); | ||
| 287 | }; | ||
| 288 | |||
| 289 | export const isGoogleDiscoveryUrl = (url: string): boolean => { | ||
| 290 | return normalizeGoogleDiscoveryUrl(url) !== null; | ||
| 260 | }; | 291 | }; |
| 261 | 292 | ||
| 262 | export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( | 293 | export const fetchGoogleDiscoveryDocument = Effect.fn("OpenApi.fetchGoogleDiscoveryDocument")( |
| 263 | function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { | 294 | function* (discoveryUrl: string, credentials?: SpecFetchCredentials) { |
| 295 | const normalizedDiscoveryUrl = normalizeGoogleDiscoveryUrl(discoveryUrl); | ||
| 296 | if (!normalizedDiscoveryUrl) { | ||
| 297 | return yield* new OpenApiParseError({ | ||
| 298 | message: | ||
| 299 | "Google Discovery document URL must be a supported googleapis.com HTTPS Discovery endpoint", | ||
| 300 | }); | ||
| 301 | } | ||
| 264 | const client = yield* HttpClient.HttpClient; | 302 | const client = yield* HttpClient.HttpClient; |
| 265 | const requestUrl = new URL(discoveryUrl); | 303 | const requestUrl = new URL(normalizedDiscoveryUrl); |
| 266 | for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { | 304 | for (const [name, value] of Object.entries(credentials?.queryParams ?? {})) { |
| 267 | requestUrl.searchParams.set(name, value); | 305 | requestUrl.searchParams.set(name, value); |
| 268 | } | 306 | } |
| @@ -12,7 +12,7 @@ · 11 unmodified lines | |||
| 12 | // --------------------------------------------------------------------------- | 12 | // --------------------------------------------------------------------------- |
| 13 | 13 | ||
| 14 | import { describe, expect, it } from "@effect/vitest"; | 14 | import { describe, expect, it } from "@effect/vitest"; |
| 15 | import { Effect, Layer } from "effect"; | 15 | import { Effect, Exit, Layer } from "effect"; |
| 16 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | 16 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; |
| 17 | 17 | ||
| 18 | import { | 18 | import { |
| @@ -174,6 +174,43 @@ const bundlePlugins = () => · 155 unmodified lines | |||
| 174 | [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; | 174 | [googlePlugin({ httpClientLayer: discoveryHttpClientLayer }), memoryCredentialsPlugin()] as const; |
| 175 | 175 | ||
| 176 | describe("Google bundle add flow", () => { | 176 | describe("Google bundle add flow", () => { |
| 177 | it.effect("rejects lookalike Discovery hosts before fetching bundle documents", () => | ||
| 178 | Effect.scoped( | ||
| 179 | Effect.gen(function* () { | ||
| 180 | let requests = 0; | ||
| 181 | const blockedHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( | ||
| 182 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => | ||
| 183 | Effect.sync(() => { | ||
| 184 | requests += 1; | ||
| 185 | return HttpClientResponse.fromWeb( | ||
| 186 | request, | ||
| 187 | new Response("unexpected request", { status: 500 }), | ||
| 188 | ); | ||
| 189 | }), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | const executor = yield* createExecutor( | ||
| 193 | makeTestConfig({ | ||
| 194 | plugins: [ | ||
| 195 | googlePlugin({ httpClientLayer: blockedHttpClientLayer }), | ||
| 196 | memoryCredentialsPlugin(), | ||
| 197 | ], | ||
| 198 | }), | ||
| 199 | ); | ||
| 200 | |||
| 201 | const exit = yield* executor.google | ||
| 202 | .addBundle({ | ||
| 203 | urls: ["https://evilgoogleapis.com/discovery/v1/apis/calendar/v3/rest"], | ||
| 204 | slug: "bad_google", | ||
| 205 | }) | ||
| 206 | .pipe(Effect.exit); | ||
| 207 | |||
| 208 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 209 | expect(requests).toBe(0); | ||
| 210 | }), | ||
| 211 | ), | ||
| 212 | ); | ||
| 213 | |||
| 177 | it.effect( | 214 | it.effect( |
| 178 | "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", | 215 | "addBundle merges calendar+gmail+drive into one google integration with no tool-name collisions", |
| 179 | () => | 216 | () => |
| @@ -33,7 +33,7 @@ import { · 32 unmodified lines | |||
| 33 | import { | 33 | import { |
| 34 | convertGoogleDiscoveryBundleToOpenApi, | 34 | convertGoogleDiscoveryBundleToOpenApi, |
| 35 | fetchGoogleDiscoveryDocument, | 35 | fetchGoogleDiscoveryDocument, |
| 36 | isGoogleDiscoveryUrl, | 36 | normalizeGoogleDiscoveryUrl, |
| 37 | } from "./discovery"; | 37 | } from "./discovery"; |
| 38 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; | 38 | import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; |
| 39 | import { googleOpenApiBundlePreset } from "./presets"; | 39 | import { googleOpenApiBundlePreset } from "./presets"; |
| @@ -83,7 +83,7 @@ const fetchGoogleBundleConversion = ( · 43 unmodified lines | |||
| 83 | ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); | 83 | ).pipe(Effect.flatMap((documents) => convertGoogleDiscoveryBundleToOpenApi({ documents }))); |
| 84 | 84 | ||
| 85 | const uniqueUrls = (urls: readonly string[]): readonly string[] => [ | 85 | const uniqueUrls = (urls: readonly string[]): readonly string[] => [ |
| 86 | ...new Set(urls.map((url) => url.trim()).filter((url) => url.length > 0)), | 86 | ...new Set(urls.flatMap((url) => normalizeGoogleDiscoveryUrl(url) ?? [])), |
| 87 | ]; | 87 | ]; |
| 88 | 88 | ||
| 89 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { | 89 | const describeGoogleAuthMethods = (record: IntegrationRecord): readonly AuthMethodDescriptor[] => { |
| @@ -326,13 +326,14 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ · 236 unmodified lines | |||
| 326 | detect: ({ ctx, url }) => | 326 | detect: ({ ctx, url }) => |
| 327 | Effect.gen(function* () { | 327 | Effect.gen(function* () { |
| 328 | const trimmed = url.trim(); | 328 | const trimmed = url.trim(); |
| 329 | if (!trimmed || !isGoogleDiscoveryUrl(trimmed)) return null; | 329 | const discoveryUrl = normalizeGoogleDiscoveryUrl(trimmed); |
| 330 | if (!trimmed || !discoveryUrl) return null; | ||
| 330 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; | 331 | const httpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; |
| 331 | const conversion = yield* fetchGoogleDiscoveryDocument(trimmed).pipe( | 332 | const conversion = yield* fetchGoogleDiscoveryDocument(discoveryUrl).pipe( |
| 332 | Effect.provide(httpClientLayer), | 333 | Effect.provide(httpClientLayer), |
| 333 | Effect.flatMap((documentText) => | 334 | Effect.flatMap((documentText) => |
| 334 | convertGoogleDiscoveryBundleToOpenApi({ | 335 | convertGoogleDiscoveryBundleToOpenApi({ |
| 335 | documents: [{ discoveryUrl: trimmed, documentText }], | 336 | documents: [{ discoveryUrl, documentText }], |
| 336 | }), | 337 | }), |
| 337 | ), | 338 | ), |
| 338 | Effect.catch(() => Effect.succeed(null)), | 339 | Effect.catch(() => Effect.succeed(null)), |
| @@ -341,7 +342,7 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ · 2 unmodified lines | |||
| 341 | return IntegrationDetectionResult.make({ | 342 | return IntegrationDetectionResult.make({ |
| 342 | kind: "google", | 343 | kind: "google", |
| 343 | confidence: "high", | 344 | confidence: "high", |
| 344 | endpoint: trimmed, | 345 | endpoint: discoveryUrl, |
| 345 | name: conversion.title, | 346 | name: conversion.title, |
| 346 | slug: DEFAULT_GOOGLE_SLUG, | 347 | slug: DEFAULT_GOOGLE_SLUG, |
| 347 | }); | 348 | }); |
| @@ -1,3 +1,5 @@ | |||
| 1 | import { normalizeGoogleDiscoveryUrl } from "./discovery"; | ||
| 2 | |||
| 1 | export interface GooglePreset { | 3 | export interface GooglePreset { |
| 2 | readonly id: string; | 4 | readonly id: string; |
| 3 | readonly name: string; | 5 | readonly name: string; |
| @@ -242,6 +244,8 @@ export const googleOAuthConsentScopesForPreset = (presetId: string): readonly st · 238 unmodified lines | |||
| 242 | // --------------------------------------------------------------------------- | 244 | // --------------------------------------------------------------------------- |
| 243 | 245 | ||
| 244 | const normalizeGooglePresetUrl = (url: string): string => { | 246 | const normalizeGooglePresetUrl = (url: string): string => { |
| 247 | const discoveryUrl = normalizeGoogleDiscoveryUrl(url); | ||
| 248 | if (discoveryUrl) return discoveryUrl; | ||
| 245 | const trimmed = url.trim(); | 249 | const trimmed = url.trim(); |
| 246 | if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); | 250 | if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); |
| 247 | const parsed = new URL(trimmed); | 251 | const parsed = new URL(trimmed); |