History

google discovery support

Rhys Sullivan committed 7 months ago

Commit 2d88a2d1 parent 3033ded38 files · +3964 −180Browse files

Unified · Split

38 files
  1. AGENTS.md +0 −3
  2. apps/server/package.json +1 −0
  3. apps/server/src/handlers/google-discovery.ts +16 −0
  4. apps/server/src/main.ts +7 −1
  5. apps/server/src/services/executor.ts +9 −0
  6. apps/web/package.json +1 −0
  7. apps/web/src/pages/secrets.tsx +26 −26
  8. apps/web/src/pages/sources.tsx +6 −1
  9. bun.lock +40 −0
  10. packages/clients/react/package.json +1 −0
  11. packages/clients/react/src/index.ts +2 −0
  12. packages/clients/react/src/secret-picker.tsx +136 −0
  13. packages/plugins/google-discovery/fixtures/drive.json +96 −0
  14. packages/plugins/google-discovery/package.json +47 −0
  15. packages/plugins/google-discovery/src/api/group.ts +128 −0
  16. packages/plugins/google-discovery/src/api/handlers.ts +139 −0
  17. packages/plugins/google-discovery/src/api/index.ts +5 −0
  18. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +667 −0
  19. packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx +38 −0
  20. packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx +16 −0
  21. packages/plugins/google-discovery/src/react/atoms.ts +18 −0
  22. packages/plugins/google-discovery/src/react/client.ts +16 −0
  23. packages/plugins/google-discovery/src/react/index.ts +8 −0
  24. packages/plugins/google-discovery/src/react/source-plugin.ts +10 −0
  25. packages/plugins/google-discovery/src/sdk/binding-store.ts +163 −0
  26. packages/plugins/google-discovery/src/sdk/document.test.ts +53 −0
  27. packages/plugins/google-discovery/src/sdk/document.ts +559 −0
  28. packages/plugins/google-discovery/src/sdk/errors.ts +32 −0
  29. packages/plugins/google-discovery/src/sdk/index.ts +40 −0
  30. packages/plugins/google-discovery/src/sdk/invoke.ts +446 −0
  31. packages/plugins/google-discovery/src/sdk/oauth.ts +169 −0
  32. packages/plugins/google-discovery/src/sdk/plugin.test.ts +350 −0
  33. packages/plugins/google-discovery/src/sdk/plugin.ts +538 −0
  34. packages/plugins/google-discovery/src/sdk/types.ts +121 −0
  35. packages/plugins/google-discovery/tsconfig.json +27 −0
  36. packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +2 −2
  37. packages/plugins/onepassword/src/sdk/client.ts +18 −2
  38. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +13 −145
AGENTS.md+0−3
@@ -1,3 +0,0 @@
1## Git
2
3Never commit, push, or create branches unless I explicitly tell you to.
apps/server/package.json+1−0
@@ -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:*",
apps/server/src/handlers/google-discovery.ts+16−0
@@ -0,0 +1,16 @@
1import { Effect, Layer } from "effect";
2import {
3 GoogleDiscoveryExtensionService,
4 GoogleDiscoveryHandlers,
5} from "@executor/plugin-google-discovery/api";
6import { ExecutorService } from "../services/executor";
7
8const GoogleDiscoveryExtensionLive = Layer.effect(
9 GoogleDiscoveryExtensionService,
10 Effect.map(ExecutorService, (executor) => executor.googleDiscovery),
11);
12
13export const GoogleDiscoveryHandlersLive = Layer.provide(
14 GoogleDiscoveryHandlers,
15 GoogleDiscoveryExtensionLive,
16);
apps/server/src/main.ts+7−1
@@ -9,12 +9,14 @@ import { Layer } from "effect"; · 8 unmodified lines
9import { addGroup } from "@executor/api";9import { addGroup } from "@executor/api";
10import { OpenApiGroup } from "@executor/plugin-openapi/api";10import { OpenApiGroup } from "@executor/plugin-openapi/api";
11import { McpGroup } from "@executor/plugin-mcp/api";11import { McpGroup } from "@executor/plugin-mcp/api";
12import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api";
12import { OnePasswordGroup } from "@executor/plugin-onepassword/api";13import { OnePasswordGroup } from "@executor/plugin-onepassword/api";
13import { ToolsHandlers } from "./handlers/tools";14import { ToolsHandlers } from "./handlers/tools";
14import { SourcesHandlers } from "./handlers/sources";15import { SourcesHandlers } from "./handlers/sources";
15import { SecretsHandlers } from "./handlers/secrets";16import { SecretsHandlers } from "./handlers/secrets";
16import { OpenApiHandlersLive } from "./handlers/openapi";17import { OpenApiHandlersLive } from "./handlers/openapi";
17import { McpSourceHandlersLive } from "./handlers/mcp-source";18import { McpSourceHandlersLive } from "./handlers/mcp-source";
19import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery";
18import { OnePasswordHandlersLive } from "./handlers/onepassword";20import { OnePasswordHandlersLive } from "./handlers/onepassword";
19import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";21import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";
20import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";22import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
@@ -23,7 +25,10 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 2 unmodified lines
23// Composed API — core + plugin groups25// Composed API — core + plugin groups
24// ---------------------------------------------------------------------------26// ---------------------------------------------------------------------------
25 27
26const ExecutorApiWithPlugins = addGroup(OpenApiGroup).add(McpGroup).add(OnePasswordGroup);28const ExecutorApiWithPlugins = addGroup(OpenApiGroup)
29 .add(McpGroup)
30 .add(GoogleDiscoveryGroup)
31 .add(OnePasswordGroup);
27 32
28// ---------------------------------------------------------------------------33// ---------------------------------------------------------------------------
29// API Layer34// 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);
apps/server/src/services/executor.ts+9−0
@@ -7,6 +7,11 @@ import { createExecutor, scopeKv } from "@executor/sdk"; · 6 unmodified lines
7import { makeSqliteKv, makeKvConfig, migrate } from "@executor/storage-file";7import { makeSqliteKv, makeKvConfig, migrate } from "@executor/storage-file";
8import { openApiPlugin, makeKvOperationStore, type OpenApiPluginExtension } from "@executor/plugin-openapi";8import { openApiPlugin, makeKvOperationStore, type OpenApiPluginExtension } from "@executor/plugin-openapi";
9import { mcpPlugin, makeKvBindingStore, type McpPluginExtension } from "@executor/plugin-mcp";9import { mcpPlugin, makeKvBindingStore, type McpPluginExtension } from "@executor/plugin-mcp";
10import {
11 googleDiscoveryPlugin,
12 makeKvBindingStore as makeKvGoogleDiscoveryBindingStore,
13 type GoogleDiscoveryPluginExtension,
14} from "@executor/plugin-google-discovery";
10import { keychainPlugin } from "@executor/plugin-keychain";15import { keychainPlugin } from "@executor/plugin-keychain";
11import { fileSecretsPlugin } from "@executor/plugin-file-secrets";16import { fileSecretsPlugin } from "@executor/plugin-file-secrets";
12import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword";17import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword";
@@ -16,6 +21,7 @@ import type { Executor, ExecutorPlugin } from "@executor/sdk"; · 3 unmodified lines
16type ServerPlugins = readonly [21type 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({
apps/web/package.json+1−0
@@ -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:*",
apps/web/src/pages/secrets.tsx+26−26
@@ -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>
apps/web/src/pages/sources.tsx+6−1
@@ -4,13 +4,18 @@ import { Result, useAtomValue, useAtomRefresh, sourcesAtom } from "@executor/rea · 3 unmodified lines
4import type { SourcePlugin } from "@executor/react";4import type { SourcePlugin } from "@executor/react";
5import { openApiSourcePlugin } from "@executor/plugin-openapi/react";5import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
6import { mcpSourcePlugin } from "@executor/plugin-mcp/react";6import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
7import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
7import { McpInstallCard } from "../components/mcp-install-card";8import { McpInstallCard } from "../components/mcp-install-card";
8 9
9// ---------------------------------------------------------------------------10// ---------------------------------------------------------------------------
10// Registered source plugins11// Registered source plugins
11// ---------------------------------------------------------------------------12// ---------------------------------------------------------------------------
12 13
13const sourcePlugins: SourcePlugin[] = [openApiSourcePlugin, mcpSourcePlugin];14const sourcePlugins: SourcePlugin[] = [
15 openApiSourcePlugin,
16 mcpSourcePlugin,
17 googleDiscoverySourcePlugin,
18];
14 19
15// ---------------------------------------------------------------------------20// ---------------------------------------------------------------------------
16// Page21// Page
bun.lock+40−0
@@ -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"],
packages/clients/react/package.json+1−0
@@ -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"
packages/clients/react/src/index.ts+2−0
@@ -37,6 +37,8 @@ export { · 36 unmodified lines
37 37
38// Provider38// Provider
39export { ExecutorProvider } from "./provider";39export { ExecutorProvider } from "./provider";
40export { SecretPicker } from "./secret-picker";
41export type { SecretPickerSecret } from "./secret-picker";
40 42
41// Re-export commonly needed SDK types43// Re-export commonly needed SDK types
42export { ScopeId, ToolId } from "@executor/sdk";44export { ScopeId, ToolId } from "@executor/sdk";
packages/clients/react/src/secret-picker.tsx+136−0
@@ -0,0 +1,136 @@
1import { useState, type ChangeEvent, type FocusEvent } from "react";
2
3import { Input } from "@executor/ui/components/input";
4import {
5 Command,
6 CommandEmpty,
7 CommandGroup,
8 CommandItem,
9 CommandList,
10} from "@executor/ui/components/command";
11import {
12 Popover,
13 PopoverAnchor,
14 PopoverContent,
15} from "@executor/ui/components/popover";
16
17export interface SecretPickerSecret {
18 readonly id: string;
19 readonly name: string;
20 readonly provider?: string;
21}
22
23const PROVIDER_LABELS: Record<string, string> = {
24 keychain: "Keychain",
25 file: "Local",
26 memory: "Memory",
27 onepassword: "1Password",
28};
29
30const providerLabel = (key: string | undefined): string => {
31 if (!key) return "Local";
32 return PROVIDER_LABELS[key] ?? key;
33};
34
35export 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}
packages/plugins/google-discovery/fixtures/drive.json+96−0
@@ -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}
packages/plugins/google-discovery/package.json+47−0
@@ -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}
packages/plugins/google-discovery/src/api/group.ts+128−0
@@ -0,0 +1,128 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import { Schema } from "effect";
3import { ScopeId } from "@executor/sdk";
4
5export { HttpApiSchema };
6
7const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
8
9const 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
26const ProbePayload = Schema.Struct({
27 discoveryUrl: Schema.String,
28});
29
30const 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
39const AddSourcePayload = Schema.Struct({
40 name: Schema.String,
41 discoveryUrl: Schema.String,
42 namespace: Schema.optional(Schema.String),
43 auth: AuthPayload,
44});
45
46const AddSourceResponse = Schema.Struct({
47 toolCount: Schema.Number,
48 namespace: Schema.String,
49});
50
51const 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
60const StartOAuthResponse = Schema.Struct({
61 sessionId: Schema.String,
62 authorizationUrl: Schema.String,
63 scopes: Schema.Array(Schema.String),
64});
65
66const CompleteOAuthPayload = Schema.Struct({
67 state: Schema.String,
68 code: Schema.optional(Schema.String),
69 error: Schema.optional(Schema.String),
70});
71
72const 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
84const 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
91const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" });
92
93const ApiError = Schema.Struct({
94 message: Schema.String,
95}).annotations(HttpApiSchema.annotations({ status: 400 }));
96
97export 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") {}
packages/plugins/google-discovery/src/api/handlers.ts+139−0
@@ -0,0 +1,139 @@
1import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2import { Context, Effect } from "effect";
3
4import { addGroup } from "@executor/api";
5import type {
6 GoogleDiscoveryAddSourceInput,
7 GoogleDiscoveryOAuthAuthResult,
8 GoogleDiscoveryPluginExtension,
9} from "../sdk/plugin";
10import { GoogleDiscoveryGroup } from "./group";
11
12export class GoogleDiscoveryExtensionService extends Context.Tag(
13 "GoogleDiscoveryExtensionService",
14)<GoogleDiscoveryExtensionService, GoogleDiscoveryPluginExtension>() {}
15
16const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
17
18type 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
31const escapeHtml = (value: string): string =>
32 value
33 .replaceAll("&", "&amp;")
34 .replaceAll("<", "&lt;")
35 .replaceAll(">", "&gt;")
36 .replaceAll('"', "&quot;")
37 .replaceAll("'", "&#39;");
38
39const 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
68export 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);
packages/plugins/google-discovery/src/api/index.ts+5−0
@@ -0,0 +1,5 @@
1export { GoogleDiscoveryGroup } from "./group";
2export {
3 GoogleDiscoveryExtensionService,
4 GoogleDiscoveryHandlers,
5} from "./handlers";
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+667−0
@@ -0,0 +1,667 @@
1import { useCallback, useMemo, useState } from "react";
2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3
4import {
5 ScopeId,
6 SecretPicker,
7 secretsAtom,
8 type SecretPickerSecret,
9} from "@executor/react";
10import { Badge } from "@executor/ui/components/badge";
11import { Button } from "@executor/ui/components/button";
12import {
13 Collapsible,
14 CollapsibleContent,
15 CollapsibleTrigger,
16} from "@executor/ui/components/collapsible";
17import { Input } from "@executor/ui/components/input";
18import { Label } from "@executor/ui/components/label";
19import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";
20import { Spinner } from "@executor/ui/components/spinner";
21import {
22 addGoogleDiscoverySource,
23 probeGoogleDiscovery,
24 startGoogleDiscoveryOAuth,
25} from "./atoms";
26
27type GoogleDiscoveryTemplate = {
28 id: string;
29 name: string;
30 summary: string;
31 service: string;
32 version: string;
33 discoveryUrl: string;
34};
35
36const defaultGoogleDiscoveryUrl = (service: string, version: string): string =>
37 `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
38
39const googleDiscoveryTemplate = (
40 template: GoogleDiscoveryTemplate,
41): GoogleDiscoveryTemplate => ({
42 ...template,
43 discoveryUrl:
44 template.discoveryUrl || defaultGoogleDiscoveryUrl(template.service, template.version),
45});
46
47const 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
146const 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
161function 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
181type ProbeResult = {
182 name: string;
183 title: string | null;
184 service: string;
185 version: string;
186 toolCount: number;
187 scopes: readonly string[];
188};
189
190type 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
202type 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
215const DEFAULT_SCOPE = ScopeId.make("default");
216
217function 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
263export 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}
packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx+38−0
@@ -0,0 +1,38 @@
1import { Badge } from "@executor/ui/components/badge";
2import { Button } from "@executor/ui/components/button";
3
4export 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}
packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx+16−0
@@ -0,0 +1,16 @@
1import { Badge } from "@executor/ui/components/badge";
2
3export 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 Google
12 </Badge>
13 <span className="text-xs text-muted-foreground">{sourceId}</span>
14 </span>
15 );
16}
packages/plugins/google-discovery/src/react/atoms.ts+18−0
@@ -0,0 +1,18 @@
1import { GoogleDiscoveryClient } from "./client";
2
3export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation(
4 "googleDiscovery",
5 "probeDiscovery",
6);
7export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation(
8 "googleDiscovery",
9 "addSource",
10);
11export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(
12 "googleDiscovery",
13 "startOAuth",
14);
15export const completeGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(
16 "googleDiscovery",
17 "completeOAuth",
18);
packages/plugins/google-discovery/src/react/client.ts+16−0
@@ -0,0 +1,16 @@
1import { AtomHttpApi } from "@effect-atom/atom-react";
2import { FetchHttpClient } from "@effect/platform";
3import { addGroup } from "@executor/api";
4import { getBaseUrl } from "@executor/react";
5import { GoogleDiscoveryGroup } from "../api/group";
6
7const GoogleDiscoveryApi = addGroup(GoogleDiscoveryGroup);
8
9export const GoogleDiscoveryClient = AtomHttpApi.Tag<"GoogleDiscoveryClient">()(
10 "GoogleDiscoveryClient",
11 {
12 api: GoogleDiscoveryApi,
13 httpClient: FetchHttpClient.layer,
14 baseUrl: getBaseUrl(),
15 },
16);
packages/plugins/google-discovery/src/react/index.ts+8−0
@@ -0,0 +1,8 @@
1export { googleDiscoverySourcePlugin } from "./source-plugin";
2export { GoogleDiscoveryClient } from "./client";
3export {
4 addGoogleDiscoverySource,
5 completeGoogleDiscoveryOAuth,
6 probeGoogleDiscovery,
7 startGoogleDiscoveryOAuth,
8} from "./atoms";
packages/plugins/google-discovery/src/react/source-plugin.ts+10−0
@@ -0,0 +1,10 @@
1import { lazy } from "react";
2import type { SourcePlugin } from "@executor/react";
3
4export 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};
packages/plugins/google-discovery/src/sdk/binding-store.ts+163−0
@@ -0,0 +1,163 @@
1import { Effect, Schema } from "effect";
2import {
3 makeInMemoryScopedKv,
4 scopeKv,
5 type Kv,
6 type ScopedKv,
7 type ToolId,
8} from "@executor/sdk";
9
10import {
11 GoogleDiscoveryMethodBinding,
12 GoogleDiscoveryStoredSourceData,
13} from "./types";
14
15const StoredBindingEntry = Schema.Struct({
16 namespace: Schema.String,
17 binding: GoogleDiscoveryMethodBinding,
18});
19
20const encodeBindingEntry = Schema.encodeSync(
21 Schema.parseJson(StoredBindingEntry),
22);
23const decodeBindingEntry = Schema.decodeUnknownSync(
24 Schema.parseJson(StoredBindingEntry),
25);
26const encodeSourceData = Schema.encodeSync(
27 Schema.parseJson(GoogleDiscoveryStoredSourceData),
28);
29const decodeSourceData = Schema.decodeUnknownSync(
30 Schema.parseJson(GoogleDiscoveryStoredSourceData),
31);
32
33export interface GoogleDiscoverySourceMeta {
34 readonly namespace: string;
35 readonly name: string;
36}
37
38export 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
74const 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
148export 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
158export const makeInMemoryBindingStore = (): GoogleDiscoveryBindingStore =>
159 makeStore(
160 makeInMemoryScopedKv(),
161 makeInMemoryScopedKv(),
162 makeInMemoryScopedKv(),
163 );
packages/plugins/google-discovery/src/sdk/document.test.ts+53−0
@@ -0,0 +1,53 @@
1import { readFileSync } from "node:fs";
2import { resolve } from "node:path";
3
4import { Effect, Option } from "effect";
5import { describe, expect, it } from "vitest";
6
7import { extractGoogleDiscoveryManifest } from "./document";
8
9const fixturePath = resolve(__dirname, "../../fixtures/drive.json");
10const fixtureText = readFileSync(fixturePath, "utf8");
11
12describe("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});
packages/plugins/google-discovery/src/sdk/document.ts+559−0
@@ -0,0 +1,559 @@
1import { Effect, Option, Schema } from "effect";
2
3import { GoogleDiscoveryParseError } from "./errors";
4import {
5 GoogleDiscoveryHttpMethod,
6 GoogleDiscoveryManifest,
7 GoogleDiscoveryManifestMethod,
8 GoogleDiscoveryMethodBinding,
9 GoogleDiscoveryParameter,
10 GoogleDiscoveryParameterLocation,
11} from "./types";
12
13type JsonObject = Record<string, unknown>;
14
15const TrimmedString = Schema.String.pipe(Schema.compose(Schema.Trim));
16const Text = TrimmedString.pipe(Schema.compose(Schema.NonEmptyTrimmedString));
17const TextOption = Schema.optionalWith(Schema.OptionFromNonEmptyTrimmedString, {
18 default: () => Option.none(),
19});
20const TextArray = Schema.optionalWith(Schema.Array(Text), {
21 default: () => [] as string[],
22});
23const UnknownRecord = Schema.Record({ key: Schema.String, value: Schema.Unknown });
24const UnknownRecordWithDefault = Schema.optionalWith(UnknownRecord, {
25 default: () => ({}),
26});
27
28const DiscoveryHttpMethodInput = Schema.optional(
29 Schema.String.pipe(
30 Schema.compose(Schema.Trim),
31 Schema.compose(Schema.Lowercase),
32 Schema.compose(GoogleDiscoveryHttpMethod),
33 ),
34);
35
36const DiscoveryParameterLocationInput = Schema.optional(
37 Schema.String.pipe(
38 Schema.compose(Schema.Trim),
39 Schema.compose(Schema.Lowercase),
40 Schema.compose(GoogleDiscoveryParameterLocation),
41 ),
42);
43
44const DiscoveryDefaultValue = Schema.Union(
45 Schema.String,
46 Schema.Number,
47 Schema.Boolean,
48);
49
50const 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});
68type DiscoverySchema = typeof DiscoverySchemaModel.Type;
69
70const 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});
90type DiscoveryParameter = typeof DiscoveryParameterModel.Type;
91
92const DiscoveryRefModel = Schema.Struct({
93 $ref: Schema.optional(Text),
94});
95
96const 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});
106type DiscoveryMethod = typeof DiscoveryMethodModel.Type;
107
108const DiscoveryResourceModel = Schema.Struct({
109 methods: UnknownRecordWithDefault,
110 resources: UnknownRecordWithDefault,
111});
112
113const 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});
141type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;
142
143const toParseError = (message: string, error: unknown) =>
144 new GoogleDiscoveryParseError({ message, error });
145
146const 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
156const decodeDiscoveryDocument = decodeUnknownWith(
157 "Failed to decode Google Discovery document",
158 Schema.decodeUnknownSync(DiscoveryDocumentModel),
159);
160
161const decodeDiscoveryDocumentJson = decodeUnknownWith(
162 "Failed to parse Google Discovery document",
163 Schema.decodeUnknownSync(Schema.parseJson(DiscoveryDocumentModel)),
164);
165
166const decodeDiscoverySchema = decodeUnknownWith(
167 "Failed to decode Google Discovery schema",
168 Schema.decodeUnknownSync(DiscoverySchemaModel),
169);
170
171const decodeDiscoveryParameter = decodeUnknownWith(
172 "Failed to decode Google Discovery parameter",
173 Schema.decodeUnknownSync(DiscoveryParameterModel),
174);
175
176const decodeDiscoveryMethod = decodeUnknownWith(
177 "Failed to decode Google Discovery method",
178 Schema.decodeUnknownSync(DiscoveryMethodModel),
179);
180
181const decodeDiscoveryResource = decodeUnknownWith(
182 "Failed to decode Google Discovery resource",
183 Schema.decodeUnknownSync(DiscoveryResourceModel),
184);
185
186const schemaRef = (name: string) => `#/$defs/${name}`;
187
188const 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
214const 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
291const 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
300const 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
340const 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
347const 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
365const 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
385const 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
413const 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
426const 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
469const 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
497export 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});
packages/plugins/google-discovery/src/sdk/errors.ts+32−0
@@ -0,0 +1,32 @@
1import { Schema } from "effect";
2
3export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()(
4 "GoogleDiscoveryParseError",
5 {
6 message: Schema.String,
7 error: Schema.Defect,
8 },
9) {}
10
11export 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
20export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscoveryOAuthError>()(
21 "GoogleDiscoveryOAuthError",
22 {
23 message: Schema.String,
24 },
25) {}
26
27export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()(
28 "GoogleDiscoverySourceError",
29 {
30 message: Schema.String,
31 },
32) {}
packages/plugins/google-discovery/src/sdk/index.ts+40−0
@@ -0,0 +1,40 @@
1export { googleDiscoveryPlugin } from "./plugin";
2export type {
3 GoogleDiscoveryAddSourceInput,
4 GoogleDiscoveryOAuthAuthResult,
5 GoogleDiscoveryOAuthCompleteInput,
6 GoogleDiscoveryOAuthStartInput,
7 GoogleDiscoveryOAuthStartResponse,
8 GoogleDiscoveryPluginExtension,
9 GoogleDiscoveryProbeResult,
10} from "./plugin";
11export { extractGoogleDiscoveryManifest } from "./document";
12export { makeKvBindingStore, makeInMemoryBindingStore } from "./binding-store";
13export type { GoogleDiscoveryBindingStore } from "./binding-store";
14export { makeGoogleDiscoveryInvoker } from "./invoke";
15export {
16 buildGoogleAuthorizationUrl,
17 createPkceCodeVerifier,
18 exchangeAuthorizationCode,
19 refreshAccessToken,
20} from "./oauth";
21export {
22 GoogleDiscoveryAuth,
23 GoogleDiscoveryHttpMethod,
24 GoogleDiscoveryInvocationResult,
25 GoogleDiscoveryManifest,
26 GoogleDiscoveryManifestMethod,
27 GoogleDiscoveryMethodBinding,
28 GoogleDiscoveryParameter,
29 GoogleDiscoveryParameterLocation,
30 GoogleDiscoveryStoredSourceData,
31} from "./types";
32export type {
33 GoogleDiscoveryOAuthSession,
34} from "./types";
35export {
36 GoogleDiscoveryInvocationError,
37 GoogleDiscoveryOAuthError,
38 GoogleDiscoveryParseError,
39 GoogleDiscoverySourceError,
40} from "./errors";
packages/plugins/google-discovery/src/sdk/invoke.ts+446−0
@@ -0,0 +1,446 @@
1import { Effect, Layer, Option } from "effect";
2import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
3
4import {
5 type ScopeId,
6 type SecretId,
7 type ToolId,
8 ToolInvocationError,
9 ToolInvocationResult,
10 type ToolInvoker,
11} from "@executor/sdk";
12
13import { GoogleDiscoveryInvocationError } from "./errors";
14import type { GoogleDiscoveryBindingStore } from "./binding-store";
15import {
16 GoogleDiscoveryInvocationResult,
17 GoogleDiscoveryStoredSourceData,
18 type GoogleDiscoveryParameter,
19} from "./types";
20import { refreshAccessToken } from "./oauth";
21
22const OAUTH_REFRESH_SKEW_MS = 60_000;
23
24const SAFE_METHODS = new Set(["get", "head", "options"]);
25
26const 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
49const 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
68const resolveBaseUrl = (source: GoogleDiscoveryStoredSourceData): string =>
69 new URL(source.servicePath || "", source.rootUrl).toString();
70
71const 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
81const 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
232export 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
243const 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
349export 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};
packages/plugins/google-discovery/src/sdk/oauth.ts+169−0
@@ -0,0 +1,169 @@
1import { createHash, randomBytes } from "node:crypto";
2
3import { Effect } from "effect";
4
5import { GoogleDiscoveryOAuthError } from "./errors";
6
7export 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
15const encodeBase64Url = (input: Buffer): string =>
16 input
17 .toString("base64")
18 .replaceAll("+", "-")
19 .replaceAll("/", "_")
20 .replaceAll("=", "");
21
22export const createPkceCodeVerifier = (): string =>
23 encodeBase64Url(randomBytes(48));
24
25const createPkceCodeChallenge = (verifier: string): string =>
26 encodeBase64Url(createHash("sha256").update(verifier).digest());
27
28export 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
52const 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
109const 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
129export 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
150export 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 });
packages/plugins/google-discovery/src/sdk/plugin.test.ts+350−0
@@ -0,0 +1,350 @@
1import { createServer } from "node:http";
2import { readFileSync } from "node:fs";
3import { resolve } from "node:path";
4
5import { Effect } from "effect";
6import { describe, expect, it, vi } from "vitest";
7
8import {
9 createExecutor,
10 makeTestConfig,
11 SecretId,
12 type InvokeOptions,
13} from "@executor/sdk";
14import { googleDiscoveryPlugin } from "./plugin";
15
16const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
17const fixturePath = resolve(__dirname, "../../fixtures/drive.json");
18const fixtureText = readFileSync(fixturePath, "utf8");
19
20const 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
108describe("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});
packages/plugins/google-discovery/src/sdk/plugin.ts+538−0
@@ -0,0 +1,538 @@
1import { randomUUID } from "node:crypto";
2
3import { Effect, Option } from "effect";
4
5import {
6 Source,
7 definePlugin,
8 type ExecutorPlugin,
9 type PluginContext,
10 SecretId,
11 ToolId,
12 type ToolRegistration,
13} from "@executor/sdk";
14
15import type {
16 GoogleDiscoveryBindingStore,
17 GoogleDiscoverySourceMeta,
18} from "./binding-store";
19import { makeInMemoryBindingStore } from "./binding-store";
20import { extractGoogleDiscoveryManifest } from "./document";
21import { makeGoogleDiscoveryInvoker } from "./invoke";
22import {
23 GoogleDiscoveryParseError,
24 GoogleDiscoveryOAuthError,
25 GoogleDiscoverySourceError,
26} from "./errors";
27import {
28 buildGoogleAuthorizationUrl,
29 createPkceCodeVerifier,
30 exchangeAuthorizationCode,
31} from "./oauth";
32import type {
33 GoogleDiscoveryAuth,
34 GoogleDiscoveryManifest,
35 GoogleDiscoveryManifestMethod,
36 GoogleDiscoveryOAuthSession,
37 GoogleDiscoveryStoredSourceData,
38} from "./types";
39import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";
40
41export 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
50export interface GoogleDiscoveryAddSourceInput {
51 readonly name: string;
52 readonly discoveryUrl: string;
53 readonly namespace?: string;
54 readonly auth: GoogleDiscoveryAuth;
55}
56
57export 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
66export interface GoogleDiscoveryOAuthStartResponse {
67 readonly sessionId: string;
68 readonly authorizationUrl: string;
69 readonly scopes: readonly string[];
70}
71
72export interface GoogleDiscoveryOAuthCompleteInput {
73 readonly state: string;
74 readonly code?: string;
75 readonly error?: string;
76}
77
78export 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
90export 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
115const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis";
116
117const 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
155const 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
176const normalizeSlug = (value: string): string =>
177 value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
178
179const 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
189const 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
240const 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
263export 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};
packages/plugins/google-discovery/src/sdk/types.ts+121−0
@@ -0,0 +1,121 @@
1import { Schema } from "effect";
2
3export const GoogleDiscoveryHttpMethod = Schema.Literal(
4 "get",
5 "put",
6 "post",
7 "delete",
8 "patch",
9 "head",
10 "options",
11);
12export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethod.Type;
13
14export const GoogleDiscoveryParameterLocation = Schema.Literal(
15 "path",
16 "query",
17 "header",
18);
19export type GoogleDiscoveryParameterLocation =
20 typeof GoogleDiscoveryParameterLocation.Type;
21
22export 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
33export 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
42export 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
53export 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
69export 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);
85export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type;
86
87export 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
99export 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
108export interface GoogleDiscoverySourceMeta {
109 readonly namespace: string;
110 readonly name: string;
111}
112
113export 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}
packages/plugins/google-discovery/tsconfig.json+27−0
@@ -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}
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+2−2
@@ -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>
packages/plugins/onepassword/src/sdk/client.ts+18−2
@@ -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}`),
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+13−145
@@ -2,25 +2,20 @@ import { useState } from "react"; · 1 unmodified line
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
3import { Option } from "effect";3import { Option } from "effect";
4 4
5import { secretsAtom, setSecret, resolveSecret, ScopeId } from "@executor/react";5import {
6 secretsAtom,
7 setSecret,
8 resolveSecret,
9 ScopeId,
10 SecretPicker,
11 type SecretPickerSecret,
12} from "@executor/react";
6import { SecretId } from "@executor/sdk";13import { SecretId } from "@executor/sdk";
7import { Button } from "@executor/ui/components/button";14import { Button } from "@executor/ui/components/button";
8import { Input } from "@executor/ui/components/input";15import { Input } from "@executor/ui/components/input";
9import { Label } from "@executor/ui/components/label";16import { Label } from "@executor/ui/components/label";
10import { Textarea } from "@executor/ui/components/textarea";17import { Textarea } from "@executor/ui/components/textarea";
11import { Badge } from "@executor/ui/components/badge";18import { Badge } from "@executor/ui/components/badge";
12import {
13 Command,
14 CommandList,
15 CommandEmpty,
16 CommandGroup,
17 CommandItem,
18} from "@executor/ui/components/command";
19import {
20 Popover,
21 PopoverAnchor,
22 PopoverContent,
23} from "@executor/ui/components/popover";
24import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";19import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";
25import { Separator } from "@executor/ui/components/separator";20import { Separator } from "@executor/ui/components/separator";
26import { Spinner } from "@executor/ui/components/spinner";21import { Spinner } from "@executor/ui/components/spinner";
@@ -28,133 +23,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; · 1 unmodified line
28import type { SpecPreview, HeaderPreset } from "../sdk/preview";23import type { SpecPreview, HeaderPreset } from "../sdk/preview";
29import type { HeaderValue } from "../sdk/types";24import type { HeaderValue } from "../sdk/types";
30 25
31// ---------------------------------------------------------------------------
32// Types
33// ---------------------------------------------------------------------------
34
35interface SecretEntry {
36 id: string;
37 name: string;
38 provider?: string;
39}
40
41// ---------------------------------------------------------------------------
42// Provider display names
43// ---------------------------------------------------------------------------
44
45const PROVIDER_LABELS: Record<string, string> = {
46 keychain: "Keychain",
47 file: "Local",
48 memory: "Memory",
49 onepassword: "1Password",
50};
51
52function 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
61function 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 creation27// 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,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t