From e91270abe9e464009c2e7198d9f9e83bb4b1313d Mon Sep 17 00:00:00 2001 From: No Name <36251971+zrm625@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:08:23 -0400 Subject: [PATCH 1/2] Fix Google Photos Picker discovery --- .../plugins/google/src/sdk/discovery.test.ts | 52 ++++++++++++++++ packages/plugins/google/src/sdk/discovery.ts | 60 +++++++++++++++++-- .../plugins/google/src/sdk/plugin.test.ts | 18 ++---- .../src/components/oauth-client-form.tsx | 29 ++++++++- 4 files changed, 140 insertions(+), 19 deletions(-) diff --git a/packages/plugins/google/src/sdk/discovery.test.ts b/packages/plugins/google/src/sdk/discovery.test.ts index 166d3e077..6ada71807 100644 --- a/packages/plugins/google/src/sdk/discovery.test.ts +++ b/packages/plugins/google/src/sdk/discovery.test.ts @@ -53,6 +53,15 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { expect( normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); + expect( + normalizeGoogleDiscoveryUrl("https://photospicker.googleapis.com/$discovery/rest?version=v1"), + ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1"); + expect( + normalizeGoogleDiscoveryUrl("https://forms.googleapis.com/$discovery/rest?version=v1"), + ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1"); + expect( + normalizeGoogleDiscoveryUrl("https://keep.googleapis.com/$discovery/rest?version=v1"), + ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1"); expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( true, @@ -332,6 +341,49 @@ it.effect("marks Google Discovery media-download methods as binary responses", ( }), ); +it.effect("supplies documented scopes when Picker Discovery omits auth metadata", () => + Effect.gen(function* () { + const result = yield* convertGoogleDiscoveryToOpenApi({ + discoveryUrl: "https://photospicker.googleapis.com/$discovery/rest?version=v1", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "photospicker", + version: "v1", + title: "Google Photos Picker API", + rootUrl: "https://photospicker.googleapis.com/", + servicePath: "v1/", + resources: { + mediaItems: { + methods: { + list: { + id: "photospicker.mediaItems.list", + httpMethod: "GET", + path: "mediaItems", + parameters: {}, + }, + }, + }, + }, + schemas: {}, + }), + }); + + const pickerScope = "https://www.googleapis.com/auth/photospicker.mediaitems.readonly"; + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ + pickerScope, + ]); + + const spec = decodeConvertedSpec(result.specText); + const operation = spec.paths["/mediaItems"]?.get; + expect(operation).toMatchObject({ + operationId: "mediaItems.list", + "x-google-scopes": [pickerScope], + security: [{ googleOAuth2: [pickerScope] }], + }); + }), +); + it.effect("bundles Google Discovery documents into one Google OpenAPI source", () => Effect.gen(function* () { const result = yield* convertGoogleDiscoveryBundleToOpenApi({ diff --git a/packages/plugins/google/src/sdk/discovery.ts b/packages/plugins/google/src/sdk/discovery.ts index bc4c0431a..e69a89fed 100644 --- a/packages/plugins/google/src/sdk/discovery.ts +++ b/packages/plugins/google/src/sdk/discovery.ts @@ -18,6 +18,10 @@ const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; +const GOOGLE_PHOTOS_PICKER_SCOPE = + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly"; +const GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION = "Read selected Google Photos media"; const OPENAPI_SCHEMA_TYPES = new Set([ "array", "boolean", @@ -28,6 +32,24 @@ const OPENAPI_SCHEMA_TYPES = new Set([ "string", ]); +type GoogleDiscoveryServiceOverride = { + readonly preserveServiceHostedUrl?: true; + readonly scopes?: Record; + readonly fallbackMethodScopes?: readonly string[]; +}; + +const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record = { + forms: { preserveServiceHostedUrl: true }, + keep: { preserveServiceHostedUrl: true }, + [GOOGLE_PHOTOS_PICKER_SERVICE]: { + preserveServiceHostedUrl: true, + scopes: { + [GOOGLE_PHOTOS_PICKER_SCOPE]: GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION, + }, + fallbackMethodScopes: [GOOGLE_PHOTOS_PICKER_SCOPE], + }, +}; + type JsonPrimitive = string | number | boolean | null; type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; @@ -283,7 +305,10 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null ) { return null; } - return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; + const override = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]; + return override?.preserveServiceHostedUrl === true + ? `https://${host}/$discovery/rest?version=${version}` + : `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; }; const normalizeDiscoveryUrl = (discoveryUrl: string): string => { @@ -677,7 +702,6 @@ const buildDiscoveryOperation = (input: { const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary"; -const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"; const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; @@ -685,6 +709,31 @@ const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; const isGooglePhotosService = (service: string): boolean => service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; +const discoveryScopesForService = ( + service: string, + document: DiscoveryDocument, +): Record => { + const scopes = discoveryScopes(document); + const overrideScopes = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.scopes; + if (!overrideScopes) { + return scopes; + } + const missingScopes = Object.fromEntries( + Object.entries(overrideScopes).filter(([scope]) => scopes[scope] === undefined), + ); + return Object.keys(missingScopes).length === 0 ? scopes : { ...scopes, ...missingScopes }; +}; + +const discoveryMethodScopesForService = ( + service: string, + method: DiscoveryMethod, +): readonly string[] => { + const scopes = method.scopes ?? []; + return scopes.length === 0 + ? (GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.fallbackMethodScopes ?? scopes) + : scopes; +}; + /** The v2 oauth auth template for a Google-discovery integration. The spec * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the * catalog-level template a connection's access token renders through. */ @@ -811,6 +860,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD method, toolPath, pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, + oauthScopes: discoveryMethodScopesForService(service, method), }); } @@ -826,7 +876,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD }); } - const scopes = compactDiscoveryScopeMap(discoveryScopes(document)); + const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document)); const authenticationTemplate = googleOauthTemplate(scopes); const spec: OpenApiDocument = { @@ -923,7 +973,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( for (const info of infos) { const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; - const scopeDescriptions = discoveryScopes(info.document); + const scopeDescriptions = discoveryScopesForService(info.service, info.document); const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); for (const [scope, description] of Object.entries(scopeDescriptions)) { @@ -939,7 +989,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const methodId = Option.getOrUndefined(method.id); const rawPathTemplate = Option.getOrUndefined(method.path); if (!methodId || !rawPathTemplate || !method.httpMethod) continue; - const methodScopes = method.scopes ?? []; + const methodScopes = discoveryMethodScopesForService(info.service, method); const oauthScopes = filterPhotosScopes ? methodScopes.filter((scope) => consentScopeSet.has(scope)) : methodScopes; diff --git a/packages/plugins/google/src/sdk/plugin.test.ts b/packages/plugins/google/src/sdk/plugin.test.ts index 213cd50af..d970566d1 100644 --- a/packages/plugins/google/src/sdk/plugin.test.ts +++ b/packages/plugins/google/src/sdk/plugin.test.ts @@ -35,7 +35,7 @@ const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/r const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; -const PHOTOS_PICKER_URL = "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest"; +const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; const calendarDoc = { name: "calendar", @@ -197,15 +197,6 @@ const photosPickerDoc = { title: "Google Photos Picker API", rootUrl: "https://photospicker.googleapis.com/", servicePath: "v1/", - auth: { - oauth2: { - scopes: { - "https://www.googleapis.com/auth/photospicker.mediaitems.readonly": { - description: "Read selected Google Photos media", - }, - }, - }, - }, resources: { mediaItems: { methods: { @@ -213,7 +204,6 @@ const photosPickerDoc = { id: "photospicker.mediaItems.list", httpMethod: "GET", path: "mediaItems", - scopes: ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"], parameters: {}, }, }, @@ -233,12 +223,14 @@ const DISCOVERY_BODIES: Readonly> = { }; // A stub HTTP client that serves the canned Discovery document for whichever -// URL the bundle converter fetches (query params are ignored when matching). +// URL the bundle converter fetches. Service-hosted Discovery URLs carry their +// version in the query string, so match the full URL before falling back to the +// path-only key used by central Discovery URLs. const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { const url = new URL(request.url); const key = `${url.origin}${url.pathname}`; - const body = DISCOVERY_BODIES[key]; + const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; return Effect.succeed( HttpClientResponse.fromWeb( request, diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 1c0ade78c..c09dd3756 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -159,6 +159,9 @@ export function OAuthClientForm(props: { const [discoveredScopes, setDiscoveredScopes] = useState( prefill?.discoveredScopes ?? [], ); + const visibleScopes = registrationScopes(declaredScopes, discoveredScopes); + const visibleScopesSource = + declaredScopes.length > 0 ? "Declared by integration" : "Discovered from server"; const [discovering, setDiscovering] = useState(false); const [submitting, setSubmitting] = useState(false); // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded @@ -470,7 +473,7 @@ export function OAuthClientForm(props: { - {/* endpoints + scopes — collapsed when the integration already declares them */} + {/* endpoints */} {endpointsKnown && !showEndpoints ? (