| @@ -7,6 +7,7 @@ import { · 6 unmodified lines |
| 7 | buildGoogleDiscoveryToolPresentation, | 7 | buildGoogleDiscoveryToolPresentation, |
| 8 | compileGoogleDiscoveryToolDefinitions, | 8 | compileGoogleDiscoveryToolDefinitions, |
| 9 | extractGoogleDiscoveryManifest, | 9 | extractGoogleDiscoveryManifest, |
| | 10 | type GoogleDiscoveryToolManifest, |
| 10 | type GoogleDiscoveryToolProviderData, | 11 | type GoogleDiscoveryToolProviderData, |
| 11 | } from "@executor/codemode-google-discovery"; | 12 | } from "@executor/codemode-google-discovery"; |
| 12 | import type { Source } from "#schema"; | 13 | import type { Source } from "#schema"; |
| @@ -205,19 +206,71 @@ const googleDiscoveryCatalogOperationFromDefinition = (input: { · 192 unmodified lines |
| 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 | const googleDiscoveryOauth2SetupConfig = (source: Source) => | 259 | const googleDiscoveryOauth2SetupConfig = (source: Source) => |
| 209 | Effect.gen(function* () { | 260 | Effect.gen(function* () { |
| 210 | const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source); | 261 | const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source); |
| 211 | const configuredScopes = bindingConfig.scopes ?? []; | 262 | const configuredScopes = bindingConfig.scopes ?? []; |
| 212 | const scopes = configuredScopes.length > 0 | 263 | const manifest = yield* fetchGoogleDiscoveryDocumentWithHeaders({ |
| 213 | ? configuredScopes | 264 | url: bindingConfig.discoveryUrl, |
| 214 | : yield* fetchGoogleDiscoveryDocumentWithHeaders({ | 265 | headers: bindingConfig.defaultHeaders ?? undefined, |
| 215 | url: bindingConfig.discoveryUrl, | 266 | }).pipe( |
| 216 | headers: bindingConfig.defaultHeaders ?? undefined, | 267 | Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)), |
| 217 | }).pipe( | 268 | Effect.catchAll(() => Effect.succeed(null)), |
| 218 | Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)), | 269 | ); |
| 219 | Effect.map((manifest) => Object.keys(manifest.oauthScopes ?? {})), | 270 | const discoveryScopes = manifest ? computeMaximalScopes(manifest) : []; |
| 220 | ); | 271 | const scopes = discoveryScopes.length > 0 |
| | 272 | ? [...new Set([...discoveryScopes, ...configuredScopes])] |
| | 273 | : configuredScopes; |
| 221 | | 274 | |
| 222 | if (scopes.length === 0) { | 275 | if (scopes.length === 0) { |
| 223 | return null; | 276 | return null; |