Unified · Split
| @@ -1,3 +0,0 @@ | ||
| 1 | −## Git | |
| 2 | − | |
| 3 | −Never commit, push, or create branches unless I explicitly tell you to. | |
| @@ -12,6 +12,7 @@ · 11 unmodified lines | ||
| 12 | 12 | "@effect/sql-sqlite-bun": "catalog:", |
| 13 | 13 | "@executor/api": "workspace:*", |
| 14 | 14 | "@executor/plugin-file-secrets": "workspace:*", |
| 15 | + "@executor/plugin-google-discovery": "workspace:*", | |
| 15 | 16 | "@executor/plugin-keychain": "workspace:*", |
| 16 | 17 | "@executor/plugin-onepassword": "workspace:*", |
| 17 | 18 | "@executor/plugin-mcp": "workspace:*", |
| @@ -0,0 +1,16 @@ | ||
| 1 | +import { Effect, Layer } from "effect"; | |
| 2 | +import { | |
| 3 | + GoogleDiscoveryExtensionService, | |
| 4 | + GoogleDiscoveryHandlers, | |
| 5 | +} from "@executor/plugin-google-discovery/api"; | |
| 6 | +import { ExecutorService } from "../services/executor"; | |
| 7 | + | |
| 8 | +const GoogleDiscoveryExtensionLive = Layer.effect( | |
| 9 | + GoogleDiscoveryExtensionService, | |
| 10 | + Effect.map(ExecutorService, (executor) => executor.googleDiscovery), | |
| 11 | +); | |
| 12 | + | |
| 13 | +export const GoogleDiscoveryHandlersLive = Layer.provide( | |
| 14 | + GoogleDiscoveryHandlers, | |
| 15 | + GoogleDiscoveryExtensionLive, | |
| 16 | +); | |
| @@ -9,12 +9,14 @@ import { Layer } from "effect"; · 8 unmodified lines | ||
| 9 | 9 | import { addGroup } from "@executor/api"; |
| 10 | 10 | import { OpenApiGroup } from "@executor/plugin-openapi/api"; |
| 11 | 11 | import { McpGroup } from "@executor/plugin-mcp/api"; |
| 12 | +import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api"; | |
| 12 | 13 | import { OnePasswordGroup } from "@executor/plugin-onepassword/api"; |
| 13 | 14 | import { ToolsHandlers } from "./handlers/tools"; |
| 14 | 15 | import { SourcesHandlers } from "./handlers/sources"; |
| 15 | 16 | import { SecretsHandlers } from "./handlers/secrets"; |
| 16 | 17 | import { OpenApiHandlersLive } from "./handlers/openapi"; |
| 17 | 18 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; |
| 19 | +import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery"; | |
| 18 | 20 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; |
| 19 | 21 | import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; |
| 20 | 22 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| @@ -23,7 +25,10 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 2 unmodified lines | ||
| 23 | 25 | // Composed API — core + plugin groups |
| 24 | 26 | // --------------------------------------------------------------------------- |
| 25 | 27 | |
| 26 | −const ExecutorApiWithPlugins = addGroup(OpenApiGroup).add(McpGroup).add(OnePasswordGroup); | |
| 28 | +const ExecutorApiWithPlugins = addGroup(OpenApiGroup) | |
| 29 | + .add(McpGroup) | |
| 30 | + .add(GoogleDiscoveryGroup) | |
| 31 | + .add(OnePasswordGroup); | |
| 27 | 32 | |
| 28 | 33 | // --------------------------------------------------------------------------- |
| 29 | 34 | // API Layer |
| @@ -36,6 +41,7 @@ const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 6 unmodified lines | ||
| 36 | 41 | SecretsHandlers, |
| 37 | 42 | OpenApiHandlersLive, |
| 38 | 43 | McpSourceHandlersLive, |
| 44 | + GoogleDiscoveryHandlersLive, | |
| 39 | 45 | OnePasswordHandlersLive, |
| 40 | 46 | ]), |
| 41 | 47 | ); |
| @@ -7,6 +7,11 @@ import { createExecutor, scopeKv } from "@executor/sdk"; · 6 unmodified lines | ||
| 7 | 7 | import { makeSqliteKv, makeKvConfig, migrate } from "@executor/storage-file"; |
| 8 | 8 | import { openApiPlugin, makeKvOperationStore, type OpenApiPluginExtension } from "@executor/plugin-openapi"; |
| 9 | 9 | import { mcpPlugin, makeKvBindingStore, type McpPluginExtension } from "@executor/plugin-mcp"; |
| 10 | +import { | |
| 11 | + googleDiscoveryPlugin, | |
| 12 | + makeKvBindingStore as makeKvGoogleDiscoveryBindingStore, | |
| 13 | + type GoogleDiscoveryPluginExtension, | |
| 14 | +} from "@executor/plugin-google-discovery"; | |
| 10 | 15 | import { keychainPlugin } from "@executor/plugin-keychain"; |
| 11 | 16 | import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; |
| 12 | 17 | import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword"; |
| @@ -16,6 +21,7 @@ import type { Executor, ExecutorPlugin } from "@executor/sdk"; · 3 unmodified lines | ||
| 16 | 21 | type ServerPlugins = readonly [ |
| 17 | 22 | ExecutorPlugin<"openapi", OpenApiPluginExtension>, |
| 18 | 23 | ExecutorPlugin<"mcp", McpPluginExtension>, |
| 24 | + ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>, | |
| 19 | 25 | ReturnType<typeof keychainPlugin>, |
| 20 | 26 | ReturnType<typeof fileSecretsPlugin>, |
| 21 | 27 | ExecutorPlugin<"onepassword", OnePasswordExtension>, |
| @@ -69,6 +75,9 @@ const ExecutorLayer = Layer.effect( · 47 unmodified lines | ||
| 69 | 75 | mcpPlugin({ |
| 70 | 76 | bindingStore: makeKvBindingStore(kv, "mcp"), |
| 71 | 77 | }), |
| 78 | + googleDiscoveryPlugin({ | |
| 79 | + bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"), | |
| 80 | + }), | |
| 72 | 81 | keychainPlugin(), |
| 73 | 82 | fileSecretsPlugin(), |
| 74 | 83 | onepasswordPlugin({ |
| @@ -6,6 +6,7 @@ · 5 unmodified lines | ||
| 6 | 6 | "dependencies": { |
| 7 | 7 | "@executor/plugin-onepassword": "workspace:*", |
| 8 | 8 | "@executor/plugin-mcp": "workspace:*", |
| 9 | + "@executor/plugin-google-discovery": "workspace:*", | |
| 9 | 10 | "@executor/plugin-openapi": "workspace:*", |
| 10 | 11 | "@executor/sdk": "workspace:*", |
| 11 | 12 | "@executor/react": "workspace:*", |
| @@ -311,6 +311,32 @@ export function SecretsPage() { · 310 unmodified lines | ||
| 311 | 311 | </Button> |
| 312 | 312 | </div> |
| 313 | 313 | |
| 314 | + {/* Provider plugins */} | |
| 315 | + {secretProviderPlugins.length > 0 && ( | |
| 316 | + <div className="mb-10"> | |
| 317 | + <div className="flex items-center gap-3 mb-4"> | |
| 318 | + <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60"> | |
| 319 | + Providers | |
| 320 | + </h2> | |
| 321 | + <div className="flex-1 h-px bg-border/50" /> | |
| 322 | + </div> | |
| 323 | + <div className="grid gap-3"> | |
| 324 | + {secretProviderPlugins.map((plugin) => ( | |
| 325 | + <Suspense | |
| 326 | + key={plugin.key} | |
| 327 | + fallback={ | |
| 328 | + <div className="rounded-xl border border-border/60 bg-card p-5 animate-pulse"> | |
| 329 | + <div className="h-4 w-24 rounded bg-muted" /> | |
| 330 | + </div> | |
| 331 | + } | |
| 332 | + > | |
| 333 | + <plugin.settings /> | |
| 334 | + </Suspense> | |
| 335 | + ))} | |
| 336 | + </div> | |
| 337 | + </div> | |
| 338 | + )} | |
| 339 | + | |
| 314 | 340 | {/* Secrets list */} |
| 315 | 341 | {Result.match(secrets, { |
| 316 | 342 | onInitial: () => ( |
| @@ -355,32 +381,6 @@ export function SecretsPage() { · 38 unmodified lines | ||
| 355 | 381 | ), |
| 356 | 382 | })} |
| 357 | 383 | |
| 358 | − {/* Provider plugins */} | |
| 359 | − {secretProviderPlugins.length > 0 && ( | |
| 360 | − <div className="mt-12"> | |
| 361 | − <div className="flex items-center gap-3 mb-4"> | |
| 362 | − <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60"> | |
| 363 | − Providers | |
| 364 | − </h2> | |
| 365 | − <div className="flex-1 h-px bg-border/50" /> | |
| 366 | − </div> | |
| 367 | − <div className="grid gap-3"> | |
| 368 | − {secretProviderPlugins.map((plugin) => ( | |
| 369 | − <Suspense | |
| 370 | − key={plugin.key} | |
| 371 | − fallback={ | |
| 372 | − <div className="rounded-xl border border-border/60 bg-card p-5 animate-pulse"> | |
| 373 | − <div className="h-4 w-24 rounded bg-muted" /> | |
| 374 | − </div> | |
| 375 | − } | |
| 376 | − > | |
| 377 | − <plugin.settings /> | |
| 378 | − </Suspense> | |
| 379 | − ))} | |
| 380 | − </div> | |
| 381 | − </div> | |
| 382 | − )} | |
| 383 | − | |
| 384 | 384 | <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} /> |
| 385 | 385 | </div> |
| 386 | 386 | </div> |
| @@ -4,13 +4,18 @@ import { Result, useAtomValue, useAtomRefresh, sourcesAtom } from "@executor/rea · 3 unmodified lines | ||
| 4 | 4 | import type { SourcePlugin } from "@executor/react"; |
| 5 | 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; |
| 6 | 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; |
| 7 | +import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; | |
| 7 | 8 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 8 | 9 | |
| 9 | 10 | // --------------------------------------------------------------------------- |
| 10 | 11 | // Registered source plugins |
| 11 | 12 | // --------------------------------------------------------------------------- |
| 12 | 13 | |
| 13 | −const sourcePlugins: SourcePlugin[] = [openApiSourcePlugin, mcpSourcePlugin]; | |
| 14 | +const sourcePlugins: SourcePlugin[] = [ | |
| 15 | + openApiSourcePlugin, | |
| 16 | + mcpSourcePlugin, | |
| 17 | + googleDiscoverySourcePlugin, | |
| 18 | +]; | |
| 14 | 19 | |
| 15 | 20 | // --------------------------------------------------------------------------- |
| 16 | 21 | // Page |
| @@ -21,6 +21,7 @@ · 20 unmodified lines | ||
| 21 | 21 | "@executor/api": "workspace:*", |
| 22 | 22 | "@executor/host-mcp": "workspace:*", |
| 23 | 23 | "@executor/plugin-file-secrets": "workspace:*", |
| 24 | + "@executor/plugin-google-discovery": "workspace:*", | |
| 24 | 25 | "@executor/plugin-keychain": "workspace:*", |
| 25 | 26 | "@executor/plugin-mcp": "workspace:*", |
| 26 | 27 | "@executor/plugin-onepassword": "workspace:*", |
| @@ -40,6 +41,7 @@ · 13 unmodified lines | ||
| 40 | 41 | "name": "@executor/web", |
| 41 | 42 | "version": "0.0.0", |
| 42 | 43 | "dependencies": { |
| 44 | + "@executor/plugin-google-discovery": "workspace:*", | |
| 43 | 45 | "@executor/plugin-mcp": "workspace:*", |
| 44 | 46 | "@executor/plugin-onepassword": "workspace:*", |
| 45 | 47 | "@executor/plugin-openapi": "workspace:*", |
| @@ -75,6 +77,7 @@ · 29 unmodified lines | ||
| 75 | 77 | "@effect/platform": "catalog:", |
| 76 | 78 | "@executor/api": "workspace:*", |
| 77 | 79 | "@executor/sdk": "workspace:*", |
| 80 | + "@executor/ui": "workspace:*", | |
| 78 | 81 | "effect": "catalog:", |
| 79 | 82 | "react": "^19.1.0", |
| 80 | 83 | }, |
| @@ -213,6 +216,41 @@ · 132 unmodified lines | ||
| 213 | 216 | "vitest": "catalog:", |
| 214 | 217 | }, |
| 215 | 218 | }, |
| 219 | + "packages/plugins/google-discovery": { | |
| 220 | + "name": "@executor/plugin-google-discovery", | |
| 221 | + "version": "0.0.0", | |
| 222 | + "dependencies": { | |
| 223 | + "@effect/platform": "catalog:", | |
| 224 | + "@executor/api": "workspace:*", | |
| 225 | + "@executor/sdk": "workspace:*", | |
| 226 | + "effect": "catalog:", | |
| 227 | + }, | |
| 228 | + "devDependencies": { | |
| 229 | + "@effect-atom/atom-react": "^0.5.0", | |
| 230 | + "@effect/vitest": "catalog:", | |
| 231 | + "@executor/react": "workspace:*", | |
| 232 | + "@executor/ui": "workspace:*", | |
| 233 | + "@types/node": "catalog:", | |
| 234 | + "@types/react": "^19.1.0", | |
| 235 | + "bun-types": "catalog:", | |
| 236 | + "react": "^19.1.0", | |
| 237 | + "vitest": "catalog:", | |
| 238 | + }, | |
| 239 | + "peerDependencies": { | |
| 240 | + "@effect-atom/atom-react": "^0.5.0", | |
| 241 | + "@executor/react": "workspace:*", | |
| 242 | + "@executor/ui": "workspace:*", | |
| 243 | + "@tanstack/react-router": "^1.168.10", | |
| 244 | + "react": "^19.1.0", | |
| 245 | + }, | |
| 246 | + "optionalPeers": [ | |
| 247 | + "@effect-atom/atom-react", | |
| 248 | + "@executor/react", | |
| 249 | + "@executor/ui", | |
| 250 | + "@tanstack/react-router", | |
| 251 | + "react", | |
| 252 | + ], | |
| 253 | + }, | |
| 216 | 254 | "packages/plugins/keychain": { |
| 217 | 255 | "name": "@executor/plugin-keychain", |
| 218 | 256 | "version": "0.0.0", |
| @@ -555,6 +593,8 @@ · 336 unmodified lines | ||
| 555 | 593 | |
| 556 | 594 | "@executor/plugin-file-secrets": ["@executor/plugin-file-secrets@workspace:packages/plugins/file-secrets"], |
| 557 | 595 | |
| 596 | + "@executor/plugin-google-discovery": ["@executor/plugin-google-discovery@workspace:packages/plugins/google-discovery"], | |
| 597 | + | |
| 558 | 598 | "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"], |
| 559 | 599 | |
| 560 | 600 | "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"], |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | ||
| 11 | 11 | "@effect-atom/atom": "^0.5.3", |
| 12 | 12 | "@effect-atom/atom-react": "^0.5.0", |
| 13 | 13 | "@executor/api": "workspace:*", |
| 14 | + "@executor/ui": "workspace:*", | |
| 14 | 15 | "@executor/sdk": "workspace:*", |
| 15 | 16 | "effect": "catalog:", |
| 16 | 17 | "react": "^19.1.0" |
| @@ -37,6 +37,8 @@ export { · 36 unmodified lines | ||
| 37 | 37 | |
| 38 | 38 | // Provider |
| 39 | 39 | export { ExecutorProvider } from "./provider"; |
| 40 | +export { SecretPicker } from "./secret-picker"; | |
| 41 | +export type { SecretPickerSecret } from "./secret-picker"; | |
| 40 | 42 | |
| 41 | 43 | // Re-export commonly needed SDK types |
| 42 | 44 | export { ScopeId, ToolId } from "@executor/sdk"; |
| @@ -0,0 +1,136 @@ | ||
| 1 | +import { useState, type ChangeEvent, type FocusEvent } from "react"; | |
| 2 | + | |
| 3 | +import { Input } from "@executor/ui/components/input"; | |
| 4 | +import { | |
| 5 | + Command, | |
| 6 | + CommandEmpty, | |
| 7 | + CommandGroup, | |
| 8 | + CommandItem, | |
| 9 | + CommandList, | |
| 10 | +} from "@executor/ui/components/command"; | |
| 11 | +import { | |
| 12 | + Popover, | |
| 13 | + PopoverAnchor, | |
| 14 | + PopoverContent, | |
| 15 | +} from "@executor/ui/components/popover"; | |
| 16 | + | |
| 17 | +export interface SecretPickerSecret { | |
| 18 | + readonly id: string; | |
| 19 | + readonly name: string; | |
| 20 | + readonly provider?: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +const PROVIDER_LABELS: Record<string, string> = { | |
| 24 | + keychain: "Keychain", | |
| 25 | + file: "Local", | |
| 26 | + memory: "Memory", | |
| 27 | + onepassword: "1Password", | |
| 28 | +}; | |
| 29 | + | |
| 30 | +const providerLabel = (key: string | undefined): string => { | |
| 31 | + if (!key) return "Local"; | |
| 32 | + return PROVIDER_LABELS[key] ?? key; | |
| 33 | +}; | |
| 34 | + | |
| 35 | +export function SecretPicker(props: { | |
| 36 | + readonly value: string | null; | |
| 37 | + readonly onSelect: (secretId: string) => void; | |
| 38 | + readonly secrets: readonly SecretPickerSecret[]; | |
| 39 | + readonly placeholder?: string; | |
| 40 | +}) { | |
| 41 | + const { value, onSelect, secrets, placeholder = "Search secrets…" } = props; | |
| 42 | + const [open, setOpen] = useState(false); | |
| 43 | + const [query, setQuery] = useState(""); | |
| 44 | + | |
| 45 | + const selected = secrets.find((secret) => secret.id === value) ?? null; | |
| 46 | + | |
| 47 | + const grouped = new Map<string, SecretPickerSecret[]>(); | |
| 48 | + for (const secret of secrets) { | |
| 49 | + const key = providerLabel(secret.provider); | |
| 50 | + const group = grouped.get(key); | |
| 51 | + if (group) { | |
| 52 | + group.push(secret); | |
| 53 | + } else { | |
| 54 | + grouped.set(key, [secret]); | |
| 55 | + } | |
| 56 | + } | |
| 57 | + | |
| 58 | + const groups: [string, SecretPickerSecret[]][] = [...grouped.entries()] | |
| 59 | + .map(([label, items]): [string, SecretPickerSecret[]] => [ | |
| 60 | + label, | |
| 61 | + [...items].sort((a, b) => a.name.localeCompare(b.name)), | |
| 62 | + ]) | |
| 63 | + .sort(([a], [b]) => a.localeCompare(b)); | |
| 64 | + | |
| 65 | + return ( | |
| 66 | + <div className="relative w-full"> | |
| 67 | + <Popover open={open} onOpenChange={setOpen} modal={false}> | |
| 68 | + <PopoverAnchor asChild> | |
| 69 | + <Input | |
| 70 | + value={open ? query : (selected ? selected.name : "")} | |
| 71 | + onChange={(event: ChangeEvent<HTMLInputElement>) => { | |
| 72 | + setQuery(event.target.value); | |
| 73 | + if (!open) setOpen(true); | |
| 74 | + }} | |
| 75 | + onFocus={() => setOpen(true)} | |
| 76 | + onBlur={(event: FocusEvent<HTMLInputElement>) => { | |
| 77 | + const related = event.relatedTarget as HTMLElement | null; | |
| 78 | + if (related?.closest("[data-slot=popover-content]")) return; | |
| 79 | + setOpen(false); | |
| 80 | + }} | |
| 81 | + placeholder={placeholder} | |
| 82 | + className="text-sm" | |
| 83 | + /> | |
| 84 | + </PopoverAnchor> | |
| 85 | + <PopoverContent | |
| 86 | + className="w-(--radix-popover-trigger-width) p-0" | |
| 87 | + align="start" | |
| 88 | + onOpenAutoFocus={(event: Event) => event.preventDefault()} | |
| 89 | + onCloseAutoFocus={(event: Event) => event.preventDefault()} | |
| 90 | + onInteractOutside={(event: Event) => { | |
| 91 | + const target = event.target as HTMLElement | null; | |
| 92 | + if (target?.closest("[data-slot=popover-anchor]")) { | |
| 93 | + event.preventDefault(); | |
| 94 | + } | |
| 95 | + }} | |
| 96 | + > | |
| 97 | + <Command shouldFilter={false}> | |
| 98 | + <CommandList> | |
| 99 | + <CommandEmpty>No secrets found</CommandEmpty> | |
| 100 | + {groups.map(([label, items]) => { | |
| 101 | + const lowerQuery = query.toLowerCase(); | |
| 102 | + const filtered = lowerQuery | |
| 103 | + ? items.filter((secret) => | |
| 104 | + secret.name.toLowerCase().includes(lowerQuery) || | |
| 105 | + secret.id.toLowerCase().includes(lowerQuery), | |
| 106 | + ) | |
| 107 | + : items; | |
| 108 | + if (filtered.length === 0) return null; | |
| 109 | + return ( | |
| 110 | + <CommandGroup key={label} heading={label}> | |
| 111 | + {filtered.map((secret) => ( | |
| 112 | + <CommandItem | |
| 113 | + key={secret.id} | |
| 114 | + value={`${secret.name} ${secret.id}`} | |
| 115 | + onSelect={() => { | |
| 116 | + onSelect(secret.id); | |
| 117 | + setOpen(false); | |
| 118 | + setQuery(""); | |
| 119 | + }} | |
| 120 | + > | |
| 121 | + <span className="truncate">{secret.name}</span> | |
| 122 | + <span className="ml-auto truncate text-[10px] font-mono text-muted-foreground"> | |
| 123 | + {secret.id} | |
| 124 | + </span> | |
| 125 | + </CommandItem> | |
| 126 | + ))} | |
| 127 | + </CommandGroup> | |
| 128 | + ); | |
| 129 | + })} | |
| 130 | + </CommandList> | |
| 131 | + </Command> | |
| 132 | + </PopoverContent> | |
| 133 | + </Popover> | |
| 134 | + </div> | |
| 135 | + ); | |
| 136 | +} | |
| @@ -0,0 +1,96 @@ | ||
| 1 | +{ | |
| 2 | + "name": "drive", | |
| 3 | + "version": "v3", | |
| 4 | + "title": "Google Drive", | |
| 5 | + "rootUrl": "https://www.googleapis.com/", | |
| 6 | + "servicePath": "drive/v3/", | |
| 7 | + "parameters": { | |
| 8 | + "prettyPrint": { | |
| 9 | + "location": "query", | |
| 10 | + "type": "boolean", | |
| 11 | + "description": "Returns response with indentations and line breaks." | |
| 12 | + } | |
| 13 | + }, | |
| 14 | + "resources": { | |
| 15 | + "files": { | |
| 16 | + "methods": { | |
| 17 | + "get": { | |
| 18 | + "id": "drive.files.get", | |
| 19 | + "path": "files/{fileId}", | |
| 20 | + "httpMethod": "GET", | |
| 21 | + "parameters": { | |
| 22 | + "fileId": { | |
| 23 | + "location": "path", | |
| 24 | + "type": "string", | |
| 25 | + "required": true | |
| 26 | + }, | |
| 27 | + "fields": { | |
| 28 | + "location": "query", | |
| 29 | + "type": "string" | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + "response": { | |
| 33 | + "$ref": "File" | |
| 34 | + }, | |
| 35 | + "scopes": [ | |
| 36 | + "https://www.googleapis.com/auth/drive.readonly" | |
| 37 | + ] | |
| 38 | + }, | |
| 39 | + "update": { | |
| 40 | + "id": "drive.files.update", | |
| 41 | + "path": "files/{fileId}", | |
| 42 | + "httpMethod": "PATCH", | |
| 43 | + "parameters": { | |
| 44 | + "fileId": { | |
| 45 | + "location": "path", | |
| 46 | + "type": "string", | |
| 47 | + "required": true | |
| 48 | + } | |
| 49 | + }, | |
| 50 | + "request": { | |
| 51 | + "$ref": "UpdateFileRequest" | |
| 52 | + }, | |
| 53 | + "response": { | |
| 54 | + "$ref": "File" | |
| 55 | + }, | |
| 56 | + "scopes": [ | |
| 57 | + "https://www.googleapis.com/auth/drive" | |
| 58 | + ] | |
| 59 | + } | |
| 60 | + } | |
| 61 | + } | |
| 62 | + }, | |
| 63 | + "schemas": { | |
| 64 | + "File": { | |
| 65 | + "type": "object", | |
| 66 | + "properties": { | |
| 67 | + "id": { | |
| 68 | + "type": "string" | |
| 69 | + }, | |
| 70 | + "name": { | |
| 71 | + "type": "string" | |
| 72 | + } | |
| 73 | + } | |
| 74 | + }, | |
| 75 | + "UpdateFileRequest": { | |
| 76 | + "type": "object", | |
| 77 | + "properties": { | |
| 78 | + "name": { | |
| 79 | + "type": "string" | |
| 80 | + } | |
| 81 | + } | |
| 82 | + } | |
| 83 | + }, | |
| 84 | + "auth": { | |
| 85 | + "oauth2": { | |
| 86 | + "scopes": { | |
| 87 | + "https://www.googleapis.com/auth/drive": { | |
| 88 | + "description": "See, edit, create, and delete all of your Google Drive files" | |
| 89 | + }, | |
| 90 | + "https://www.googleapis.com/auth/drive.readonly": { | |
| 91 | + "description": "See and download all your Google Drive files" | |
| 92 | + } | |
| 93 | + } | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| @@ -0,0 +1,47 @@ | ||
| 1 | +{ | |
| 2 | + "name": "@executor/plugin-google-discovery", | |
| 3 | + "private": true, | |
| 4 | + "type": "module", | |
| 5 | + "version": "0.0.0", | |
| 6 | + "exports": { | |
| 7 | + ".": "./src/sdk/index.ts", | |
| 8 | + "./api": "./src/api/index.ts", | |
| 9 | + "./react": "./src/react/index.ts" | |
| 10 | + }, | |
| 11 | + "scripts": { | |
| 12 | + "typecheck": "bunx tsc --noEmit -p tsconfig.json", | |
| 13 | + "test": "vitest run", | |
| 14 | + "test:watch": "vitest" | |
| 15 | + }, | |
| 16 | + "dependencies": { | |
| 17 | + "@effect/platform": "catalog:", | |
| 18 | + "@executor/api": "workspace:*", | |
| 19 | + "@executor/sdk": "workspace:*", | |
| 20 | + "effect": "catalog:" | |
| 21 | + }, | |
| 22 | + "peerDependencies": { | |
| 23 | + "react": "^19.1.0", | |
| 24 | + "@effect-atom/atom-react": "^0.5.0", | |
| 25 | + "@tanstack/react-router": "^1.168.10", | |
| 26 | + "@executor/react": "workspace:*", | |
| 27 | + "@executor/ui": "workspace:*" | |
| 28 | + }, | |
| 29 | + "peerDependenciesMeta": { | |
| 30 | + "react": { "optional": true }, | |
| 31 | + "@effect-atom/atom-react": { "optional": true }, | |
| 32 | + "@tanstack/react-router": { "optional": true }, | |
| 33 | + "@executor/react": { "optional": true }, | |
| 34 | + "@executor/ui": { "optional": true } | |
| 35 | + }, | |
| 36 | + "devDependencies": { | |
| 37 | + "@effect-atom/atom-react": "^0.5.0", | |
| 38 | + "@effect/vitest": "catalog:", | |
| 39 | + "@executor/react": "workspace:*", | |
| 40 | + "@executor/ui": "workspace:*", | |
| 41 | + "@types/node": "catalog:", | |
| 42 | + "@types/react": "^19.1.0", | |
| 43 | + "bun-types": "catalog:", | |
| 44 | + "react": "^19.1.0", | |
| 45 | + "vitest": "catalog:" | |
| 46 | + } | |
| 47 | +} | |
| @@ -0,0 +1,128 @@ | ||
| 1 | +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform"; | |
| 2 | +import { Schema } from "effect"; | |
| 3 | +import { ScopeId } from "@executor/sdk"; | |
| 4 | + | |
| 5 | +export { HttpApiSchema }; | |
| 6 | + | |
| 7 | +const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId); | |
| 8 | + | |
| 9 | +const AuthPayload = Schema.Union( | |
| 10 | + Schema.Struct({ | |
| 11 | + kind: Schema.Literal("none"), | |
| 12 | + }), | |
| 13 | + Schema.Struct({ | |
| 14 | + kind: Schema.Literal("oauth2"), | |
| 15 | + clientId: Schema.String, | |
| 16 | + clientSecretSecretId: Schema.NullOr(Schema.String), | |
| 17 | + accessTokenSecretId: Schema.String, | |
| 18 | + refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 19 | + tokenType: Schema.optional(Schema.String), | |
| 20 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 21 | + scope: Schema.NullOr(Schema.String), | |
| 22 | + scopes: Schema.Array(Schema.String), | |
| 23 | + }), | |
| 24 | +); | |
| 25 | + | |
| 26 | +const ProbePayload = Schema.Struct({ | |
| 27 | + discoveryUrl: Schema.String, | |
| 28 | +}); | |
| 29 | + | |
| 30 | +const ProbeResponse = Schema.Struct({ | |
| 31 | + name: Schema.String, | |
| 32 | + title: Schema.NullOr(Schema.String), | |
| 33 | + service: Schema.String, | |
| 34 | + version: Schema.String, | |
| 35 | + toolCount: Schema.Number, | |
| 36 | + scopes: Schema.Array(Schema.String), | |
| 37 | +}); | |
| 38 | + | |
| 39 | +const AddSourcePayload = Schema.Struct({ | |
| 40 | + name: Schema.String, | |
| 41 | + discoveryUrl: Schema.String, | |
| 42 | + namespace: Schema.optional(Schema.String), | |
| 43 | + auth: AuthPayload, | |
| 44 | +}); | |
| 45 | + | |
| 46 | +const AddSourceResponse = Schema.Struct({ | |
| 47 | + toolCount: Schema.Number, | |
| 48 | + namespace: Schema.String, | |
| 49 | +}); | |
| 50 | + | |
| 51 | +const StartOAuthPayload = Schema.Struct({ | |
| 52 | + name: Schema.String, | |
| 53 | + discoveryUrl: Schema.String, | |
| 54 | + clientId: Schema.String, | |
| 55 | + clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), | |
| 56 | + redirectUrl: Schema.String, | |
| 57 | + scopes: Schema.optional(Schema.Array(Schema.String)), | |
| 58 | +}); | |
| 59 | + | |
| 60 | +const StartOAuthResponse = Schema.Struct({ | |
| 61 | + sessionId: Schema.String, | |
| 62 | + authorizationUrl: Schema.String, | |
| 63 | + scopes: Schema.Array(Schema.String), | |
| 64 | +}); | |
| 65 | + | |
| 66 | +const CompleteOAuthPayload = Schema.Struct({ | |
| 67 | + state: Schema.String, | |
| 68 | + code: Schema.optional(Schema.String), | |
| 69 | + error: Schema.optional(Schema.String), | |
| 70 | +}); | |
| 71 | + | |
| 72 | +const CompleteOAuthResponse = Schema.Struct({ | |
| 73 | + kind: Schema.Literal("oauth2"), | |
| 74 | + clientId: Schema.String, | |
| 75 | + clientSecretSecretId: Schema.NullOr(Schema.String), | |
| 76 | + accessTokenSecretId: Schema.String, | |
| 77 | + refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 78 | + tokenType: Schema.String, | |
| 79 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 80 | + scope: Schema.NullOr(Schema.String), | |
| 81 | + scopes: Schema.Array(Schema.String), | |
| 82 | +}); | |
| 83 | + | |
| 84 | +const OAuthCallbackParams = Schema.Struct({ | |
| 85 | + state: Schema.String, | |
| 86 | + code: Schema.optional(Schema.String), | |
| 87 | + error: Schema.optional(Schema.String), | |
| 88 | + error_description: Schema.optional(Schema.String), | |
| 89 | +}); | |
| 90 | + | |
| 91 | +const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" }); | |
| 92 | + | |
| 93 | +const ApiError = Schema.Struct({ | |
| 94 | + message: Schema.String, | |
| 95 | +}).annotations(HttpApiSchema.annotations({ status: 400 })); | |
| 96 | + | |
| 97 | +export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery") | |
| 98 | + .add( | |
| 99 | + HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe` | |
| 100 | + .setPayload(ProbePayload) | |
| 101 | + .addSuccess(ProbeResponse) | |
| 102 | + .addError(ApiError), | |
| 103 | + ) | |
| 104 | + .add( | |
| 105 | + HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources` | |
| 106 | + .setPayload(AddSourcePayload) | |
| 107 | + .addSuccess(AddSourceResponse) | |
| 108 | + .addError(ApiError), | |
| 109 | + ) | |
| 110 | + .add( | |
| 111 | + HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start` | |
| 112 | + .setPayload(StartOAuthPayload) | |
| 113 | + .addSuccess(StartOAuthResponse) | |
| 114 | + .addError(ApiError), | |
| 115 | + ) | |
| 116 | + .add( | |
| 117 | + HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete` | |
| 118 | + .setPayload(CompleteOAuthPayload) | |
| 119 | + .addSuccess(CompleteOAuthResponse) | |
| 120 | + .addError(ApiError), | |
| 121 | + ) | |
| 122 | + .add( | |
| 123 | + HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback` | |
| 124 | + .setUrlParams(OAuthCallbackParams) | |
| 125 | + .addSuccess(HtmlResponse) | |
| 126 | + .addError(ApiError), | |
| 127 | + ) | |
| 128 | + .prefix("/v1") {} | |
| @@ -0,0 +1,139 @@ | ||
| 1 | +import { HttpApiBuilder, HttpServerResponse } from "@effect/platform"; | |
| 2 | +import { Context, Effect } from "effect"; | |
| 3 | + | |
| 4 | +import { addGroup } from "@executor/api"; | |
| 5 | +import type { | |
| 6 | + GoogleDiscoveryAddSourceInput, | |
| 7 | + GoogleDiscoveryOAuthAuthResult, | |
| 8 | + GoogleDiscoveryPluginExtension, | |
| 9 | +} from "../sdk/plugin"; | |
| 10 | +import { GoogleDiscoveryGroup } from "./group"; | |
| 11 | + | |
| 12 | +export class GoogleDiscoveryExtensionService extends Context.Tag( | |
| 13 | + "GoogleDiscoveryExtensionService", | |
| 14 | +)<GoogleDiscoveryExtensionService, GoogleDiscoveryPluginExtension>() {} | |
| 15 | + | |
| 16 | +const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup); | |
| 17 | + | |
| 18 | +type OAuthPopupResult = | |
| 19 | + | ({ | |
| 20 | + type: "executor:oauth-result"; | |
| 21 | + ok: true; | |
| 22 | + sessionId: string; | |
| 23 | + } & GoogleDiscoveryOAuthAuthResult) | |
| 24 | + | { | |
| 25 | + type: "executor:oauth-result"; | |
| 26 | + ok: false; | |
| 27 | + sessionId: null; | |
| 28 | + error: string; | |
| 29 | + }; | |
| 30 | + | |
| 31 | +const escapeHtml = (value: string): string => | |
| 32 | + value | |
| 33 | + .replaceAll("&", "&") | |
| 34 | + .replaceAll("<", "<") | |
| 35 | + .replaceAll(">", ">") | |
| 36 | + .replaceAll('"', """) | |
| 37 | + .replaceAll("'", "'"); | |
| 38 | + | |
| 39 | +const popupDocument = (payload: OAuthPopupResult): string => { | |
| 40 | + const serialized = JSON.stringify(payload) | |
| 41 | + .replaceAll("<", "\\u003c") | |
| 42 | + .replaceAll(">", "\\u003e") | |
| 43 | + .replaceAll("&", "\\u0026"); | |
| 44 | + const title = payload.ok ? "Connected" : "Connection failed"; | |
| 45 | + const message = payload.ok | |
| 46 | + ? "Authentication complete. This window will close automatically." | |
| 47 | + : payload.error; | |
| 48 | + const statusColor = payload.ok ? "#22c55e" : "#ef4444"; | |
| 49 | + | |
| 50 | + return `<!doctype html> | |
| 51 | +<html lang="en"> | |
| 52 | +<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeHtml(title)}</title></head> | |
| 53 | +<body style="font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#111"> | |
| 54 | +<style>@media(prefers-color-scheme:dark){body{background:#09090b!important;color:#fafafa!important}p{color:#a1a1aa!important}}</style> | |
| 55 | +<main style="text-align:center;max-width:360px;padding:24px"> | |
| 56 | +<div style="width:40px;height:40px;border-radius:50%;background:${statusColor};margin:0 auto 16px;display:flex;align-items:center;justify-content:center"> | |
| 57 | +<svg width="20" height="20" viewBox="0 0 20 20" fill="none">${payload.ok ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>'}</svg> | |
| 58 | +</div> | |
| 59 | +<h1 style="margin:0 0 8px;font-size:18px;font-weight:600">${escapeHtml(title)}</h1> | |
| 60 | +<p style="margin:0;font-size:14px;color:#666;line-height:1.5">${escapeHtml(message)}</p> | |
| 61 | +</main> | |
| 62 | +<script> | |
| 63 | +(()=>{const p=${serialized};try{if(window.opener)window.opener.postMessage(p,window.location.origin)}finally{setTimeout(()=>window.close(),150)}})(); | |
| 64 | +</script> | |
| 65 | +</body></html>`; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +export const GoogleDiscoveryHandlers = HttpApiBuilder.group( | |
| 69 | + ExecutorApiWithGoogleDiscovery, | |
| 70 | + "googleDiscovery", | |
| 71 | + (handlers) => | |
| 72 | + handlers | |
| 73 | + .handle("probeDiscovery", ({ payload }) => | |
| 74 | + Effect.gen(function* () { | |
| 75 | + const ext = yield* GoogleDiscoveryExtensionService; | |
| 76 | + return yield* ext.probeDiscovery(payload.discoveryUrl); | |
| 77 | + }).pipe(Effect.orDie), | |
| 78 | + ) | |
| 79 | + .handle("addSource", ({ payload }) => | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const ext = yield* GoogleDiscoveryExtensionService; | |
| 82 | + return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput); | |
| 83 | + }).pipe(Effect.orDie), | |
| 84 | + ) | |
| 85 | + .handle("startOAuth", ({ payload }) => | |
| 86 | + Effect.gen(function* () { | |
| 87 | + const ext = yield* GoogleDiscoveryExtensionService; | |
| 88 | + return yield* ext.startOAuth({ | |
| 89 | + name: payload.name, | |
| 90 | + discoveryUrl: payload.discoveryUrl, | |
| 91 | + clientId: payload.clientId, | |
| 92 | + clientSecretSecretId: payload.clientSecretSecretId, | |
| 93 | + redirectUrl: payload.redirectUrl, | |
| 94 | + scopes: payload.scopes, | |
| 95 | + }); | |
| 96 | + }).pipe(Effect.orDie), | |
| 97 | + ) | |
| 98 | + .handle("completeOAuth", ({ payload }) => | |
| 99 | + Effect.gen(function* () { | |
| 100 | + const ext = yield* GoogleDiscoveryExtensionService; | |
| 101 | + return yield* ext.completeOAuth({ | |
| 102 | + state: payload.state, | |
| 103 | + code: payload.code, | |
| 104 | + error: payload.error, | |
| 105 | + }); | |
| 106 | + }).pipe(Effect.orDie), | |
| 107 | + ) | |
| 108 | + .handle("oauthCallback", ({ urlParams }) => | |
| 109 | + Effect.gen(function* () { | |
| 110 | + const ext = yield* GoogleDiscoveryExtensionService; | |
| 111 | + const result = yield* ext | |
| 112 | + .completeOAuth({ | |
| 113 | + state: urlParams.state, | |
| 114 | + code: urlParams.code, | |
| 115 | + error: urlParams.error ?? urlParams.error_description, | |
| 116 | + }) | |
| 117 | + .pipe( | |
| 118 | + Effect.map( | |
| 119 | + (auth): OAuthPopupResult => ({ | |
| 120 | + type: "executor:oauth-result", | |
| 121 | + ok: true, | |
| 122 | + sessionId: urlParams.state, | |
| 123 | + ...auth, | |
| 124 | + }), | |
| 125 | + ), | |
| 126 | + Effect.catchAll((error) => | |
| 127 | + Effect.succeed<OAuthPopupResult>({ | |
| 128 | + type: "executor:oauth-result", | |
| 129 | + ok: false, | |
| 130 | + sessionId: null, | |
| 131 | + error: | |
| 132 | + error instanceof Error ? error.message : String(error), | |
| 133 | + }), | |
| 134 | + ), | |
| 135 | + ); | |
| 136 | + return yield* HttpServerResponse.html(popupDocument(result)); | |
| 137 | + }).pipe(Effect.orDie), | |
| 138 | + ), | |
| 139 | +); | |
| @@ -0,0 +1,5 @@ | ||
| 1 | +export { GoogleDiscoveryGroup } from "./group"; | |
| 2 | +export { | |
| 3 | + GoogleDiscoveryExtensionService, | |
| 4 | + GoogleDiscoveryHandlers, | |
| 5 | +} from "./handlers"; | |
| @@ -0,0 +1,667 @@ | ||
| 1 | +import { useCallback, useMemo, useState } from "react"; | |
| 2 | +import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + ScopeId, | |
| 6 | + SecretPicker, | |
| 7 | + secretsAtom, | |
| 8 | + type SecretPickerSecret, | |
| 9 | +} from "@executor/react"; | |
| 10 | +import { Badge } from "@executor/ui/components/badge"; | |
| 11 | +import { Button } from "@executor/ui/components/button"; | |
| 12 | +import { | |
| 13 | + Collapsible, | |
| 14 | + CollapsibleContent, | |
| 15 | + CollapsibleTrigger, | |
| 16 | +} from "@executor/ui/components/collapsible"; | |
| 17 | +import { Input } from "@executor/ui/components/input"; | |
| 18 | +import { Label } from "@executor/ui/components/label"; | |
| 19 | +import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group"; | |
| 20 | +import { Spinner } from "@executor/ui/components/spinner"; | |
| 21 | +import { | |
| 22 | + addGoogleDiscoverySource, | |
| 23 | + probeGoogleDiscovery, | |
| 24 | + startGoogleDiscoveryOAuth, | |
| 25 | +} from "./atoms"; | |
| 26 | + | |
| 27 | +type GoogleDiscoveryTemplate = { | |
| 28 | + id: string; | |
| 29 | + name: string; | |
| 30 | + summary: string; | |
| 31 | + service: string; | |
| 32 | + version: string; | |
| 33 | + discoveryUrl: string; | |
| 34 | +}; | |
| 35 | + | |
| 36 | +const defaultGoogleDiscoveryUrl = (service: string, version: string): string => | |
| 37 | + `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; | |
| 38 | + | |
| 39 | +const googleDiscoveryTemplate = ( | |
| 40 | + template: GoogleDiscoveryTemplate, | |
| 41 | +): GoogleDiscoveryTemplate => ({ | |
| 42 | + ...template, | |
| 43 | + discoveryUrl: | |
| 44 | + template.discoveryUrl || defaultGoogleDiscoveryUrl(template.service, template.version), | |
| 45 | +}); | |
| 46 | + | |
| 47 | +const GOOGLE_DISCOVERY_TEMPLATES: readonly GoogleDiscoveryTemplate[] = [ | |
| 48 | + googleDiscoveryTemplate({ | |
| 49 | + id: "google-calendar", | |
| 50 | + name: "Google Calendar", | |
| 51 | + summary: "Calendars, events, ACLs, and scheduling workflows.", | |
| 52 | + service: "calendar", | |
| 53 | + version: "v3", | |
| 54 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", | |
| 55 | + }), | |
| 56 | + googleDiscoveryTemplate({ | |
| 57 | + id: "google-drive", | |
| 58 | + name: "Google Drive", | |
| 59 | + summary: "Files, folders, permissions, comments, and shared drives.", | |
| 60 | + service: "drive", | |
| 61 | + version: "v3", | |
| 62 | + discoveryUrl: defaultGoogleDiscoveryUrl("drive", "v3"), | |
| 63 | + }), | |
| 64 | + googleDiscoveryTemplate({ | |
| 65 | + id: "google-gmail", | |
| 66 | + name: "Gmail", | |
| 67 | + summary: "Messages, threads, labels, drafts, and mailbox automation.", | |
| 68 | + service: "gmail", | |
| 69 | + version: "v1", | |
| 70 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", | |
| 71 | + }), | |
| 72 | + googleDiscoveryTemplate({ | |
| 73 | + id: "google-docs", | |
| 74 | + name: "Google Docs", | |
| 75 | + summary: "Documents, structural edits, text ranges, and formatting.", | |
| 76 | + service: "docs", | |
| 77 | + version: "v1", | |
| 78 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest", | |
| 79 | + }), | |
| 80 | + googleDiscoveryTemplate({ | |
| 81 | + id: "google-sheets", | |
| 82 | + name: "Google Sheets", | |
| 83 | + summary: "Spreadsheets, values, ranges, formatting, and batch updates.", | |
| 84 | + service: "sheets", | |
| 85 | + version: "v4", | |
| 86 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | |
| 87 | + }), | |
| 88 | + googleDiscoveryTemplate({ | |
| 89 | + id: "google-slides", | |
| 90 | + name: "Google Slides", | |
| 91 | + summary: "Presentations, slides, page elements, and deck updates.", | |
| 92 | + service: "slides", | |
| 93 | + version: "v1", | |
| 94 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/slides/v1/rest", | |
| 95 | + }), | |
| 96 | + googleDiscoveryTemplate({ | |
| 97 | + id: "google-forms", | |
| 98 | + name: "Google Forms", | |
| 99 | + summary: "Forms, questions, responses, quizzes, and form metadata.", | |
| 100 | + service: "forms", | |
| 101 | + version: "v1", | |
| 102 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/forms/v1/rest", | |
| 103 | + }), | |
| 104 | + googleDiscoveryTemplate({ | |
| 105 | + id: "google-people", | |
| 106 | + name: "Google People", | |
| 107 | + summary: "Contacts, profiles, directory people, and contact groups.", | |
| 108 | + service: "people", | |
| 109 | + version: "v1", | |
| 110 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/people/v1/rest", | |
| 111 | + }), | |
| 112 | + googleDiscoveryTemplate({ | |
| 113 | + id: "google-tasks", | |
| 114 | + name: "Google Tasks", | |
| 115 | + summary: "Task lists, task items, notes, and due dates.", | |
| 116 | + service: "tasks", | |
| 117 | + version: "v1", | |
| 118 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest", | |
| 119 | + }), | |
| 120 | + googleDiscoveryTemplate({ | |
| 121 | + id: "google-chat", | |
| 122 | + name: "Google Chat", | |
| 123 | + summary: "Spaces, messages, members, reactions, and chat workflows.", | |
| 124 | + service: "chat", | |
| 125 | + version: "v1", | |
| 126 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest", | |
| 127 | + }), | |
| 128 | + googleDiscoveryTemplate({ | |
| 129 | + id: "google-bigquery", | |
| 130 | + name: "Google BigQuery", | |
| 131 | + summary: "Datasets, tables, jobs, routines, and analytics workflows.", | |
| 132 | + service: "bigquery", | |
| 133 | + version: "v2", | |
| 134 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/bigquery/v2/rest", | |
| 135 | + }), | |
| 136 | + googleDiscoveryTemplate({ | |
| 137 | + id: "google-youtube", | |
| 138 | + name: "YouTube Data", | |
| 139 | + summary: "Channels, playlists, videos, comments, captions, and uploads.", | |
| 140 | + service: "youtube", | |
| 141 | + version: "v3", | |
| 142 | + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest", | |
| 143 | + }), | |
| 144 | +]; | |
| 145 | + | |
| 146 | +const GOOGLE_SERVICE_ICON_URLS: Record<string, string> = { | |
| 147 | + calendar: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg", | |
| 148 | + drive: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg", | |
| 149 | + gmail: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png", | |
| 150 | + docs: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg", | |
| 151 | + sheets: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg", | |
| 152 | + slides: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg", | |
| 153 | + forms: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg", | |
| 154 | + people: "https://fonts.gstatic.com/s/i/productlogos/contacts/v9/192px.svg", | |
| 155 | + tasks: "https://fonts.gstatic.com/s/i/productlogos/tasks/v10/192px.svg", | |
| 156 | + chat: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg", | |
| 157 | + bigquery: "https://fonts.gstatic.com/s/i/productlogos/cloud/v8/192px.svg", | |
| 158 | + youtube: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg", | |
| 159 | +}; | |
| 160 | + | |
| 161 | +function GoogleServiceIcon(props: { | |
| 162 | + readonly service: string; | |
| 163 | + readonly className?: string; | |
| 164 | +}) { | |
| 165 | + const { service, className = "size-11" } = props; | |
| 166 | + const src = GOOGLE_SERVICE_ICON_URLS[service] ?? GOOGLE_SERVICE_ICON_URLS.bigquery; | |
| 167 | + | |
| 168 | + return ( | |
| 169 | + <img | |
| 170 | + alt="" | |
| 171 | + aria-hidden="true" | |
| 172 | + className={`${className} shrink-0 object-contain`} | |
| 173 | + decoding="async" | |
| 174 | + draggable={false} | |
| 175 | + loading="lazy" | |
| 176 | + src={src} | |
| 177 | + /> | |
| 178 | + ); | |
| 179 | +} | |
| 180 | + | |
| 181 | +type ProbeResult = { | |
| 182 | + name: string; | |
| 183 | + title: string | null; | |
| 184 | + service: string; | |
| 185 | + version: string; | |
| 186 | + toolCount: number; | |
| 187 | + scopes: readonly string[]; | |
| 188 | +}; | |
| 189 | + | |
| 190 | +type OAuthAuth = { | |
| 191 | + kind: "oauth2"; | |
| 192 | + clientId: string; | |
| 193 | + clientSecretSecretId: string | null; | |
| 194 | + accessTokenSecretId: string; | |
| 195 | + refreshTokenSecretId: string | null; | |
| 196 | + tokenType: string; | |
| 197 | + expiresAt: number | null; | |
| 198 | + scope: string | null; | |
| 199 | + scopes: string[]; | |
| 200 | +}; | |
| 201 | + | |
| 202 | +type OAuthPopupResult = | |
| 203 | + | ({ | |
| 204 | + type: "executor:oauth-result"; | |
| 205 | + ok: true; | |
| 206 | + sessionId: string; | |
| 207 | + } & OAuthAuth) | |
| 208 | + | { | |
| 209 | + type: "executor:oauth-result"; | |
| 210 | + ok: false; | |
| 211 | + sessionId: null; | |
| 212 | + error: string; | |
| 213 | + }; | |
| 214 | + | |
| 215 | +const DEFAULT_SCOPE = ScopeId.make("default"); | |
| 216 | + | |
| 217 | +function openOAuthPopup( | |
| 218 | + url: string, | |
| 219 | + onResult: (data: OAuthPopupResult) => void, | |
| 220 | + onClosed?: () => void, | |
| 221 | +): void { | |
| 222 | + const w = 640; | |
| 223 | + const h = 760; | |
| 224 | + const left = window.screenX + (window.outerWidth - w) / 2; | |
| 225 | + const top = window.screenY + (window.outerHeight - h) / 2; | |
| 226 | + const popup = window.open( | |
| 227 | + url, | |
| 228 | + "google-discovery-oauth", | |
| 229 | + `width=${w},height=${h},left=${left},top=${top},popup=1`, | |
| 230 | + ); | |
| 231 | + | |
| 232 | + let settled = false; | |
| 233 | + const settle = () => { | |
| 234 | + settled = true; | |
| 235 | + window.removeEventListener("message", onMessage); | |
| 236 | + }; | |
| 237 | + | |
| 238 | + const onMessage = (event: MessageEvent) => { | |
| 239 | + if ( | |
| 240 | + event.origin === window.location.origin && | |
| 241 | + event.data?.type === "executor:oauth-result" && | |
| 242 | + !settled | |
| 243 | + ) { | |
| 244 | + settle(); | |
| 245 | + onResult(event.data as OAuthPopupResult); | |
| 246 | + } | |
| 247 | + }; | |
| 248 | + window.addEventListener("message", onMessage); | |
| 249 | + | |
| 250 | + if (popup) { | |
| 251 | + const interval = window.setInterval(() => { | |
| 252 | + if (popup.closed) { | |
| 253 | + window.clearInterval(interval); | |
| 254 | + if (!settled) { | |
| 255 | + settle(); | |
| 256 | + onClosed?.(); | |
| 257 | + } | |
| 258 | + } | |
| 259 | + }, 500); | |
| 260 | + } | |
| 261 | +} | |
| 262 | + | |
| 263 | +export default function AddGoogleDiscoverySource(props: { | |
| 264 | + readonly onComplete: () => void; | |
| 265 | + readonly onCancel: () => void; | |
| 266 | +}) { | |
| 267 | + const defaultTemplate = | |
| 268 | + GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") | |
| 269 | + ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; | |
| 270 | + const [discoveryUrl, setDiscoveryUrl] = useState( | |
| 271 | + defaultTemplate.discoveryUrl, | |
| 272 | + ); | |
| 273 | + const [name, setName] = useState(defaultTemplate.name); | |
| 274 | + const [selectedTemplateId, setSelectedTemplateId] = useState(defaultTemplate.id); | |
| 275 | + const selectedTemplate = | |
| 276 | + GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) | |
| 277 | + ?? null; | |
| 278 | + const [authKind, setAuthKind] = useState<"none" | "oauth2">("oauth2"); | |
| 279 | + const [clientId, setClientId] = useState(""); | |
| 280 | + const [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null); | |
| 281 | + const [probe, setProbe] = useState<ProbeResult | null>(null); | |
| 282 | + const [oauthAuth, setOauthAuth] = useState<OAuthAuth | null>(null); | |
| 283 | + const [loadingProbe, setLoadingProbe] = useState(false); | |
| 284 | + const [startingOAuth, setStartingOAuth] = useState(false); | |
| 285 | + const [adding, setAdding] = useState(false); | |
| 286 | + const [error, setError] = useState<string | null>(null); | |
| 287 | + const [showScopes, setShowScopes] = useState(false); | |
| 288 | + | |
| 289 | + const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" }); | |
| 290 | + const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); | |
| 291 | + const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" }); | |
| 292 | + const secrets = useAtomValue(secretsAtom()); | |
| 293 | + | |
| 294 | + const canUseOAuth = useMemo( | |
| 295 | + () => (probe?.scopes.length ?? 0) > 0, | |
| 296 | + [probe], | |
| 297 | + ); | |
| 298 | + const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { | |
| 299 | + onInitial: () => [] as SecretPickerSecret[], | |
| 300 | + onFailure: () => [] as SecretPickerSecret[], | |
| 301 | + onSuccess: ({ value }) => | |
| 302 | + value.map((secret) => ({ | |
| 303 | + id: secret.id, | |
| 304 | + name: secret.name, | |
| 305 | + provider: secret.provider ? String(secret.provider) : undefined, | |
| 306 | + })), | |
| 307 | + }); | |
| 308 | + | |
| 309 | + const applyTemplate = useCallback((template: GoogleDiscoveryTemplate) => { | |
| 310 | + setSelectedTemplateId(template.id); | |
| 311 | + setDiscoveryUrl(template.discoveryUrl); | |
| 312 | + setName(template.name); | |
| 313 | + setClientSecretSecretId(null); | |
| 314 | + setProbe(null); | |
| 315 | + setOauthAuth(null); | |
| 316 | + setError(null); | |
| 317 | + setShowScopes(false); | |
| 318 | + setAuthKind("oauth2"); | |
| 319 | + }, []); | |
| 320 | + | |
| 321 | + const handleProbe = useCallback(async () => { | |
| 322 | + setLoadingProbe(true); | |
| 323 | + setError(null); | |
| 324 | + setOauthAuth(null); | |
| 325 | + setShowScopes(false); | |
| 326 | + try { | |
| 327 | + const result = await doProbe({ | |
| 328 | + path: { scopeId: DEFAULT_SCOPE }, | |
| 329 | + payload: { discoveryUrl: discoveryUrl.trim() }, | |
| 330 | + }); | |
| 331 | + setProbe({ ...result, scopes: [...result.scopes] }); | |
| 332 | + if (!name.trim()) { | |
| 333 | + setName(result.name); | |
| 334 | + } | |
| 335 | + if (result.scopes.length === 0) { | |
| 336 | + setAuthKind("none"); | |
| 337 | + } | |
| 338 | + } catch (e) { | |
| 339 | + setProbe(null); | |
| 340 | + setError(e instanceof Error ? e.message : "Failed to inspect discovery document"); | |
| 341 | + } finally { | |
| 342 | + setLoadingProbe(false); | |
| 343 | + } | |
| 344 | + }, [discoveryUrl, doProbe, name]); | |
| 345 | + | |
| 346 | + const handleStartOAuth = useCallback(async () => { | |
| 347 | + if (!probe) return; | |
| 348 | + setStartingOAuth(true); | |
| 349 | + setError(null); | |
| 350 | + try { | |
| 351 | + const response = await doStartOAuth({ | |
| 352 | + path: { scopeId: DEFAULT_SCOPE }, | |
| 353 | + payload: { | |
| 354 | + name: name.trim() || probe.name, | |
| 355 | + discoveryUrl: discoveryUrl.trim(), | |
| 356 | + clientId: clientId.trim(), | |
| 357 | + clientSecretSecretId, | |
| 358 | + redirectUrl: `${window.location.origin}/v1/google-discovery/oauth/callback`, | |
| 359 | + scopes: probe.scopes, | |
| 360 | + }, | |
| 361 | + }); | |
| 362 | + | |
| 363 | + openOAuthPopup( | |
| 364 | + response.authorizationUrl, | |
| 365 | + (result) => { | |
| 366 | + setStartingOAuth(false); | |
| 367 | + if (result.ok) { | |
| 368 | + setOauthAuth({ | |
| 369 | + kind: "oauth2", | |
| 370 | + clientId: result.clientId, | |
| 371 | + clientSecretSecretId: result.clientSecretSecretId, | |
| 372 | + accessTokenSecretId: result.accessTokenSecretId, | |
| 373 | + refreshTokenSecretId: result.refreshTokenSecretId, | |
| 374 | + tokenType: result.tokenType, | |
| 375 | + expiresAt: result.expiresAt, | |
| 376 | + scope: result.scope, | |
| 377 | + scopes: [...result.scopes], | |
| 378 | + }); | |
| 379 | + setError(null); | |
| 380 | + } else { | |
| 381 | + setError(result.error); | |
| 382 | + } | |
| 383 | + }, | |
| 384 | + () => { | |
| 385 | + setStartingOAuth(false); | |
| 386 | + }, | |
| 387 | + ); | |
| 388 | + } catch (e) { | |
| 389 | + setStartingOAuth(false); | |
| 390 | + setError(e instanceof Error ? e.message : "Failed to start OAuth"); | |
| 391 | + } | |
| 392 | + }, [probe, doStartOAuth, name, discoveryUrl, clientId, clientSecretSecretId]); | |
| 393 | + | |
| 394 | + const handleAdd = useCallback(async () => { | |
| 395 | + if (!probe) return; | |
| 396 | + setAdding(true); | |
| 397 | + setError(null); | |
| 398 | + try { | |
| 399 | + await doAdd({ | |
| 400 | + path: { scopeId: DEFAULT_SCOPE }, | |
| 401 | + payload: { | |
| 402 | + name: name.trim() || probe.name, | |
| 403 | + discoveryUrl: discoveryUrl.trim(), | |
| 404 | + auth: | |
| 405 | + authKind === "oauth2" | |
| 406 | + ? oauthAuth ?? { kind: "none" as const } | |
| 407 | + : { kind: "none" as const }, | |
| 408 | + }, | |
| 409 | + }); | |
| 410 | + props.onComplete(); | |
| 411 | + } catch (e) { | |
| 412 | + setError(e instanceof Error ? e.message : "Failed to add source"); | |
| 413 | + setAdding(false); | |
| 414 | + } | |
| 415 | + }, [probe, doAdd, name, discoveryUrl, authKind, oauthAuth, props]); | |
| 416 | + | |
| 417 | + const addDisabled = | |
| 418 | + !probe || | |
| 419 | + adding || | |
| 420 | + (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); | |
| 421 | + | |
| 422 | + return ( | |
| 423 | + <div className="space-y-6"> | |
| 424 | + <div> | |
| 425 | + <h1 className="text-xl font-semibold text-foreground"> | |
| 426 | + Add Google Discovery Source | |
| 427 | + </h1> | |
| 428 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 429 | + Connect a Google API from its Discovery document and register its methods as tools. | |
| 430 | + </p> | |
| 431 | + </div> | |
| 432 | + | |
| 433 | + <section className="space-y-2"> | |
| 434 | + <div className="flex items-center justify-between gap-3"> | |
| 435 | + <Label>Presets</Label> | |
| 436 | + <span className="text-xs text-muted-foreground"> | |
| 437 | + Select a Google API to prefill the source. | |
| 438 | + </span> | |
| 439 | + </div> | |
| 440 | + <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> | |
| 441 | + {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { | |
| 442 | + const selected = template.id === selectedTemplateId; | |
| 443 | + return ( | |
| 444 | + <button | |
| 445 | + key={template.id} | |
| 446 | + type="button" | |
| 447 | + onClick={() => applyTemplate(template)} | |
| 448 | + className={`relative rounded-xl border px-4 py-3 text-left transition-colors ${ | |
| 449 | + selected | |
| 450 | + ? "border-primary bg-primary/5 shadow-[0_0_0_1px_rgba(0,0,0,0.02)]" | |
| 451 | + : "border-border bg-card hover:border-primary/30 hover:bg-card/80" | |
| 452 | + }`} | |
| 453 | + > | |
| 454 | + {selected && ( | |
| 455 | + <Badge variant="secondary" className="absolute top-3 right-3"> | |
| 456 | + Selected | |
| 457 | + </Badge> | |
| 458 | + )} | |
| 459 | + <div className="flex min-w-0 gap-3 pr-20"> | |
| 460 | + <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs"> | |
| 461 | + <GoogleServiceIcon service={template.service} className="size-6" /> | |
| 462 | + </div> | |
| 463 | + <div className="min-w-0"> | |
| 464 | + <p className="text-sm font-semibold text-foreground"> | |
| 465 | + {template.name} | |
| 466 | + </p> | |
| 467 | + <p className="mt-1 text-xs text-muted-foreground"> | |
| 468 | + {template.summary} | |
| 469 | + </p> | |
| 470 | + </div> | |
| 471 | + </div> | |
| 472 | + <div className="mt-3 flex items-center justify-between gap-3"> | |
| 473 | + <p className="text-[11px] font-mono text-muted-foreground"> | |
| 474 | + {template.service} · {template.version} | |
| 475 | + </p> | |
| 476 | + <div className="h-px flex-1 bg-border/70" /> | |
| 477 | + </div> | |
| 478 | + </button> | |
| 479 | + ); | |
| 480 | + })} | |
| 481 | + </div> | |
| 482 | + </section> | |
| 483 | + | |
| 484 | + <section className="space-y-2"> | |
| 485 | + <Label>Discovery URL</Label> | |
| 486 | + <div className="flex gap-2"> | |
| 487 | + <Input | |
| 488 | + value={discoveryUrl} | |
| 489 | + onChange={(e) => { | |
| 490 | + setSelectedTemplateId(""); | |
| 491 | + setDiscoveryUrl((e.target as HTMLInputElement).value); | |
| 492 | + }} | |
| 493 | + placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" | |
| 494 | + className="flex-1 font-mono text-sm" | |
| 495 | + /> | |
| 496 | + <Button onClick={handleProbe} disabled={!discoveryUrl.trim() || loadingProbe}> | |
| 497 | + {loadingProbe ? <><Spinner className="size-3.5" /> Inspecting…</> : "Inspect"} | |
| 498 | + </Button> | |
| 499 | + </div> | |
| 500 | + </section> | |
| 501 | + | |
| 502 | + <section className="space-y-2"> | |
| 503 | + <Label>Display Name</Label> | |
| 504 | + <Input | |
| 505 | + value={name} | |
| 506 | + onChange={(e) => setName((e.target as HTMLInputElement).value)} | |
| 507 | + placeholder="Google Sheets" | |
| 508 | + /> | |
| 509 | + </section> | |
| 510 | + | |
| 511 | + {probe && ( | |
| 512 | + <section className="space-y-3 rounded-xl border border-border bg-card px-4 py-4"> | |
| 513 | + <div className="flex items-center justify-between gap-3"> | |
| 514 | + <div className="flex items-center gap-3"> | |
| 515 | + <div className="flex size-9 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs"> | |
| 516 | + <GoogleServiceIcon | |
| 517 | + service={selectedTemplate?.service ?? probe.service} | |
| 518 | + className="size-5" | |
| 519 | + /> | |
| 520 | + </div> | |
| 521 | + <div> | |
| 522 | + <p className="text-sm font-semibold text-foreground"> | |
| 523 | + {probe.title ?? probe.name} | |
| 524 | + </p> | |
| 525 | + <p className="mt-1 text-xs text-muted-foreground"> | |
| 526 | + {probe.service} · {probe.version} | |
| 527 | + </p> | |
| 528 | + </div> | |
| 529 | + </div> | |
| 530 | + <div className="flex gap-2"> | |
| 531 | + <Badge variant="secondary">{probe.toolCount} tools</Badge> | |
| 532 | + <Badge variant="outline">{probe.scopes.length} scopes</Badge> | |
| 533 | + </div> | |
| 534 | + </div> | |
| 535 | + </section> | |
| 536 | + )} | |
| 537 | + | |
| 538 | + <section className="space-y-3"> | |
| 539 | + <RadioGroup | |
| 540 | + value={authKind} | |
| 541 | + onValueChange={(value) => setAuthKind(value as "none" | "oauth2")} | |
| 542 | + className="flex items-center gap-4" | |
| 543 | + > | |
| 544 | + <div className="flex items-center gap-2"> | |
| 545 | + <RadioGroupItem id="google-discovery-auth-none" value="none" /> | |
| 546 | + <Label htmlFor="google-discovery-auth-none" className="text-sm"> | |
| 547 | + No auth | |
| 548 | + </Label> | |
| 549 | + </div> | |
| 550 | + <div className="flex items-center gap-2"> | |
| 551 | + <RadioGroupItem | |
| 552 | + id="google-discovery-auth-oauth2" | |
| 553 | + value="oauth2" | |
| 554 | + /> | |
| 555 | + <Label htmlFor="google-discovery-auth-oauth2" className="text-sm"> | |
| 556 | + OAuth 2.0 | |
| 557 | + </Label> | |
| 558 | + </div> | |
| 559 | + </RadioGroup> | |
| 560 | + | |
| 561 | + {authKind === "oauth2" && ( | |
| 562 | + <div className="space-y-3 rounded-xl border border-border bg-card px-4 py-4"> | |
| 563 | + <div className="space-y-2"> | |
| 564 | + <Label>OAuth Client ID</Label> | |
| 565 | + <Input | |
| 566 | + value={clientId} | |
| 567 | + onChange={(e) => setClientId((e.target as HTMLInputElement).value)} | |
| 568 | + placeholder="1234567890-abc.apps.googleusercontent.com" | |
| 569 | + /> | |
| 570 | + </div> | |
| 571 | + <div className="space-y-2"> | |
| 572 | + <Label>OAuth Client Secret</Label> | |
| 573 | + <div className="flex items-center gap-2"> | |
| 574 | + <div className="flex-1 min-w-0"> | |
| 575 | + <SecretPicker | |
| 576 | + value={clientSecretSecretId} | |
| 577 | + onSelect={setClientSecretSecretId} | |
| 578 | + secrets={secretList} | |
| 579 | + placeholder="Optional for confidential clients" | |
| 580 | + /> | |
| 581 | + </div> | |
| 582 | + {clientSecretSecretId && ( | |
| 583 | + <Button | |
| 584 | + variant="outline" | |
| 585 | + onClick={() => setClientSecretSecretId(null)} | |
| 586 | + > | |
| 587 | + Clear | |
| 588 | + </Button> | |
| 589 | + )} | |
| 590 | + </div> | |
| 591 | + </div> | |
| 592 | + <Collapsible | |
| 593 | + open={showScopes} | |
| 594 | + onOpenChange={setShowScopes} | |
| 595 | + className="space-y-2" | |
| 596 | + > | |
| 597 | + <div className="flex items-start justify-between gap-3"> | |
| 598 | + <div className="min-w-0 space-y-1"> | |
| 599 | + <p className="text-xs text-muted-foreground"> | |
| 600 | + {canUseOAuth | |
| 601 | + ? `${probe?.scopes.length ?? 0} scopes will be requested from Google.` | |
| 602 | + : "This API does not advertise OAuth scopes."} | |
| 603 | + </p> | |
| 604 | + {canUseOAuth && (probe?.scopes.length ?? 0) > 0 && ( | |
| 605 | + <CollapsibleTrigger asChild> | |
| 606 | + <button | |
| 607 | + type="button" | |
| 608 | + className="text-xs font-medium text-primary hover:underline" | |
| 609 | + > | |
| 610 | + {showScopes ? "Hide scopes" : "View scopes"} | |
| 611 | + </button> | |
| 612 | + </CollapsibleTrigger> | |
| 613 | + )} | |
| 614 | + </div> | |
| 615 | + <Button | |
| 616 | + variant="outline" | |
| 617 | + onClick={handleStartOAuth} | |
| 618 | + disabled={!probe || !clientId.trim() || !canUseOAuth || startingOAuth} | |
| 619 | + > | |
| 620 | + {startingOAuth | |
| 621 | + ? <><Spinner className="size-3.5" /> Waiting…</> | |
| 622 | + : oauthAuth | |
| 623 | + ? "Re-authenticate" | |
| 624 | + : "Connect Google"} | |
| 625 | + </Button> | |
| 626 | + </div> | |
| 627 | + <CollapsibleContent> | |
| 628 | + <div className="rounded-lg border border-border/70 bg-muted/20 px-3 py-2"> | |
| 629 | + <ul className="space-y-1"> | |
| 630 | + {(probe?.scopes ?? []).map((scope) => ( | |
| 631 | + <li | |
| 632 | + key={scope} | |
| 633 | + className="break-all font-mono text-[11px] text-muted-foreground" | |
| 634 | + > | |
| 635 | + {scope} | |
| 636 | + </li> | |
| 637 | + ))} | |
| 638 | + </ul> | |
| 639 | + </div> | |
| 640 | + </CollapsibleContent> | |
| 641 | + </Collapsible> | |
| 642 | + {oauthAuth && ( | |
| 643 | + <div className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300"> | |
| 644 | + Connected. Access token stored as secret `{oauthAuth.accessTokenSecretId}`. | |
| 645 | + </div> | |
| 646 | + )} | |
| 647 | + </div> | |
| 648 | + )} | |
| 649 | + </section> | |
| 650 | + | |
| 651 | + {error && ( | |
| 652 | + <div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive"> | |
| 653 | + {error} | |
| 654 | + </div> | |
| 655 | + )} | |
| 656 | + | |
| 657 | + <div className="flex items-center justify-between border-t border-border pt-4"> | |
| 658 | + <Button variant="outline" onClick={props.onCancel}> | |
| 659 | + Cancel | |
| 660 | + </Button> | |
| 661 | + <Button onClick={handleAdd} disabled={addDisabled}> | |
| 662 | + {adding ? "Adding…" : "Add Source"} | |
| 663 | + </Button> | |
| 664 | + </div> | |
| 665 | + </div> | |
| 666 | + ); | |
| 667 | +} | |
| @@ -0,0 +1,38 @@ | ||
| 1 | +import { Badge } from "@executor/ui/components/badge"; | |
| 2 | +import { Button } from "@executor/ui/components/button"; | |
| 3 | + | |
| 4 | +export default function EditGoogleDiscoverySource({ | |
| 5 | + sourceId, | |
| 6 | + onSave, | |
| 7 | +}: { | |
| 8 | + readonly sourceId: string; | |
| 9 | + readonly onSave: () => void; | |
| 10 | +}) { | |
| 11 | + return ( | |
| 12 | + <div className="space-y-6"> | |
| 13 | + <div> | |
| 14 | + <h1 className="text-xl font-semibold text-foreground"> | |
| 15 | + Edit Google Discovery Source | |
| 16 | + </h1> | |
| 17 | + <p className="mt-1 text-[13px] text-muted-foreground"> | |
| 18 | + This source is managed from its Discovery document and can be refreshed from the source header. | |
| 19 | + </p> | |
| 20 | + </div> | |
| 21 | + | |
| 22 | + <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3"> | |
| 23 | + <div className="min-w-0 flex-1"> | |
| 24 | + <p className="truncate text-sm font-semibold text-card-foreground"> | |
| 25 | + {sourceId} | |
| 26 | + </p> | |
| 27 | + </div> | |
| 28 | + <Badge variant="secondary" className="text-[10px]"> | |
| 29 | + Google Discovery | |
| 30 | + </Badge> | |
| 31 | + </div> | |
| 32 | + | |
| 33 | + <div className="flex items-center justify-end border-t border-border pt-4"> | |
| 34 | + <Button onClick={onSave}>Done</Button> | |
| 35 | + </div> | |
| 36 | + </div> | |
| 37 | + ); | |
| 38 | +} | |
| @@ -0,0 +1,16 @@ | ||
| 1 | +import { Badge } from "@executor/ui/components/badge"; | |
| 2 | + | |
| 3 | +export default function GoogleDiscoverySourceSummary({ | |
| 4 | + sourceId, | |
| 5 | +}: { | |
| 6 | + readonly sourceId: string; | |
| 7 | +}) { | |
| 8 | + return ( | |
| 9 | + <span className="inline-flex items-center gap-1.5"> | |
| 10 | + <Badge variant="secondary" className="text-[10px]"> | |
| 11 | ||
| 12 | + </Badge> | |
| 13 | + <span className="text-xs text-muted-foreground">{sourceId}</span> | |
| 14 | + </span> | |
| 15 | + ); | |
| 16 | +} | |
| @@ -0,0 +1,18 @@ | ||
| 1 | +import { GoogleDiscoveryClient } from "./client"; | |
| 2 | + | |
| 3 | +export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation( | |
| 4 | + "googleDiscovery", | |
| 5 | + "probeDiscovery", | |
| 6 | +); | |
| 7 | +export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( | |
| 8 | + "googleDiscovery", | |
| 9 | + "addSource", | |
| 10 | +); | |
| 11 | +export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation( | |
| 12 | + "googleDiscovery", | |
| 13 | + "startOAuth", | |
| 14 | +); | |
| 15 | +export const completeGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation( | |
| 16 | + "googleDiscovery", | |
| 17 | + "completeOAuth", | |
| 18 | +); | |
| @@ -0,0 +1,16 @@ | ||
| 1 | +import { AtomHttpApi } from "@effect-atom/atom-react"; | |
| 2 | +import { FetchHttpClient } from "@effect/platform"; | |
| 3 | +import { addGroup } from "@executor/api"; | |
| 4 | +import { getBaseUrl } from "@executor/react"; | |
| 5 | +import { GoogleDiscoveryGroup } from "../api/group"; | |
| 6 | + | |
| 7 | +const GoogleDiscoveryApi = addGroup(GoogleDiscoveryGroup); | |
| 8 | + | |
| 9 | +export const GoogleDiscoveryClient = AtomHttpApi.Tag<"GoogleDiscoveryClient">()( | |
| 10 | + "GoogleDiscoveryClient", | |
| 11 | + { | |
| 12 | + api: GoogleDiscoveryApi, | |
| 13 | + httpClient: FetchHttpClient.layer, | |
| 14 | + baseUrl: getBaseUrl(), | |
| 15 | + }, | |
| 16 | +); | |
| @@ -0,0 +1,8 @@ | ||
| 1 | +export { googleDiscoverySourcePlugin } from "./source-plugin"; | |
| 2 | +export { GoogleDiscoveryClient } from "./client"; | |
| 3 | +export { | |
| 4 | + addGoogleDiscoverySource, | |
| 5 | + completeGoogleDiscoveryOAuth, | |
| 6 | + probeGoogleDiscovery, | |
| 7 | + startGoogleDiscoveryOAuth, | |
| 8 | +} from "./atoms"; | |
| @@ -0,0 +1,10 @@ | ||
| 1 | +import { lazy } from "react"; | |
| 2 | +import type { SourcePlugin } from "@executor/react"; | |
| 3 | + | |
| 4 | +export const googleDiscoverySourcePlugin: SourcePlugin = { | |
| 5 | + key: "googleDiscovery", | |
| 6 | + label: "Google Discovery", | |
| 7 | + add: lazy(() => import("./AddGoogleDiscoverySource")), | |
| 8 | + edit: lazy(() => import("./EditGoogleDiscoverySource")), | |
| 9 | + summary: lazy(() => import("./GoogleDiscoverySourceSummary")), | |
| 10 | +}; | |
| @@ -0,0 +1,163 @@ | ||
| 1 | +import { Effect, Schema } from "effect"; | |
| 2 | +import { | |
| 3 | + makeInMemoryScopedKv, | |
| 4 | + scopeKv, | |
| 5 | + type Kv, | |
| 6 | + type ScopedKv, | |
| 7 | + type ToolId, | |
| 8 | +} from "@executor/sdk"; | |
| 9 | + | |
| 10 | +import { | |
| 11 | + GoogleDiscoveryMethodBinding, | |
| 12 | + GoogleDiscoveryStoredSourceData, | |
| 13 | +} from "./types"; | |
| 14 | + | |
| 15 | +const StoredBindingEntry = Schema.Struct({ | |
| 16 | + namespace: Schema.String, | |
| 17 | + binding: GoogleDiscoveryMethodBinding, | |
| 18 | +}); | |
| 19 | + | |
| 20 | +const encodeBindingEntry = Schema.encodeSync( | |
| 21 | + Schema.parseJson(StoredBindingEntry), | |
| 22 | +); | |
| 23 | +const decodeBindingEntry = Schema.decodeUnknownSync( | |
| 24 | + Schema.parseJson(StoredBindingEntry), | |
| 25 | +); | |
| 26 | +const encodeSourceData = Schema.encodeSync( | |
| 27 | + Schema.parseJson(GoogleDiscoveryStoredSourceData), | |
| 28 | +); | |
| 29 | +const decodeSourceData = Schema.decodeUnknownSync( | |
| 30 | + Schema.parseJson(GoogleDiscoveryStoredSourceData), | |
| 31 | +); | |
| 32 | + | |
| 33 | +export interface GoogleDiscoverySourceMeta { | |
| 34 | + readonly namespace: string; | |
| 35 | + readonly name: string; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export interface GoogleDiscoveryBindingStore { | |
| 39 | + readonly get: ( | |
| 40 | + toolId: ToolId, | |
| 41 | + ) => Effect.Effect<{ | |
| 42 | + namespace: string; | |
| 43 | + binding: GoogleDiscoveryMethodBinding; | |
| 44 | + } | null>; | |
| 45 | + | |
| 46 | + readonly put: ( | |
| 47 | + toolId: ToolId, | |
| 48 | + namespace: string, | |
| 49 | + binding: GoogleDiscoveryMethodBinding, | |
| 50 | + ) => Effect.Effect<void>; | |
| 51 | + | |
| 52 | + readonly listByNamespace: ( | |
| 53 | + namespace: string, | |
| 54 | + ) => Effect.Effect<readonly ToolId[]>; | |
| 55 | + | |
| 56 | + readonly removeByNamespace: ( | |
| 57 | + namespace: string, | |
| 58 | + ) => Effect.Effect<readonly ToolId[]>; | |
| 59 | + | |
| 60 | + readonly putSourceMeta: (meta: GoogleDiscoverySourceMeta) => Effect.Effect<void>; | |
| 61 | + readonly removeSourceMeta: (namespace: string) => Effect.Effect<void>; | |
| 62 | + readonly listSourceMeta: () => Effect.Effect<readonly GoogleDiscoverySourceMeta[]>; | |
| 63 | + | |
| 64 | + readonly putSourceData: ( | |
| 65 | + namespace: string, | |
| 66 | + data: GoogleDiscoveryStoredSourceData, | |
| 67 | + ) => Effect.Effect<void>; | |
| 68 | + readonly getSourceData: ( | |
| 69 | + namespace: string, | |
| 70 | + ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null>; | |
| 71 | + readonly removeSourceData: (namespace: string) => Effect.Effect<void>; | |
| 72 | +} | |
| 73 | + | |
| 74 | +const makeStore = ( | |
| 75 | + bindings: ScopedKv, | |
| 76 | + meta: ScopedKv, | |
| 77 | + config: ScopedKv, | |
| 78 | +): GoogleDiscoveryBindingStore => ({ | |
| 79 | + get: (toolId) => | |
| 80 | + Effect.gen(function* () { | |
| 81 | + const raw = yield* bindings.get(toolId); | |
| 82 | + if (!raw) return null; | |
| 83 | + const entry = decodeBindingEntry(raw); | |
| 84 | + return { | |
| 85 | + namespace: entry.namespace, | |
| 86 | + binding: entry.binding, | |
| 87 | + }; | |
| 88 | + }), | |
| 89 | + | |
| 90 | + put: (toolId, namespace, binding) => | |
| 91 | + bindings.set( | |
| 92 | + toolId, | |
| 93 | + encodeBindingEntry({ namespace, binding }), | |
| 94 | + ), | |
| 95 | + | |
| 96 | + listByNamespace: (namespace) => | |
| 97 | + Effect.gen(function* () { | |
| 98 | + const entries = yield* bindings.list(); | |
| 99 | + const ids: ToolId[] = []; | |
| 100 | + for (const entry of entries) { | |
| 101 | + const decoded = decodeBindingEntry(entry.value); | |
| 102 | + if (decoded.namespace === namespace) { | |
| 103 | + ids.push(entry.key as ToolId); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + return ids; | |
| 107 | + }), | |
| 108 | + | |
| 109 | + removeByNamespace: (namespace) => | |
| 110 | + Effect.gen(function* () { | |
| 111 | + const entries = yield* bindings.list(); | |
| 112 | + const ids: ToolId[] = []; | |
| 113 | + for (const entry of entries) { | |
| 114 | + const decoded = decodeBindingEntry(entry.value); | |
| 115 | + if (decoded.namespace === namespace) { | |
| 116 | + ids.push(entry.key as ToolId); | |
| 117 | + yield* bindings.delete(entry.key); | |
| 118 | + } | |
| 119 | + } | |
| 120 | + return ids; | |
| 121 | + }), | |
| 122 | + | |
| 123 | + putSourceMeta: (sourceMeta) => | |
| 124 | + meta.set(sourceMeta.namespace, JSON.stringify(sourceMeta)), | |
| 125 | + | |
| 126 | + removeSourceMeta: (namespace) => | |
| 127 | + meta.delete(namespace).pipe(Effect.asVoid), | |
| 128 | + | |
| 129 | + listSourceMeta: () => | |
| 130 | + Effect.gen(function* () { | |
| 131 | + const entries = yield* meta.list(); | |
| 132 | + return entries.map((entry) => JSON.parse(entry.value) as GoogleDiscoverySourceMeta); | |
| 133 | + }), | |
| 134 | + | |
| 135 | + putSourceData: (namespace, data) => | |
| 136 | + config.set(namespace, encodeSourceData(data)), | |
| 137 | + | |
| 138 | + getSourceData: (namespace) => | |
| 139 | + Effect.gen(function* () { | |
| 140 | + const raw = yield* config.get(namespace); | |
| 141 | + return raw ? decodeSourceData(raw) : null; | |
| 142 | + }), | |
| 143 | + | |
| 144 | + removeSourceData: (namespace) => | |
| 145 | + config.delete(namespace).pipe(Effect.asVoid), | |
| 146 | +}); | |
| 147 | + | |
| 148 | +export const makeKvBindingStore = ( | |
| 149 | + kv: Kv, | |
| 150 | + namespace: string, | |
| 151 | +): GoogleDiscoveryBindingStore => | |
| 152 | + makeStore( | |
| 153 | + scopeKv(kv, `${namespace}.bindings`), | |
| 154 | + scopeKv(kv, `${namespace}.sources`), | |
| 155 | + scopeKv(kv, `${namespace}.config`), | |
| 156 | + ); | |
| 157 | + | |
| 158 | +export const makeInMemoryBindingStore = (): GoogleDiscoveryBindingStore => | |
| 159 | + makeStore( | |
| 160 | + makeInMemoryScopedKv(), | |
| 161 | + makeInMemoryScopedKv(), | |
| 162 | + makeInMemoryScopedKv(), | |
| 163 | + ); | |
| @@ -0,0 +1,53 @@ | ||
| 1 | +import { readFileSync } from "node:fs"; | |
| 2 | +import { resolve } from "node:path"; | |
| 3 | + | |
| 4 | +import { Effect, Option } from "effect"; | |
| 5 | +import { describe, expect, it } from "vitest"; | |
| 6 | + | |
| 7 | +import { extractGoogleDiscoveryManifest } from "./document"; | |
| 8 | + | |
| 9 | +const fixturePath = resolve(__dirname, "../../fixtures/drive.json"); | |
| 10 | +const fixtureText = readFileSync(fixturePath, "utf8"); | |
| 11 | + | |
| 12 | +describe("Google Discovery document", () => { | |
| 13 | + it("extracts methods, refs, and oauth scopes", async () => { | |
| 14 | + const manifest = await Effect.runPromise( | |
| 15 | + extractGoogleDiscoveryManifest(fixtureText), | |
| 16 | + ); | |
| 17 | + | |
| 18 | + expect(Option.getOrElse(manifest.title, () => "")).toBe("Google Drive"); | |
| 19 | + expect(manifest.service).toBe("drive"); | |
| 20 | + expect(manifest.version).toBe("v3"); | |
| 21 | + expect(manifest.methods.map((method) => method.toolPath)).toEqual([ | |
| 22 | + "files.get", | |
| 23 | + "files.update", | |
| 24 | + ]); | |
| 25 | + expect(Object.keys(manifest.schemaDefinitions)).toEqual([ | |
| 26 | + "File", | |
| 27 | + "UpdateFileRequest", | |
| 28 | + ]); | |
| 29 | + | |
| 30 | + const getFile = manifest.methods.find((method) => method.toolPath === "files.get"); | |
| 31 | + expect(getFile).toBeDefined(); | |
| 32 | + expect(getFile!.binding.pathTemplate).toBe("files/{fileId}"); | |
| 33 | + expect(Option.isSome(getFile!.inputSchema)).toBe(true); | |
| 34 | + expect(Option.isSome(getFile!.outputSchema)).toBe(true); | |
| 35 | + | |
| 36 | + const inputSchema = Option.getOrThrow(getFile!.inputSchema) as Record<string, unknown>; | |
| 37 | + const properties = inputSchema.properties as Record<string, unknown>; | |
| 38 | + expect(properties.fileId).toMatchObject({ type: "string" }); | |
| 39 | + expect(properties.prettyPrint).toMatchObject({ type: "boolean" }); | |
| 40 | + | |
| 41 | + const updateFile = manifest.methods.find((method) => method.toolPath === "files.update"); | |
| 42 | + expect(updateFile).toBeDefined(); | |
| 43 | + const updateInputSchema = Option.getOrThrow(updateFile!.inputSchema) as Record< | |
| 44 | + string, | |
| 45 | + unknown | |
| 46 | + >; | |
| 47 | + expect(updateInputSchema.properties).toHaveProperty("body"); | |
| 48 | + | |
| 49 | + const scopes = Option.getOrElse(manifest.oauthScopes, () => ({})); | |
| 50 | + expect(Object.keys(scopes)).toContain("https://www.googleapis.com/auth/drive"); | |
| 51 | + expect(Object.keys(scopes)).toContain("https://www.googleapis.com/auth/drive.readonly"); | |
| 52 | + }); | |
| 53 | +}); | |
| @@ -0,0 +1,559 @@ | ||
| 1 | +import { Effect, Option, Schema } from "effect"; | |
| 2 | + | |
| 3 | +import { GoogleDiscoveryParseError } from "./errors"; | |
| 4 | +import { | |
| 5 | + GoogleDiscoveryHttpMethod, | |
| 6 | + GoogleDiscoveryManifest, | |
| 7 | + GoogleDiscoveryManifestMethod, | |
| 8 | + GoogleDiscoveryMethodBinding, | |
| 9 | + GoogleDiscoveryParameter, | |
| 10 | + GoogleDiscoveryParameterLocation, | |
| 11 | +} from "./types"; | |
| 12 | + | |
| 13 | +type JsonObject = Record<string, unknown>; | |
| 14 | + | |
| 15 | +const TrimmedString = Schema.String.pipe(Schema.compose(Schema.Trim)); | |
| 16 | +const Text = TrimmedString.pipe(Schema.compose(Schema.NonEmptyTrimmedString)); | |
| 17 | +const TextOption = Schema.optionalWith(Schema.OptionFromNonEmptyTrimmedString, { | |
| 18 | + default: () => Option.none(), | |
| 19 | +}); | |
| 20 | +const TextArray = Schema.optionalWith(Schema.Array(Text), { | |
| 21 | + default: () => [] as string[], | |
| 22 | +}); | |
| 23 | +const UnknownRecord = Schema.Record({ key: Schema.String, value: Schema.Unknown }); | |
| 24 | +const UnknownRecordWithDefault = Schema.optionalWith(UnknownRecord, { | |
| 25 | + default: () => ({}), | |
| 26 | +}); | |
| 27 | + | |
| 28 | +const DiscoveryHttpMethodInput = Schema.optional( | |
| 29 | + Schema.String.pipe( | |
| 30 | + Schema.compose(Schema.Trim), | |
| 31 | + Schema.compose(Schema.Lowercase), | |
| 32 | + Schema.compose(GoogleDiscoveryHttpMethod), | |
| 33 | + ), | |
| 34 | +); | |
| 35 | + | |
| 36 | +const DiscoveryParameterLocationInput = Schema.optional( | |
| 37 | + Schema.String.pipe( | |
| 38 | + Schema.compose(Schema.Trim), | |
| 39 | + Schema.compose(Schema.Lowercase), | |
| 40 | + Schema.compose(GoogleDiscoveryParameterLocation), | |
| 41 | + ), | |
| 42 | +); | |
| 43 | + | |
| 44 | +const DiscoveryDefaultValue = Schema.Union( | |
| 45 | + Schema.String, | |
| 46 | + Schema.Number, | |
| 47 | + Schema.Boolean, | |
| 48 | +); | |
| 49 | + | |
| 50 | +const DiscoverySchemaModel = Schema.Struct({ | |
| 51 | + type: Schema.optional( | |
| 52 | + Schema.String.pipe( | |
| 53 | + Schema.compose(Schema.Trim), | |
| 54 | + Schema.compose(Schema.Lowercase), | |
| 55 | + ), | |
| 56 | + ), | |
| 57 | + description: TextOption, | |
| 58 | + properties: UnknownRecordWithDefault, | |
| 59 | + items: Schema.optional(Schema.Unknown), | |
| 60 | + additionalProperties: Schema.optional(Schema.Union(Schema.Boolean, Schema.Unknown)), | |
| 61 | + enum: TextArray, | |
| 62 | + format: Schema.optional(Text), | |
| 63 | + readOnly: Schema.optional(Schema.Boolean), | |
| 64 | + default: Schema.optional(DiscoveryDefaultValue), | |
| 65 | + $ref: Schema.optional(Text), | |
| 66 | + required: TextArray, | |
| 67 | +}); | |
| 68 | +type DiscoverySchema = typeof DiscoverySchemaModel.Type; | |
| 69 | + | |
| 70 | +const DiscoveryParameterModel = Schema.Struct({ | |
| 71 | + type: Schema.optional( | |
| 72 | + Schema.String.pipe( | |
| 73 | + Schema.compose(Schema.Trim), | |
| 74 | + Schema.compose(Schema.Lowercase), | |
| 75 | + ), | |
| 76 | + ), | |
| 77 | + description: TextOption, | |
| 78 | + properties: UnknownRecordWithDefault, | |
| 79 | + items: Schema.optional(Schema.Unknown), | |
| 80 | + additionalProperties: Schema.optional(Schema.Union(Schema.Boolean, Schema.Unknown)), | |
| 81 | + enum: TextArray, | |
| 82 | + format: Schema.optional(Text), | |
| 83 | + readOnly: Schema.optional(Schema.Boolean), | |
| 84 | + default: Schema.optional(DiscoveryDefaultValue), | |
| 85 | + $ref: Schema.optional(Text), | |
| 86 | + location: DiscoveryParameterLocationInput, | |
| 87 | + required: Schema.optional(Schema.Boolean), | |
| 88 | + repeated: Schema.optional(Schema.Boolean), | |
| 89 | +}); | |
| 90 | +type DiscoveryParameter = typeof DiscoveryParameterModel.Type; | |
| 91 | + | |
| 92 | +const DiscoveryRefModel = Schema.Struct({ | |
| 93 | + $ref: Schema.optional(Text), | |
| 94 | +}); | |
| 95 | + | |
| 96 | +const DiscoveryMethodModel = Schema.Struct({ | |
| 97 | + id: TextOption, | |
| 98 | + description: TextOption, | |
| 99 | + httpMethod: DiscoveryHttpMethodInput, | |
| 100 | + path: TextOption, | |
| 101 | + parameters: UnknownRecordWithDefault, | |
| 102 | + request: Schema.optional(DiscoveryRefModel), | |
| 103 | + response: Schema.optional(DiscoveryRefModel), | |
| 104 | + scopes: TextArray, | |
| 105 | +}); | |
| 106 | +type DiscoveryMethod = typeof DiscoveryMethodModel.Type; | |
| 107 | + | |
| 108 | +const DiscoveryResourceModel = Schema.Struct({ | |
| 109 | + methods: UnknownRecordWithDefault, | |
| 110 | + resources: UnknownRecordWithDefault, | |
| 111 | +}); | |
| 112 | + | |
| 113 | +const DiscoveryDocumentModel = Schema.Struct({ | |
| 114 | + name: TextOption, | |
| 115 | + version: TextOption, | |
| 116 | + title: TextOption, | |
| 117 | + rootUrl: TextOption, | |
| 118 | + servicePath: Schema.optionalWith(TrimmedString, { default: () => "" }), | |
| 119 | + parameters: UnknownRecordWithDefault, | |
| 120 | + methods: UnknownRecordWithDefault, | |
| 121 | + resources: UnknownRecordWithDefault, | |
| 122 | + schemas: UnknownRecordWithDefault, | |
| 123 | + auth: Schema.optional( | |
| 124 | + Schema.Struct({ | |
| 125 | + oauth2: Schema.optional( | |
| 126 | + Schema.Struct({ | |
| 127 | + scopes: Schema.optionalWith( | |
| 128 | + Schema.Record({ | |
| 129 | + key: Schema.String, | |
| 130 | + value: Schema.Struct({ | |
| 131 | + description: TextOption, | |
| 132 | + }), | |
| 133 | + }), | |
| 134 | + { default: () => ({}) }, | |
| 135 | + ), | |
| 136 | + }), | |
| 137 | + ), | |
| 138 | + }), | |
| 139 | + ), | |
| 140 | +}); | |
| 141 | +type DiscoveryDocument = typeof DiscoveryDocumentModel.Type; | |
| 142 | + | |
| 143 | +const toParseError = (message: string, error: unknown) => | |
| 144 | + new GoogleDiscoveryParseError({ message, error }); | |
| 145 | + | |
| 146 | +const decodeUnknownWith = <A>( | |
| 147 | + message: string, | |
| 148 | + decode: (value: unknown) => A, | |
| 149 | +): ((value: unknown) => Effect.Effect<A, GoogleDiscoveryParseError>) => | |
| 150 | + (value) => | |
| 151 | + Effect.try({ | |
| 152 | + try: () => decode(value), | |
| 153 | + catch: (error) => toParseError(message, error), | |
| 154 | + }); | |
| 155 | + | |
| 156 | +const decodeDiscoveryDocument = decodeUnknownWith( | |
| 157 | + "Failed to decode Google Discovery document", | |
| 158 | + Schema.decodeUnknownSync(DiscoveryDocumentModel), | |
| 159 | +); | |
| 160 | + | |
| 161 | +const decodeDiscoveryDocumentJson = decodeUnknownWith( | |
| 162 | + "Failed to parse Google Discovery document", | |
| 163 | + Schema.decodeUnknownSync(Schema.parseJson(DiscoveryDocumentModel)), | |
| 164 | +); | |
| 165 | + | |
| 166 | +const decodeDiscoverySchema = decodeUnknownWith( | |
| 167 | + "Failed to decode Google Discovery schema", | |
| 168 | + Schema.decodeUnknownSync(DiscoverySchemaModel), | |
| 169 | +); | |
| 170 | + | |
| 171 | +const decodeDiscoveryParameter = decodeUnknownWith( | |
| 172 | + "Failed to decode Google Discovery parameter", | |
| 173 | + Schema.decodeUnknownSync(DiscoveryParameterModel), | |
| 174 | +); | |
| 175 | + | |
| 176 | +const decodeDiscoveryMethod = decodeUnknownWith( | |
| 177 | + "Failed to decode Google Discovery method", | |
| 178 | + Schema.decodeUnknownSync(DiscoveryMethodModel), | |
| 179 | +); | |
| 180 | + | |
| 181 | +const decodeDiscoveryResource = decodeUnknownWith( | |
| 182 | + "Failed to decode Google Discovery resource", | |
| 183 | + Schema.decodeUnknownSync(DiscoveryResourceModel), | |
| 184 | +); | |
| 185 | + | |
| 186 | +const schemaRef = (name: string) => `#/$defs/${name}`; | |
| 187 | + | |
| 188 | +const toJsonSchemaSeed = (input: { | |
| 189 | + type: DiscoverySchema["type"]; | |
| 190 | + description: DiscoverySchema["description"]; | |
| 191 | + properties: DiscoverySchema["properties"]; | |
| 192 | + items: DiscoverySchema["items"]; | |
| 193 | + additionalProperties: DiscoverySchema["additionalProperties"]; | |
| 194 | + enum: DiscoverySchema["enum"]; | |
| 195 | + format: DiscoverySchema["format"]; | |
| 196 | + readOnly: DiscoverySchema["readOnly"]; | |
| 197 | + default: DiscoverySchema["default"]; | |
| 198 | + $ref: DiscoverySchema["$ref"]; | |
| 199 | + required: DiscoverySchema["required"]; | |
| 200 | +}): DiscoverySchema => ({ | |
| 201 | + type: input.type, | |
| 202 | + description: input.description, | |
| 203 | + properties: input.properties, | |
| 204 | + items: input.items, | |
| 205 | + additionalProperties: input.additionalProperties, | |
| 206 | + enum: input.enum, | |
| 207 | + format: input.format, | |
| 208 | + readOnly: input.readOnly, | |
| 209 | + default: input.default, | |
| 210 | + $ref: input.$ref, | |
| 211 | + required: input.required, | |
| 212 | +}); | |
| 213 | + | |
| 214 | +const discoverySchemaToJsonSchema = ( | |
| 215 | + schema: DiscoverySchema, | |
| 216 | +): Effect.Effect<unknown, GoogleDiscoveryParseError> => | |
| 217 | + Effect.gen(function* () { | |
| 218 | + const ref = schema.$ref; | |
| 219 | + if (ref) { | |
| 220 | + return { $ref: schemaRef(ref) }; | |
| 221 | + } | |
| 222 | + | |
| 223 | + const description = Option.getOrUndefined(schema.description); | |
| 224 | + const base: Record<string, unknown> = { | |
| 225 | + ...(description ? { description } : {}), | |
| 226 | + ...(schema.format ? { format: schema.format } : {}), | |
| 227 | + ...(schema.enum.length > 0 ? { enum: schema.enum } : {}), | |
| 228 | + ...(schema.readOnly === true ? { readOnly: true } : {}), | |
| 229 | + ...(schema.default !== undefined ? { default: schema.default } : {}), | |
| 230 | + }; | |
| 231 | + | |
| 232 | + if (schema.type === "array") { | |
| 233 | + return { | |
| 234 | + ...base, | |
| 235 | + type: "array", | |
| 236 | + items: yield* googleSchemaToJsonSchema(schema.items), | |
| 237 | + }; | |
| 238 | + } | |
| 239 | + | |
| 240 | + const properties = schema.properties; | |
| 241 | + const additionalProperties = schema.additionalProperties; | |
| 242 | + if ( | |
| 243 | + schema.type === "object" || | |
| 244 | + Object.keys(properties).length > 0 || | |
| 245 | + additionalProperties !== undefined | |
| 246 | + ) { | |
| 247 | + const convertedProperties = Object.fromEntries( | |
| 248 | + yield* Effect.forEach( | |
| 249 | + Object.entries(properties), | |
| 250 | + ([name, property]) => | |
| 251 | + googleSchemaToJsonSchema(property).pipe( | |
| 252 | + Effect.map((jsonSchema) => [name, jsonSchema] as const), | |
| 253 | + ), | |
| 254 | + ), | |
| 255 | + ); | |
| 256 | + | |
| 257 | + const convertedAdditionalProperties = | |
| 258 | + additionalProperties === undefined | |
| 259 | + ? undefined | |
| 260 | + : additionalProperties === true | |
| 261 | + ? true | |
| 262 | + : yield* googleSchemaToJsonSchema(additionalProperties); | |
| 263 | + | |
| 264 | + return { | |
| 265 | + ...base, | |
| 266 | + type: "object", | |
| 267 | + ...(Object.keys(convertedProperties).length > 0 | |
| 268 | + ? { properties: convertedProperties } | |
| 269 | + : {}), | |
| 270 | + ...(schema.required.length > 0 ? { required: schema.required } : {}), | |
| 271 | + ...(convertedAdditionalProperties !== undefined | |
| 272 | + ? { additionalProperties: convertedAdditionalProperties } | |
| 273 | + : {}), | |
| 274 | + }; | |
| 275 | + } | |
| 276 | + | |
| 277 | + if ( | |
| 278 | + schema.type === "boolean" || | |
| 279 | + schema.type === "integer" || | |
| 280 | + schema.type === "number" || | |
| 281 | + schema.type === "string" | |
| 282 | + ) { | |
| 283 | + return { ...base, type: schema.type }; | |
| 284 | + } | |
| 285 | + | |
| 286 | + if (schema.type === "any") return base; | |
| 287 | + | |
| 288 | + return Object.keys(base).length > 0 ? base : {}; | |
| 289 | + }); | |
| 290 | + | |
| 291 | +const googleSchemaToJsonSchema = ( | |
| 292 | + rawSchema: unknown, | |
| 293 | +): Effect.Effect<unknown, GoogleDiscoveryParseError> => | |
| 294 | + rawSchema === undefined | |
| 295 | + ? Effect.succeed({}) | |
| 296 | + : decodeDiscoverySchema(rawSchema).pipe( | |
| 297 | + Effect.flatMap(discoverySchemaToJsonSchema), | |
| 298 | + ); | |
| 299 | + | |
| 300 | +const parameterToJsonSchema = ( | |
| 301 | + parameter: DiscoveryParameter, | |
| 302 | +): Effect.Effect<unknown, GoogleDiscoveryParseError> => | |
| 303 | + parameter.repeated === true | |
| 304 | + ? discoverySchemaToJsonSchema( | |
| 305 | + toJsonSchemaSeed({ | |
| 306 | + type: parameter.type, | |
| 307 | + description: parameter.description, | |
| 308 | + properties: parameter.properties, | |
| 309 | + items: parameter.items, | |
| 310 | + additionalProperties: parameter.additionalProperties, | |
| 311 | + enum: parameter.enum, | |
| 312 | + format: parameter.format, | |
| 313 | + readOnly: parameter.readOnly, | |
| 314 | + default: parameter.default, | |
| 315 | + $ref: parameter.$ref, | |
| 316 | + required: [], | |
| 317 | + }), | |
| 318 | + ).pipe( | |
| 319 | + Effect.map((items) => ({ | |
| 320 | + type: "array", | |
| 321 | + items, | |
| 322 | + })), | |
| 323 | + ) | |
| 324 | + : discoverySchemaToJsonSchema( | |
| 325 | + toJsonSchemaSeed({ | |
| 326 | + type: parameter.type, | |
| 327 | + description: parameter.description, | |
| 328 | + properties: parameter.properties, | |
| 329 | + items: parameter.items, | |
| 330 | + additionalProperties: parameter.additionalProperties, | |
| 331 | + enum: parameter.enum, | |
| 332 | + format: parameter.format, | |
| 333 | + readOnly: parameter.readOnly, | |
| 334 | + default: parameter.default, | |
| 335 | + $ref: parameter.$ref, | |
| 336 | + required: [], | |
| 337 | + }), | |
| 338 | + ); | |
| 339 | + | |
| 340 | +const toToolPath = (service: string, methodId: string): string => { | |
| 341 | + const withoutPrefix = methodId.startsWith(`${service}.`) | |
| 342 | + ? methodId.slice(service.length + 1) | |
| 343 | + : methodId; | |
| 344 | + return withoutPrefix.trim(); | |
| 345 | +}; | |
| 346 | + | |
| 347 | +const toParameter = ( | |
| 348 | + name: string, | |
| 349 | + rawParameter: unknown, | |
| 350 | +): Effect.Effect<GoogleDiscoveryParameter | null, GoogleDiscoveryParseError> => | |
| 351 | + Effect.gen(function* () { | |
| 352 | + const parameter = yield* decodeDiscoveryParameter(rawParameter); | |
| 353 | + if (parameter.location === undefined) return null; | |
| 354 | + | |
| 355 | + return new GoogleDiscoveryParameter({ | |
| 356 | + name, | |
| 357 | + location: parameter.location, | |
| 358 | + required: parameter.required === true, | |
| 359 | + repeated: parameter.repeated === true, | |
| 360 | + description: parameter.description, | |
| 361 | + schema: Option.some(yield* parameterToJsonSchema(parameter)), | |
| 362 | + }); | |
| 363 | + }); | |
| 364 | + | |
| 365 | +const mergeParameters = (input: { | |
| 366 | + globalParameters: Readonly<Record<string, unknown>>; | |
| 367 | + method: DiscoveryMethod; | |
| 368 | +}): Effect.Effect<GoogleDiscoveryParameter[], GoogleDiscoveryParseError> => | |
| 369 | + Effect.gen(function* () { | |
| 370 | + const merged = new Map<string, GoogleDiscoveryParameter>(); | |
| 371 | + | |
| 372 | + for (const [name, parameter] of Object.entries(input.globalParameters)) { | |
| 373 | + const converted = yield* toParameter(name, parameter); | |
| 374 | + if (converted) merged.set(name, converted); | |
| 375 | + } | |
| 376 | + | |
| 377 | + for (const [name, parameter] of Object.entries(input.method.parameters)) { | |
| 378 | + const converted = yield* toParameter(name, parameter); | |
| 379 | + if (converted) merged.set(name, converted); | |
| 380 | + } | |
| 381 | + | |
| 382 | + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); | |
| 383 | + }); | |
| 384 | + | |
| 385 | +const buildInputSchema = (input: { | |
| 386 | + parameters: readonly GoogleDiscoveryParameter[]; | |
| 387 | + requestRef: string | undefined; | |
| 388 | +}): unknown | undefined => { | |
| 389 | + const properties: Record<string, unknown> = {}; | |
| 390 | + const required: string[] = []; | |
| 391 | + | |
| 392 | + for (const parameter of input.parameters) { | |
| 393 | + properties[parameter.name] = Option.getOrElse(parameter.schema, () => ({ | |
| 394 | + type: "string", | |
| 395 | + })); | |
| 396 | + if (parameter.required) required.push(parameter.name); | |
| 397 | + } | |
| 398 | + | |
| 399 | + if (input.requestRef) { | |
| 400 | + properties.body = { $ref: schemaRef(input.requestRef) }; | |
| 401 | + } | |
| 402 | + | |
| 403 | + if (Object.keys(properties).length === 0) return undefined; | |
| 404 | + | |
| 405 | + return { | |
| 406 | + type: "object", | |
| 407 | + properties, | |
| 408 | + ...(required.length > 0 ? { required } : {}), | |
| 409 | + additionalProperties: false, | |
| 410 | + }; | |
| 411 | +}; | |
| 412 | + | |
| 413 | +const extractScopes = ( | |
| 414 | + document: DiscoveryDocument, | |
| 415 | +): Record<string, string> | undefined => { | |
| 416 | + const scopes = document.auth?.oauth2?.scopes ?? {}; | |
| 417 | + const normalized = Object.fromEntries( | |
| 418 | + Object.entries(scopes).map(([scope, value]) => [ | |
| 419 | + scope, | |
| 420 | + Option.getOrElse(value.description, () => ""), | |
| 421 | + ]), | |
| 422 | + ); | |
| 423 | + return Object.keys(normalized).length > 0 ? normalized : undefined; | |
| 424 | +}; | |
| 425 | + | |
| 426 | +const manifestMethodFromMethod = (input: { | |
| 427 | + service: string; | |
| 428 | + rawMethod: unknown; | |
| 429 | + globalParameters: Readonly<Record<string, unknown>>; | |
| 430 | +}): Effect.Effect<GoogleDiscoveryManifestMethod | null, GoogleDiscoveryParseError> => | |
| 431 | + Effect.gen(function* () { | |
| 432 | + const method = yield* decodeDiscoveryMethod(input.rawMethod); | |
| 433 | + const methodId = Option.getOrUndefined(method.id); | |
| 434 | + const path = Option.getOrUndefined(method.path); | |
| 435 | + if (!methodId || !path) return null; | |
| 436 | + if (!method.httpMethod) { | |
| 437 | + return yield* new GoogleDiscoveryParseError({ | |
| 438 | + message: `Google Discovery method '${methodId}' is missing httpMethod`, | |
| 439 | + error: new Error(`Missing httpMethod for ${methodId}`), | |
| 440 | + }); | |
| 441 | + } | |
| 442 | + | |
| 443 | + const requestRef = method.request?.$ref; | |
| 444 | + const responseRef = method.response?.$ref; | |
| 445 | + const parameters = yield* mergeParameters({ | |
| 446 | + globalParameters: input.globalParameters, | |
| 447 | + method, | |
| 448 | + }); | |
| 449 | + | |
| 450 | + return new GoogleDiscoveryManifestMethod({ | |
| 451 | + toolPath: toToolPath(input.service, methodId), | |
| 452 | + description: method.description, | |
| 453 | + binding: new GoogleDiscoveryMethodBinding({ | |
| 454 | + method: method.httpMethod, | |
| 455 | + pathTemplate: path, | |
| 456 | + parameters, | |
| 457 | + hasBody: requestRef !== undefined, | |
| 458 | + }), | |
| 459 | + inputSchema: Option.fromNullable( | |
| 460 | + buildInputSchema({ parameters, requestRef }), | |
| 461 | + ), | |
| 462 | + outputSchema: Option.fromNullable( | |
| 463 | + responseRef ? { $ref: schemaRef(responseRef) } : undefined, | |
| 464 | + ), | |
| 465 | + scopes: method.scopes, | |
| 466 | + }); | |
| 467 | + }); | |
| 468 | + | |
| 469 | +const collectMethods = (input: { | |
| 470 | + service: string; | |
| 471 | + rawResource: unknown; | |
| 472 | + globalParameters: Readonly<Record<string, unknown>>; | |
| 473 | +}): Effect.Effect<GoogleDiscoveryManifestMethod[], GoogleDiscoveryParseError> => | |
| 474 | + Effect.gen(function* () { | |
| 475 | + const resource = yield* decodeDiscoveryResource(input.rawResource); | |
| 476 | + const methods = yield* Effect.forEach( | |
| 477 | + Object.values(resource.methods), | |
| 478 | + (rawMethod) => | |
| 479 | + manifestMethodFromMethod({ | |
| 480 | + service: input.service, | |
| 481 | + rawMethod, | |
| 482 | + globalParameters: input.globalParameters, | |
| 483 | + }), | |
| 484 | + ); | |
| 485 | + const nested = yield* Effect.forEach( | |
| 486 | + Object.values(resource.resources), | |
| 487 | + (rawResource) => | |
| 488 | + collectMethods({ | |
| 489 | + ...input, | |
| 490 | + rawResource, | |
| 491 | + }), | |
| 492 | + ); | |
| 493 | + | |
| 494 | + return [...methods.flatMap((method) => (method ? [method] : [])), ...nested.flat()]; | |
| 495 | + }); | |
| 496 | + | |
| 497 | +export const extractGoogleDiscoveryManifest = Effect.fn( | |
| 498 | + "GoogleDiscovery.extractManifest", | |
| 499 | +)(function* (discoveryDocument: string | JsonObject) { | |
| 500 | + const document = | |
| 501 | + typeof discoveryDocument === "string" | |
| 502 | + ? yield* decodeDiscoveryDocumentJson(discoveryDocument) | |
| 503 | + : yield* decodeDiscoveryDocument(discoveryDocument); | |
| 504 | + | |
| 505 | + const service = Option.getOrUndefined(document.name); | |
| 506 | + const version = Option.getOrUndefined(document.version); | |
| 507 | + const rootUrl = Option.getOrUndefined(document.rootUrl); | |
| 508 | + if (!service || !version || !rootUrl) { | |
| 509 | + return yield* new GoogleDiscoveryParseError({ | |
| 510 | + message: | |
| 511 | + "Google Discovery document is missing one of: name, version, rootUrl", | |
| 512 | + error: new Error("Invalid document"), | |
| 513 | + }); | |
| 514 | + } | |
| 515 | + | |
| 516 | + const schemaDefinitions = Object.fromEntries( | |
| 517 | + yield* Effect.forEach( | |
| 518 | + Object.entries(document.schemas), | |
| 519 | + ([name, rawSchema]) => | |
| 520 | + googleSchemaToJsonSchema(rawSchema).pipe( | |
| 521 | + Effect.map((schema) => [name, schema] as const), | |
| 522 | + ), | |
| 523 | + ), | |
| 524 | + ); | |
| 525 | + | |
| 526 | + const topLevelMethods = yield* Effect.forEach( | |
| 527 | + Object.values(document.methods), | |
| 528 | + (rawMethod) => | |
| 529 | + manifestMethodFromMethod({ | |
| 530 | + service, | |
| 531 | + rawMethod, | |
| 532 | + globalParameters: document.parameters, | |
| 533 | + }), | |
| 534 | + ); | |
| 535 | + | |
| 536 | + const nestedMethods = yield* Effect.forEach( | |
| 537 | + Object.values(document.resources), | |
| 538 | + (rawResource) => | |
| 539 | + collectMethods({ | |
| 540 | + service, | |
| 541 | + rawResource, | |
| 542 | + globalParameters: document.parameters, | |
| 543 | + }), | |
| 544 | + ); | |
| 545 | + | |
| 546 | + return new GoogleDiscoveryManifest({ | |
| 547 | + title: document.title, | |
| 548 | + service, | |
| 549 | + version, | |
| 550 | + rootUrl, | |
| 551 | + servicePath: document.servicePath, | |
| 552 | + oauthScopes: Option.fromNullable(extractScopes(document)), | |
| 553 | + schemaDefinitions, | |
| 554 | + methods: [ | |
| 555 | + ...topLevelMethods.flatMap((method) => (method ? [method] : [])), | |
| 556 | + ...nestedMethods.flat(), | |
| 557 | + ].sort((a, b) => a.toolPath.localeCompare(b.toolPath)), | |
| 558 | + }); | |
| 559 | +}); | |
| @@ -0,0 +1,32 @@ | ||
| 1 | +import { Schema } from "effect"; | |
| 2 | + | |
| 3 | +export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()( | |
| 4 | + "GoogleDiscoveryParseError", | |
| 5 | + { | |
| 6 | + message: Schema.String, | |
| 7 | + error: Schema.Defect, | |
| 8 | + }, | |
| 9 | +) {} | |
| 10 | + | |
| 11 | +export class GoogleDiscoveryInvocationError extends Schema.TaggedError<GoogleDiscoveryInvocationError>()( | |
| 12 | + "GoogleDiscoveryInvocationError", | |
| 13 | + { | |
| 14 | + message: Schema.String, | |
| 15 | + statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }), | |
| 16 | + error: Schema.Defect, | |
| 17 | + }, | |
| 18 | +) {} | |
| 19 | + | |
| 20 | +export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscoveryOAuthError>()( | |
| 21 | + "GoogleDiscoveryOAuthError", | |
| 22 | + { | |
| 23 | + message: Schema.String, | |
| 24 | + }, | |
| 25 | +) {} | |
| 26 | + | |
| 27 | +export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()( | |
| 28 | + "GoogleDiscoverySourceError", | |
| 29 | + { | |
| 30 | + message: Schema.String, | |
| 31 | + }, | |
| 32 | +) {} | |
| @@ -0,0 +1,40 @@ | ||
| 1 | +export { googleDiscoveryPlugin } from "./plugin"; | |
| 2 | +export type { | |
| 3 | + GoogleDiscoveryAddSourceInput, | |
| 4 | + GoogleDiscoveryOAuthAuthResult, | |
| 5 | + GoogleDiscoveryOAuthCompleteInput, | |
| 6 | + GoogleDiscoveryOAuthStartInput, | |
| 7 | + GoogleDiscoveryOAuthStartResponse, | |
| 8 | + GoogleDiscoveryPluginExtension, | |
| 9 | + GoogleDiscoveryProbeResult, | |
| 10 | +} from "./plugin"; | |
| 11 | +export { extractGoogleDiscoveryManifest } from "./document"; | |
| 12 | +export { makeKvBindingStore, makeInMemoryBindingStore } from "./binding-store"; | |
| 13 | +export type { GoogleDiscoveryBindingStore } from "./binding-store"; | |
| 14 | +export { makeGoogleDiscoveryInvoker } from "./invoke"; | |
| 15 | +export { | |
| 16 | + buildGoogleAuthorizationUrl, | |
| 17 | + createPkceCodeVerifier, | |
| 18 | + exchangeAuthorizationCode, | |
| 19 | + refreshAccessToken, | |
| 20 | +} from "./oauth"; | |
| 21 | +export { | |
| 22 | + GoogleDiscoveryAuth, | |
| 23 | + GoogleDiscoveryHttpMethod, | |
| 24 | + GoogleDiscoveryInvocationResult, | |
| 25 | + GoogleDiscoveryManifest, | |
| 26 | + GoogleDiscoveryManifestMethod, | |
| 27 | + GoogleDiscoveryMethodBinding, | |
| 28 | + GoogleDiscoveryParameter, | |
| 29 | + GoogleDiscoveryParameterLocation, | |
| 30 | + GoogleDiscoveryStoredSourceData, | |
| 31 | +} from "./types"; | |
| 32 | +export type { | |
| 33 | + GoogleDiscoveryOAuthSession, | |
| 34 | +} from "./types"; | |
| 35 | +export { | |
| 36 | + GoogleDiscoveryInvocationError, | |
| 37 | + GoogleDiscoveryOAuthError, | |
| 38 | + GoogleDiscoveryParseError, | |
| 39 | + GoogleDiscoverySourceError, | |
| 40 | +} from "./errors"; | |
| @@ -0,0 +1,446 @@ | ||
| 1 | +import { Effect, Layer, Option } from "effect"; | |
| 2 | +import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + type ScopeId, | |
| 6 | + type SecretId, | |
| 7 | + type ToolId, | |
| 8 | + ToolInvocationError, | |
| 9 | + ToolInvocationResult, | |
| 10 | + type ToolInvoker, | |
| 11 | +} from "@executor/sdk"; | |
| 12 | + | |
| 13 | +import { GoogleDiscoveryInvocationError } from "./errors"; | |
| 14 | +import type { GoogleDiscoveryBindingStore } from "./binding-store"; | |
| 15 | +import { | |
| 16 | + GoogleDiscoveryInvocationResult, | |
| 17 | + GoogleDiscoveryStoredSourceData, | |
| 18 | + type GoogleDiscoveryParameter, | |
| 19 | +} from "./types"; | |
| 20 | +import { refreshAccessToken } from "./oauth"; | |
| 21 | + | |
| 22 | +const OAUTH_REFRESH_SKEW_MS = 60_000; | |
| 23 | + | |
| 24 | +const SAFE_METHODS = new Set(["get", "head", "options"]); | |
| 25 | + | |
| 26 | +const stringValuesFromParameter = ( | |
| 27 | + value: unknown, | |
| 28 | + repeated: boolean, | |
| 29 | +): string[] => { | |
| 30 | + if (value === undefined || value === null) { | |
| 31 | + return []; | |
| 32 | + } | |
| 33 | + if (Array.isArray(value)) { | |
| 34 | + const normalized = value.flatMap((entry) => | |
| 35 | + entry === undefined || entry === null ? [] : [String(entry)], | |
| 36 | + ); | |
| 37 | + return repeated ? normalized : [normalized.join(",")]; | |
| 38 | + } | |
| 39 | + if ( | |
| 40 | + typeof value === "string" || | |
| 41 | + typeof value === "number" || | |
| 42 | + typeof value === "boolean" | |
| 43 | + ) { | |
| 44 | + return [String(value)]; | |
| 45 | + } | |
| 46 | + return [JSON.stringify(value)]; | |
| 47 | +}; | |
| 48 | + | |
| 49 | +const replacePathParameters = (input: { | |
| 50 | + pathTemplate: string; | |
| 51 | + args: Record<string, unknown>; | |
| 52 | + parameters: readonly GoogleDiscoveryParameter[]; | |
| 53 | +}): string => | |
| 54 | + input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { | |
| 55 | + const parameter = input.parameters.find( | |
| 56 | + (entry) => entry.location === "path" && entry.name === name, | |
| 57 | + ); | |
| 58 | + const values = stringValuesFromParameter(input.args[name], false); | |
| 59 | + if (values.length === 0) { | |
| 60 | + if (parameter?.required) { | |
| 61 | + throw new Error(`Missing required path parameter: ${name}`); | |
| 62 | + } | |
| 63 | + return ""; | |
| 64 | + } | |
| 65 | + return encodeURIComponent(values[0]!); | |
| 66 | + }); | |
| 67 | + | |
| 68 | +const resolveBaseUrl = (source: GoogleDiscoveryStoredSourceData): string => | |
| 69 | + new URL(source.servicePath || "", source.rootUrl).toString(); | |
| 70 | + | |
| 71 | +const isJsonContentType = (contentType: string | null | undefined): boolean => { | |
| 72 | + if (!contentType) return false; | |
| 73 | + const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? ""; | |
| 74 | + return ( | |
| 75 | + normalized === "application/json" || | |
| 76 | + normalized.includes("+json") || | |
| 77 | + normalized.includes("json") | |
| 78 | + ); | |
| 79 | +}; | |
| 80 | + | |
| 81 | +const resolveOAuthAccessToken = (input: { | |
| 82 | + sourceId: string; | |
| 83 | + source: GoogleDiscoveryStoredSourceData; | |
| 84 | + secrets: { | |
| 85 | + readonly resolve: ( | |
| 86 | + secretId: SecretId, | |
| 87 | + scopeId: ScopeId, | |
| 88 | + ) => Effect.Effect<string, unknown>; | |
| 89 | + readonly set: (input: { | |
| 90 | + id: SecretId; | |
| 91 | + scopeId: ScopeId; | |
| 92 | + name: string; | |
| 93 | + value: string; | |
| 94 | + purpose?: string; | |
| 95 | + }) => Effect.Effect<unknown, unknown>; | |
| 96 | + }; | |
| 97 | + scopeId: ScopeId; | |
| 98 | + bindingStore: GoogleDiscoveryBindingStore; | |
| 99 | +}): Effect.Effect<string, ToolInvocationError> => | |
| 100 | + Effect.gen(function* () { | |
| 101 | + if (input.source.auth.kind !== "oauth2") { | |
| 102 | + return ""; | |
| 103 | + } | |
| 104 | + | |
| 105 | + const auth = input.source.auth; | |
| 106 | + const now = Date.now(); | |
| 107 | + const needsRefresh = | |
| 108 | + auth.refreshTokenSecretId !== null && | |
| 109 | + auth.expiresAt !== null && | |
| 110 | + auth.expiresAt <= now + OAUTH_REFRESH_SKEW_MS; | |
| 111 | + | |
| 112 | + if (!needsRefresh) { | |
| 113 | + return yield* input.secrets | |
| 114 | + .resolve(auth.accessTokenSecretId as SecretId, input.scopeId) | |
| 115 | + .pipe( | |
| 116 | + Effect.mapError( | |
| 117 | + () => | |
| 118 | + new ToolInvocationError({ | |
| 119 | + toolId: "" as ToolId, | |
| 120 | + message: "Failed to resolve Google OAuth access token", | |
| 121 | + cause: undefined, | |
| 122 | + }), | |
| 123 | + ), | |
| 124 | + ); | |
| 125 | + } | |
| 126 | + | |
| 127 | + const refreshToken = yield* input.secrets | |
| 128 | + .resolve(auth.refreshTokenSecretId as SecretId, input.scopeId) | |
| 129 | + .pipe( | |
| 130 | + Effect.mapError( | |
| 131 | + () => | |
| 132 | + new ToolInvocationError({ | |
| 133 | + toolId: "" as ToolId, | |
| 134 | + message: "Failed to resolve Google OAuth refresh token", | |
| 135 | + cause: undefined, | |
| 136 | + }), | |
| 137 | + ), | |
| 138 | + ); | |
| 139 | + | |
| 140 | + const clientSecret = | |
| 141 | + auth.clientSecretSecretId === null | |
| 142 | + ? null | |
| 143 | + : yield* input.secrets | |
| 144 | + .resolve(auth.clientSecretSecretId as SecretId, input.scopeId) | |
| 145 | + .pipe( | |
| 146 | + Effect.mapError( | |
| 147 | + () => | |
| 148 | + new ToolInvocationError({ | |
| 149 | + toolId: "" as ToolId, | |
| 150 | + message: "Failed to resolve Google OAuth client secret", | |
| 151 | + cause: undefined, | |
| 152 | + }), | |
| 153 | + ), | |
| 154 | + ); | |
| 155 | + | |
| 156 | + const refreshed = yield* refreshAccessToken({ | |
| 157 | + clientId: auth.clientId, | |
| 158 | + clientSecret, | |
| 159 | + refreshToken, | |
| 160 | + scopes: auth.scopes, | |
| 161 | + }).pipe( | |
| 162 | + Effect.mapError( | |
| 163 | + (error) => | |
| 164 | + new ToolInvocationError({ | |
| 165 | + toolId: "" as ToolId, | |
| 166 | + message: error.message, | |
| 167 | + cause: undefined, | |
| 168 | + }), | |
| 169 | + ), | |
| 170 | + ); | |
| 171 | + | |
| 172 | + yield* input.secrets.set({ | |
| 173 | + id: auth.accessTokenSecretId as SecretId, | |
| 174 | + scopeId: input.scopeId, | |
| 175 | + name: `${input.source.name} Access Token`, | |
| 176 | + value: refreshed.access_token, | |
| 177 | + purpose: "google_oauth_access_token", | |
| 178 | + }).pipe( | |
| 179 | + Effect.mapError( | |
| 180 | + () => | |
| 181 | + new ToolInvocationError({ | |
| 182 | + toolId: "" as ToolId, | |
| 183 | + message: "Failed to persist refreshed Google OAuth access token", | |
| 184 | + cause: undefined, | |
| 185 | + }), | |
| 186 | + ), | |
| 187 | + ); | |
| 188 | + | |
| 189 | + let refreshTokenSecretId = auth.refreshTokenSecretId; | |
| 190 | + if (refreshed.refresh_token && auth.refreshTokenSecretId) { | |
| 191 | + yield* input.secrets.set({ | |
| 192 | + id: auth.refreshTokenSecretId as SecretId, | |
| 193 | + scopeId: input.scopeId, | |
| 194 | + name: `${input.source.name} Refresh Token`, | |
| 195 | + value: refreshed.refresh_token, | |
| 196 | + purpose: "google_oauth_refresh_token", | |
| 197 | + }).pipe( | |
| 198 | + Effect.mapError( | |
| 199 | + () => | |
| 200 | + new ToolInvocationError({ | |
| 201 | + toolId: "" as ToolId, | |
| 202 | + message: "Failed to persist refreshed Google OAuth refresh token", | |
| 203 | + cause: undefined, | |
| 204 | + }), | |
| 205 | + ), | |
| 206 | + ); | |
| 207 | + refreshTokenSecretId = auth.refreshTokenSecretId; | |
| 208 | + } | |
| 209 | + | |
| 210 | + const updatedSource = new GoogleDiscoveryStoredSourceData({ | |
| 211 | + ...input.source, | |
| 212 | + auth: { | |
| 213 | + kind: "oauth2", | |
| 214 | + clientId: auth.clientId, | |
| 215 | + clientSecretSecretId: auth.clientSecretSecretId, | |
| 216 | + accessTokenSecretId: auth.accessTokenSecretId, | |
| 217 | + refreshTokenSecretId, | |
| 218 | + tokenType: refreshed.token_type ?? auth.tokenType, | |
| 219 | + expiresAt: | |
| 220 | + typeof refreshed.expires_in === "number" | |
| 221 | + ? Date.now() + refreshed.expires_in * 1000 | |
| 222 | + : auth.expiresAt, | |
| 223 | + scope: refreshed.scope ?? auth.scope, | |
| 224 | + scopes: auth.scopes, | |
| 225 | + }, | |
| 226 | + }); | |
| 227 | + yield* input.bindingStore.putSourceData(input.sourceId, updatedSource); | |
| 228 | + | |
| 229 | + return refreshed.access_token; | |
| 230 | + }); | |
| 231 | + | |
| 232 | +export const annotationsForOperation = (method: string, pathTemplate: string): { | |
| 233 | + requiresApproval?: boolean; | |
| 234 | + approvalDescription?: string; | |
| 235 | +} => { | |
| 236 | + if (SAFE_METHODS.has(method.toLowerCase())) return {}; | |
| 237 | + return { | |
| 238 | + requiresApproval: true, | |
| 239 | + approvalDescription: `${method.toUpperCase()} ${pathTemplate}`, | |
| 240 | + }; | |
| 241 | +}; | |
| 242 | + | |
| 243 | +const invoke = Effect.fn("GoogleDiscovery.invoke")(function* (input: { | |
| 244 | + method: string; | |
| 245 | + pathTemplate: string; | |
| 246 | + parameters: readonly GoogleDiscoveryParameter[]; | |
| 247 | + hasBody: boolean; | |
| 248 | + source: GoogleDiscoveryStoredSourceData; | |
| 249 | + args: Record<string, unknown>; | |
| 250 | + authorizationHeader?: string; | |
| 251 | +}) { | |
| 252 | + const client = yield* HttpClient.HttpClient; | |
| 253 | + | |
| 254 | + const resolvedPath = replacePathParameters({ | |
| 255 | + pathTemplate: input.pathTemplate, | |
| 256 | + args: input.args, | |
| 257 | + parameters: input.parameters, | |
| 258 | + }); | |
| 259 | + const requestUrl = new URL( | |
| 260 | + resolvedPath.replace(/^\//, ""), | |
| 261 | + resolveBaseUrl(input.source), | |
| 262 | + ); | |
| 263 | + | |
| 264 | + for (const parameter of input.parameters) { | |
| 265 | + if (parameter.location === "path") continue; | |
| 266 | + | |
| 267 | + const values = stringValuesFromParameter( | |
| 268 | + input.args[parameter.name], | |
| 269 | + parameter.repeated, | |
| 270 | + ); | |
| 271 | + if (values.length === 0) { | |
| 272 | + if (parameter.required) { | |
| 273 | + return yield* new GoogleDiscoveryInvocationError({ | |
| 274 | + message: `Missing required ${parameter.location} parameter: ${parameter.name}`, | |
| 275 | + statusCode: Option.none(), | |
| 276 | + error: undefined, | |
| 277 | + }); | |
| 278 | + } | |
| 279 | + continue; | |
| 280 | + } | |
| 281 | + | |
| 282 | + if (parameter.location === "query") { | |
| 283 | + for (const value of values) { | |
| 284 | + requestUrl.searchParams.append(parameter.name, value); | |
| 285 | + } | |
| 286 | + continue; | |
| 287 | + } | |
| 288 | + } | |
| 289 | + | |
| 290 | + let request = HttpClientRequest.make( | |
| 291 | + input.method.toUpperCase() as "GET", | |
| 292 | + )(requestUrl.toString()); | |
| 293 | + | |
| 294 | + for (const parameter of input.parameters) { | |
| 295 | + if (parameter.location !== "header") continue; | |
| 296 | + const values = stringValuesFromParameter( | |
| 297 | + input.args[parameter.name], | |
| 298 | + parameter.repeated, | |
| 299 | + ); | |
| 300 | + if (values.length === 0) continue; | |
| 301 | + request = HttpClientRequest.setHeader( | |
| 302 | + request, | |
| 303 | + parameter.name, | |
| 304 | + parameter.repeated ? values.join(",") : values[0]!, | |
| 305 | + ); | |
| 306 | + } | |
| 307 | + | |
| 308 | + if (input.authorizationHeader) { | |
| 309 | + request = HttpClientRequest.setHeader( | |
| 310 | + request, | |
| 311 | + "Authorization", | |
| 312 | + input.authorizationHeader, | |
| 313 | + ); | |
| 314 | + } | |
| 315 | + | |
| 316 | + if (input.hasBody && input.args.body !== undefined) { | |
| 317 | + request = HttpClientRequest.bodyUnsafeJson(request, input.args.body); | |
| 318 | + } | |
| 319 | + | |
| 320 | + const response = yield* client.execute(request).pipe( | |
| 321 | + Effect.mapError( | |
| 322 | + (err) => | |
| 323 | + new GoogleDiscoveryInvocationError({ | |
| 324 | + message: `HTTP request failed: ${err.message}`, | |
| 325 | + statusCode: Option.none(), | |
| 326 | + error: err, | |
| 327 | + }), | |
| 328 | + ), | |
| 329 | + ); | |
| 330 | + | |
| 331 | + const contentType = response.headers["content-type"] ?? null; | |
| 332 | + const body = | |
| 333 | + response.status === 204 | |
| 334 | + ? null | |
| 335 | + : isJsonContentType(contentType) | |
| 336 | + ? yield* response.json.pipe(Effect.catchAll(() => response.text)) | |
| 337 | + : yield* response.text; | |
| 338 | + | |
| 339 | + const ok = response.status >= 200 && response.status < 300; | |
| 340 | + | |
| 341 | + return new GoogleDiscoveryInvocationResult({ | |
| 342 | + status: response.status, | |
| 343 | + headers: { ...response.headers }, | |
| 344 | + data: ok ? body : null, | |
| 345 | + error: ok ? null : body, | |
| 346 | + }); | |
| 347 | +}); | |
| 348 | + | |
| 349 | +export const makeGoogleDiscoveryInvoker = (input: { | |
| 350 | + readonly bindingStore: GoogleDiscoveryBindingStore; | |
| 351 | + readonly secrets: { | |
| 352 | + readonly resolve: ( | |
| 353 | + secretId: SecretId, | |
| 354 | + scopeId: ScopeId, | |
| 355 | + ) => Effect.Effect<string, unknown>; | |
| 356 | + readonly set: (input: { | |
| 357 | + id: SecretId; | |
| 358 | + scopeId: ScopeId; | |
| 359 | + name: string; | |
| 360 | + value: string; | |
| 361 | + purpose?: string; | |
| 362 | + }) => Effect.Effect<unknown, unknown>; | |
| 363 | + }; | |
| 364 | + readonly scopeId: ScopeId; | |
| 365 | + readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>; | |
| 366 | +}): ToolInvoker => { | |
| 367 | + const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | |
| 368 | + | |
| 369 | + return { | |
| 370 | + resolveAnnotations: (toolId: ToolId) => | |
| 371 | + Effect.gen(function* () { | |
| 372 | + const entry = yield* input.bindingStore.get(toolId); | |
| 373 | + if (!entry) return undefined; | |
| 374 | + return annotationsForOperation( | |
| 375 | + entry.binding.method, | |
| 376 | + entry.binding.pathTemplate, | |
| 377 | + ); | |
| 378 | + }), | |
| 379 | + | |
| 380 | + invoke: (toolId: ToolId, args: unknown) => | |
| 381 | + Effect.gen(function* () { | |
| 382 | + const entry = yield* input.bindingStore.get(toolId); | |
| 383 | + if (!entry) { | |
| 384 | + return yield* new ToolInvocationError({ | |
| 385 | + toolId, | |
| 386 | + message: `No Google Discovery operation found for tool "${toolId}"`, | |
| 387 | + cause: undefined, | |
| 388 | + }); | |
| 389 | + } | |
| 390 | + | |
| 391 | + const source = yield* input.bindingStore.getSourceData(entry.namespace); | |
| 392 | + if (!source) { | |
| 393 | + return yield* new ToolInvocationError({ | |
| 394 | + toolId, | |
| 395 | + message: `No Google Discovery source found for "${entry.namespace}"`, | |
| 396 | + cause: undefined, | |
| 397 | + }); | |
| 398 | + } | |
| 399 | + | |
| 400 | + const accessToken = | |
| 401 | + source.auth.kind === "oauth2" | |
| 402 | + ? yield* resolveOAuthAccessToken({ | |
| 403 | + sourceId: entry.namespace, | |
| 404 | + source, | |
| 405 | + secrets: input.secrets, | |
| 406 | + scopeId: input.scopeId, | |
| 407 | + bindingStore: input.bindingStore, | |
| 408 | + }) | |
| 409 | + : ""; | |
| 410 | + | |
| 411 | + const authHeader = | |
| 412 | + source.auth.kind === "oauth2" | |
| 413 | + ? `${source.auth.tokenType} ${accessToken}` | |
| 414 | + : undefined; | |
| 415 | + | |
| 416 | + const result = yield* invoke({ | |
| 417 | + method: entry.binding.method, | |
| 418 | + pathTemplate: entry.binding.pathTemplate, | |
| 419 | + parameters: entry.binding.parameters, | |
| 420 | + hasBody: entry.binding.hasBody, | |
| 421 | + source, | |
| 422 | + args: (args ?? {}) as Record<string, unknown>, | |
| 423 | + authorizationHeader: authHeader, | |
| 424 | + }).pipe(Effect.provide(httpClientLayer)); | |
| 425 | + | |
| 426 | + return new ToolInvocationResult({ | |
| 427 | + data: result.data, | |
| 428 | + error: result.error, | |
| 429 | + status: result.status, | |
| 430 | + }); | |
| 431 | + }).pipe( | |
| 432 | + Effect.catchAll((error) => | |
| 433 | + error instanceof ToolInvocationError | |
| 434 | + ? Effect.fail(error) | |
| 435 | + : Effect.fail( | |
| 436 | + new ToolInvocationError({ | |
| 437 | + toolId, | |
| 438 | + message: | |
| 439 | + error instanceof Error ? error.message : String(error), | |
| 440 | + cause: undefined, | |
| 441 | + }), | |
| 442 | + ), | |
| 443 | + ), | |
| 444 | + ), | |
| 445 | + }; | |
| 446 | +}; | |
| @@ -0,0 +1,169 @@ | ||
| 1 | +import { createHash, randomBytes } from "node:crypto"; | |
| 2 | + | |
| 3 | +import { Effect } from "effect"; | |
| 4 | + | |
| 5 | +import { GoogleDiscoveryOAuthError } from "./errors"; | |
| 6 | + | |
| 7 | +export type OAuth2TokenResponse = { | |
| 8 | + readonly access_token: string; | |
| 9 | + readonly token_type?: string; | |
| 10 | + readonly refresh_token?: string; | |
| 11 | + readonly expires_in?: number; | |
| 12 | + readonly scope?: string; | |
| 13 | +}; | |
| 14 | + | |
| 15 | +const encodeBase64Url = (input: Buffer): string => | |
| 16 | + input | |
| 17 | + .toString("base64") | |
| 18 | + .replaceAll("+", "-") | |
| 19 | + .replaceAll("/", "_") | |
| 20 | + .replaceAll("=", ""); | |
| 21 | + | |
| 22 | +export const createPkceCodeVerifier = (): string => | |
| 23 | + encodeBase64Url(randomBytes(48)); | |
| 24 | + | |
| 25 | +const createPkceCodeChallenge = (verifier: string): string => | |
| 26 | + encodeBase64Url(createHash("sha256").update(verifier).digest()); | |
| 27 | + | |
| 28 | +export const buildGoogleAuthorizationUrl = (input: { | |
| 29 | + readonly clientId: string; | |
| 30 | + readonly redirectUrl: string; | |
| 31 | + readonly scopes: readonly string[]; | |
| 32 | + readonly state: string; | |
| 33 | + readonly codeVerifier: string; | |
| 34 | +}): string => { | |
| 35 | + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); | |
| 36 | + url.searchParams.set("client_id", input.clientId); | |
| 37 | + url.searchParams.set("redirect_uri", input.redirectUrl); | |
| 38 | + url.searchParams.set("response_type", "code"); | |
| 39 | + url.searchParams.set("scope", input.scopes.join(" ")); | |
| 40 | + url.searchParams.set("state", input.state); | |
| 41 | + url.searchParams.set("code_challenge_method", "S256"); | |
| 42 | + url.searchParams.set( | |
| 43 | + "code_challenge", | |
| 44 | + createPkceCodeChallenge(input.codeVerifier), | |
| 45 | + ); | |
| 46 | + url.searchParams.set("access_type", "offline"); | |
| 47 | + url.searchParams.set("include_granted_scopes", "true"); | |
| 48 | + url.searchParams.set("prompt", "consent"); | |
| 49 | + return url.toString(); | |
| 50 | +}; | |
| 51 | + | |
| 52 | +const decodeTokenResponse = async ( | |
| 53 | + response: Response, | |
| 54 | +): Promise<OAuth2TokenResponse> => { | |
| 55 | + const rawText = await response.text(); | |
| 56 | + let parsed: unknown; | |
| 57 | + try { | |
| 58 | + parsed = JSON.parse(rawText); | |
| 59 | + } catch { | |
| 60 | + throw new Error( | |
| 61 | + `OAuth token endpoint returned non-JSON response (${response.status})`, | |
| 62 | + ); | |
| 63 | + } | |
| 64 | + | |
| 65 | + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 66 | + throw new Error( | |
| 67 | + `OAuth token endpoint returned invalid JSON payload (${response.status})`, | |
| 68 | + ); | |
| 69 | + } | |
| 70 | + | |
| 71 | + const record = parsed as Record<string, unknown>; | |
| 72 | + const accessToken = | |
| 73 | + typeof record.access_token === "string" && record.access_token.length > 0 | |
| 74 | + ? record.access_token | |
| 75 | + : null; | |
| 76 | + | |
| 77 | + if (!response.ok) { | |
| 78 | + const description = | |
| 79 | + typeof record.error_description === "string" | |
| 80 | + ? record.error_description | |
| 81 | + : typeof record.error === "string" | |
| 82 | + ? record.error | |
| 83 | + : `status ${response.status}`; | |
| 84 | + throw new Error(`OAuth token exchange failed: ${description}`); | |
| 85 | + } | |
| 86 | + | |
| 87 | + if (accessToken === null) { | |
| 88 | + throw new Error("OAuth token endpoint did not return an access_token"); | |
| 89 | + } | |
| 90 | + | |
| 91 | + return { | |
| 92 | + access_token: accessToken, | |
| 93 | + token_type: | |
| 94 | + typeof record.token_type === "string" ? record.token_type : undefined, | |
| 95 | + refresh_token: | |
| 96 | + typeof record.refresh_token === "string" | |
| 97 | + ? record.refresh_token | |
| 98 | + : undefined, | |
| 99 | + expires_in: | |
| 100 | + typeof record.expires_in === "number" | |
| 101 | + ? record.expires_in | |
| 102 | + : typeof record.expires_in === "string" | |
| 103 | + ? Number(record.expires_in) | |
| 104 | + : undefined, | |
| 105 | + scope: typeof record.scope === "string" ? record.scope : undefined, | |
| 106 | + }; | |
| 107 | +}; | |
| 108 | + | |
| 109 | +const postToTokenEndpoint = (body: URLSearchParams) => | |
| 110 | + Effect.tryPromise({ | |
| 111 | + try: async () => { | |
| 112 | + const response = await fetch("https://oauth2.googleapis.com/token", { | |
| 113 | + method: "POST", | |
| 114 | + headers: { | |
| 115 | + "content-type": "application/x-www-form-urlencoded", | |
| 116 | + accept: "application/json", | |
| 117 | + }, | |
| 118 | + body, | |
| 119 | + signal: AbortSignal.timeout(20_000), | |
| 120 | + }); | |
| 121 | + return decodeTokenResponse(response); | |
| 122 | + }, | |
| 123 | + catch: (cause) => | |
| 124 | + new GoogleDiscoveryOAuthError({ | |
| 125 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 126 | + }), | |
| 127 | + }); | |
| 128 | + | |
| 129 | +export const exchangeAuthorizationCode = (input: { | |
| 130 | + readonly clientId: string; | |
| 131 | + readonly clientSecret?: string | null; | |
| 132 | + readonly redirectUrl: string; | |
| 133 | + readonly codeVerifier: string; | |
| 134 | + readonly code: string; | |
| 135 | +}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> => | |
| 136 | + Effect.gen(function* () { | |
| 137 | + const body = new URLSearchParams({ | |
| 138 | + grant_type: "authorization_code", | |
| 139 | + client_id: input.clientId, | |
| 140 | + redirect_uri: input.redirectUrl, | |
| 141 | + code_verifier: input.codeVerifier, | |
| 142 | + code: input.code, | |
| 143 | + }); | |
| 144 | + if (input.clientSecret) { | |
| 145 | + body.set("client_secret", input.clientSecret); | |
| 146 | + } | |
| 147 | + return yield* postToTokenEndpoint(body); | |
| 148 | + }); | |
| 149 | + | |
| 150 | +export const refreshAccessToken = (input: { | |
| 151 | + readonly clientId: string; | |
| 152 | + readonly clientSecret?: string | null; | |
| 153 | + readonly refreshToken: string; | |
| 154 | + readonly scopes?: readonly string[]; | |
| 155 | +}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> => | |
| 156 | + Effect.gen(function* () { | |
| 157 | + const body = new URLSearchParams({ | |
| 158 | + grant_type: "refresh_token", | |
| 159 | + client_id: input.clientId, | |
| 160 | + refresh_token: input.refreshToken, | |
| 161 | + }); | |
| 162 | + if (input.clientSecret) { | |
| 163 | + body.set("client_secret", input.clientSecret); | |
| 164 | + } | |
| 165 | + if (input.scopes && input.scopes.length > 0) { | |
| 166 | + body.set("scope", input.scopes.join(" ")); | |
| 167 | + } | |
| 168 | + return yield* postToTokenEndpoint(body); | |
| 169 | + }); | |
| @@ -0,0 +1,350 @@ | ||
| 1 | +import { createServer } from "node:http"; | |
| 2 | +import { readFileSync } from "node:fs"; | |
| 3 | +import { resolve } from "node:path"; | |
| 4 | + | |
| 5 | +import { Effect } from "effect"; | |
| 6 | +import { describe, expect, it, vi } from "vitest"; | |
| 7 | + | |
| 8 | +import { | |
| 9 | + createExecutor, | |
| 10 | + makeTestConfig, | |
| 11 | + SecretId, | |
| 12 | + type InvokeOptions, | |
| 13 | +} from "@executor/sdk"; | |
| 14 | +import { googleDiscoveryPlugin } from "./plugin"; | |
| 15 | + | |
| 16 | +const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; | |
| 17 | +const fixturePath = resolve(__dirname, "../../fixtures/drive.json"); | |
| 18 | +const fixtureText = readFileSync(fixturePath, "utf8"); | |
| 19 | + | |
| 20 | +const withGoogleDiscoveryServer = async <T>( | |
| 21 | + run: (input: { | |
| 22 | + baseUrl: string; | |
| 23 | + discoveryUrl: string; | |
| 24 | + requests: Array<{ | |
| 25 | + method: string; | |
| 26 | + url: string; | |
| 27 | + headers: Record<string, string | string[] | undefined>; | |
| 28 | + body: string; | |
| 29 | + }>; | |
| 30 | + }) => Promise<T>, | |
| 31 | +): Promise<T> => { | |
| 32 | + const requests: Array<{ | |
| 33 | + method: string; | |
| 34 | + url: string; | |
| 35 | + headers: Record<string, string | string[] | undefined>; | |
| 36 | + body: string; | |
| 37 | + }> = []; | |
| 38 | + | |
| 39 | + const server = createServer(async (request, response) => { | |
| 40 | + const chunks: Buffer[] = []; | |
| 41 | + for await (const chunk of request) { | |
| 42 | + chunks.push(Buffer.from(chunk)); | |
| 43 | + } | |
| 44 | + const body = Buffer.concat(chunks).toString("utf8"); | |
| 45 | + const url = request.url ?? "/"; | |
| 46 | + | |
| 47 | + requests.push({ | |
| 48 | + method: request.method ?? "GET", | |
| 49 | + url, | |
| 50 | + headers: request.headers, | |
| 51 | + body, | |
| 52 | + }); | |
| 53 | + | |
| 54 | + if (url === "/$discovery/rest?version=v3") { | |
| 55 | + const address = server.address(); | |
| 56 | + if (!address || typeof address === "string") { | |
| 57 | + throw new Error("Failed to resolve test server address"); | |
| 58 | + } | |
| 59 | + const dynamicFixture = JSON.stringify({ | |
| 60 | + ...JSON.parse(fixtureText), | |
| 61 | + rootUrl: `http://127.0.0.1:${address.port}/`, | |
| 62 | + }); | |
| 63 | + response.statusCode = 200; | |
| 64 | + response.setHeader("content-type", "application/json"); | |
| 65 | + response.end(dynamicFixture); | |
| 66 | + return; | |
| 67 | + } | |
| 68 | + | |
| 69 | + response.statusCode = 200; | |
| 70 | + response.setHeader("content-type", "application/json"); | |
| 71 | + response.end(JSON.stringify({ id: "123", name: "Quarterly Plan" })); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + await new Promise<void>((resolvePromise, reject) => { | |
| 75 | + server.listen(0, "127.0.0.1", (error?: Error) => { | |
| 76 | + if (error) { | |
| 77 | + reject(error); | |
| 78 | + return; | |
| 79 | + } | |
| 80 | + resolvePromise(); | |
| 81 | + }); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + try { | |
| 85 | + const address = server.address(); | |
| 86 | + if (!address || typeof address === "string") { | |
| 87 | + throw new Error("Failed to resolve test server address"); | |
| 88 | + } | |
| 89 | + const baseUrl = `http://127.0.0.1:${address.port}`; | |
| 90 | + return await run({ | |
| 91 | + baseUrl, | |
| 92 | + discoveryUrl: `${baseUrl}/$discovery/rest?version=v3`, | |
| 93 | + requests, | |
| 94 | + }); | |
| 95 | + } finally { | |
| 96 | + await new Promise<void>((resolvePromise, reject) => { | |
| 97 | + server.close((error) => { | |
| 98 | + if (error) { | |
| 99 | + reject(error); | |
| 100 | + return; | |
| 101 | + } | |
| 102 | + resolvePromise(); | |
| 103 | + }); | |
| 104 | + }); | |
| 105 | + } | |
| 106 | +}; | |
| 107 | + | |
| 108 | +describe("Google Discovery plugin", () => { | |
| 109 | + it("normalizes legacy googleapis discovery urls", async () => { | |
| 110 | + const executor = await Effect.runPromise( | |
| 111 | + createExecutor( | |
| 112 | + makeTestConfig({ | |
| 113 | + plugins: [googleDiscoveryPlugin()] as const, | |
| 114 | + }), | |
| 115 | + ), | |
| 116 | + ); | |
| 117 | + | |
| 118 | + const originalFetch = globalThis.fetch; | |
| 119 | + const fetchMock = vi | |
| 120 | + .spyOn(globalThis, "fetch") | |
| 121 | + .mockImplementation((( | |
| 122 | + input: RequestInfo | URL, | |
| 123 | + init?: RequestInit, | |
| 124 | + ) => { | |
| 125 | + const url = | |
| 126 | + typeof input === "string" | |
| 127 | + ? input | |
| 128 | + : input instanceof URL | |
| 129 | + ? input.toString() | |
| 130 | + : input.url; | |
| 131 | + | |
| 132 | + if (url === "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest") { | |
| 133 | + return Promise.resolve( | |
| 134 | + new Response(fixtureText, { | |
| 135 | + status: 200, | |
| 136 | + headers: { "content-type": "application/json" }, | |
| 137 | + }), | |
| 138 | + ); | |
| 139 | + } | |
| 140 | + | |
| 141 | + return originalFetch(input, init); | |
| 142 | + }) as typeof fetch); | |
| 143 | + | |
| 144 | + try { | |
| 145 | + const result = await Effect.runPromise( | |
| 146 | + executor.googleDiscovery.probeDiscovery( | |
| 147 | + "https://drive.googleapis.com/$discovery/rest?version=v3", | |
| 148 | + ), | |
| 149 | + ); | |
| 150 | + | |
| 151 | + expect(result.service).toBe("drive"); | |
| 152 | + expect(fetchMock).toHaveBeenCalledWith( | |
| 153 | + "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest", | |
| 154 | + expect.objectContaining({ | |
| 155 | + signal: expect.any(AbortSignal), | |
| 156 | + }), | |
| 157 | + ); | |
| 158 | + } finally { | |
| 159 | + fetchMock.mockRestore(); | |
| 160 | + await Effect.runPromise(executor.close()); | |
| 161 | + } | |
| 162 | + }); | |
| 163 | + | |
| 164 | + it("starts oauth using discovery scopes", async () => { | |
| 165 | + await withGoogleDiscoveryServer(async ({ discoveryUrl }) => { | |
| 166 | + const executor = await Effect.runPromise( | |
| 167 | + createExecutor( | |
| 168 | + makeTestConfig({ | |
| 169 | + plugins: [googleDiscoveryPlugin()] as const, | |
| 170 | + }), | |
| 171 | + ), | |
| 172 | + ); | |
| 173 | + | |
| 174 | + const result = await Effect.runPromise( | |
| 175 | + executor.googleDiscovery.startOAuth({ | |
| 176 | + name: "Google Drive", | |
| 177 | + discoveryUrl, | |
| 178 | + clientId: "client-123", | |
| 179 | + redirectUrl: "http://localhost/callback", | |
| 180 | + }), | |
| 181 | + ); | |
| 182 | + | |
| 183 | + const authorizationUrl = new URL(result.authorizationUrl); | |
| 184 | + expect(result.scopes).toContain("https://www.googleapis.com/auth/drive"); | |
| 185 | + expect(authorizationUrl.searchParams.get("client_id")).toBe("client-123"); | |
| 186 | + expect(authorizationUrl.searchParams.get("access_type")).toBe("offline"); | |
| 187 | + expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); | |
| 188 | + | |
| 189 | + await Effect.runPromise(executor.close()); | |
| 190 | + }); | |
| 191 | + }); | |
| 192 | + | |
| 193 | + it("completes oauth and stores token secrets", async () => { | |
| 194 | + await withGoogleDiscoveryServer(async ({ discoveryUrl }) => { | |
| 195 | + const executor = await Effect.runPromise( | |
| 196 | + createExecutor( | |
| 197 | + makeTestConfig({ | |
| 198 | + plugins: [googleDiscoveryPlugin()] as const, | |
| 199 | + }), | |
| 200 | + ), | |
| 201 | + ); | |
| 202 | + | |
| 203 | + await Effect.runPromise( | |
| 204 | + executor.secrets.set({ | |
| 205 | + id: SecretId.make("google-client-secret"), | |
| 206 | + name: "Google Client Secret", | |
| 207 | + value: "client-secret-value", | |
| 208 | + purpose: "google_oauth_client_secret", | |
| 209 | + }), | |
| 210 | + ); | |
| 211 | + | |
| 212 | + const originalFetch = globalThis.fetch; | |
| 213 | + const fetchMock = vi | |
| 214 | + .spyOn(globalThis, "fetch") | |
| 215 | + .mockImplementation((( | |
| 216 | + input: RequestInfo | URL, | |
| 217 | + init?: RequestInit, | |
| 218 | + ) => { | |
| 219 | + const url = | |
| 220 | + typeof input === "string" | |
| 221 | + ? input | |
| 222 | + : input instanceof URL | |
| 223 | + ? input.toString() | |
| 224 | + : input.url; | |
| 225 | + if (url === "https://oauth2.googleapis.com/token") { | |
| 226 | + expect(init?.method).toBe("POST"); | |
| 227 | + return Promise.resolve( | |
| 228 | + new Response( | |
| 229 | + JSON.stringify({ | |
| 230 | + access_token: "access-token-value", | |
| 231 | + refresh_token: "refresh-token-value", | |
| 232 | + token_type: "Bearer", | |
| 233 | + expires_in: 3600, | |
| 234 | + scope: "https://www.googleapis.com/auth/drive", | |
| 235 | + }), | |
| 236 | + { | |
| 237 | + status: 200, | |
| 238 | + headers: { "content-type": "application/json" }, | |
| 239 | + }, | |
| 240 | + ), | |
| 241 | + ); | |
| 242 | + } | |
| 243 | + return originalFetch(input, init); | |
| 244 | + }) as typeof fetch); | |
| 245 | + | |
| 246 | + try { | |
| 247 | + const started = await Effect.runPromise( | |
| 248 | + executor.googleDiscovery.startOAuth({ | |
| 249 | + name: "Google Drive", | |
| 250 | + discoveryUrl, | |
| 251 | + clientId: "client-123", | |
| 252 | + clientSecretSecretId: "google-client-secret", | |
| 253 | + redirectUrl: "http://localhost/callback", | |
| 254 | + }), | |
| 255 | + ); | |
| 256 | + | |
| 257 | + const auth = await Effect.runPromise( | |
| 258 | + executor.googleDiscovery.completeOAuth({ | |
| 259 | + state: started.sessionId, | |
| 260 | + code: "code-123", | |
| 261 | + }), | |
| 262 | + ); | |
| 263 | + | |
| 264 | + expect(auth.kind).toBe("oauth2"); | |
| 265 | + expect(auth.clientId).toBe("client-123"); | |
| 266 | + expect(auth.refreshTokenSecretId).not.toBeNull(); | |
| 267 | + | |
| 268 | + const accessToken = await Effect.runPromise( | |
| 269 | + executor.secrets.resolve(SecretId.make(auth.accessTokenSecretId)), | |
| 270 | + ); | |
| 271 | + const refreshToken = await Effect.runPromise( | |
| 272 | + executor.secrets.resolve(SecretId.make(auth.refreshTokenSecretId!)), | |
| 273 | + ); | |
| 274 | + expect(accessToken).toBe("access-token-value"); | |
| 275 | + expect(refreshToken).toBe("refresh-token-value"); | |
| 276 | + } finally { | |
| 277 | + fetchMock.mockRestore(); | |
| 278 | + await Effect.runPromise(executor.close()); | |
| 279 | + } | |
| 280 | + }); | |
| 281 | + }); | |
| 282 | + | |
| 283 | + it("registers and invokes google discovery tools with oauth headers", async () => { | |
| 284 | + await withGoogleDiscoveryServer(async ({ discoveryUrl, requests }) => { | |
| 285 | + const executor = await Effect.runPromise( | |
| 286 | + createExecutor( | |
| 287 | + makeTestConfig({ | |
| 288 | + plugins: [googleDiscoveryPlugin()] as const, | |
| 289 | + }), | |
| 290 | + ), | |
| 291 | + ); | |
| 292 | + | |
| 293 | + try { | |
| 294 | + await Effect.runPromise( | |
| 295 | + executor.secrets.set({ | |
| 296 | + id: SecretId.make("drive-access-token"), | |
| 297 | + name: "Drive Access Token", | |
| 298 | + value: "secret-token", | |
| 299 | + purpose: "google_oauth_access_token", | |
| 300 | + }), | |
| 301 | + ); | |
| 302 | + | |
| 303 | + const result = await Effect.runPromise( | |
| 304 | + executor.googleDiscovery.addSource({ | |
| 305 | + name: "Google Drive", | |
| 306 | + discoveryUrl, | |
| 307 | + namespace: "drive", | |
| 308 | + auth: { | |
| 309 | + kind: "oauth2", | |
| 310 | + clientId: "client-123", | |
| 311 | + clientSecretSecretId: null, | |
| 312 | + accessTokenSecretId: "drive-access-token", | |
| 313 | + refreshTokenSecretId: null, | |
| 314 | + tokenType: "Bearer", | |
| 315 | + expiresAt: null, | |
| 316 | + scope: null, | |
| 317 | + scopes: ["https://www.googleapis.com/auth/drive.readonly"], | |
| 318 | + }, | |
| 319 | + }), | |
| 320 | + ); | |
| 321 | + | |
| 322 | + expect(result.toolCount).toBe(2); | |
| 323 | + | |
| 324 | + const invocation = await Effect.runPromise( | |
| 325 | + executor.tools.invoke( | |
| 326 | + "drive.files.get", | |
| 327 | + { fileId: "123", fields: "id,name", prettyPrint: true }, | |
| 328 | + autoApprove, | |
| 329 | + ), | |
| 330 | + ); | |
| 331 | + | |
| 332 | + expect(invocation.error).toBeNull(); | |
| 333 | + expect(invocation.data).toEqual({ | |
| 334 | + id: "123", | |
| 335 | + name: "Quarterly Plan", | |
| 336 | + }); | |
| 337 | + | |
| 338 | + const apiRequest = requests.find((request) => | |
| 339 | + request.url.startsWith("/drive/v3/files/123"), | |
| 340 | + ); | |
| 341 | + expect(apiRequest).toBeDefined(); | |
| 342 | + expect(apiRequest!.headers.authorization).toBe("Bearer secret-token"); | |
| 343 | + expect(apiRequest!.url).toContain("fields=id%2Cname"); | |
| 344 | + expect(apiRequest!.url).toContain("prettyPrint=true"); | |
| 345 | + } finally { | |
| 346 | + await Effect.runPromise(executor.close()); | |
| 347 | + } | |
| 348 | + }); | |
| 349 | + }); | |
| 350 | +}); | |
| @@ -0,0 +1,538 @@ | ||
| 1 | +import { randomUUID } from "node:crypto"; | |
| 2 | + | |
| 3 | +import { Effect, Option } from "effect"; | |
| 4 | + | |
| 5 | +import { | |
| 6 | + Source, | |
| 7 | + definePlugin, | |
| 8 | + type ExecutorPlugin, | |
| 9 | + type PluginContext, | |
| 10 | + SecretId, | |
| 11 | + ToolId, | |
| 12 | + type ToolRegistration, | |
| 13 | +} from "@executor/sdk"; | |
| 14 | + | |
| 15 | +import type { | |
| 16 | + GoogleDiscoveryBindingStore, | |
| 17 | + GoogleDiscoverySourceMeta, | |
| 18 | +} from "./binding-store"; | |
| 19 | +import { makeInMemoryBindingStore } from "./binding-store"; | |
| 20 | +import { extractGoogleDiscoveryManifest } from "./document"; | |
| 21 | +import { makeGoogleDiscoveryInvoker } from "./invoke"; | |
| 22 | +import { | |
| 23 | + GoogleDiscoveryParseError, | |
| 24 | + GoogleDiscoveryOAuthError, | |
| 25 | + GoogleDiscoverySourceError, | |
| 26 | +} from "./errors"; | |
| 27 | +import { | |
| 28 | + buildGoogleAuthorizationUrl, | |
| 29 | + createPkceCodeVerifier, | |
| 30 | + exchangeAuthorizationCode, | |
| 31 | +} from "./oauth"; | |
| 32 | +import type { | |
| 33 | + GoogleDiscoveryAuth, | |
| 34 | + GoogleDiscoveryManifest, | |
| 35 | + GoogleDiscoveryManifestMethod, | |
| 36 | + GoogleDiscoveryOAuthSession, | |
| 37 | + GoogleDiscoveryStoredSourceData, | |
| 38 | +} from "./types"; | |
| 39 | +import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; | |
| 40 | + | |
| 41 | +export interface GoogleDiscoveryProbeResult { | |
| 42 | + readonly name: string; | |
| 43 | + readonly title: string | null; | |
| 44 | + readonly service: string; | |
| 45 | + readonly version: string; | |
| 46 | + readonly toolCount: number; | |
| 47 | + readonly scopes: readonly string[]; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export interface GoogleDiscoveryAddSourceInput { | |
| 51 | + readonly name: string; | |
| 52 | + readonly discoveryUrl: string; | |
| 53 | + readonly namespace?: string; | |
| 54 | + readonly auth: GoogleDiscoveryAuth; | |
| 55 | +} | |
| 56 | + | |
| 57 | +export interface GoogleDiscoveryOAuthStartInput { | |
| 58 | + readonly name: string; | |
| 59 | + readonly discoveryUrl: string; | |
| 60 | + readonly clientId: string; | |
| 61 | + readonly clientSecretSecretId?: string | null; | |
| 62 | + readonly redirectUrl: string; | |
| 63 | + readonly scopes?: readonly string[]; | |
| 64 | +} | |
| 65 | + | |
| 66 | +export interface GoogleDiscoveryOAuthStartResponse { | |
| 67 | + readonly sessionId: string; | |
| 68 | + readonly authorizationUrl: string; | |
| 69 | + readonly scopes: readonly string[]; | |
| 70 | +} | |
| 71 | + | |
| 72 | +export interface GoogleDiscoveryOAuthCompleteInput { | |
| 73 | + readonly state: string; | |
| 74 | + readonly code?: string; | |
| 75 | + readonly error?: string; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export interface GoogleDiscoveryOAuthAuthResult { | |
| 79 | + readonly kind: "oauth2"; | |
| 80 | + readonly clientId: string; | |
| 81 | + readonly clientSecretSecretId: string | null; | |
| 82 | + readonly accessTokenSecretId: string; | |
| 83 | + readonly refreshTokenSecretId: string | null; | |
| 84 | + readonly tokenType: string; | |
| 85 | + readonly expiresAt: number | null; | |
| 86 | + readonly scope: string | null; | |
| 87 | + readonly scopes: readonly string[]; | |
| 88 | +} | |
| 89 | + | |
| 90 | +export interface GoogleDiscoveryPluginExtension { | |
| 91 | + readonly probeDiscovery: ( | |
| 92 | + discoveryUrl: string, | |
| 93 | + ) => Effect.Effect< | |
| 94 | + GoogleDiscoveryProbeResult, | |
| 95 | + GoogleDiscoveryParseError | GoogleDiscoverySourceError | |
| 96 | + >; | |
| 97 | + readonly addSource: ( | |
| 98 | + input: GoogleDiscoveryAddSourceInput, | |
| 99 | + ) => Effect.Effect< | |
| 100 | + { readonly toolCount: number; readonly namespace: string }, | |
| 101 | + GoogleDiscoveryParseError | GoogleDiscoverySourceError | |
| 102 | + >; | |
| 103 | + readonly removeSource: (namespace: string) => Effect.Effect<void>; | |
| 104 | + readonly startOAuth: ( | |
| 105 | + input: GoogleDiscoveryOAuthStartInput, | |
| 106 | + ) => Effect.Effect< | |
| 107 | + GoogleDiscoveryOAuthStartResponse, | |
| 108 | + GoogleDiscoveryParseError | GoogleDiscoverySourceError | GoogleDiscoveryOAuthError | |
| 109 | + >; | |
| 110 | + readonly completeOAuth: ( | |
| 111 | + input: GoogleDiscoveryOAuthCompleteInput, | |
| 112 | + ) => Effect.Effect<GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryOAuthError>; | |
| 113 | +} | |
| 114 | + | |
| 115 | +const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis"; | |
| 116 | + | |
| 117 | +const normalizeDiscoveryUrl = (discoveryUrl: string): string => { | |
| 118 | + const trimmed = discoveryUrl.trim(); | |
| 119 | + if (trimmed.length === 0) return trimmed; | |
| 120 | + | |
| 121 | + let parsed: URL; | |
| 122 | + try { | |
| 123 | + parsed = new URL(trimmed); | |
| 124 | + } catch { | |
| 125 | + return trimmed; | |
| 126 | + } | |
| 127 | + | |
| 128 | + if (parsed.pathname !== "/$discovery/rest") { | |
| 129 | + return trimmed; | |
| 130 | + } | |
| 131 | + | |
| 132 | + const version = parsed.searchParams.get("version")?.trim(); | |
| 133 | + if (!version) { | |
| 134 | + return trimmed; | |
| 135 | + } | |
| 136 | + | |
| 137 | + const host = parsed.hostname.toLowerCase(); | |
| 138 | + if (!host.endsWith(".googleapis.com")) { | |
| 139 | + return trimmed; | |
| 140 | + } | |
| 141 | + | |
| 142 | + const rawService = host.slice(0, -".googleapis.com".length); | |
| 143 | + const service = | |
| 144 | + rawService === "calendar-json" ? "calendar" | |
| 145 | + : rawService.endsWith("-json") ? rawService.slice(0, -5) | |
| 146 | + : rawService; | |
| 147 | + | |
| 148 | + if (!service) { | |
| 149 | + return trimmed; | |
| 150 | + } | |
| 151 | + | |
| 152 | + return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`; | |
| 153 | +}; | |
| 154 | + | |
| 155 | +const fetchDiscoveryDocument = (discoveryUrl: string) => | |
| 156 | + Effect.tryPromise({ | |
| 157 | + try: async () => { | |
| 158 | + const response = await fetch(normalizeDiscoveryUrl(discoveryUrl), { | |
| 159 | + signal: AbortSignal.timeout(20_000), | |
| 160 | + }); | |
| 161 | + if (!response.ok) { | |
| 162 | + throw new GoogleDiscoverySourceError({ | |
| 163 | + message: `Google Discovery fetch failed with status ${response.status}`, | |
| 164 | + }); | |
| 165 | + } | |
| 166 | + return response.text(); | |
| 167 | + }, | |
| 168 | + catch: (cause) => | |
| 169 | + cause instanceof GoogleDiscoverySourceError | |
| 170 | + ? cause | |
| 171 | + : new GoogleDiscoverySourceError({ | |
| 172 | + message: cause instanceof Error ? cause.message : String(cause), | |
| 173 | + }), | |
| 174 | + }); | |
| 175 | + | |
| 176 | +const normalizeSlug = (value: string): string => | |
| 177 | + value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); | |
| 178 | + | |
| 179 | +const deriveNamespace = (input: { | |
| 180 | + name: string; | |
| 181 | + service: string; | |
| 182 | + version: string; | |
| 183 | +}): string => | |
| 184 | + normalizeSlug( | |
| 185 | + input.name || | |
| 186 | + `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`, | |
| 187 | + ) || `google_${input.service}`; | |
| 188 | + | |
| 189 | +const registerManifest = ( | |
| 190 | + ctx: PluginContext, | |
| 191 | + bindingStore: GoogleDiscoveryBindingStore, | |
| 192 | + namespace: string, | |
| 193 | + manifest: GoogleDiscoveryManifest, | |
| 194 | + sourceData: GoogleDiscoveryStoredSourceData, | |
| 195 | +) => | |
| 196 | + Effect.gen(function* () { | |
| 197 | + const existingToolIds = yield* bindingStore.listByNamespace(namespace); | |
| 198 | + if (existingToolIds.length > 0) { | |
| 199 | + yield* ctx.tools.unregister(existingToolIds); | |
| 200 | + yield* bindingStore.removeByNamespace(namespace); | |
| 201 | + } | |
| 202 | + | |
| 203 | + if (Object.keys(manifest.schemaDefinitions).length > 0) { | |
| 204 | + yield* ctx.tools.registerDefinitions(manifest.schemaDefinitions); | |
| 205 | + } | |
| 206 | + | |
| 207 | + const registrations: ToolRegistration[] = manifest.methods.map( | |
| 208 | + (method: GoogleDiscoveryManifestMethod) => ({ | |
| 209 | + id: ToolId.make(`${namespace}.${method.toolPath}`), | |
| 210 | + pluginKey: "googleDiscovery", | |
| 211 | + sourceId: namespace, | |
| 212 | + name: method.toolPath, | |
| 213 | + description: Option.getOrUndefined(method.description), | |
| 214 | + inputSchema: Option.getOrUndefined(method.inputSchema), | |
| 215 | + outputSchema: Option.getOrUndefined(method.outputSchema), | |
| 216 | + }), | |
| 217 | + ); | |
| 218 | + | |
| 219 | + yield* Effect.forEach( | |
| 220 | + manifest.methods, | |
| 221 | + (method) => | |
| 222 | + bindingStore.put( | |
| 223 | + ToolId.make(`${namespace}.${method.toolPath}`), | |
| 224 | + namespace, | |
| 225 | + method.binding, | |
| 226 | + ), | |
| 227 | + { discard: true }, | |
| 228 | + ); | |
| 229 | + | |
| 230 | + yield* ctx.tools.register(registrations); | |
| 231 | + yield* bindingStore.putSourceMeta({ | |
| 232 | + namespace, | |
| 233 | + name: sourceData.name, | |
| 234 | + } satisfies GoogleDiscoverySourceMeta); | |
| 235 | + yield* bindingStore.putSourceData(namespace, sourceData); | |
| 236 | + | |
| 237 | + return registrations.length; | |
| 238 | + }); | |
| 239 | + | |
| 240 | +const storeSecret = (ctx: PluginContext, input: { | |
| 241 | + readonly idPrefix: string; | |
| 242 | + readonly name: string; | |
| 243 | + readonly value: string; | |
| 244 | + readonly purpose: string; | |
| 245 | +}) => | |
| 246 | + ctx.secrets | |
| 247 | + .set({ | |
| 248 | + id: SecretId.make(`${input.idPrefix}_${randomUUID().slice(0, 8)}`), | |
| 249 | + scopeId: ctx.scope.id, | |
| 250 | + name: input.name, | |
| 251 | + value: input.value, | |
| 252 | + purpose: input.purpose, | |
| 253 | + }) | |
| 254 | + .pipe( | |
| 255 | + Effect.mapError( | |
| 256 | + (error) => | |
| 257 | + new GoogleDiscoveryOAuthError({ | |
| 258 | + message: error.message, | |
| 259 | + }), | |
| 260 | + ), | |
| 261 | + ); | |
| 262 | + | |
| 263 | +export const googleDiscoveryPlugin = (options?: { | |
| 264 | + readonly bindingStore?: GoogleDiscoveryBindingStore; | |
| 265 | +}): ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension> => { | |
| 266 | + const bindingStore = options?.bindingStore ?? makeInMemoryBindingStore(); | |
| 267 | + const oauthSessions = new Map<string, GoogleDiscoveryOAuthSession>(); | |
| 268 | + | |
| 269 | + return definePlugin({ | |
| 270 | + key: "googleDiscovery", | |
| 271 | + init: (ctx: PluginContext) => | |
| 272 | + Effect.gen(function* () { | |
| 273 | + yield* ctx.tools.registerInvoker( | |
| 274 | + "googleDiscovery", | |
| 275 | + makeGoogleDiscoveryInvoker({ | |
| 276 | + bindingStore, | |
| 277 | + secrets: ctx.secrets, | |
| 278 | + scopeId: ctx.scope.id, | |
| 279 | + }), | |
| 280 | + ); | |
| 281 | + | |
| 282 | + yield* ctx.sources.addManager({ | |
| 283 | + kind: "googleDiscovery", | |
| 284 | + list: () => | |
| 285 | + bindingStore.listSourceMeta().pipe( | |
| 286 | + Effect.map((metas) => | |
| 287 | + metas.map( | |
| 288 | + (meta) => | |
| 289 | + new Source({ | |
| 290 | + id: meta.namespace, | |
| 291 | + name: meta.name, | |
| 292 | + kind: "googleDiscovery", | |
| 293 | + runtime: false, | |
| 294 | + canRemove: true, | |
| 295 | + canRefresh: true, | |
| 296 | + }), | |
| 297 | + ), | |
| 298 | + ), | |
| 299 | + ), | |
| 300 | + remove: (sourceId) => | |
| 301 | + Effect.gen(function* () { | |
| 302 | + const toolIds = yield* bindingStore.removeByNamespace(sourceId); | |
| 303 | + if (toolIds.length > 0) { | |
| 304 | + yield* ctx.tools.unregister(toolIds); | |
| 305 | + } | |
| 306 | + yield* bindingStore.removeSourceMeta(sourceId); | |
| 307 | + yield* bindingStore.removeSourceData(sourceId); | |
| 308 | + }), | |
| 309 | + refresh: (sourceId) => | |
| 310 | + Effect.gen(function* () { | |
| 311 | + const sourceData = yield* bindingStore.getSourceData(sourceId); | |
| 312 | + if (!sourceData) return; | |
| 313 | + const discoveryText = yield* fetchDiscoveryDocument( | |
| 314 | + sourceData.discoveryUrl, | |
| 315 | + ); | |
| 316 | + const manifest = yield* extractGoogleDiscoveryManifest( | |
| 317 | + discoveryText, | |
| 318 | + ); | |
| 319 | + const nextSourceData = new GoogleDiscoveryStoredSourceDataSchema({ | |
| 320 | + ...sourceData, | |
| 321 | + service: manifest.service, | |
| 322 | + version: manifest.version, | |
| 323 | + rootUrl: manifest.rootUrl, | |
| 324 | + servicePath: manifest.servicePath, | |
| 325 | + }); | |
| 326 | + yield* registerManifest( | |
| 327 | + ctx, | |
| 328 | + bindingStore, | |
| 329 | + sourceId, | |
| 330 | + manifest, | |
| 331 | + nextSourceData, | |
| 332 | + ); | |
| 333 | + }).pipe(Effect.orDie), | |
| 334 | + }); | |
| 335 | + | |
| 336 | + const extension: GoogleDiscoveryPluginExtension = { | |
| 337 | + probeDiscovery: (discoveryUrl: string) => | |
| 338 | + Effect.gen(function* () { | |
| 339 | + const discoveryText = yield* fetchDiscoveryDocument(discoveryUrl); | |
| 340 | + const manifest = yield* extractGoogleDiscoveryManifest( | |
| 341 | + discoveryText, | |
| 342 | + ); | |
| 343 | + const scopes = Object.keys( | |
| 344 | + manifest.oauthScopes._tag === "Some" | |
| 345 | + ? manifest.oauthScopes.value | |
| 346 | + : {}, | |
| 347 | + ).sort(); | |
| 348 | + return { | |
| 349 | + name: | |
| 350 | + manifest.title._tag === "Some" | |
| 351 | + ? manifest.title.value | |
| 352 | + : `${manifest.service} ${manifest.version}`, | |
| 353 | + title: | |
| 354 | + manifest.title._tag === "Some" | |
| 355 | + ? manifest.title.value | |
| 356 | + : null, | |
| 357 | + service: manifest.service, | |
| 358 | + version: manifest.version, | |
| 359 | + toolCount: manifest.methods.length, | |
| 360 | + scopes, | |
| 361 | + }; | |
| 362 | + }), | |
| 363 | + | |
| 364 | + addSource: (input) => | |
| 365 | + Effect.gen(function* () { | |
| 366 | + const discoveryText = yield* fetchDiscoveryDocument( | |
| 367 | + input.discoveryUrl, | |
| 368 | + ); | |
| 369 | + const manifest = yield* extractGoogleDiscoveryManifest( | |
| 370 | + discoveryText, | |
| 371 | + ); | |
| 372 | + const namespace = | |
| 373 | + input.namespace ?? | |
| 374 | + deriveNamespace({ | |
| 375 | + name: input.name, | |
| 376 | + service: manifest.service, | |
| 377 | + version: manifest.version, | |
| 378 | + }); | |
| 379 | + const sourceData = new GoogleDiscoveryStoredSourceDataSchema({ | |
| 380 | + name: input.name, | |
| 381 | + discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), | |
| 382 | + service: manifest.service, | |
| 383 | + version: manifest.version, | |
| 384 | + rootUrl: manifest.rootUrl, | |
| 385 | + servicePath: manifest.servicePath, | |
| 386 | + auth: input.auth, | |
| 387 | + }); | |
| 388 | + const toolCount = yield* registerManifest( | |
| 389 | + ctx, | |
| 390 | + bindingStore, | |
| 391 | + namespace, | |
| 392 | + manifest, | |
| 393 | + sourceData, | |
| 394 | + ); | |
| 395 | + return { toolCount, namespace }; | |
| 396 | + }), | |
| 397 | + | |
| 398 | + removeSource: (namespace: string) => | |
| 399 | + Effect.gen(function* () { | |
| 400 | + const toolIds = yield* bindingStore.removeByNamespace(namespace); | |
| 401 | + if (toolIds.length > 0) { | |
| 402 | + yield* ctx.tools.unregister(toolIds); | |
| 403 | + } | |
| 404 | + yield* bindingStore.removeSourceMeta(namespace); | |
| 405 | + yield* bindingStore.removeSourceData(namespace); | |
| 406 | + }), | |
| 407 | + | |
| 408 | + startOAuth: (input) => | |
| 409 | + Effect.gen(function* () { | |
| 410 | + const discoveryText = yield* fetchDiscoveryDocument( | |
| 411 | + input.discoveryUrl, | |
| 412 | + ); | |
| 413 | + const manifest = yield* extractGoogleDiscoveryManifest( | |
| 414 | + discoveryText, | |
| 415 | + ); | |
| 416 | + const scopes = | |
| 417 | + input.scopes && input.scopes.length > 0 | |
| 418 | + ? [...input.scopes] | |
| 419 | + : Object.keys( | |
| 420 | + manifest.oauthScopes._tag === "Some" | |
| 421 | + ? manifest.oauthScopes.value | |
| 422 | + : {}, | |
| 423 | + ).sort(); | |
| 424 | + if (scopes.length === 0) { | |
| 425 | + return yield* new GoogleDiscoveryOAuthError({ | |
| 426 | + message: | |
| 427 | + "This Google Discovery document does not declare any OAuth scopes", | |
| 428 | + }); | |
| 429 | + } | |
| 430 | + const sessionId = randomUUID(); | |
| 431 | + const codeVerifier = createPkceCodeVerifier(); | |
| 432 | + oauthSessions.set(sessionId, { | |
| 433 | + discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), | |
| 434 | + name: input.name, | |
| 435 | + clientId: input.clientId, | |
| 436 | + clientSecretSecretId: input.clientSecretSecretId ?? null, | |
| 437 | + redirectUrl: input.redirectUrl, | |
| 438 | + scopes, | |
| 439 | + codeVerifier, | |
| 440 | + }); | |
| 441 | + return { | |
| 442 | + sessionId, | |
| 443 | + authorizationUrl: buildGoogleAuthorizationUrl({ | |
| 444 | + clientId: input.clientId, | |
| 445 | + redirectUrl: input.redirectUrl, | |
| 446 | + scopes, | |
| 447 | + state: sessionId, | |
| 448 | + codeVerifier, | |
| 449 | + }), | |
| 450 | + scopes, | |
| 451 | + }; | |
| 452 | + }), | |
| 453 | + | |
| 454 | + completeOAuth: (input) => | |
| 455 | + Effect.gen(function* () { | |
| 456 | + const session = oauthSessions.get(input.state); | |
| 457 | + if (!session) { | |
| 458 | + return yield* new GoogleDiscoveryOAuthError({ | |
| 459 | + message: "OAuth session not found or has expired", | |
| 460 | + }); | |
| 461 | + } | |
| 462 | + oauthSessions.delete(input.state); | |
| 463 | + | |
| 464 | + if (input.error) { | |
| 465 | + return yield* new GoogleDiscoveryOAuthError({ | |
| 466 | + message: input.error, | |
| 467 | + }); | |
| 468 | + } | |
| 469 | + if (!input.code) { | |
| 470 | + return yield* new GoogleDiscoveryOAuthError({ | |
| 471 | + message: "OAuth callback did not include an authorization code", | |
| 472 | + }); | |
| 473 | + } | |
| 474 | + | |
| 475 | + const tokenResponse = yield* exchangeAuthorizationCode({ | |
| 476 | + clientId: session.clientId, | |
| 477 | + clientSecret: | |
| 478 | + session.clientSecretSecretId === null | |
| 479 | + ? null | |
| 480 | + : yield* ctx.secrets | |
| 481 | + .resolve( | |
| 482 | + SecretId.make(session.clientSecretSecretId), | |
| 483 | + ctx.scope.id, | |
| 484 | + ) | |
| 485 | + .pipe( | |
| 486 | + Effect.mapError( | |
| 487 | + (error) => | |
| 488 | + new GoogleDiscoveryOAuthError({ | |
| 489 | + message: error.message, | |
| 490 | + }), | |
| 491 | + ), | |
| 492 | + ), | |
| 493 | + redirectUrl: session.redirectUrl, | |
| 494 | + codeVerifier: session.codeVerifier, | |
| 495 | + code: input.code, | |
| 496 | + }); | |
| 497 | + | |
| 498 | + const accessTokenRef = yield* storeSecret(ctx, { | |
| 499 | + idPrefix: `${normalizeSlug(session.name)}_google_access_token`, | |
| 500 | + name: `${session.name} Access Token`, | |
| 501 | + value: tokenResponse.access_token, | |
| 502 | + purpose: "google_oauth_access_token", | |
| 503 | + }); | |
| 504 | + const refreshTokenRef = tokenResponse.refresh_token | |
| 505 | + ? yield* storeSecret(ctx, { | |
| 506 | + idPrefix: `${normalizeSlug(session.name)}_google_refresh_token`, | |
| 507 | + name: `${session.name} Refresh Token`, | |
| 508 | + value: tokenResponse.refresh_token, | |
| 509 | + purpose: "google_oauth_refresh_token", | |
| 510 | + }) | |
| 511 | + : null; | |
| 512 | + return { | |
| 513 | + kind: "oauth2" as const, | |
| 514 | + clientId: session.clientId, | |
| 515 | + clientSecretSecretId: session.clientSecretSecretId, | |
| 516 | + accessTokenSecretId: accessTokenRef.id, | |
| 517 | + refreshTokenSecretId: refreshTokenRef?.id ?? null, | |
| 518 | + tokenType: tokenResponse.token_type ?? "Bearer", | |
| 519 | + expiresAt: | |
| 520 | + typeof tokenResponse.expires_in === "number" | |
| 521 | + ? Date.now() + tokenResponse.expires_in * 1000 | |
| 522 | + : null, | |
| 523 | + scope: tokenResponse.scope ?? null, | |
| 524 | + scopes: [...session.scopes], | |
| 525 | + }; | |
| 526 | + }), | |
| 527 | + }; | |
| 528 | + | |
| 529 | + return { | |
| 530 | + extension, | |
| 531 | + close: () => | |
| 532 | + Effect.sync(() => { | |
| 533 | + oauthSessions.clear(); | |
| 534 | + }), | |
| 535 | + }; | |
| 536 | + }), | |
| 537 | + }); | |
| 538 | +}; | |
| @@ -0,0 +1,121 @@ | ||
| 1 | +import { Schema } from "effect"; | |
| 2 | + | |
| 3 | +export const GoogleDiscoveryHttpMethod = Schema.Literal( | |
| 4 | + "get", | |
| 5 | + "put", | |
| 6 | + "post", | |
| 7 | + "delete", | |
| 8 | + "patch", | |
| 9 | + "head", | |
| 10 | + "options", | |
| 11 | +); | |
| 12 | +export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethod.Type; | |
| 13 | + | |
| 14 | +export const GoogleDiscoveryParameterLocation = Schema.Literal( | |
| 15 | + "path", | |
| 16 | + "query", | |
| 17 | + "header", | |
| 18 | +); | |
| 19 | +export type GoogleDiscoveryParameterLocation = | |
| 20 | + typeof GoogleDiscoveryParameterLocation.Type; | |
| 21 | + | |
| 22 | +export class GoogleDiscoveryParameter extends Schema.Class<GoogleDiscoveryParameter>( | |
| 23 | + "GoogleDiscoveryParameter", | |
| 24 | +)({ | |
| 25 | + name: Schema.String, | |
| 26 | + location: GoogleDiscoveryParameterLocation, | |
| 27 | + required: Schema.Boolean, | |
| 28 | + repeated: Schema.Boolean, | |
| 29 | + description: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 30 | + schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }), | |
| 31 | +}) {} | |
| 32 | + | |
| 33 | +export class GoogleDiscoveryMethodBinding extends Schema.Class<GoogleDiscoveryMethodBinding>( | |
| 34 | + "GoogleDiscoveryMethodBinding", | |
| 35 | +)({ | |
| 36 | + method: GoogleDiscoveryHttpMethod, | |
| 37 | + pathTemplate: Schema.String, | |
| 38 | + parameters: Schema.Array(GoogleDiscoveryParameter), | |
| 39 | + hasBody: Schema.Boolean, | |
| 40 | +}) {} | |
| 41 | + | |
| 42 | +export class GoogleDiscoveryManifestMethod extends Schema.Class<GoogleDiscoveryManifestMethod>( | |
| 43 | + "GoogleDiscoveryManifestMethod", | |
| 44 | +)({ | |
| 45 | + toolPath: Schema.String, | |
| 46 | + description: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 47 | + binding: GoogleDiscoveryMethodBinding, | |
| 48 | + inputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }), | |
| 49 | + outputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }), | |
| 50 | + scopes: Schema.Array(Schema.String), | |
| 51 | +}) {} | |
| 52 | + | |
| 53 | +export class GoogleDiscoveryManifest extends Schema.Class<GoogleDiscoveryManifest>( | |
| 54 | + "GoogleDiscoveryManifest", | |
| 55 | +)({ | |
| 56 | + title: Schema.optionalWith(Schema.String, { as: "Option" }), | |
| 57 | + service: Schema.String, | |
| 58 | + version: Schema.String, | |
| 59 | + rootUrl: Schema.String, | |
| 60 | + servicePath: Schema.String, | |
| 61 | + oauthScopes: Schema.optionalWith( | |
| 62 | + Schema.Record({ key: Schema.String, value: Schema.String }), | |
| 63 | + { as: "Option" }, | |
| 64 | + ), | |
| 65 | + schemaDefinitions: Schema.Record({ key: Schema.String, value: Schema.Unknown }), | |
| 66 | + methods: Schema.Array(GoogleDiscoveryManifestMethod), | |
| 67 | +}) {} | |
| 68 | + | |
| 69 | +export const GoogleDiscoveryAuth = Schema.Union( | |
| 70 | + Schema.Struct({ | |
| 71 | + kind: Schema.Literal("none"), | |
| 72 | + }), | |
| 73 | + Schema.Struct({ | |
| 74 | + kind: Schema.Literal("oauth2"), | |
| 75 | + clientId: Schema.String, | |
| 76 | + clientSecretSecretId: Schema.NullOr(Schema.String), | |
| 77 | + accessTokenSecretId: Schema.String, | |
| 78 | + refreshTokenSecretId: Schema.NullOr(Schema.String), | |
| 79 | + tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }), | |
| 80 | + expiresAt: Schema.NullOr(Schema.Number), | |
| 81 | + scope: Schema.NullOr(Schema.String), | |
| 82 | + scopes: Schema.Array(Schema.String), | |
| 83 | + }), | |
| 84 | +); | |
| 85 | +export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type; | |
| 86 | + | |
| 87 | +export class GoogleDiscoveryStoredSourceData extends Schema.Class<GoogleDiscoveryStoredSourceData>( | |
| 88 | + "GoogleDiscoveryStoredSourceData", | |
| 89 | +)({ | |
| 90 | + name: Schema.String, | |
| 91 | + discoveryUrl: Schema.String, | |
| 92 | + service: Schema.String, | |
| 93 | + version: Schema.String, | |
| 94 | + rootUrl: Schema.String, | |
| 95 | + servicePath: Schema.String, | |
| 96 | + auth: GoogleDiscoveryAuth, | |
| 97 | +}) {} | |
| 98 | + | |
| 99 | +export class GoogleDiscoveryInvocationResult extends Schema.Class<GoogleDiscoveryInvocationResult>( | |
| 100 | + "GoogleDiscoveryInvocationResult", | |
| 101 | +)({ | |
| 102 | + status: Schema.Number, | |
| 103 | + headers: Schema.Record({ key: Schema.String, value: Schema.String }), | |
| 104 | + data: Schema.NullOr(Schema.Unknown), | |
| 105 | + error: Schema.NullOr(Schema.Unknown), | |
| 106 | +}) {} | |
| 107 | + | |
| 108 | +export interface GoogleDiscoverySourceMeta { | |
| 109 | + readonly namespace: string; | |
| 110 | + readonly name: string; | |
| 111 | +} | |
| 112 | + | |
| 113 | +export interface GoogleDiscoveryOAuthSession { | |
| 114 | + readonly discoveryUrl: string; | |
| 115 | + readonly name: string; | |
| 116 | + readonly clientId: string; | |
| 117 | + readonly clientSecretSecretId: string | null; | |
| 118 | + readonly redirectUrl: string; | |
| 119 | + readonly scopes: readonly string[]; | |
| 120 | + readonly codeVerifier: string; | |
| 121 | +} | |
| @@ -0,0 +1,27 @@ | ||
| 1 | +{ | |
| 2 | + "compilerOptions": { | |
| 3 | + "target": "ES2022", | |
| 4 | + "module": "ESNext", | |
| 5 | + "moduleResolution": "Bundler", | |
| 6 | + "strict": true, | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "noEmit": true, | |
| 9 | + "lib": ["ES2022", "DOM"], | |
| 10 | + "types": ["bun-types", "node"], | |
| 11 | + "noUnusedLocals": true, | |
| 12 | + "noImplicitOverride": true, | |
| 13 | + "jsx": "react-jsx", | |
| 14 | + "plugins": [ | |
| 15 | + { | |
| 16 | + "name": "@effect/language-service", | |
| 17 | + "diagnostics": { | |
| 18 | + "preferSchemaOverJson": "off" | |
| 19 | + } | |
| 20 | + } | |
| 21 | + ] | |
| 22 | + }, | |
| 23 | + "include": [ | |
| 24 | + "src/**/*.ts", | |
| 25 | + "src/**/*.tsx" | |
| 26 | + ] | |
| 27 | +} | |
| @@ -107,7 +107,7 @@ function VaultPicker(props: { · 106 unmodified lines | ||
| 107 | 107 | )} |
| 108 | 108 | {error && ( |
| 109 | 109 | <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5"> |
| 110 | − <p className="text-[11px] text-destructive leading-relaxed">{error}</p> | |
| 110 | + <p className="text-[11px] text-destructive leading-relaxed whitespace-pre-line">{error}</p> | |
| 111 | 111 | </div> |
| 112 | 112 | )} |
| 113 | 113 | </div> |
| @@ -246,7 +246,7 @@ function ConfigDialog(props: { · 132 unmodified lines | ||
| 246 | 246 | |
| 247 | 247 | {error && ( |
| 248 | 248 | <div className="rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2"> |
| 249 | − <p className="text-[12px] text-destructive">{error}</p> | |
| 249 | + <p className="text-[12px] text-destructive whitespace-pre-line">{error}</p> | |
| 250 | 250 | </div> |
| 251 | 251 | )} |
| 252 | 252 | </div> |
| @@ -67,7 +67,15 @@ export const makeOnePasswordClient = ( · 66 unmodified lines | ||
| 67 | 67 | onTimeout: () => |
| 68 | 68 | new OnePasswordError({ |
| 69 | 69 | operation: "client setup", |
| 70 | − message: `timed out after ${Math.floor(timeoutMs / 1000)}s — approve the request in the 1Password desktop app and try again`, | |
| 70 | + message: [ | |
| 71 | + `timed out after ${Math.floor(timeoutMs / 1000)}s.`, | |
| 72 | + "Troubleshooting:", | |
| 73 | + "1. Make sure the 1Password desktop app is open and unlocked", | |
| 74 | + "2. Check for an approval prompt in the 1Password app — it may be behind other windows", | |
| 75 | + "3. Ensure 'Developer > Connect with 1Password CLI' is enabled in 1Password Settings", | |
| 76 | + "4. Make sure no other app or terminal is waiting for 1Password approval (only one prompt at a time)", | |
| 77 | + "5. Try quitting 1Password completely and reopening it, then retry", | |
| 78 | + ].join("\n"), | |
| 71 | 79 | }), |
| 72 | 80 | }), |
| 73 | 81 | ); |
| @@ -89,7 +97,15 @@ export const makeOnePasswordClient = ( · 15 unmodified lines | ||
| 89 | 97 | onTimeout: () => |
| 90 | 98 | new OnePasswordError({ |
| 91 | 99 | operation, |
| 92 | − message: `timed out after ${Math.floor(timeoutMs / 1000)}s — approve the request in the 1Password desktop app and try again`, | |
| 100 | + message: [ | |
| 101 | + `timed out after ${Math.floor(timeoutMs / 1000)}s.`, | |
| 102 | + "Troubleshooting:", | |
| 103 | + "1. Make sure the 1Password desktop app is open and unlocked", | |
| 104 | + "2. Check for an approval prompt in the 1Password app — it may be behind other windows", | |
| 105 | + "3. Ensure 'Developer > Connect with 1Password CLI' is enabled in 1Password Settings", | |
| 106 | + "4. Make sure no other app or terminal is waiting for 1Password approval (only one prompt at a time)", | |
| 107 | + "5. Try quitting 1Password completely and reopening it, then retry", | |
| 108 | + ].join("\n"), | |
| 93 | 109 | }), |
| 94 | 110 | }), |
| 95 | 111 | Effect.withSpan(`onepassword.${operation}`), |
| @@ -2,25 +2,20 @@ import { useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; |
| 3 | 3 | import { Option } from "effect"; |
| 4 | 4 | |
| 5 | −import { secretsAtom, setSecret, resolveSecret, ScopeId } from "@executor/react"; | |
| 5 | +import { | |
| 6 | + secretsAtom, | |
| 7 | + setSecret, | |
| 8 | + resolveSecret, | |
| 9 | + ScopeId, | |
| 10 | + SecretPicker, | |
| 11 | + type SecretPickerSecret, | |
| 12 | +} from "@executor/react"; | |
| 6 | 13 | import { SecretId } from "@executor/sdk"; |
| 7 | 14 | import { Button } from "@executor/ui/components/button"; |
| 8 | 15 | import { Input } from "@executor/ui/components/input"; |
| 9 | 16 | import { Label } from "@executor/ui/components/label"; |
| 10 | 17 | import { Textarea } from "@executor/ui/components/textarea"; |
| 11 | 18 | import { Badge } from "@executor/ui/components/badge"; |
| 12 | −import { | |
| 13 | − Command, | |
| 14 | − CommandList, | |
| 15 | − CommandEmpty, | |
| 16 | − CommandGroup, | |
| 17 | − CommandItem, | |
| 18 | −} from "@executor/ui/components/command"; | |
| 19 | −import { | |
| 20 | − Popover, | |
| 21 | − PopoverAnchor, | |
| 22 | − PopoverContent, | |
| 23 | −} from "@executor/ui/components/popover"; | |
| 24 | 19 | import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group"; |
| 25 | 20 | import { Separator } from "@executor/ui/components/separator"; |
| 26 | 21 | import { Spinner } from "@executor/ui/components/spinner"; |
| @@ -28,133 +23,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; · 1 unmodified line | ||
| 28 | 23 | import type { SpecPreview, HeaderPreset } from "../sdk/preview"; |
| 29 | 24 | import type { HeaderValue } from "../sdk/types"; |
| 30 | 25 | |
| 31 | −// --------------------------------------------------------------------------- | |
| 32 | −// Types | |
| 33 | −// --------------------------------------------------------------------------- | |
| 34 | − | |
| 35 | −interface SecretEntry { | |
| 36 | − id: string; | |
| 37 | − name: string; | |
| 38 | − provider?: string; | |
| 39 | −} | |
| 40 | − | |
| 41 | −// --------------------------------------------------------------------------- | |
| 42 | −// Provider display names | |
| 43 | −// --------------------------------------------------------------------------- | |
| 44 | − | |
| 45 | −const PROVIDER_LABELS: Record<string, string> = { | |
| 46 | − keychain: "Keychain", | |
| 47 | − file: "Local", | |
| 48 | − memory: "Memory", | |
| 49 | − onepassword: "1Password", | |
| 50 | −}; | |
| 51 | − | |
| 52 | −function providerLabel(key: string | undefined): string { | |
| 53 | − if (!key) return "Local"; | |
| 54 | − return PROVIDER_LABELS[key] ?? key; | |
| 55 | −} | |
| 56 | − | |
| 57 | −// --------------------------------------------------------------------------- | |
| 58 | −// Secret picker — combobox grouped by provider | |
| 59 | −// --------------------------------------------------------------------------- | |
| 60 | − | |
| 61 | −function SecretPicker(props: { | |
| 62 | − value: string | null; | |
| 63 | − onSelect: (secretId: string) => void; | |
| 64 | − secrets: readonly SecretEntry[]; | |
| 65 | − placeholder?: string; | |
| 66 | −}) { | |
| 67 | − const { value, onSelect, secrets, placeholder = "Search secrets…" } = props; | |
| 68 | − const [open, setOpen] = useState(false); | |
| 69 | − const [query, setQuery] = useState(""); | |
| 70 | − | |
| 71 | − const selected = secrets.find((s) => s.id === value) ?? null; | |
| 72 | − | |
| 73 | − // Group by provider, sorted alphabetically | |
| 74 | − const grouped = new Map<string, SecretEntry[]>(); | |
| 75 | − for (const s of secrets) { | |
| 76 | − const key = providerLabel(s.provider); | |
| 77 | − const group = grouped.get(key); | |
| 78 | − if (group) { | |
| 79 | − group.push(s); | |
| 80 | − } else { | |
| 81 | − grouped.set(key, [s]); | |
| 82 | − } | |
| 83 | − } | |
| 84 | − const groups: [string, SecretEntry[]][] = [...grouped.entries()] | |
| 85 | − .map(([label, items]): [string, SecretEntry[]] => [label, [...items].sort((a, b) => a.name.localeCompare(b.name))]) | |
| 86 | − .sort(([a], [b]) => a.localeCompare(b)); | |
| 87 | − | |
| 88 | − return ( | |
| 89 | − <div className="relative w-full"> | |
| 90 | − <Popover open={open} onOpenChange={setOpen} modal={false}> | |
| 91 | − <PopoverAnchor asChild> | |
| 92 | − <Input | |
| 93 | − value={open ? query : (selected ? selected.name : "")} | |
| 94 | − onChange={(e) => { | |
| 95 | − setQuery((e.target as HTMLInputElement).value); | |
| 96 | − if (!open) setOpen(true); | |
| 97 | − }} | |
| 98 | − onFocus={() => setOpen(true)} | |
| 99 | − onBlur={(e) => { | |
| 100 | − // Don't close if focus moved into the popover | |
| 101 | − const related = (e as React.FocusEvent).relatedTarget as HTMLElement | null; | |
| 102 | − if (related?.closest("[data-slot=popover-content]")) return; | |
| 103 | − setOpen(false); | |
| 104 | − }} | |
| 105 | − placeholder={placeholder} | |
| 106 | − className="text-sm" | |
| 107 | − /> | |
| 108 | − </PopoverAnchor> | |
| 109 | − <PopoverContent | |
| 110 | − className="w-(--radix-popover-trigger-width) p-0" | |
| 111 | − align="start" | |
| 112 | − onOpenAutoFocus={(e) => e.preventDefault()} | |
| 113 | − onCloseAutoFocus={(e) => e.preventDefault()} | |
| 114 | − onInteractOutside={(e) => { | |
| 115 | − // Don't close if interacting with the input | |
| 116 | − const target = e.target as HTMLElement | null; | |
| 117 | − if (target?.closest("[data-slot=popover-anchor]")) { | |
| 118 | − e.preventDefault(); | |
| 119 | − } | |
| 120 | − }} | |
| 121 | − > | |
| 122 | − <Command shouldFilter={false}> | |
| 123 | − <CommandList> | |
| 124 | − <CommandEmpty>No secrets found</CommandEmpty> | |
| 125 | − {groups.map(([label, items]) => { | |
| 126 | − const lq = query.toLowerCase(); | |
| 127 | − const filtered = lq | |
| 128 | − ? items.filter((s) => s.name.toLowerCase().includes(lq) || s.id.toLowerCase().includes(lq)) | |
| 129 | − : items; | |
| 130 | − if (filtered.length === 0) return null; | |
| 131 | − return ( | |
| 132 | − <CommandGroup key={label} heading={label}> | |
| 133 | − {filtered.map((s) => ( | |
| 134 | − <CommandItem | |
| 135 | − key={s.id} | |
| 136 | − value={`${s.name} ${s.id}`} | |
| 137 | − onSelect={() => { | |
| 138 | − onSelect(s.id); | |
| 139 | − setOpen(false); | |
| 140 | − setQuery(""); | |
| 141 | − }} | |
| 142 | − > | |
| 143 | − <span className="truncate">{s.name}</span> | |
| 144 | − <span className="ml-auto text-[10px] font-mono text-muted-foreground truncate">{s.id}</span> | |
| 145 | − </CommandItem> | |
| 146 | − ))} | |
| 147 | − </CommandGroup> | |
| 148 | − ); | |
| 149 | − })} | |
| 150 | − </CommandList> | |
| 151 | − </Command> | |
| 152 | − </PopoverContent> | |
| 153 | − </Popover> | |
| 154 | − </div> | |
| 155 | − ); | |
| 156 | −} | |
| 157 | − | |
| 158 | 26 | // --------------------------------------------------------------------------- |
| 159 | 27 | // Inline secret creation |
| 160 | 28 | // --------------------------------------------------------------------------- |
| @@ -333,7 +201,7 @@ function HeaderSecretRow(props: { · 172 unmodified lines | ||
| 333 | 201 | prefix?: string; |
| 334 | 202 | selectedSecretId: string | null; |
| 335 | 203 | onSelect: (secretId: string) => void; |
| 336 | − existingSecrets: readonly SecretEntry[]; | |
| 204 | + existingSecrets: readonly SecretPickerSecret[]; | |
| 337 | 205 | }) { |
| 338 | 206 | const [creating, setCreating] = useState(false); |
| 339 | 207 | const { headerName, prefix, selectedSecretId, onSelect, existingSecrets } = props; |
| @@ -419,7 +287,7 @@ function CustomHeaderRow(props: { · 79 unmodified lines | ||
| 419 | 287 | onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void; |
| 420 | 288 | onSelectSecret: (secretId: string) => void; |
| 421 | 289 | onRemove: () => void; |
| 422 | − existingSecrets: readonly SecretEntry[]; | |
| 290 | + existingSecrets: readonly SecretPickerSecret[]; | |
| 423 | 291 | }) { |
| 424 | 292 | const [creating, setCreating] = useState(false); |
| 425 | 293 | const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props; |
| @@ -568,9 +436,9 @@ export default function AddOpenApiSource(props: { · 142 unmodified lines | ||
| 568 | 436 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); |
| 569 | 437 | const secrets = useAtomValue(secretsAtom()); |
| 570 | 438 | |
| 571 | − const secretList: readonly SecretEntry[] = Result.match(secrets, { | |
| 572 | − onInitial: () => [] as SecretEntry[], | |
| 573 | − onFailure: () => [] as SecretEntry[], | |
| 439 | + const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { | |
| 440 | + onInitial: () => [] as SecretPickerSecret[], | |
| 441 | + onFailure: () => [] as SecretPickerSecret[], | |
| 574 | 442 | onSuccess: ({ value }) => |
| 575 | 443 | value.map((s) => ({ |
| 576 | 444 | id: s.id, |