Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/plugins/google/src/sdk/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,29 @@ it("accepts only supported HTTPS Google Discovery endpoints", () => {
expect(
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest/"),
).toBe("https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest");
expect(
normalizeGoogleDiscoveryUrl(
"https://www.googleapis.com/discovery/v1/apis/photospicker/v1/rest",
),
).toBe("https://photospicker.googleapis.com/$discovery/rest?version=v1");
expect(
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/forms/v1/rest"),
).toBe("https://forms.googleapis.com/$discovery/rest?version=v1");
expect(
normalizeGoogleDiscoveryUrl("https://www.googleapis.com/discovery/v1/apis/keep/v1/rest"),
).toBe("https://keep.googleapis.com/$discovery/rest?version=v1");
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,
Expand Down Expand Up @@ -332,6 +352,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({
Expand Down
70 changes: 64 additions & 6 deletions packages/plugins/google/src/sdk/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -28,6 +32,35 @@ const OPENAPI_SCHEMA_TYPES = new Set([
"string",
]);

type GoogleDiscoveryServiceOverride = {
readonly preserveServiceHostedUrl?: true;
readonly scopes?: Record<string, string>;
readonly fallbackMethodScopes?: readonly string[];
};

const GOOGLE_DISCOVERY_SERVICE_OVERRIDES: Record<string, GoogleDiscoveryServiceOverride> = {
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],
},
};

const googleDiscoveryUrlForService = (
service: string,
version: string,
host = `${service}.googleapis.com`,
): string => {
const override = GOOGLE_DISCOVERY_SERVICE_OVERRIDES[service];
return override?.preserveServiceHostedUrl === true
? `https://${host}/$discovery/rest?version=${version}`
: `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
};

type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | readonly JsonValue[] | { readonly [key: string]: JsonValue };

Expand Down Expand Up @@ -266,7 +299,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null
const match = parsed.pathname.match(DISCOVERY_SERVICE_PATH_RE);
const service = match?.[1];
const version = match?.[2];
return service && version ? `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest` : null;
return service && version ? googleDiscoveryUrlForService(service, version) : null;
}

const service = serviceFromGoogleApisHost(host);
Expand All @@ -283,7 +316,7 @@ export const normalizeGoogleDiscoveryUrl = (discoveryUrl: string): string | null
) {
return null;
}
return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
return googleDiscoveryUrlForService(service, version, host);
};

const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
Expand Down Expand Up @@ -677,14 +710,38 @@ 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";

const isGooglePhotosService = (service: string): boolean =>
service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;

const discoveryScopesForService = (
service: string,
document: DiscoveryDocument,
): Record<string, string> => {
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. */
Expand Down Expand Up @@ -811,6 +868,7 @@ export const convertGoogleDiscoveryToOpenApi = Effect.fn("OpenApi.convertGoogleD
method,
toolPath,
pathTemplate: pathTemplate.startsWith("/") ? pathTemplate : `/${pathTemplate}`,
oauthScopes: discoveryMethodScopesForService(service, method),
});
}

Expand All @@ -826,7 +884,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 = {
Expand Down Expand Up @@ -923,7 +981,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)) {
Expand All @@ -939,7 +997,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;
Expand Down
18 changes: 5 additions & 13 deletions packages/plugins/google/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -197,23 +197,13 @@ 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: {
list: {
id: "photospicker.mediaItems.list",
httpMethod: "GET",
path: "mediaItems",
scopes: ["https://www.googleapis.com/auth/photospicker.mediaitems.readonly"],
parameters: {},
},
},
Expand All @@ -233,12 +223,14 @@ const DISCOVERY_BODIES: Readonly<Record<string, string>> = {
};

// 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,
Expand Down
29 changes: 28 additions & 1 deletion packages/react/src/components/oauth-client-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ export function OAuthClientForm(props: {
const [discoveredScopes, setDiscoveredScopes] = useState<readonly string[]>(
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
Expand Down Expand Up @@ -470,7 +473,7 @@ export function OAuthClientForm(props: {
</div>
</div>

{/* endpoints + scopes — collapsed when the integration already declares them */}
{/* endpoints */}
{endpointsKnown && !showEndpoints ? (
<Button
type="button"
Expand Down Expand Up @@ -552,6 +555,30 @@ export function OAuthClientForm(props: {
</div>
)}

{visibleScopes.length > 0 ? (
<div className="space-y-2 rounded-lg border border-border/50 bg-background/30 p-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-medium text-foreground">Required OAuth scopes</p>
<p className="text-[11px] text-muted-foreground">{visibleScopesSource}</p>
</div>
<span className="shrink-0 rounded-md bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{visibleScopes.length}
</span>
</div>
<ul className="space-y-1">
{visibleScopes.map((scope: string) => (
<li
key={scope}
className="rounded-md border border-border bg-muted/20 px-2.5 py-1 font-mono text-[11px] break-all text-muted-foreground"
>
{scope}
</li>
))}
</ul>
</div>
) : null}

{/* client owner (distinct from the connection's saved-to owner). Locked
when editing — an app's owner is part of its (owner, slug) identity. */}
{fixedOwner ? (
Expand Down
Loading