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 | "@effect/sql-sqlite-bun": "catalog:", | 12 | "@effect/sql-sqlite-bun": "catalog:", |
| 13 | "@executor/api": "workspace:*", | 13 | "@executor/api": "workspace:*", |
| 14 | "@executor/plugin-file-secrets": "workspace:*", | 14 | "@executor/plugin-file-secrets": "workspace:*", |
| 15 | "@executor/plugin-google-discovery": "workspace:*", | ||
| 15 | "@executor/plugin-keychain": "workspace:*", | 16 | "@executor/plugin-keychain": "workspace:*", |
| 16 | "@executor/plugin-onepassword": "workspace:*", | 17 | "@executor/plugin-onepassword": "workspace:*", |
| 17 | "@executor/plugin-mcp": "workspace:*", | 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 | import { addGroup } from "@executor/api"; | 9 | import { addGroup } from "@executor/api"; |
| 10 | import { OpenApiGroup } from "@executor/plugin-openapi/api"; | 10 | import { OpenApiGroup } from "@executor/plugin-openapi/api"; |
| 11 | import { McpGroup } from "@executor/plugin-mcp/api"; | 11 | import { McpGroup } from "@executor/plugin-mcp/api"; |
| 12 | import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api"; | ||
| 12 | import { OnePasswordGroup } from "@executor/plugin-onepassword/api"; | 13 | import { OnePasswordGroup } from "@executor/plugin-onepassword/api"; |
| 13 | import { ToolsHandlers } from "./handlers/tools"; | 14 | import { ToolsHandlers } from "./handlers/tools"; |
| 14 | import { SourcesHandlers } from "./handlers/sources"; | 15 | import { SourcesHandlers } from "./handlers/sources"; |
| 15 | import { SecretsHandlers } from "./handlers/secrets"; | 16 | import { SecretsHandlers } from "./handlers/secrets"; |
| 16 | import { OpenApiHandlersLive } from "./handlers/openapi"; | 17 | import { OpenApiHandlersLive } from "./handlers/openapi"; |
| 17 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; | 18 | import { McpSourceHandlersLive } from "./handlers/mcp-source"; |
| 19 | import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery"; | ||
| 18 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; | 20 | import { OnePasswordHandlersLive } from "./handlers/onepassword"; |
| 19 | import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; | 21 | import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor"; |
| 20 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; | 22 | import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; |
| @@ -23,7 +25,10 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 2 unmodified lines | |||
| 23 | // Composed API — core + plugin groups | 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 | // API Layer | 34 | // API Layer |
| @@ -36,6 +41,7 @@ const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 6 unmodified lines | |||
| 36 | SecretsHandlers, | 41 | SecretsHandlers, |
| 37 | OpenApiHandlersLive, | 42 | OpenApiHandlersLive, |
| 38 | McpSourceHandlersLive, | 43 | McpSourceHandlersLive, |
| 44 | GoogleDiscoveryHandlersLive, | ||
| 39 | OnePasswordHandlersLive, | 45 | OnePasswordHandlersLive, |
| 40 | ]), | 46 | ]), |
| 41 | ); | 47 | ); |
| @@ -7,6 +7,11 @@ import { createExecutor, scopeKv } from "@executor/sdk"; · 6 unmodified lines | |||
| 7 | import { makeSqliteKv, makeKvConfig, migrate } from "@executor/storage-file"; | 7 | import { makeSqliteKv, makeKvConfig, migrate } from "@executor/storage-file"; |
| 8 | import { openApiPlugin, makeKvOperationStore, type OpenApiPluginExtension } from "@executor/plugin-openapi"; | 8 | import { openApiPlugin, makeKvOperationStore, type OpenApiPluginExtension } from "@executor/plugin-openapi"; |
| 9 | import { mcpPlugin, makeKvBindingStore, type McpPluginExtension } from "@executor/plugin-mcp"; | 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 | import { keychainPlugin } from "@executor/plugin-keychain"; | 15 | import { keychainPlugin } from "@executor/plugin-keychain"; |
| 11 | import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; | 16 | import { fileSecretsPlugin } from "@executor/plugin-file-secrets"; |
| 12 | import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword"; | 17 | import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword"; |
| @@ -16,6 +21,7 @@ import type { Executor, ExecutorPlugin } from "@executor/sdk"; · 3 unmodified lines | |||
| 16 | type ServerPlugins = readonly [ | 21 | type ServerPlugins = readonly [ |
| 17 | ExecutorPlugin<"openapi", OpenApiPluginExtension>, | 22 | ExecutorPlugin<"openapi", OpenApiPluginExtension>, |
| 18 | ExecutorPlugin<"mcp", McpPluginExtension>, | 23 | ExecutorPlugin<"mcp", McpPluginExtension>, |
| 24 | ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>, | ||
| 19 | ReturnType<typeof keychainPlugin>, | 25 | ReturnType<typeof keychainPlugin>, |
| 20 | ReturnType<typeof fileSecretsPlugin>, | 26 | ReturnType<typeof fileSecretsPlugin>, |
| 21 | ExecutorPlugin<"onepassword", OnePasswordExtension>, | 27 | ExecutorPlugin<"onepassword", OnePasswordExtension>, |
| @@ -69,6 +75,9 @@ const ExecutorLayer = Layer.effect( · 47 unmodified lines | |||
| 69 | mcpPlugin({ | 75 | mcpPlugin({ |
| 70 | bindingStore: makeKvBindingStore(kv, "mcp"), | 76 | bindingStore: makeKvBindingStore(kv, "mcp"), |
| 71 | }), | 77 | }), |
| 78 | googleDiscoveryPlugin({ | ||
| 79 | bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"), | ||
| 80 | }), | ||
| 72 | keychainPlugin(), | 81 | keychainPlugin(), |
| 73 | fileSecretsPlugin(), | 82 | fileSecretsPlugin(), |
| 74 | onepasswordPlugin({ | 83 | onepasswordPlugin({ |
| @@ -6,6 +6,7 @@ · 5 unmodified lines | |||
| 6 | "dependencies": { | 6 | "dependencies": { |
| 7 | "@executor/plugin-onepassword": "workspace:*", | 7 | "@executor/plugin-onepassword": "workspace:*", |
| 8 | "@executor/plugin-mcp": "workspace:*", | 8 | "@executor/plugin-mcp": "workspace:*", |
| 9 | "@executor/plugin-google-discovery": "workspace:*", | ||
| 9 | "@executor/plugin-openapi": "workspace:*", | 10 | "@executor/plugin-openapi": "workspace:*", |
| 10 | "@executor/sdk": "workspace:*", | 11 | "@executor/sdk": "workspace:*", |
| 11 | "@executor/react": "workspace:*", | 12 | "@executor/react": "workspace:*", |
| @@ -311,6 +311,32 @@ export function SecretsPage() { · 310 unmodified lines | |||
| 311 | </Button> | 311 | </Button> |
| 312 | </div> | 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 | {/* Secrets list */} | 340 | {/* Secrets list */} |
| 315 | {Result.match(secrets, { | 341 | {Result.match(secrets, { |
| 316 | onInitial: () => ( | 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 | <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} /> | 384 | <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} /> |
| 385 | </div> | 385 | </div> |
| 386 | </div> | 386 | </div> |
| @@ -4,13 +4,18 @@ import { Result, useAtomValue, useAtomRefresh, sourcesAtom } from "@executor/rea · 3 unmodified lines | |||
| 4 | import type { SourcePlugin } from "@executor/react"; | 4 | import type { SourcePlugin } from "@executor/react"; |
| 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; | 5 | import { openApiSourcePlugin } from "@executor/plugin-openapi/react"; |
| 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; | 6 | import { mcpSourcePlugin } from "@executor/plugin-mcp/react"; |
| 7 | import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react"; | ||
| 7 | import { McpInstallCard } from "../components/mcp-install-card"; | 8 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 8 | 9 | ||
| 9 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 10 | // Registered source plugins | 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 | // Page | 21 | // Page |
| @@ -21,6 +21,7 @@ · 20 unmodified lines | |||
| 21 | "@executor/api": "workspace:*", | 21 | "@executor/api": "workspace:*", |
| 22 | "@executor/host-mcp": "workspace:*", | 22 | "@executor/host-mcp": "workspace:*", |
| 23 | "@executor/plugin-file-secrets": "workspace:*", | 23 | "@executor/plugin-file-secrets": "workspace:*", |
| 24 | "@executor/plugin-google-discovery": "workspace:*", | ||
| 24 | "@executor/plugin-keychain": "workspace:*", | 25 | "@executor/plugin-keychain": "workspace:*", |
| 25 | "@executor/plugin-mcp": "workspace:*", | 26 | "@executor/plugin-mcp": "workspace:*", |
| 26 | "@executor/plugin-onepassword": "workspace:*", | 27 | "@executor/plugin-onepassword": "workspace:*", |
| @@ -40,6 +41,7 @@ · 13 unmodified lines | |||
| 40 | "name": "@executor/web", | 41 | "name": "@executor/web", |
| 41 | "version": "0.0.0", | 42 | "version": "0.0.0", |
| 42 | "dependencies": { | 43 | "dependencies": { |
| 44 | "@executor/plugin-google-discovery": "workspace:*", | ||
| 43 | "@executor/plugin-mcp": "workspace:*", | 45 | "@executor/plugin-mcp": "workspace:*", |
| 44 | "@executor/plugin-onepassword": "workspace:*", | 46 | "@executor/plugin-onepassword": "workspace:*", |
| 45 | "@executor/plugin-openapi": "workspace:*", | 47 | "@executor/plugin-openapi": "workspace:*", |
| @@ -75,6 +77,7 @@ · 29 unmodified lines | |||
| 75 | "@effect/platform": "catalog:", | 77 | "@effect/platform": "catalog:", |
| 76 | "@executor/api": "workspace:*", | 78 | "@executor/api": "workspace:*", |
| 77 | "@executor/sdk": "workspace:*", | 79 | "@executor/sdk": "workspace:*", |
| 80 | "@executor/ui": "workspace:*", | ||
| 78 | "effect": "catalog:", | 81 | "effect": "catalog:", |
| 79 | "react": "^19.1.0", | 82 | "react": "^19.1.0", |
| 80 | }, | 83 | }, |
| @@ -213,6 +216,41 @@ · 132 unmodified lines | |||
| 213 | "vitest": "catalog:", | 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 | "packages/plugins/keychain": { | 254 | "packages/plugins/keychain": { |
| 217 | "name": "@executor/plugin-keychain", | 255 | "name": "@executor/plugin-keychain", |
| 218 | "version": "0.0.0", | 256 | "version": "0.0.0", |
| @@ -555,6 +593,8 @@ · 336 unmodified lines | |||
| 555 | 593 | ||
| 556 | "@executor/plugin-file-secrets": ["@executor/plugin-file-secrets@workspace:packages/plugins/file-secrets"], | 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 | "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"], | 598 | "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"], |
| 559 | 599 | ||
| 560 | "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"], | 600 | "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"], |
| @@ -11,6 +11,7 @@ · 10 unmodified lines | |||
| 11 | "@effect-atom/atom": "^0.5.3", | 11 | "@effect-atom/atom": "^0.5.3", |
| 12 | "@effect-atom/atom-react": "^0.5.0", | 12 | "@effect-atom/atom-react": "^0.5.0", |
| 13 | "@executor/api": "workspace:*", | 13 | "@executor/api": "workspace:*", |
| 14 | "@executor/ui": "workspace:*", | ||
| 14 | "@executor/sdk": "workspace:*", | 15 | "@executor/sdk": "workspace:*", |
| 15 | "effect": "catalog:", | 16 | "effect": "catalog:", |
| 16 | "react": "^19.1.0" | 17 | "react": "^19.1.0" |
| @@ -37,6 +37,8 @@ export { · 36 unmodified lines | |||
| 37 | 37 | ||
| 38 | // Provider | 38 | // Provider |
| 39 | export { ExecutorProvider } from "./provider"; | 39 | export { ExecutorProvider } from "./provider"; |
| 40 | export { SecretPicker } from "./secret-picker"; | ||
| 41 | export type { SecretPickerSecret } from "./secret-picker"; | ||
| 40 | 42 | ||
| 41 | // Re-export commonly needed SDK types | 43 | // Re-export commonly needed SDK types |
| 42 | export { ScopeId, ToolId } from "@executor/sdk"; | 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 | {error && ( | 108 | {error && ( |
| 109 | <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5"> | 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 | </div> | 111 | </div> |
| 112 | )} | 112 | )} |
| 113 | </div> | 113 | </div> |
| @@ -246,7 +246,7 @@ function ConfigDialog(props: { · 132 unmodified lines | |||
| 246 | 246 | ||
| 247 | {error && ( | 247 | {error && ( |
| 248 | <div className="rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2"> | 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 | </div> | 250 | </div> |
| 251 | )} | 251 | )} |
| 252 | </div> | 252 | </div> |
| @@ -67,7 +67,15 @@ export const makeOnePasswordClient = ( · 66 unmodified lines | |||
| 67 | onTimeout: () => | 67 | onTimeout: () => |
| 68 | new OnePasswordError({ | 68 | new OnePasswordError({ |
| 69 | operation: "client setup", | 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 | onTimeout: () => | 97 | onTimeout: () => |
| 90 | new OnePasswordError({ | 98 | new OnePasswordError({ |
| 91 | operation, | 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 | Effect.withSpan(`onepassword.${operation}`), | 111 | Effect.withSpan(`onepassword.${operation}`), |
| @@ -2,25 +2,20 @@ import { useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; |
| 3 | import { Option } from "effect"; | 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 | import { SecretId } from "@executor/sdk"; | 13 | import { SecretId } from "@executor/sdk"; |
| 7 | import { Button } from "@executor/ui/components/button"; | 14 | import { Button } from "@executor/ui/components/button"; |
| 8 | import { Input } from "@executor/ui/components/input"; | 15 | import { Input } from "@executor/ui/components/input"; |
| 9 | import { Label } from "@executor/ui/components/label"; | 16 | import { Label } from "@executor/ui/components/label"; |
| 10 | import { Textarea } from "@executor/ui/components/textarea"; | 17 | import { Textarea } from "@executor/ui/components/textarea"; |
| 11 | import { Badge } from "@executor/ui/components/badge"; | 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 | import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group"; | 19 | import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group"; |
| 25 | import { Separator } from "@executor/ui/components/separator"; | 20 | import { Separator } from "@executor/ui/components/separator"; |
| 26 | import { Spinner } from "@executor/ui/components/spinner"; | 21 | import { Spinner } from "@executor/ui/components/spinner"; |
| @@ -28,133 +23,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; · 1 unmodified line | |||
| 28 | import type { SpecPreview, HeaderPreset } from "../sdk/preview"; | 23 | import type { SpecPreview, HeaderPreset } from "../sdk/preview"; |
| 29 | import type { HeaderValue } from "../sdk/types"; | 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 | // Inline secret creation | 27 | // Inline secret creation |
| 160 | // --------------------------------------------------------------------------- | 28 | // --------------------------------------------------------------------------- |
| @@ -333,7 +201,7 @@ function HeaderSecretRow(props: { · 172 unmodified lines | |||
| 333 | prefix?: string; | 201 | prefix?: string; |
| 334 | selectedSecretId: string | null; | 202 | selectedSecretId: string | null; |
| 335 | onSelect: (secretId: string) => void; | 203 | onSelect: (secretId: string) => void; |
| 336 | existingSecrets: readonly SecretEntry[]; | 204 | existingSecrets: readonly SecretPickerSecret[]; |
| 337 | }) { | 205 | }) { |
| 338 | const [creating, setCreating] = useState(false); | 206 | const [creating, setCreating] = useState(false); |
| 339 | const { headerName, prefix, selectedSecretId, onSelect, existingSecrets } = props; | 207 | const { headerName, prefix, selectedSecretId, onSelect, existingSecrets } = props; |
| @@ -419,7 +287,7 @@ function CustomHeaderRow(props: { · 79 unmodified lines | |||
| 419 | onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void; | 287 | onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void; |
| 420 | onSelectSecret: (secretId: string) => void; | 288 | onSelectSecret: (secretId: string) => void; |
| 421 | onRemove: () => void; | 289 | onRemove: () => void; |
| 422 | existingSecrets: readonly SecretEntry[]; | 290 | existingSecrets: readonly SecretPickerSecret[]; |
| 423 | }) { | 291 | }) { |
| 424 | const [creating, setCreating] = useState(false); | 292 | const [creating, setCreating] = useState(false); |
| 425 | const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props; | 293 | const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props; |
| @@ -568,9 +436,9 @@ export default function AddOpenApiSource(props: { · 142 unmodified lines | |||
| 568 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); | 436 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); |
| 569 | const secrets = useAtomValue(secretsAtom()); | 437 | const secrets = useAtomValue(secretsAtom()); |
| 570 | 438 | ||
| 571 | const secretList: readonly SecretEntry[] = Result.match(secrets, { | 439 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { |
| 572 | onInitial: () => [] as SecretEntry[], | 440 | onInitial: () => [] as SecretPickerSecret[], |
| 573 | onFailure: () => [] as SecretEntry[], | 441 | onFailure: () => [] as SecretPickerSecret[], |
| 574 | onSuccess: ({ value }) => | 442 | onSuccess: ({ value }) => |
| 575 | value.map((s) => ({ | 443 | value.map((s) => ({ |
| 576 | id: s.id, | 444 | id: s.id, |