* Fix Google Photos Picker discovery * Handle central Google discovery overrides
Unified · Split
| @@ -50,9 +50,29 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => { · 49 unmodified lines | ||
| 50 | 50 | expect( |
| 51 | 51 | normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"), |
| 52 | 52 | ).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"); |
| 53 | + expect( | |
| 54 | + normalizeGoogleDiscoveryUrl( | |
| 55 | + "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest", | |
| 56 | + ), | |
| 57 | + ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1"); | |
| 58 | + expect( | |
| 59 | + normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/forms/v1/rest"), | |
| 60 | + ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1"); | |
| 61 | + expect( | |
| 62 | + normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/keep/v1/rest"), | |
| 63 | + ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1"); | |
| 53 | 64 | expect( |
| 54 | 65 | normalizeGoogleDiscoveryUrl("https://chat.googleapis.com/$discovery/rest?version=v1"), |
| 55 | 66 | ).toBe("https://www.googleapis.com/discovery/v1/apis/chat/v1/rest"); |
| 67 | + expect( | |
| 68 | + normalizeGoogleDiscoveryUrl("https://photospicker.googleapis.com/$discovery/rest?version=v1"), | |
| 69 | + ).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1"); | |
| 70 | + expect( | |
| 71 | + normalizeGoogleDiscoveryUrl("https://forms.googleapis.com/$discovery/rest?version=v1"), | |
| 72 | + ).toBe("https://forms.googleapis.com/$discovery/rest?version=v1"); | |
| 73 | + expect( | |
| 74 | + normalizeGoogleDiscoveryUrl("https://keep.googleapis.com/$discovery/rest?version=v1"), | |
| 75 | + ).toBe("https://keep.googleapis.com/$discovery/rest?version=v1"); | |
| 56 | 76 | |
| 57 | 77 | expect(isGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest")).toBe( |
| 58 | 78 | true, |
| @@ -332,6 +352,49 @@ it.effect("marks Google Discovery media-download methods as binary responses", ( · 273 unmodified lines | ||
| 332 | 352 | }), |
| 333 | 353 | ); |
| 334 | 354 | |
| 355 | +it.effect("supplies documented scopes when Picker Discovery omits auth metadata", () => | |
| 356 | + Effect.gen(function* () { | |
| 357 | + const result = yield* convertGoogleDiscoveryToOpenApi({ | |
| 358 | + discoveryUrl: "https://photospicker.googleapis.com/$discovery/rest?version=v1", | |
| 359 | + // @effect-diagnostics-next-line preferSchemaOverJson:off | |
| 360 | + documentText: JSON.stringify({ | |
| 361 | + name: "photospicker", | |
| 362 | + version: "v1", | |
| 363 | + title: "Google Photos Picker API", | |
| 364 | + rootUrl: "https://photospicker.googleapis.com/", | |
| 365 | + servicePath: "v1/", | |
| 366 | + resources: { | |
| 367 | + mediaItems: { | |
| 368 | + methods: { | |
| 369 | + list: { | |
| 370 | + id: "photospicker.mediaItems.list", | |
| 371 | + httpMethod: "GET", | |
| 372 | + path: "mediaItems", | |
| 373 | + parameters: {}, | |
| 374 | + }, | |
| 375 | + }, | |
| 376 | + }, | |
| 377 | + }, | |
| 378 | + schemas: {}, | |
| 379 | + }), | |
| 380 | + }); | |
| 381 | + | |
| 382 | + const pickerScope = "https://www.googleapis.com/auth/photospicker.mediaitems.readonly"; | |
| 383 | + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); | |
| 384 | + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ | |
| 385 | + pickerScope, | |
| 386 | + ]); | |
| 387 | + | |
| 388 | + const spec = decodeConvertedSpec(result.specText); | |
| 389 | + const operation = spec.paths["/mediaItems"]?.get; | |
| 390 | + expect(operation).toMatchObject({ | |
| 391 | + operationId: "mediaItems.list", | |
| 392 | + "x-google-scopes": [pickerScope], | |
| 393 | + security: [{ googleOAuth2: [pickerScope] }], | |
| 394 | + }); | |
| 395 | + }), | |
| 396 | +); | |
| 397 | + | |
| 335 | 398 | it.effect("bundles Google Discovery documents into one Google OpenAPI source", () => |
| 336 | 399 | Effect.gen(function* () { |
| 337 | 400 | const result = yield* convertGoogleDiscoveryBundleToOpenApi({ |
| @@ -18,6 +18,10 @@ const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; · 17 unmodified lines | ||
| 18 | 18 | const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; |
| 19 | 19 | const GOOGLE_OAUTH_AUTHORIZATION_URL = "https://accounts.google.com/o/oauth2/v2/auth"; |
| 20 | 20 | const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; |
| 21 | +const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; | |
| 22 | +const GOOGLE_PHOTOS_PICKER_SCOPE = | |
| 23 | + "https://www.googleapis.com/auth/photospicker.mediaitems.readonly"; | |
| 24 | +const GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION = "Read selected Google Photos media"; | |
| 21 | 25 | const OPENAPI_SCHEMA_TYPES = new Set([ |
| 22 | 26 | "array", |
| 23 | 27 | "boolean", |
| @@ -28,6 +32,35 @@ const OPENAPI_SCHEMA_TYPES = new Set([ · 4 unmodified lines | ||
| 28 | 32 | "string", |
| 29 | 33 | ]); |
| 30 | 34 | |
| 35 | +type GoogleDiscoveryServiceOverride = { | |
| 36 | + readonly preserveServiceHostedUrl?: true; | |
| 37 | + readonly scopes?: Record<string, string>; | |
| 38 | + readonly fallbackMethodScopes?: readonly string[]; | |
| 39 | +}; | |
| 40 | + | |
| 41 | +const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record<string, GoogleDiscoveryServiceOverride> = { | |
| 42 | + forms: { preserveServiceHostedUrl: true }, | |
| 43 | + keep: { preserveServiceHostedUrl: true }, | |
| 44 | + [GOOGLE_PHOTOS_PICKER_SERVICE]: { | |
| 45 | + preserveServiceHostedUrl: true, | |
| 46 | + scopes: { | |
| 47 | + [GOOGLE_PHOTOS_PICKER_SCOPE]: GOOGLE_PHOTOS_PICKER_SCOPE_DESCRIPTION, | |
| 48 | + }, | |
| 49 | + fallbackMethodScopes: [GOOGLE_PHOTOS_PICKER_SCOPE], | |
| 50 | + }, | |
| 51 | +}; | |
| 52 | + | |
| 53 | +const googleDiscoveryUrlForService = ( | |
| 54 | + service: string, | |
| 55 | + version: string, | |
| 56 | + host = `${service}.googleapis.com`, | |
| 57 | +): string => { | |
| 58 | + const override = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]; | |
| 59 | + return override?.preserveServiceHostedUrl === true | |
| 60 | + ? `https://${host}/$discovery/rest?version=${version}` | |
| 61 | + : `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; | |
| 62 | +}; | |
| 63 | + | |
| 31 | 64 | type JsonPrimitive = string | number | boolean | null; |
| 32 | 65 | type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue }; |
| 33 | 66 | |
| @@ -266,7 +299,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null · 232 unmodified lines | ||
| 266 | 299 | const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE); |
| 267 | 300 | const service = match?.[1]; |
| 268 | 301 | const version = match?.[2]; |
| 269 | − return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null; | |
| 302 | + return service && version ? googleDiscoveryUrlForService(service, version) : null; | |
| 270 | 303 | } |
| 271 | 304 | |
| 272 | 305 | const service = serviceFromGoogleApisHost(host); |
| @@ -283,7 +316,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null · 10 unmodified lines | ||
| 283 | 316 | ) { |
| 284 | 317 | return null; |
| 285 | 318 | } |
| 286 | − return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; | |
| 319 | + return googleDiscoveryUrlForService(service, version, host); | |
| 287 | 320 | }; |
| 288 | 321 | |
| 289 | 322 | const normalizeDiscoveryUrl = (discoveryUrl: string): string => { |
| @@ -677,7 +710,6 @@ const buildDiscoveryOperation = (input: { · 387 unmodified lines | ||
| 677 | 710 | |
| 678 | 711 | const GOOGLE_OAUTH_SECURITY_SCHEME = "googleOAuth2"; |
| 679 | 712 | const GOOGLE_PHOTOS_LIBRARY_SERVICE = "photoslibrary"; |
| 680 | −const GOOGLE_PHOTOS_PICKER_SERVICE = "photospicker"; | |
| 681 | 713 | const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photoslibrary.appendonly"; |
| 682 | 714 | const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; |
| 683 | 715 | const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; |
| @@ -685,6 +717,31 @@ const GOOGLE_PHOTOS_UPLOAD_PATH = "/uploads"; · 1 unmodified line | ||
| 685 | 717 | const isGooglePhotosService = (service: string): boolean => |
| 686 | 718 | service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; |
| 687 | 719 | |
| 720 | +const discoveryScopesForService = ( | |
| 721 | + service: string, | |
| 722 | + document: DiscoveryDocument, | |
| 723 | +): Record<string, string> => { | |
| 724 | + const scopes = discoveryScopes(document); | |
| 725 | + const overrideScopes = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.scopes; | |
| 726 | + if (!overrideScopes) { | |
| 727 | + return scopes; | |
| 728 | + } | |
| 729 | + const missingScopes = Object.fromEntries( | |
| 730 | + Object.entries(overrideScopes).filter(([scope]) => scopes[scope] === undefined), | |
| 731 | + ); | |
| 732 | + return Object.keys(missingScopes).length === 0 ? scopes : { ...scopes, ...missingScopes }; | |
| 733 | +}; | |
| 734 | + | |
| 735 | +const discoveryMethodScopesForService = ( | |
| 736 | + service: string, | |
| 737 | + method: DiscoveryMethod, | |
| 738 | +): readonly string[] => { | |
| 739 | + const scopes = method.scopes ?? []; | |
| 740 | + return scopes.length === 0 | |
| 741 | + ? (GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service]?.fallbackMethodScopes ?? scopes) | |
| 742 | + : scopes; | |
| 743 | +}; | |
| 744 | + | |
| 688 | 745 | /** The v2 oauth auth template for a Google-discovery integration. The spec |
| 689 | 746 | * itself carries the matching `securitySchemes.googleOAuth2` entry; this is the |
| 690 | 747 | * catalog-level template a connection's access token renders through. */ |
| @@ -811,6 +868,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 120 unmodified lines | ||
| 811 | 868 | method, |
| 812 | 869 | toolPath, |
| 813 | 870 | pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`, |
| 871 | + oauthScopes: discoveryMethodScopesForService(service, method), | |
| 814 | 872 | }); |
| 815 | 873 | } |
| 816 | 874 | |
| @@ -826,7 +884,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD · 9 unmodified lines | ||
| 826 | 884 | }); |
| 827 | 885 | } |
| 828 | 886 | |
| 829 | − const scopes = compactDiscoveryScopeMap(discoveryScopes(document)); | |
| 887 | + const scopes = compactDiscoveryScopeMap(discoveryScopesForService(service, document)); | |
| 830 | 888 | const authenticationTemplate = googleOauthTemplate(scopes); |
| 831 | 889 | |
| 832 | 890 | const spec: OpenApiDocument = { |
| @@ -923,7 +981,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 90 unmodified lines | ||
| 923 | 981 | for (const info of infos) { |
| 924 | 982 | const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); |
| 925 | 983 | const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; |
| 926 | − const scopeDescriptions = discoveryScopes(info.document); | |
| 984 | + const scopeDescriptions = discoveryScopesForService(info.service, info.document); | |
| 927 | 985 | const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); |
| 928 | 986 | |
| 929 | 987 | for (const [scope, description] of Object.entries(scopeDescriptions)) { |
| @@ -939,7 +997,7 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 9 unmodified lines | ||
| 939 | 997 | const methodId = Option.getOrUndefined(method.id); |
| 940 | 998 | const rawPathTemplate = Option.getOrUndefined(method.path); |
| 941 | 999 | if (!methodId || !rawPathTemplate || !method.httpMethod) continue; |
| 942 | − const methodScopes = method.scopes ?? []; | |
| 1000 | + const methodScopes = discoveryMethodScopesForService(info.service, method); | |
| 943 | 1001 | const oauthScopes = filterPhotosScopes |
| 944 | 1002 | ? methodScopes.filter((scope) => consentScopeSet.has(scope)) |
| 945 | 1003 | : methodScopes; |
| @@ -35,7 +35,7 @@ const CALENDAR_URL = "https://www.googleapis.com/discovery/v1/apis/calendar/v3/r · 34 unmodified lines | ||
| 35 | 35 | const GMAIL_URL = "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest"; |
| 36 | 36 | const DRIVE_URL = "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest"; |
| 37 | 37 | const PHOTOS_LIBRARY_URL = "https://www.googleapis.com/discovery/v1/apis/photoslibrary/v1/rest"; |
| 38 | −const PHOTOS_PICKER_URL = "https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest"; | |
| 38 | +const PHOTOS_PICKER_URL = "https://photospicker.googleapis.com/$discovery/rest?version=v1"; | |
| 39 | 39 | |
| 40 | 40 | const calendarDoc = { |
| 41 | 41 | name: "calendar", |
| @@ -197,15 +197,6 @@ const photosPickerDoc = { · 155 unmodified lines | ||
| 197 | 197 | title: "Google Photos Picker API", |
| 198 | 198 | rootUrl: "https://photospicker.googleapis.com/", |
| 199 | 199 | servicePath: "v1/", |
| 200 | − auth: { | |
| 201 | − oauth2: { | |
| 202 | − scopes: { | |
| 203 | − "https://www.googleapis.com/auth/photospicker.mediaitems.readonly": { | |
| 204 | − description: "Read selected Google Photos media", | |
| 205 | − }, | |
| 206 | − }, | |
| 207 | − }, | |
| 208 | − }, | |
| 209 | 200 | resources: { |
| 210 | 201 | mediaItems: { |
| 211 | 202 | methods: { |
| @@ -213,7 +204,6 @@ const photosPickerDoc = { · 1 unmodified line | ||
| 213 | 204 | id: "photospicker.mediaItems.list", |
| 214 | 205 | httpMethod: "GET", |
| 215 | 206 | path: "mediaItems", |
| 216 | − scopes: ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"], | |
| 217 | 207 | parameters: {}, |
| 218 | 208 | }, |
| 219 | 209 | }, |
| @@ -233,12 +223,14 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = { · 13 unmodified lines | ||
| 233 | 223 | }; |
| 234 | 224 | |
| 235 | 225 | // A stub HTTP client that serves the canned Discovery document for whichever |
| 236 | −// URL the bundle converter fetches (query params are ignored when matching). | |
| 226 | +// URL the bundle converter fetches. Service-hosted Discovery URLs carry their | |
| 227 | +// version in the query string, so match the full URL before falling back to the | |
| 228 | +// path-only key used by central Discovery URLs. | |
| 237 | 229 | const discoveryHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( |
| 238 | 230 | HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { |
| 239 | 231 | const url = new URL(request.url); |
| 240 | 232 | const key = `${url.origin}${url.pathname}`; |
| 241 | − const body = DISCOVERY_BODIES[key]; | |
| 233 | + const body = DISCOVERY_BODIES[url.toString()] ?? DISCOVERY_BODIES[key]; | |
| 242 | 234 | return Effect.succeed( |
| 243 | 235 | HttpClientResponse.fromWeb( |
| 244 | 236 | request, |
| @@ -159,6 +159,9 @@ export function OAuthClientForm(props: { · 158 unmodified lines | ||
| 159 | 159 | const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>( |
| 160 | 160 | prefill?.discoveredScopes ?? [], |
| 161 | 161 | ); |
| 162 | + const visibleScopes = registrationScopes(declaredScopes, discoveredScopes); | |
| 163 | + const visibleScopesSource = | |
| 164 | + declaredScopes.length > 0 ? "Declared by integration" : "Discovered from server"; | |
| 162 | 165 | const [discovering, setDiscovering] = useState(false); |
| 163 | 166 | const [submitting, setSubmitting] = useState(false); |
| 164 | 167 | // DCR (RFC 7591): the registration endpoint + advertised auth methods. Seeded |
| @@ -470,7 +473,7 @@ export function OAuthClientForm(props: { · 305 unmodified lines | ||
| 470 | 473 | </div> |
| 471 | 474 | </div> |
| 472 | 475 | |
| 473 | − {/* endpoints + scopes — collapsed when the integration already declares them */} | |
| 476 | + {/* endpoints */} | |
| 474 | 477 | {endpointsKnown && !showEndpoints ? ( |
| 475 | 478 | <Button |
| 476 | 479 | type="button" |
| @@ -552,6 +555,30 @@ export function OAuthClientForm(props: { · 75 unmodified lines | ||
| 552 | 555 | </div> |
| 553 | 556 | )} |
| 554 | 557 | |
| 558 | + {visibleScopes.length > 0 ? ( | |
| 559 | + <div className="space-y-2 rounded-lg border border-border/50 bg-background/30 p-3"> | |
| 560 | + <div className="flex items-center justify-between gap-3"> | |
| 561 | + <div> | |
| 562 | + <p className="text-xs font-medium text-foreground">Required OAuth scopes</p> | |
| 563 | + <p className="text-[11px] text-muted-foreground">{visibleScopesSource}</p> | |
| 564 | + </div> | |
| 565 | + <span className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"> | |
| 566 | + {visibleScopes.length} | |
| 567 | + </span> | |
| 568 | + </div> | |
| 569 | + <ul className="space-y-1"> | |
| 570 | + {visibleScopes.map((scope: string) => ( | |
| 571 | + <li | |
| 572 | + key={scope} | |
| 573 | + className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground" | |
| 574 | + > | |
| 575 | + {scope} | |
| 576 | + </li> | |
| 577 | + ))} | |
| 578 | + </ul> | |
| 579 | + </div> | |
| 580 | + ) : null} | |
| 581 | + | |
| 555 | 582 | {/* client owner (distinct from the connection's saved-to owner). Locked |
| 556 | 583 | when editing — an app's owner is part of its (owner, slug) identity. */} |
| 557 | 584 | {fixedOwner ? ( |