|
7 | 7 | buildGoogleDiscoveryToolPresentation, |
8 | 8 | compileGoogleDiscoveryToolDefinitions, |
9 | 9 | extractGoogleDiscoveryManifest, |
| 10 | + type GoogleDiscoveryToolManifest, |
10 | 11 | type GoogleDiscoveryToolProviderData, |
11 | 12 | } from "@executor/codemode-google-discovery"; |
12 | 13 | import type { Source } from "#schema"; |
@@ -205,19 +206,71 @@ const googleDiscoveryCatalogOperationFromDefinition = (input: { |
205 | 206 | }; |
206 | 207 | }; |
207 | 208 |
|
| 209 | +/** |
| 210 | + * Google's API server enforces a "most restrictive matching scope" policy: when |
| 211 | + * a narrow scope (e.g. gmail.metadata) is granted alongside a broader scope |
| 212 | + * (e.g. gmail.readonly), the server may restrict behaviour to the narrow scope. |
| 213 | + * |
| 214 | + * For Gmail, this means having gmail.metadata in the grant blocks the `q` |
| 215 | + * parameter on messages.list and prevents reading message bodies, even when |
| 216 | + * gmail.readonly or gmail.modify are also granted. |
| 217 | + * |
| 218 | + * To avoid this, we compute the maximal non-redundant scope set from the |
| 219 | + * discovery document's per-method scope declarations. A scope is "subsumed" if |
| 220 | + * every method that accepts it also accepts some other scope in the set — meaning |
| 221 | + * the other scope is strictly broader. Subsumed scopes are dropped so that |
| 222 | + * Google's server never picks the narrower one. |
| 223 | + */ |
| 224 | +const computeMaximalScopes = (manifest: GoogleDiscoveryToolManifest): ReadonlyArray<string> => { |
| 225 | + const topLevelScopes = Object.keys(manifest.oauthScopes ?? {}); |
| 226 | + if (topLevelScopes.length === 0) return []; |
| 227 | + |
| 228 | + // Build a map of scope -> set of method IDs that accept it |
| 229 | + const scopeToMethods = new Map<string, Set<string>>(); |
| 230 | + for (const scope of topLevelScopes) { |
| 231 | + scopeToMethods.set(scope, new Set()); |
| 232 | + } |
| 233 | + for (const method of manifest.methods) { |
| 234 | + for (const scope of method.scopes) { |
| 235 | + scopeToMethods.get(scope)?.add(method.methodId); |
| 236 | + } |
| 237 | + } |
| 238 | + |
| 239 | + // A scope is subsumed if there exists another scope whose method set is a |
| 240 | + // strict superset of this scope's method set. Remove subsumed scopes. |
| 241 | + const maximal = topLevelScopes.filter((scope) => { |
| 242 | + const methods = scopeToMethods.get(scope); |
| 243 | + if (!methods || methods.size === 0) return true; // keep scopes not used by any method |
| 244 | + return !topLevelScopes.some((other) => { |
| 245 | + if (other === scope) return false; |
| 246 | + const otherMethods = scopeToMethods.get(other); |
| 247 | + if (!otherMethods || otherMethods.size <= methods.size) return false; |
| 248 | + // Check if `other` is a strict superset of `scope` |
| 249 | + for (const m of methods) { |
| 250 | + if (!otherMethods.has(m)) return false; |
| 251 | + } |
| 252 | + return true; |
| 253 | + }); |
| 254 | + }); |
| 255 | + |
| 256 | + return maximal; |
| 257 | +}; |
| 258 | + |
208 | 259 | const googleDiscoveryOauth2SetupConfig = (source: Source) => |
209 | 260 | Effect.gen(function* () { |
210 | 261 | const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source); |
211 | 262 | const configuredScopes = bindingConfig.scopes ?? []; |
212 | | - const scopes = configuredScopes.length > 0 |
213 | | - ? configuredScopes |
214 | | - : yield* fetchGoogleDiscoveryDocumentWithHeaders({ |
215 | | - url: bindingConfig.discoveryUrl, |
216 | | - headers: bindingConfig.defaultHeaders ?? undefined, |
217 | | - }).pipe( |
218 | | - Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)), |
219 | | - Effect.map((manifest) => Object.keys(manifest.oauthScopes ?? {})), |
220 | | - ); |
| 263 | + const manifest = yield* fetchGoogleDiscoveryDocumentWithHeaders({ |
| 264 | + url: bindingConfig.discoveryUrl, |
| 265 | + headers: bindingConfig.defaultHeaders ?? undefined, |
| 266 | + }).pipe( |
| 267 | + Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)), |
| 268 | + Effect.catchAll(() => Effect.succeed(null)), |
| 269 | + ); |
| 270 | + const discoveryScopes = manifest ? computeMaximalScopes(manifest) : []; |
| 271 | + const scopes = discoveryScopes.length > 0 |
| 272 | + ? [...new Set([...discoveryScopes, ...configuredScopes])] |
| 273 | + : configuredScopes; |
221 | 274 |
|
222 | 275 | if (scopes.length === 0) { |
223 | 276 | return null; |
|
0 commit comments