History

feat: add shared provider oauth grants

Rhys Sullivan committed 8 months ago

Commit 35d32251 parent c0ea4b348 files · +7368 −287Browse files

Unified · Split

48 files
  1. .changeset/long-webs-sing.md +5 −0
  2. apps/executor/src/cli/main.ts +3 −1
  3. apps/executor/src/distribution/artifact.ts +0 −21
  4. apps/executor/src/distribution/distribution.test.ts +6 −0
  5. apps/executor/src/distribution/harness.ts +19 −1
  6. apps/web/src/components/source-favicon.tsx +25 −3
  7. apps/web/src/globals.css +6 −0
  8. apps/web/src/lib/source-favicon.ts +60 −0
  9. apps/web/src/views/add-source.tsx +559 −109
  10. apps/web/src/views/source-detail.tsx +20 −8
  11. apps/web/src/views/source-editor.tsx +151 −30
  12. apps/web/src/views/source-templates.ts +12 −0
  13. bun.lock +6 −0
  14. packages/codemode-mcp/src/mcp-connection.ts +12 −2
  15. packages/control-plane/package.json +2 −0
  16. packages/control-plane/src/api/index.ts +6 −0
  17. packages/control-plane/src/api/sources/api.ts +105 −0
  18. packages/control-plane/src/api/sources/http.ts +186 −6
  19. packages/control-plane/src/runtime/auth-artifacts.ts +111 −0
  20. packages/control-plane/src/runtime/auth-leases.ts +193 −0
  21. packages/control-plane/src/runtime/control-plane-runtime.test.ts +836 −0
  22. packages/control-plane/src/runtime/index.ts +15 −0
  23. packages/control-plane/src/runtime/ir-execution.ts +3 −0
  24. packages/control-plane/src/runtime/legacy-postgres-migration.test.ts +639 −0
  25. packages/control-plane/src/runtime/legacy-postgres-migration.ts +1578 −0
  26. packages/control-plane/src/runtime/local-config.ts +1 −1
  27. packages/control-plane/src/runtime/local-control-plane-store.test.ts +33 −0
  28. packages/control-plane/src/runtime/local-control-plane-store.ts +198 −1
  29. packages/control-plane/src/runtime/mcp-auth-provider.ts +262 −0
  30. packages/control-plane/src/runtime/provider-grant-lifecycle.ts +93 −0
  31. packages/control-plane/src/runtime/source-adapters/google-discovery.ts +4 −1
  32. packages/control-plane/src/runtime/source-adapters/mcp.test.ts +227 −0
  33. packages/control-plane/src/runtime/source-adapters/mcp.ts +1 −0
  34. packages/control-plane/src/runtime/source-auth-material.test.ts +94 −0
  35. packages/control-plane/src/runtime/source-auth-material.ts +5 −1
  36. packages/control-plane/src/runtime/source-auth-service.ts +1312 −77
  37. packages/control-plane/src/runtime/source-definitions.ts +77 −4
  38. packages/control-plane/src/runtime/source-store.ts +59 −1
  39. packages/control-plane/src/runtime/store.ts +28 −0
  40. packages/control-plane/src/schema/ids.ts +8 −0
  41. packages/control-plane/src/schema/index.ts +2 −0
  42. packages/control-plane/src/schema/models/auth-artifact.ts +90 −19
  43. packages/control-plane/src/schema/models/provider-auth-grant.ts +34 −0
  44. packages/control-plane/src/schema/models/source-auth-session.ts +34 −1
  45. packages/control-plane/src/schema/models/source.ts +23 −0
  46. packages/control-plane/src/schema/models/workspace-oauth-client.ts +22 −0
  47. packages/react/src/index.ts +202 −0
  48. packages/server/src/index.ts +1 −0
.changeset/long-webs-sing.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": minor
3---
4
5add multiple sources at same time
apps/executor/src/cli/main.ts+3−1
@@ -273,7 +273,9 @@ const buildRunWorkflowText = ( · 272 unmodified lines
273 273
274const loadRunWorkflowText = (): Effect.Effect<string, Error, never> =>274const loadRunWorkflowText = (): Effect.Effect<string, Error, never> =>
275 Effect.acquireUseRelease(275 Effect.acquireUseRelease(
276 createControlPlaneRuntime({}).pipe(Effect.mapError(toError)),276 createControlPlaneRuntime({
277 localDataDir: DEFAULT_LOCAL_DATA_DIR,
278 }).pipe(Effect.mapError(toError)),
277 (runtime) =>279 (runtime) =>
278 Effect.gen(function* () {280 Effect.gen(function* () {
279 const environment = yield* Effect.gen(function* () {281 const environment = yield* Effect.gen(function* () {
apps/executor/src/distribution/artifact.ts+0−21
@@ -90,25 +90,6 @@ const resolveQuickJsWasmPath = (): string => { · 89 unmodified lines
90 return wasmPath;90 return wasmPath;
91};91};
92 92
93const resolveJsoncParserUmdImplPath = async (): Promise<string> => {
94 const requireFromControlPlane = createRequire(
95 join(repoRoot, "packages/control-plane/package.json"),
96 );
97 const jsoncParserPackagePath = requireFromControlPlane.resolve(
98 "jsonc-parser/package.json",
99 );
100 const jsoncParserUmdImplPath = resolve(dirname(jsoncParserPackagePath), "lib/umd/impl");
101
102 if (!existsSync(jsoncParserUmdImplPath)) {
103 throw new Error(
104 `Unable to locate jsonc-parser UMD implementation files at ${jsoncParserUmdImplPath}`,
105 );
106 }
107
108 return jsoncParserUmdImplPath;
109};
110
111
112const createPackageJson = (input: {93const createPackageJson = (input: {
113 packageName: string;94 packageName: string;
114 packageVersion: string;95 packageVersion: string;
@@ -169,7 +150,6 @@ export const buildDistributionPackage = async ( · 54 unmodified lines
169 const bundlePath = join(binDir, "executor.mjs");150 const bundlePath = join(binDir, "executor.mjs");
170 const launcherPath = join(binDir, "executor.js");151 const launcherPath = join(binDir, "executor.js");
171 const quickJsWasmPath = resolveQuickJsWasmPath();152 const quickJsWasmPath = resolveQuickJsWasmPath();
172 const jsoncParserUmdImplPath = await resolveJsoncParserUmdImplPath();
173 const webDistDir = join(repoRoot, "apps/web/dist");153 const webDistDir = join(repoRoot, "apps/web/dist");
174 const readmePath = join(repoRoot, "README.md");154 const readmePath = join(repoRoot, "README.md");
175 const packageName = options.packageName ?? defaults.name;155 const packageName = options.packageName ?? defaults.name;
@@ -205,7 +185,6 @@ export const buildDistributionPackage = async ( · 29 unmodified lines
205 185
206 await cp(webDistDir, webDir, { recursive: true });186 await cp(webDistDir, webDir, { recursive: true });
207 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));187 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
208 await cp(jsoncParserUmdImplPath, join(binDir, "impl"), { recursive: true });
209 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });188 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });
210 await cp(189 await cp(
211 join(repoRoot, "packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm"),190 join(repoRoot, "packages/codemode-openapi/src/openapi-extractor-wasm/openapi_extractor_bg.wasm"),
apps/executor/src/distribution/distribution.test.ts+6−0
@@ -15,6 +15,12 @@ describe("distribution flow", () => { · 14 unmodified lines
15 Effect.gen(function* () {15 Effect.gen(function* () {
16 const harness = yield* DistributionHarness;16 const harness = yield* DistributionHarness;
17 17
18 yield* harness.writeProjectConfig(`{
19 // local workspace config
20 "sources": {},
21}
22`);
23
18 const initialDoctor = yield* runCommand([24 const initialDoctor = yield* runCommand([
19 "doctor",25 "doctor",
20 "--json",26 "--json",
apps/executor/src/distribution/harness.ts+19−1
@@ -1,5 +1,5 @@
1import { spawn } from "node:child_process";1import { spawn } from "node:child_process";
2import { mkdir, mkdtemp, rm } from "node:fs/promises";2import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3import { createServer } from "node:net";3import { createServer } from "node:net";
4import { tmpdir } from "node:os";4import { tmpdir } from "node:os";
5import { dirname, join } from "node:path";5import { dirname, join } from "node:path";
@@ -26,6 +26,9 @@ export class DistributionHarness extends Context.Tag( · 20 unmodified lines
26 readonly tarballPath: string;26 readonly tarballPath: string;
27 readonly executorHome: string;27 readonly executorHome: string;
28 readonly baseUrl: string;28 readonly baseUrl: string;
29 readonly writeProjectConfig: (
30 contents: string,
31 ) => Effect.Effect<void, Error, never>;
29 readonly run: (32 readonly run: (
30 args: ReadonlyArray<string>,33 args: ReadonlyArray<string>,
31 options?: {34 options?: {
@@ -185,6 +188,8 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 153 unmodified lines
185 const prefixDir = join(tempRoot, "prefix");188 const prefixDir = join(tempRoot, "prefix");
186 const homeDir = join(tempRoot, "home");189 const homeDir = join(tempRoot, "home");
187 const executorHome = join(homeDir, ".executor");190 const executorHome = join(homeDir, ".executor");
191 const stagedWorkspaceRoot = packageDir;
192 const installedWorkspaceRoot = tempRoot;
188 const baseUrl = `http://127.0.0.1:${yield* allocatePort()}`;193 const baseUrl = `http://127.0.0.1:${yield* allocatePort()}`;
189 194
190 yield* Effect.tryPromise({195 yield* Effect.tryPromise({
@@ -256,12 +261,25 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 65 unmodified lines
256 catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),261 catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
257 });262 });
258 263
264 const writeProjectConfig = (contents: string) =>
265 Effect.tryPromise({
266 try: async () => {
267 for (const workspaceRoot of [stagedWorkspaceRoot, installedWorkspaceRoot]) {
268 const configDir = join(workspaceRoot, ".executor");
269 await mkdir(configDir, { recursive: true });
270 await writeFile(join(configDir, "executor.jsonc"), contents, "utf8");
271 }
272 },
273 catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
274 });
275
259 return DistributionHarness.of({276 return DistributionHarness.of({
260 packageDir,277 packageDir,
261 launcherPath: artifact.launcherPath,278 launcherPath: artifact.launcherPath,
262 tarballPath,279 tarballPath,
263 executorHome,280 executorHome,
264 baseUrl,281 baseUrl,
282 writeProjectConfig,
265 run,283 run,
266 runInstalled,284 runInstalled,
267 fetchText,285 fetchText,
apps/web/src/components/source-favicon.tsx+25−3
@@ -1,6 +1,6 @@
1import { useMemo, useState } from "react";1import { useMemo, useState } from "react";
2import type { Source } from "@executor/react";2import type { Source } from "@executor/react";
3import { getSourceFaviconUrl } from "../lib/source-favicon";3import { getGoogleProductIconUrl, getSourceFaviconUrl } from "../lib/source-favicon";
4import { cn } from "../lib/utils";4import { cn } from "../lib/utils";
5 5
6type SourceKind = Source["kind"] | string;6type SourceKind = Source["kind"] | string;
@@ -16,7 +16,14 @@ export function SourceFavicon({ · 9 unmodified lines
16 className?: string;16 className?: string;
17 size?: number;17 size?: number;
18}) {18}) {
19 const faviconUrl = useMemo(() => getSourceFaviconUrl(endpoint), [endpoint]);19 const faviconUrl = useMemo(() => {
20 // For google_discovery, prefer the real product icon
21 if (kind === "google_discovery") {
22 return getGoogleProductIconUrl(endpoint) ?? getSourceFaviconUrl(endpoint);
23 }
24 return getSourceFaviconUrl(endpoint);
25 }, [endpoint, kind]);
26
20 const [failedUrl, setFailedUrl] = useState<string | null>(null);27 const [failedUrl, setFailedUrl] = useState<string | null>(null);
21 const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl);28 const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl);
22 29
@@ -31,7 +38,7 @@ export function SourceFavicon({ · 8 unmodified lines
31 alt=""38 alt=""
32 width={size}39 width={size}
33 height={size}40 height={size}
34 className={cn("size-full rounded-full object-cover", className)}41 className={cn("size-full object-contain", className)}
35 loading="lazy"42 loading="lazy"
36 referrerPolicy="no-referrer"43 referrerPolicy="no-referrer"
37 onError={() => setFailedUrl(faviconUrl)}44 onError={() => setFailedUrl(faviconUrl)}
@@ -70,6 +77,21 @@ export function DefaultSourceIcon({ · 32 unmodified lines
70 <path d="M5 6h6M5 8h4M5 10h5" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />77 <path d="M5 6h6M5 8h4M5 10h5" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />
71 </svg>78 </svg>
72 );79 );
80 case "google_discovery":
81 return (
82 <svg viewBox="0 0 16 16" className={base}>
83 <path d="M8 3.2a4.8 4.8 0 00-3.39 8.2l1.02-1.02A3.36 3.36 0 018 4.64a3.33 3.33 0 012.34.96l1.03-1.03A4.78 4.78 0 008 3.2z" fill="#EA4335" />
84 <path d="M12.8 8c0-.37-.04-.72-.1-1.06H8v2.12h2.7a2.4 2.4 0 01-1 1.52l1.02 1.02A4.8 4.8 0 0012.8 8z" fill="#4285F4" />
85 <path d="M5.63 9.38A3.36 3.36 0 014.64 8c0-.5.11-.97.3-1.4L3.92 5.58A4.78 4.78 0 003.2 8c0 .88.26 1.7.72 2.4l1.02-1.02z" fill="#FBBC05" />
86 <path d="M8 12.8c1.2 0 2.27-.4 3.1-1.1l-1.02-1.02c-.54.38-1.24.6-2.08.6a3.36 3.36 0 01-3.07-2.28l-1.02 1.02A4.8 4.8 0 008 12.8z" fill="#34A853" />
87 </svg>
88 );
89 case "internal":
90 return (
91 <svg viewBox="0 0 16 16" fill="none" className={base}>
92 <path d="M8 2v12M4 6l4-4 4 4M4 10l4 4 4-4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
93 </svg>
94 );
73 default:95 default:
74 return (96 return (
75 <svg viewBox="0 0 16 16" fill="none" className={base}>97 <svg viewBox="0 0 16 16" fill="none" className={base}>
apps/web/src/globals.css+6−0
@@ -152,6 +152,12 @@ · 151 unmodified lines
152 }152 }
153}153}
154 154
155/* Indeterminate progress shimmer */
156@keyframes indeterminate-progress {
157 0% { transform: translateX(-100%); }
158 100% { transform: translateX(400%); }
159}
160
155/* Streamdown code block overrides */161/* Streamdown code block overrides */
156[data-streamdown="code-block-body"] {162[data-streamdown="code-block-body"] {
157 background-color: var(--muted) !important;163 background-color: var(--muted) !important;
apps/web/src/lib/source-favicon.ts+60−0
@@ -7,6 +7,64 @@ const RAW_HOSTS = new Set([ · 6 unmodified lines
7 "raw.github.com",7 "raw.github.com",
8]);8]);
9 9
10// ---------------------------------------------------------------------------
11// Google product icon URLs (official CDN assets)
12// ---------------------------------------------------------------------------
13
14const GOOGLE_SERVICE_ICONS: Record<string, string> = {
15 calendar: "https://ssl.gstatic.com/calendar/images/dynamiclogo_2020q4/calendar_31_2x.png",
16 drive: "https://ssl.gstatic.com/images/branding/product/2x/drive_2020q4_48dp.png",
17 gmail: "https://ssl.gstatic.com/ui/v1/icons/mail/rfr/gmail.ico",
18 docs: "https://ssl.gstatic.com/docs/documents/images/kix-favicon7.ico",
19 sheets: "https://ssl.gstatic.com/docs/spreadsheets/favicon3.ico",
20 slides: "https://ssl.gstatic.com/docs/presentations/images/favicon5.ico",
21 forms: "https://ssl.gstatic.com/docs/forms/device_home/android_192.png",
22 searchconsole: "https://ssl.gstatic.com/search-console/scfe/search_console-64.png",
23 people: "https://ssl.gstatic.com/images/branding/product/2x/contacts_2022_48dp.png",
24 tasks: "https://ssl.gstatic.com/tasks/images/favicon.ico",
25 chat: "https://ssl.gstatic.com/chat/favicon/favicon_v2.ico",
26 keep: "https://ssl.gstatic.com/keep/icon_2020q4v2_128.png",
27 classroom: "https://ssl.gstatic.com/classroom/favicon.png",
28 admin: "https://ssl.gstatic.com/images/branding/product/2x/admin_2020q4_48dp.png",
29 script: "https://ssl.gstatic.com/script/images/favicon.ico",
30 bigquery: "https://ssl.gstatic.com/bqui1/favicon.ico",
31 cloudresourcemanager: "https://www.gstatic.com/devrel-devsite/prod/v0e0f589edd85502a40d78d7d0825db8ea5ef3b99b1571571945f0f3f764ff61b/cloud/images/favicons/onecloud/favicon.ico",
32 youtube: "https://www.youtube.com/s/desktop/a94e1818/img/favicon_32x32.png",
33};
34
35/**
36 * For google_discovery endpoints, extract the service name and return
37 * the official product icon URL if we have one.
38 */
39const getGoogleServiceIconUrl = (endpoint: string | null | undefined): string | null => {
40 if (!endpoint) return null;
41 try {
42 const url = new URL(endpoint);
43 const host = url.hostname;
44
45 // Direct service subdomain: "calendar-json.googleapis.com", "gmail.googleapis.com"
46 if (host.endsWith(".googleapis.com")) {
47 const sub = host.replace(/\.googleapis\.com$/, "").replace(/-json$/, "");
48 if (GOOGLE_SERVICE_ICONS[sub]) return GOOGLE_SERVICE_ICONS[sub];
49 }
50
51 // Discovery URL pattern: /discovery/v1/apis/{service}/{version}/rest
52 const discoveryMatch = url.pathname.match(/\/apis\/([^/]+)\//);
53 if (discoveryMatch?.[1] && GOOGLE_SERVICE_ICONS[discoveryMatch[1]]) {
54 return GOOGLE_SERVICE_ICONS[discoveryMatch[1]];
55 }
56
57 // $discovery/rest pattern on service subdomains is already handled above
58 } catch {
59 // fall through
60 }
61 return null;
62};
63
64// ---------------------------------------------------------------------------
65// General favicon resolution
66// ---------------------------------------------------------------------------
67
10const parseUrl = (value: string | null | undefined): URL | null => {68const parseUrl = (value: string | null | undefined): URL | null => {
11 if (!value) {69 if (!value) {
12 return null;70 return null;
@@ -44,6 +102,8 @@ const normalizeSeedUrl = (value: string | null | undefined): string | null => { · 31 unmodified lines
44 return parsed.origin;102 return parsed.origin;
45};103};
46 104
105export const getGoogleProductIconUrl = getGoogleServiceIconUrl;
106
47export const getSourceFaviconUrl = (107export const getSourceFaviconUrl = (
48 value: string | null | undefined,108 value: string | null | undefined,
49): string | null => {109): string | null => {
apps/web/src/views/add-source.tsx+559−109
@@ -2,21 +2,29 @@ import { useEffect, useState, type ReactNode } from "react"; · 1 unmodified line
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import {3import {
4 type CompleteSourceOAuthResult,4 type CompleteSourceOAuthResult,
5 type ConnectSourceBatchPayload,
6 type ConnectSourceBatchResult,
5 type ConnectSourcePayload,7 type ConnectSourcePayload,
6 type ConnectSourceResult,8 type ConnectSourceResult,
9 type CreateWorkspaceOauthClientPayload,
7 type DiscoverSourcePayload,10 type DiscoverSourcePayload,
8 type InstanceConfig,11 type InstanceConfig,
9 type Loadable,12 type Loadable,
10 type SecretListItem,13 type SecretListItem,
11 type Source,14 type Source,
12 type SourceDiscoveryResult,15 type SourceDiscoveryResult,
16 type WorkspaceOauthClient,
17 useConnectSourceBatch,
13 useConnectSource,18 useConnectSource,
19 useCreateWorkspaceOauthClient,
14 useCreateSecret,20 useCreateSecret,
15 useDiscoverSource,21 useDiscoverSource,
22 useInvalidateExecutorQueries,
16 useInstanceConfig,23 useInstanceConfig,
17 useRefreshSecrets,24 useRefreshSecrets,
18 useSecrets,25 useSecrets,
19 useStartSourceOAuth,26 useStartSourceOAuth,
27 useWorkspaceOauthClients,
20} from "@executor/react";28} from "@executor/react";
21import { Badge } from "../components/ui/badge";29import { Badge } from "../components/ui/badge";
22import { Button } from "../components/ui/button";30import { Button } from "../components/ui/button";
@@ -74,6 +82,7 @@ type ConnectFormState = { · 51 unmodified lines
74 bearerToken: string;82 bearerToken: string;
75 bearerProviderId: string;83 bearerProviderId: string;
76 bearerHandle: string;84 bearerHandle: string;
85 workspaceOauthClientId: string;
77 oauthClientId: string;86 oauthClientId: string;
78 oauthClientSecret: string;87 oauthClientSecret: string;
79 queryParamsText: string;88 queryParamsText: string;
@@ -86,6 +95,12 @@ type OAuthRequiredInfo = { · 6 unmodified lines
86 authorizationUrl: string;95 authorizationUrl: string;
87};96};
88 97
98type BatchOAuthRequiredInfo = {
99 sessionId: string;
100 authorizationUrl: string;
101 sourceIds: ReadonlyArray<string>;
102};
103
89// ---------------------------------------------------------------------------104// ---------------------------------------------------------------------------
90// Constants105// Constants
91// ---------------------------------------------------------------------------106// ---------------------------------------------------------------------------
@@ -220,6 +235,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 128 unmodified lines
220 bearerToken: "",235 bearerToken: "",
221 bearerProviderId: "",236 bearerProviderId: "",
222 bearerHandle: "",237 bearerHandle: "",
238 workspaceOauthClientId: "",
223 oauthClientId: "",239 oauthClientId: "",
224 oauthClientSecret: "",240 oauthClientSecret: "",
225 queryParamsText: "",241 queryParamsText: "",
@@ -272,6 +288,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 46 unmodified lines
272 bearerToken: "",288 bearerToken: "",
273 bearerProviderId: "",289 bearerProviderId: "",
274 bearerHandle: "",290 bearerHandle: "",
291 workspaceOauthClientId: "",
275 oauthClientId: "",292 oauthClientId: "",
276 oauthClientSecret: "",293 oauthClientSecret: "",
277 queryParamsText: "",294 queryParamsText: "",
@@ -293,6 +310,7 @@ const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => · 15 unmodified lines
293 : namespaceFromUrl(template.endpoint),310 : namespaceFromUrl(template.endpoint),
294 transport: template.kind === "mcp" ? "auto" : "",311 transport: template.kind === "mcp" ? "auto" : "",
295 authKind: template.kind === "google_discovery" ? "oauth2" : "none",312 authKind: template.kind === "google_discovery" ? "oauth2" : "none",
313 workspaceOauthClientId: "",
296});314});
297 315
298const authKindForSourceKind = (316const authKindForSourceKind = (
@@ -366,8 +384,12 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 67 unmodified lines
366 const version = form.version.trim();384 const version = form.version.trim();
367 if (!service) throw new Error("Google Discovery sources require a service name.");385 if (!service) throw new Error("Google Discovery sources require a service name.");
368 if (!version) throw new Error("Google Discovery sources require a version.");386 if (!version) throw new Error("Google Discovery sources require a version.");
369 if (form.authKind === "oauth2" && form.oauthClientId.trim().length === 0) {387 if (
370 throw new Error("Google OAuth requires a client ID.");388 form.authKind === "oauth2"
389 && form.workspaceOauthClientId.trim().length === 0
390 && form.oauthClientId.trim().length === 0
391 ) {
392 throw new Error("Google OAuth requires a workspace OAuth client or a new client ID.");
371 }393 }
372 return {394 return {
373 kind: "google_discovery",395 kind: "google_discovery",
@@ -376,12 +398,17 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 2 unmodified lines
376 discoveryUrl: trimToNull(form.discoveryUrl),398 discoveryUrl: trimToNull(form.discoveryUrl),
377 name: trimToNull(form.name),399 name: trimToNull(form.name),
378 namespace: trimToNull(form.namespace),400 namespace: trimToNull(form.namespace),
379 oauthClient: form.authKind === "oauth2"401 workspaceOauthClientId:
380 ? {402 form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length > 0
381 clientId: form.oauthClientId.trim(),403 ? form.workspaceOauthClientId.trim()
382 clientSecret: trimToNull(form.oauthClientSecret),404 : undefined,
383 }405 oauthClient:
384 : null,406 form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length === 0
407 ? {
408 clientId: form.oauthClientId.trim(),
409 clientSecret: trimToNull(form.oauthClientSecret),
410 }
411 : null,
385 auth,412 auth,
386 };413 };
387 }414 }
@@ -592,7 +619,11 @@ export function AddSourcePage() { · 204 unmodified lines
592 const navigate = useNavigate();619 const navigate = useNavigate();
593 const discoverSource = useDiscoverSource();620 const discoverSource = useDiscoverSource();
594 const connectSource = useConnectSource();621 const connectSource = useConnectSource();
622 const connectSourceBatch = useConnectSourceBatch();
623 const createWorkspaceOauthClient = useCreateWorkspaceOauthClient();
624 const workspaceOauthClients = useWorkspaceOauthClients("google_workspace");
595 const instanceConfig = useInstanceConfig();625 const instanceConfig = useInstanceConfig();
626 const invalidateExecutorQueries = useInvalidateExecutorQueries();
596 const secrets = useSecrets();627 const secrets = useSecrets();
597 const refreshSecrets = useRefreshSecrets();628 const refreshSecrets = useRefreshSecrets();
598 const startSourceOAuth = useStartSourceOAuth();629 const startSourceOAuth = useStartSourceOAuth();
@@ -626,7 +657,10 @@ export function AddSourcePage() { · 27 unmodified lines
626 657
627 // OAuth required state658 // OAuth required state
628 const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null);659 const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null);
660 const [batchOauthInfo, setBatchOauthInfo] = useState<BatchOAuthRequiredInfo | null>(null);
629 const [oauthBusy, setOauthBusy] = useState(false);661 const [oauthBusy, setOauthBusy] = useState(false);
662 const [batchConnecting, setBatchConnecting] = useState(false);
663 const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState<ReadonlyArray<string>>([]);
630 664
631 // Status banner665 // Status banner
632 const [statusBanner, setStatusBanner] = useState<{666 const [statusBanner, setStatusBanner] = useState<{
@@ -643,6 +677,7 @@ export function AddSourcePage() { · 10 unmodified lines
643 ...current,677 ...current,
644 kind,678 kind,
645 authKind: authKindForSourceKind(current.authKind, kind),679 authKind: authKindForSourceKind(current.authKind, kind),
680 workspaceOauthClientId: kind === "google_discovery" ? current.workspaceOauthClientId : "",
646 }));681 }));
647 };682 };
648 683
@@ -650,6 +685,10 @@ export function AddSourcePage() { · 1 unmodified line
650 setProbeAuth((current) => ({ ...current, [key]: value }));685 setProbeAuth((current) => ({ ...current, [key]: value }));
651 };686 };
652 687
688 const googleTemplates = sourceTemplates.filter(
689 (template) => template.groupId === "google_workspace" && template.batchable,
690 );
691
653 // -------------------------------------------------------------------------692 // -------------------------------------------------------------------------
654 // Handlers693 // Handlers
655 // -------------------------------------------------------------------------694 // -------------------------------------------------------------------------
@@ -704,6 +743,16 @@ export function AddSourcePage() { · 48 unmodified lines
704 };743 };
705 744
706 const applyTemplate = async (template: SourceTemplate) => {745 const applyTemplate = async (template: SourceTemplate) => {
746 if (template.batchable) {
747 setSelectedGoogleTemplateIds((current) =>
748 current.includes(template.id)
749 ? current.filter((id) => id !== template.id)
750 : [...current, template.id],
751 );
752 setStatusBanner(null);
753 return;
754 }
755
707 const discoveryUrl = "specUrl" in template ? template.specUrl : template.endpoint;756 const discoveryUrl = "specUrl" in template ? template.specUrl : template.endpoint;
708 setUrl(template.endpoint);757 setUrl(template.endpoint);
709 setStatusBanner(null);758 setStatusBanner(null);
@@ -752,6 +801,7 @@ export function AddSourcePage() { · 42 unmodified lines
752 setConnectResult(result);801 setConnectResult(result);
753 802
754 if (result.kind === "connected") {803 if (result.kind === "connected") {
804 setBatchOauthInfo(null);
755 setPhase("connected");805 setPhase("connected");
756 setStatusBanner({806 setStatusBanner({
757 tone: "success",807 tone: "success",
@@ -766,6 +816,7 @@ export function AddSourcePage() { · 8 unmodified lines
766 });816 });
767 }, 1200);817 }, 1200);
768 } else if (result.kind === "credential_required") {818 } else if (result.kind === "credential_required") {
819 setBatchOauthInfo(null);
769 setPhase("credential_required");820 setPhase("credential_required");
770 setStatusBanner({821 setStatusBanner({
771 tone: "info",822 tone: "info",
@@ -776,6 +827,7 @@ export function AddSourcePage() { · 4 unmodified lines
776 setFormField("authKind", "bearer");827 setFormField("authKind", "bearer");
777 }828 }
778 } else if (result.kind === "oauth_required") {829 } else if (result.kind === "oauth_required") {
830 setBatchOauthInfo(null);
779 setPhase("oauth_required");831 setPhase("oauth_required");
780 setOauthInfo({832 setOauthInfo({
781 source: result.source,833 source: result.source,
@@ -797,28 +849,38 @@ export function AddSourcePage() { · 15 unmodified lines
797 };849 };
798 850
799 const handleOAuthPopup = async () => {851 const handleOAuthPopup = async () => {
800 if (!oauthInfo) return;852 if (!oauthInfo && !batchOauthInfo) return;
801 setStatusBanner(null);853 setStatusBanner(null);
802 setOauthBusy(true);854 setOauthBusy(true);
803 855
804 try {856 try {
805 await startSourceOAuthPopup({857 await startSourceOAuthPopup({
806 authorizationUrl: oauthInfo.authorizationUrl,858 authorizationUrl: oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl,
807 sessionId: oauthInfo.sessionId,859 sessionId: oauthInfo?.sessionId ?? batchOauthInfo!.sessionId,
808 });860 });
809 861
810 refreshSecrets();862 refreshSecrets();
863 invalidateExecutorQueries();
811 setPhase("connected");864 setPhase("connected");
812 setStatusBanner({865 setStatusBanner({
813 tone: "success",866 tone: "success",
814 text: `"${oauthInfo.source.name}" connected via OAuth.`,867 text: oauthInfo
868 ? `"${oauthInfo.source.name}" connected via OAuth.`
869 : `Connected ${batchOauthInfo?.sourceIds.length ?? 0} Google source${(batchOauthInfo?.sourceIds.length ?? 0) === 1 ? "" : "s"} via OAuth.`,
815 });870 });
816 871
817 setTimeout(() => {872 setTimeout(() => {
873 if (oauthInfo) {
874 void navigate({
875 to: "/sources/$sourceId",
876 params: { sourceId: oauthInfo.source.id },
877 search: { tab: "model" },
878 });
879 return;
880 }
881
818 void navigate({882 void navigate({
819 to: "/sources/$sourceId",883 to: "/",
820 params: { sourceId: oauthInfo.source.id },
821 search: { tab: "model" },
822 });884 });
823 }, 1200);885 }, 1200);
824 } catch (error) {886 } catch (error) {
@@ -831,6 +893,91 @@ export function AddSourcePage() { · 6 unmodified lines
831 }893 }
832 };894 };
833 895
896 const ensureGoogleWorkspaceOauthClientId = async (): Promise<string> => {
897 const selectedClientId = connectForm.workspaceOauthClientId.trim();
898 if (selectedClientId.length > 0) {
899 return selectedClientId;
900 }
901
902 const clientId = connectForm.oauthClientId.trim();
903 if (clientId.length === 0) {
904 throw new Error("Choose an existing Google workspace OAuth client or enter a new client ID.");
905 }
906
907 const created = await createWorkspaceOauthClient.mutateAsync({
908 providerKey: "google_workspace",
909 label: "Google Workspace",
910 oauthClient: {
911 clientId,
912 clientSecret: trimToNull(connectForm.oauthClientSecret),
913 },
914 } satisfies CreateWorkspaceOauthClientPayload);
915
916 setFormField("workspaceOauthClientId", created.id);
917 return created.id;
918 };
919
920 const handleConnectGoogleBatch = async () => {
921 if (selectedGoogleTemplateIds.length === 0) {
922 return;
923 }
924
925 setStatusBanner(null);
926
927 try {
928 const workspaceOauthClientId = await ensureGoogleWorkspaceOauthClientId();
929 const selectedTemplates = googleTemplates.filter(
930 (template): template is Extract<SourceTemplate, { kind: "google_discovery" }> =>
931 selectedGoogleTemplateIds.includes(template.id),
932 );
933 const payload: ConnectSourceBatchPayload = {
934 workspaceOauthClientId: workspaceOauthClientId as WorkspaceOauthClient["id"],
935 sources: selectedTemplates.map((template) => ({
936 service: template.service,
937 version: template.version,
938 discoveryUrl: template.discoveryUrl,
939 name: template.name,
940 namespace: googleDiscoveryNamespace(template.service),
941 })),
942 };
943
944 setBatchConnecting(true);
945 setPhase("connecting");
946 const result = await connectSourceBatch.mutateAsync(payload);
947 if (result.providerOauthSession) {
948 setBatchOauthInfo({
949 sessionId: result.providerOauthSession.sessionId,
950 authorizationUrl: result.providerOauthSession.authorizationUrl,
951 sourceIds: result.providerOauthSession.sourceIds,
952 });
953 setOauthInfo(null);
954 setBatchConnecting(false);
955 setPhase("oauth_required");
956 setStatusBanner(null);
957 return;
958 }
959
960 setSelectedGoogleTemplateIds([]);
961 setBatchOauthInfo(null);
962 setBatchConnecting(false);
963 setPhase("connected");
964 setStatusBanner({
965 tone: "success",
966 text: `Connected ${result.results.length} Google source${result.results.length === 1 ? "" : "s"}.`,
967 });
968 setTimeout(() => {
969 void navigate({ to: "/" });
970 }, 1200);
971 } catch (error) {
972 setBatchConnecting(false);
973 setPhase("idle");
974 setStatusBanner({
975 tone: "error",
976 text: error instanceof Error ? error.message : "Batch connection failed.",
977 });
978 }
979 };
980
834 const handleCredentialConnect = async () => {981 const handleCredentialConnect = async () => {
835 // Re-run connect with the auth now configured982 // Re-run connect with the auth now configured
836 await handleConnect();983 await handleConnect();
@@ -845,7 +992,12 @@ export function AddSourcePage() { · 8 unmodified lines
845 992
846 const isDiscovering = phase === "discovering";993 const isDiscovering = phase === "discovering";
847 const isConnecting = phase === "connecting";994 const isConnecting = phase === "connecting";
848 const isBusy = isDiscovering || isConnecting || oauthBusy;995 const isBusy =
996 isDiscovering
997 || isConnecting
998 || oauthBusy
999 || connectSourceBatch.status === "pending"
1000 || createWorkspaceOauthClient.status === "pending";
849 1001
850 // -------------------------------------------------------------------------1002 // -------------------------------------------------------------------------
851 // Render1003 // Render
@@ -870,11 +1022,53 @@ export function AddSourcePage() { · 18 unmodified lines
870 {phase === "editing" && (1022 {phase === "editing" && (
871 <Badge variant="outline">{connectForm.kind}</Badge>1023 <Badge variant="outline">{connectForm.kind}</Badge>
872 )}1024 )}
873 {phase === "connected" && (
874 <Badge variant="default">connected</Badge>
875 )}
876 </div>1025 </div>
877 1026
1027 {/* Step indicator */}
1028 {phase !== "idle" && phase !== "discovering" && (
1029 <div className="mb-6 flex items-center gap-1.5" aria-label="Progress">
1030 {(["discover", "configure", "connect"] as const).map((step, i) => {
1031 const stepPhases: Record<string, number> = {
1032 editing: 1,
1033 connecting: 2,
1034 credential_required: 1,
1035 oauth_required: 2,
1036 connected: 2,
1037 };
1038 const currentStep = stepPhases[phase] ?? 0;
1039 const isComplete = i < currentStep;
1040 const isCurrent = i === currentStep;
1041 return (
1042 <div key={step} className="flex items-center gap-1.5">
1043 {i > 0 && (
1044 <div className={cn(
1045 "h-px w-6 transition-colors",
1046 isComplete || isCurrent ? "bg-primary/40" : "bg-border",
1047 )} />
1048 )}
1049 <div className={cn(
1050 "flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.06em] transition-colors",
1051 isComplete
1052 ? "text-primary"
1053 : isCurrent
1054 ? "text-foreground bg-muted/50"
1055 : "text-muted-foreground/40",
1056 )}>
1057 {isComplete ? (
1058 <IconCheck className="size-2.5" />
1059 ) : (
1060 <span className="flex size-3.5 items-center justify-center rounded-full border text-[9px] tabular-nums border-current">
1061 {i + 1}
1062 </span>
1063 )}
1064 {step}
1065 </div>
1066 </div>
1067 );
1068 })}
1069 </div>
1070 )}
1071
878 {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}1072 {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}
879 1073
880 1074
@@ -895,22 +1089,29 @@ export function AddSourcePage() { · 14 unmodified lines
895 </p>1089 </p>
896 1090
897 <Field label="URL">1091 <Field label="URL">
898 <div className="flex gap-2">1092 <div className="space-y-0">
899 <input1093 <div className="flex gap-2">
900 value={url}1094 <input
901 onChange={(e) => setUrl(e.target.value)}1095 value={url}
902 placeholder="https://api.example.com or https://mcp.example.com/mcp"1096 onChange={(e) => setUrl(e.target.value)}
903 onKeyDown={(e) => {1097 placeholder="https://api.example.com or https://mcp.example.com/mcp"
904 if (e.key === "Enter" && !isDiscovering) {1098 onKeyDown={(e) => {
905 void handleDiscover();1099 if (e.key === "Enter" && !isDiscovering) {
906 }1100 void handleDiscover();
907 }}1101 }
908 className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"1102 }}
909 />1103 className="h-9 flex-1 rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25"
910 <Button onClick={handleDiscover} disabled={isBusy}>1104 />
911 {isDiscovering ? <IconSpinner className="size-3.5" /> : <IconDiscover className="size-3.5" />}1105 <Button onClick={handleDiscover} disabled={isBusy}>
912 {isDiscovering ? "Discovering\u2026" : "Discover"}1106 {isDiscovering ? <IconSpinner className="size-3.5" /> : <IconDiscover className="size-3.5" />}
913 </Button>1107 {isDiscovering ? "Discovering\u2026" : "Discover"}
1108 </Button>
1109 </div>
1110 {isDiscovering && (
1111 <div className="mt-1.5 h-0.5 w-full overflow-hidden rounded-full bg-muted">
1112 <div className="h-full w-1/4 rounded-full bg-primary/60" style={{ animation: "indeterminate-progress 1.2s ease-in-out infinite" }} />
1113 </div>
1114 )}
914 </div>1115 </div>
915 </Field>1116 </Field>
916 1117
@@ -1005,36 +1206,187 @@ export function AddSourcePage() { · 88 unmodified lines
1005 </button>1206 </button>
1006 </div>1207 </div>
1007 1208
1008 <div className="space-y-3 border-t border-border pt-4">1209 {/* ---- Template catalogue ---- */}
1210 <div className="space-y-5 border-t border-border pt-5">
1009 <div>1211 <div>
1010 <p className="text-[12px] font-medium text-foreground">Start from a known source</p>1212 <p className="text-[13px] font-medium text-foreground">Start from a template</p>
1011 <p className="text-[11px] text-muted-foreground">1213 <p className="mt-0.5 text-[12px] text-muted-foreground">
1012 Load a real MCP, OpenAPI, GraphQL, or Google Discovery template without running discovery first.1214 Pick a known source to skip discovery, or select multiple Google APIs for batch connect.
1013 </p>1215 </p>
1014 </div>1216 </div>
1015 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">1217
1016 {sourceTemplates.map((template) => (1218 {/* -- Popular / non-Google templates -- */}
1017 <button1219 {(() => {
1018 key={template.id}1220 const popularTemplates = sourceTemplates.filter((t) => t.groupId !== "google_workspace");
1019 type="button"1221 return popularTemplates.length > 0 && (
1020 onClick={() => applyTemplate(template)}1222 <div className="space-y-2.5">
1021 disabled={isBusy}1223 <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">Popular</p>
1022 className="rounded-xl border border-border bg-card/70 px-4 py-3 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"1224 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1023 >1225 {popularTemplates.map((template) => (
1024 <div className="mb-1 flex items-center justify-between gap-2">1226 <button
1025 <div className="flex min-w-0 items-center gap-2">1227 key={template.id}
1026 <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">1228 type="button"
1027 <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-4" />1229 onClick={() => applyTemplate(template)}
1230 disabled={isBusy}
1231 className="group rounded-xl border border-border bg-card/70 px-4 py-3 text-left transition-all hover:bg-accent/50 hover:border-primary/20 hover:shadow-sm disabled:opacity-60"
1232 >
1233 <div className="mb-1.5 flex items-center gap-2.5">
1234 <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-muted/50">
1235 <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-3.5" />
1236 </div>
1237 <span className="flex-1 truncate text-[13px] font-medium text-foreground group-hover:text-primary transition-colors">{template.name}</span>
1238 <Badge variant="outline" className="text-[9px] opacity-60 group-hover:opacity-100 transition-opacity">{template.kind}</Badge>
1239 </div>
1240 <span className="line-clamp-1 text-[11px] text-muted-foreground/70">
1241 {template.summary}
1242 </span>
1243 </button>
1244 ))}
1245 </div>
1246 </div>
1247 );
1248 })()}
1249
1250 {/* -- Google Workspace batch section -- */}
1251 <div className="space-y-3">
1252 <div className="flex items-center justify-between gap-3">
1253 <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">Google Workspace</p>
1254 {googleTemplates.length > 0 && (
1255 <div className="flex items-center gap-2">
1256 {selectedGoogleTemplateIds.length > 0 && (
1257 <button
1258 type="button"
1259 onClick={() => setSelectedGoogleTemplateIds([])}
1260 disabled={isBusy}
1261 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1262 >
1263 Clear
1264 </button>
1265 )}
1266 <button
1267 type="button"
1268 onClick={() =>
1269 setSelectedGoogleTemplateIds(
1270 selectedGoogleTemplateIds.length === googleTemplates.length
1271 ? []
1272 : googleTemplates.map((t) => t.id),
1273 )
1274 }
1275 disabled={isBusy}
1276 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1277 >
1278 {selectedGoogleTemplateIds.length === googleTemplates.length ? "Deselect all" : "Select all"}
1279 </button>
1280 </div>
1281 )}
1282 </div>
1283 <div className="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
1284 {googleTemplates.map((template) => {
1285 const selected = selectedGoogleTemplateIds.includes(template.id);
1286 return (
1287 <button
1288 key={template.id}
1289 type="button"
1290 onClick={() => applyTemplate(template)}
1291 disabled={isBusy}
1292 className={cn(
1293 "group flex items-center gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-all disabled:opacity-60",
1294 selected
1295 ? "border-primary/40 bg-primary/6 shadow-sm"
1296 : "border-border bg-card/50 hover:bg-accent/40 hover:border-border",
1297 )}
1298 >
1299 <div className={cn(
1300 "flex size-4 shrink-0 items-center justify-center rounded transition-colors",
1301 selected
1302 ? "text-primary"
1303 : "text-muted-foreground/30 group-hover:text-muted-foreground/50",
1304 )}>
1305 {selected ? (
1306 <IconCheck className="size-3.5" />
1307 ) : (
1308 <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-3.5" />
1309 )}
1310 </div>
1311 <div className="min-w-0 flex-1">
1312 <span className={cn(
1313 "block truncate text-[12px] font-medium transition-colors",
1314 selected ? "text-foreground" : "text-foreground/80",
1315 )}>
1316 {template.name}
1317 </span>
1318 <span className="block truncate text-[10px] text-muted-foreground/60">
1319 {template.summary}
1320 </span>
1321 </div>
1322 </button>
1323 );
1324 })}
1325 </div>
1326
1327 {/* -- Batch connect panel (slides in when templates selected) -- */}
1328 {selectedGoogleTemplateIds.length > 0 && (
1329 <div className="rounded-xl border border-primary/20 bg-gradient-to-b from-primary/4 to-transparent overflow-hidden">
1330 {/* Header strip */}
1331 <div className="flex items-center justify-between gap-3 border-b border-primary/10 px-5 py-3">
1332 <div className="flex items-center gap-2.5">
1333 <div className="flex size-6 items-center justify-center rounded-md bg-primary/10 text-primary">
1334 <span className="text-[11px] font-bold tabular-nums">{selectedGoogleTemplateIds.length}</span>
1335 </div>
1336 <p className="text-[13px] font-medium text-foreground">
1337 Connect Google API{selectedGoogleTemplateIds.length === 1 ? "" : "s"}
1338 </p>
1339 </div>
1340 <p className="hidden text-[11px] text-muted-foreground/60 sm:block">
1341 One consent screen for all
1342 </p>
1343 </div>
1344
1345 {/* Body */}
1346 <div className="px-5 py-4 space-y-4">
1347 <Field label="Workspace OAuth client">
1348 <select
1349 value={connectForm.workspaceOauthClientId}
1350 onChange={(event) => setFormField("workspaceOauthClientId", event.target.value)}
1351 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1352 >
1353 <option value="">Enter new client credentials</option>
1354 {workspaceOauthClients.status === "ready" && workspaceOauthClients.data.map((client) => (
1355 <option key={client.id} value={client.id}>
1356 {client.label ?? client.clientId}
1357 </option>
1358 ))}
1359 </select>
1360 </Field>
1361 {connectForm.workspaceOauthClientId.trim().length === 0 && (
1362 <div className="grid gap-3 sm:grid-cols-2">
1363 <Field label="Client ID" className="sm:col-span-2">
1364 <TextInput
1365 type="password"
1366 value={connectForm.oauthClientId}
1367 onChange={(v) => setFormField("oauthClientId", v)}
1368 placeholder="1234567890-abcdef.apps.googleusercontent.com"
1369 />
1370 </Field>
1371 <Field label="Client secret" className="sm:col-span-2">
1372 <TextInput
1373 type="password"
1374 value={connectForm.oauthClientSecret}
1375 onChange={(v) => setFormField("oauthClientSecret", v)}
1376 placeholder="GOCSPX-..."
1377 />
1378 </Field>
1028 </div>1379 </div>
1029 <span className="truncate text-[13px] font-medium text-foreground">{template.name}</span>1380 )}
1381 <div className="flex items-center justify-end pt-1">
1382 <Button type="button" onClick={handleConnectGoogleBatch} disabled={isBusy}>
1383 {isBusy ? <IconSpinner className="size-3.5" /> : <IconPlus className="size-3.5" />}
1384 Connect {selectedGoogleTemplateIds.length} source{selectedGoogleTemplateIds.length === 1 ? "" : "s"}
1385 </Button>
1030 </div>1386 </div>
1031 <Badge variant="outline" className="text-[9px]">{template.kind}</Badge>
1032 </div>1387 </div>
1033 <span className="line-clamp-2 text-[11px] text-muted-foreground">1388 </div>
1034 {template.summary}1389 )}
1035 </span>
1036 </button>
1037 ))}
1038 </div>1390 </div>
1039 </div>1391 </div>
1040 </div>1392 </div>
@@ -1044,8 +1396,25 @@ export function AddSourcePage() { · 3 unmodified lines
1044 1396
1045 1397
1046 1398
1399 {/* Batch connecting interstitial */}
1400 {batchConnecting && phase === "connecting" && (
1401 <div className="flex flex-col items-center justify-center py-20">
1402 <div className="space-y-5 text-center">
1403 <div className="mx-auto flex size-14 items-center justify-center rounded-2xl border border-primary/15 bg-primary/6">
1404 <IconSpinner className="size-6 text-primary" />
1405 </div>
1406 <div className="space-y-1.5">
1407 <h2 className="font-display text-xl tracking-tight text-foreground">
1408 Connecting {selectedGoogleTemplateIds.length} source{selectedGoogleTemplateIds.length === 1 ? "" : "s"}
1409 </h2>
1410 <p className="text-[13px] text-muted-foreground/70">Setting up Google Workspace APIs\u2026</p>
1411 </div>
1412 </div>
1413 </div>
1414 )}
1415
1047 {/* Step 2-4: Editing / Connecting */}1416 {/* Step 2-4: Editing / Connecting */}
1048 {(phase === "editing" || phase === "connecting" || phase === "credential_required") && (1417 {(phase === "editing" || (phase === "connecting" && !batchConnecting) || phase === "credential_required") && (
1049 <div className="space-y-6">1418 <div className="space-y-6">
1050 <Section title="Configuration">1419 <Section title="Configuration">
1051 <div className="grid gap-4 sm:grid-cols-2">1420 <div className="grid gap-4 sm:grid-cols-2">
@@ -1154,6 +1523,22 @@ export function AddSourcePage() { · 102 unmodified lines
1154 )}1523 )}
1155 1524
1156 {/* Auth section (for non-MCP kinds, or when credential_required) */}1525 {/* Auth section (for non-MCP kinds, or when credential_required) */}
1526 {phase === "credential_required" && (
1527 <div className="flex items-start gap-3 rounded-xl border border-amber-300/40 bg-amber-50/50 px-4 py-3.5 dark:border-amber-500/20 dark:bg-amber-950/20">
1528 <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-amber-100/80 dark:bg-amber-900/40">
1529 <svg className="size-3.5 text-amber-600 dark:text-amber-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1530 <path d="M12 9v4" /><path d="M12 17h.01" />
1531 <path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
1532 </svg>
1533 </div>
1534 <div className="space-y-0.5">
1535 <p className="text-[12px] font-medium text-amber-900 dark:text-amber-200">Credentials required</p>
1536 <p className="text-[11px] leading-relaxed text-amber-800/70 dark:text-amber-300/60">
1537 The server responded that this source requires authentication. Add a token or secret below, then try connecting again.
1538 </p>
1539 </div>
1540 </div>
1541 )}
1157 {connectForm.kind !== "mcp" && (1542 {connectForm.kind !== "mcp" && (
1158 <Section title="Authentication">1543 <Section title="Authentication">
1159 <div className="grid gap-4 sm:grid-cols-2">1544 <div className="grid gap-4 sm:grid-cols-2">
@@ -1167,28 +1552,47 @@ export function AddSourcePage() { · 7 unmodified lines
1167 {connectForm.kind === "google_discovery" && (1552 {connectForm.kind === "google_discovery" && (
1168 <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">1553 <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">
1169 {connectForm.authKind === "oauth2"1554 {connectForm.authKind === "oauth2"
1170 ? "OAuth starts after you click Connect. Executor will store the OAuth client secret in the configured secret store, then open the Google sign-in flow for this source."1555 ? "OAuth starts after you click Connect. Choose an existing workspace OAuth client or enter a new client once, then reuse it across Google sources."
1171 : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}1556 : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}
1172 </div>1557 </div>
1173 )}1558 )}
1174 {connectForm.kind === "google_discovery" && connectForm.authKind === "oauth2" && (1559 {connectForm.kind === "google_discovery" && connectForm.authKind === "oauth2" && (
1175 <>1560 <>
1176 <Field label="OAuth client ID" className="sm:col-span-2">1561 <Field label="Workspace OAuth client" className="sm:col-span-2">
1177 <TextInput1562 <SelectInput
1178 type="password"1563 value={connectForm.workspaceOauthClientId}
1179 value={connectForm.oauthClientId}1564 onChange={(value) => setFormField("workspaceOauthClientId", value)}
1180 onChange={(v) => setFormField("oauthClientId", v)}1565 options={[
1181 placeholder="1234567890-abcdef.apps.googleusercontent.com"1566 { value: "", label: "Enter new client" },
1182 />1567 ...(workspaceOauthClients.status === "ready"
1183 </Field>1568 ? workspaceOauthClients.data.map((client) => ({
1184 <Field label="OAuth client secret" className="sm:col-span-2">1569 value: client.id,
1185 <TextInput1570 label: client.label ?? client.clientId,
1186 type="password"1571 }))
1187 value={connectForm.oauthClientSecret}1572 : []),
1188 onChange={(v) => setFormField("oauthClientSecret", v)}1573 ]}
1189 placeholder="GOCSPX-..."
1190 />1574 />
1191 </Field>1575 </Field>
1576 {connectForm.workspaceOauthClientId.trim().length === 0 && (
1577 <>
1578 <Field label="New client ID" className="sm:col-span-2">
1579 <TextInput
1580 type="password"
1581 value={connectForm.oauthClientId}
1582 onChange={(v) => setFormField("oauthClientId", v)}
1583 placeholder="1234567890-abcdef.apps.googleusercontent.com"
1584 />
1585 </Field>
1586 <Field label="New client secret" className="sm:col-span-2">
1587 <TextInput
1588 type="password"
1589 value={connectForm.oauthClientSecret}
1590 onChange={(v) => setFormField("oauthClientSecret", v)}
1591 placeholder="GOCSPX-..."
1592 />
1593 </Field>
1594 </>
1595 )}
1192 </>1596 </>
1193 )}1597 )}
1194 {connectForm.authKind !== "none" && (1598 {connectForm.authKind !== "none" && (
@@ -1256,45 +1660,78 @@ export function AddSourcePage() { · 61 unmodified lines
1256 )}1660 )}
1257 1661
1258 {/* Step 5a: OAuth required */}1662 {/* Step 5a: OAuth required */}
1259 {phase === "oauth_required" && oauthInfo && (1663 {phase === "oauth_required" && (oauthInfo || batchOauthInfo) && (
1260 <div className="space-y-6">1664 <div className="flex flex-col items-center justify-center py-16">
1261 <Section title="OAuth Authentication Required">1665 <div className="w-full max-w-sm space-y-6 text-center">
1262 <div className="space-y-4">1666 {/* Visual anchor */}
1263 <p className="text-[13px] text-muted-foreground">1667 <div className="mx-auto flex size-16 items-center justify-center rounded-2xl border border-primary/20 bg-gradient-to-b from-primary/10 to-primary/4">
1264 The source <strong className="text-foreground">{oauthInfo.source.name}</strong> requires OAuth.1668 {oauthBusy ? (
1265 Click the button below to open a popup and complete authentication.1669 <IconSpinner className="size-6 text-primary" />
1670 ) : (
1671 <svg className="size-6 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1672 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
1673 <path d="M7 11V7a5 5 0 0110 0v4" />
1674 </svg>
1675 )}
1676 </div>
1677
1678 <div className="space-y-2">
1679 <h2 className="font-display text-xl tracking-tight text-foreground">
1680 {oauthBusy ? "Waiting for sign-in\u2026" : "Sign in to continue"}
1681 </h2>
1682 <p className="text-[13px] leading-relaxed text-muted-foreground">
1683 {oauthInfo
1684 ? <>
1685 <strong className="text-foreground">{oauthInfo.source.name}</strong> requires OAuth authentication.
1686 A popup will open for you to authorize access.
1687 </>
1688 : <>
1689 {batchOauthInfo!.sourceIds.length} Google source{batchOauthInfo!.sourceIds.length === 1 ? "" : "s"} need{batchOauthInfo!.sourceIds.length === 1 ? "s" : ""} OAuth.
1690 One consent screen covers all selected APIs.
1691 </>
1692 }
1266 </p>1693 </p>
1267 <div className="flex items-center gap-3">
1268 <Button onClick={handleOAuthPopup} disabled={oauthBusy}>
1269 {oauthBusy ? <IconSpinner className="size-3.5" /> : null}
1270 {oauthBusy ? "Authenticating\u2026" : "Sign in with OAuth"}
1271 </Button>
1272 <Button variant="ghost" onClick={handleBackToEditing}>
1273 Back to edit
1274 </Button>
1275 </div>
1276 </div>1694 </div>
1277 </Section>1695
1696 <div className="flex flex-col items-center gap-3">
1697 <Button onClick={handleOAuthPopup} disabled={oauthBusy} className="w-full max-w-xs">
1698 {oauthBusy ? <IconSpinner className="size-3.5" /> : null}
1699 {oauthBusy ? "Authenticating\u2026" : "Open sign-in"}
1700 </Button>
1701 {!oauthBusy && (
1702 <button
1703 type="button"
1704 onClick={handleBackToEditing}
1705 className="text-[12px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
1706 >
1707 Back to configuration
1708 </button>
1709 )}
1710 </div>
1711 </div>
1278 </div>1712 </div>
1279 )}1713 )}
1280 1714
1281 {/* Step 5b: Connected */}1715 {/* Step 5b: Connected */}
1282 {phase === "connected" && connectResult && (1716 {phase === "connected" && (
1283 <Section title="Connected">1717 <div className="flex flex-col items-center justify-center py-20">
1284 <div className="flex items-center gap-3">1718 <div className="space-y-5 text-center">
1285 <div className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">1719 <div className="mx-auto flex size-14 items-center justify-center rounded-full bg-primary/10 ring-4 ring-primary/5">
1286 <IconCheck className="size-5" />1720 <IconCheck className="size-6 text-primary" />
1287 </div>1721 </div>
1288 <div>1722 <div className="space-y-1.5">
1289 <p className="text-[14px] font-medium text-foreground">1723 <h2 className="font-display text-xl tracking-tight text-foreground">
1290 {connectResult.source.name}1724 {connectResult
1291 </p>1725 ? connectResult.source.name
1292 <p className="text-[12px] text-muted-foreground">1726 : batchOauthInfo
1293 Source connected. Redirecting...1727 ? `${batchOauthInfo.sourceIds.length} source${batchOauthInfo.sourceIds.length === 1 ? "" : "s"}`
1294 </p>1728 : "Source"}
1729 {" "}connected
1730 </h2>
1731 <p className="text-[13px] text-muted-foreground/70">Redirecting\u2026</p>
1295 </div>1732 </div>
1296 </div>1733 </div>
1297 </Section>1734 </div>
1298 )}1735 )}
1299 </div>1736 </div>
1300 </div>1737 </div>
@@ -1390,14 +1827,27 @@ function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text · 89 unmodified lines
1390 return (1827 return (
1391 <div1828 <div
1392 className={cn(1829 className={cn(
1393 "rounded-lg border px-4 py-3 text-[13px]",1830 "flex items-start gap-2.5 rounded-lg border px-4 py-3 text-[13px]",
1394 props.state.tone === "success" && "border-primary/30 bg-primary/8 text-foreground",1831 props.state.tone === "success" && "border-primary/30 bg-primary/8 text-foreground",
1395 props.state.tone === "info" && "border-border bg-card text-muted-foreground",1832 props.state.tone === "info" && "border-border bg-card text-muted-foreground",
1396 props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive",1833 props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive",
1397 props.className,1834 props.className,
1398 )}1835 )}
1399 >1836 >
1400 {props.state.text}1837 <span className="mt-0.5 shrink-0">
1838 {props.state.tone === "success" && <IconCheck className="size-3.5 text-primary" />}
1839 {props.state.tone === "error" && (
1840 <svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1841 <circle cx="12" cy="12" r="10" /><path d="M15 9l-6 6" /><path d="M9 9l6 6" />
1842 </svg>
1843 )}
1844 {props.state.tone === "info" && (
1845 <svg className="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1846 <circle cx="12" cy="12" r="10" /><path d="M12 16v-4" /><path d="M12 8h.01" />
1847 </svg>
1848 )}
1849 </span>
1850 <span>{props.state.text}</span>
1401 </div>1851 </div>
1402 );1852 );
1403}1853}
apps/web/src/views/source-detail.tsx+20−8
@@ -89,6 +89,13 @@ const readBindingTransport = (source: Source): "auto" | "streamable-http" | "sse · 88 unmodified lines
89 : undefined;89 : undefined;
90};90};
91 91
92const refreshableHttpAuth = (
93 auth: Source["auth"],
94): Extract<Source["auth"], { kind: "none" | "bearer" | "oauth2" }> | undefined =>
95 auth.kind === "none" || auth.kind === "bearer" || auth.kind === "oauth2"
96 ? auth
97 : undefined;
98
92const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null => {99const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null => {
93 switch (source.kind) {100 switch (source.kind) {
94 case "mcp":101 case "mcp":
@@ -114,8 +121,8 @@ const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null = · 19 unmodified lines
114 name: source.name,121 name: source.name,
115 namespace: source.namespace,122 namespace: source.namespace,
116 importAuthPolicy: source.importAuthPolicy,123 importAuthPolicy: source.importAuthPolicy,
117 importAuth: source.importAuth,124 importAuth: refreshableHttpAuth(source.importAuth),
118 auth: source.auth,125 auth: refreshableHttpAuth(source.auth),
119 };126 };
120 }127 }
121 case "graphql":128 case "graphql":
@@ -125,8 +132,8 @@ const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null = · 3 unmodified lines
125 name: source.name,132 name: source.name,
126 namespace: source.namespace,133 namespace: source.namespace,
127 importAuthPolicy: source.importAuthPolicy,134 importAuthPolicy: source.importAuthPolicy,
128 importAuth: source.importAuth,135 importAuth: refreshableHttpAuth(source.importAuth),
129 auth: source.auth,136 auth: refreshableHttpAuth(source.auth),
130 };137 };
131 case "google_discovery": {138 case "google_discovery": {
132 const service = readBindingString(source, "service");139 const service = readBindingString(source, "service");
@@ -145,8 +152,8 @@ const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null = · 12 unmodified lines
145 name: source.name,152 name: source.name,
146 namespace: source.namespace,153 namespace: source.namespace,
147 importAuthPolicy: source.importAuthPolicy,154 importAuthPolicy: source.importAuthPolicy,
148 importAuth: source.importAuth,155 importAuth: refreshableHttpAuth(source.importAuth),
149 auth: source.auth,156 auth: refreshableHttpAuth(source.auth),
150 };157 };
151 }158 }
152 }159 }
@@ -250,11 +257,16 @@ export function SourceDetailPage(props: { · 97 unmodified lines
250 {refreshFeedback && (257 {refreshFeedback && (
251 <span258 <span
252 className={cn(259 className={cn(
253 "hidden max-w-72 truncate text-[11px] sm:block",260 "hidden max-w-72 truncate text-[11px] font-medium sm:inline-flex sm:items-center sm:gap-1.5",
254 refreshFeedback.tone === "success" ? "text-emerald-600" : "text-destructive",261 refreshFeedback.tone === "success"
262 ? "text-primary"
263 : "text-destructive",
255 )}264 )}
256 title={refreshFeedback.text}265 title={refreshFeedback.text}
257 >266 >
267 {refreshFeedback.tone === "success" && (
268 <IconCheck className="size-3 shrink-0" />
269 )}
258 {refreshFeedback.text}270 {refreshFeedback.text}
259 </span>271 </span>
260 )}272 )}
apps/web/src/views/source-editor.tsx+151−30
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
13 useCreateSource,13 useCreateSource,
14 useInstanceConfig,14 useInstanceConfig,
15 useRefreshSecrets,15 useRefreshSecrets,
16 useRemoveProviderAuthGrant,
16 useRemoveSource,17 useRemoveSource,
17 useSecrets,18 useSecrets,
18 useSources,19 useSources,
@@ -86,6 +87,7 @@ type SourceFormState = { · 67 unmodified lines
86 oauthAccessHandle: string;87 oauthAccessHandle: string;
87 oauthRefreshProviderId: string;88 oauthRefreshProviderId: string;
88 oauthRefreshHandle: string;89 oauthRefreshHandle: string;
90 managedAuth: Extract<Source["auth"], { kind: "provider_grant_ref" | "mcp_oauth" }> | null;
89};91};
90 92
91const kindOptions: ReadonlyArray<Source["kind"]> = [93const kindOptions: ReadonlyArray<Source["kind"]> = [
@@ -102,7 +104,13 @@ const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [ · 10 unmodified lines
102 "sse",104 "sse",
103];105];
104 106
105const authOptions: ReadonlyArray<Source["auth"]["kind"]> = ["none", "bearer", "oauth2"];107const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [
108 "none",
109 "bearer",
110 "oauth2",
111 "provider_grant_ref",
112 "mcp_oauth",
113];
106 114
107const trimToNull = (value: string): string | null => {115const trimToNull = (value: string): string | null => {
108 const trimmed = value.trim();116 const trimmed = value.trim();
@@ -321,6 +329,7 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 212 unmodified lines
321 oauthAccessHandle: "",329 oauthAccessHandle: "",
322 oauthRefreshProviderId: "",330 oauthRefreshProviderId: "",
323 oauthRefreshHandle: "",331 oauthRefreshHandle: "",
332 managedAuth: null,
324});333});
325 334
326const formStateFromSource = (source: Source): SourceFormState => ({335const formStateFromSource = (source: Source): SourceFormState => ({
@@ -337,8 +346,14 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 10 unmodified lines
337 version: readBindingString(source, "version"),346 version: readBindingString(source, "version"),
338 defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),347 defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),
339 authKind: source.auth.kind,348 authKind: source.auth.kind,
340 authHeaderName: source.auth.kind === "none" ? "Authorization" : source.auth.headerName,349 authHeaderName:
341 authPrefix: source.auth.kind === "none" ? "Bearer " : source.auth.prefix,350 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
351 ? "Authorization"
352 : source.auth.headerName,
353 authPrefix:
354 source.auth.kind === "none" || source.auth.kind === "mcp_oauth"
355 ? "Bearer "
356 : source.auth.prefix,
342 bearerProviderId: source.auth.kind === "bearer" ? source.auth.token.providerId : "",357 bearerProviderId: source.auth.kind === "bearer" ? source.auth.token.providerId : "",
343 bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "",358 bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "",
344 oauthAccessProviderId: source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",359 oauthAccessProviderId: source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",
@@ -351,6 +366,10 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 6 unmodified lines
351 source.auth.kind === "oauth2" && source.auth.refreshToken !== null366 source.auth.kind === "oauth2" && source.auth.refreshToken !== null
352 ? source.auth.refreshToken.handle367 ? source.auth.refreshToken.handle
353 : "",368 : "",
369 managedAuth:
370 source.auth.kind === "provider_grant_ref" || source.auth.kind === "mcp_oauth"
371 ? source.auth
372 : null,
354});373});
355 374
356const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {375const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {
@@ -387,6 +406,10 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 30 unmodified lines
387 return { kind: "none" };406 return { kind: "none" };
388 }407 }
389 408
409 if ((state.authKind === "provider_grant_ref" || state.authKind === "mcp_oauth") && state.managedAuth !== null) {
410 return state.managedAuth;
411 }
412
390 const headerName = state.authHeaderName.trim() || "Authorization";413 const headerName = state.authHeaderName.trim() || "Authorization";
391 const prefix = state.authPrefix;414 const prefix = state.authPrefix;
392 415
@@ -589,6 +612,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 196 unmodified lines
589 const startSourceOAuth = useStartSourceOAuth();612 const startSourceOAuth = useStartSourceOAuth();
590 const updateSource = useUpdateSource();613 const updateSource = useUpdateSource();
591 const removeSource = useRemoveSource();614 const removeSource = useRemoveSource();
615 const removeProviderAuthGrant = useRemoveProviderAuthGrant();
592 const instanceConfig = useInstanceConfig();616 const instanceConfig = useInstanceConfig();
593 const secrets = useSecrets();617 const secrets = useSecrets();
594 const refreshSecrets = useRefreshSecrets();618 const refreshSecrets = useRefreshSecrets();
@@ -602,6 +626,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 7 unmodified lines
602 626
603 const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";627 const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";
604 const isDeleting = removeSource.status === "pending";628 const isDeleting = removeSource.status === "pending";
629 const isRevokingGrant = removeProviderAuthGrant.status === "pending";
605 const isOAuthSubmitting = startSourceOAuth.status === "pending" || oauthPopupBusy;630 const isOAuthSubmitting = startSourceOAuth.status === "pending" || oauthPopupBusy;
606 const oauthSecretRefTarget =631 const oauthSecretRefTarget =
607 formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0632 formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0
@@ -690,6 +715,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 82 unmodified lines
690 oauthAccessHandle: auth.accessToken.handle,715 oauthAccessHandle: auth.accessToken.handle,
691 oauthRefreshProviderId: auth.refreshToken?.providerId ?? "",716 oauthRefreshProviderId: auth.refreshToken?.providerId ?? "",
692 oauthRefreshHandle: auth.refreshToken?.handle ?? "",717 oauthRefreshHandle: auth.refreshToken?.handle ?? "",
718 managedAuth: null,
693 }));719 }));
694 setStatusBanner({720 setStatusBanner({
695 tone: "success",721 tone: "success",
@@ -730,6 +756,42 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 34 unmodified lines
730 }756 }
731 };757 };
732 758
759 const handleRevokeProviderGrant = async () => {
760 if (
761 !props.source
762 || isRevokingGrant
763 || formState.managedAuth?.kind !== "provider_grant_ref"
764 ) {
765 return;
766 }
767
768 const confirmed = window.confirm(
769 `Revoke the shared Google auth for "${props.source.name}"? This disconnects every source using the same shared grant.`,
770 );
771 if (!confirmed) {
772 return;
773 }
774
775 setStatusBanner(null);
776
777 try {
778 const result = await removeProviderAuthGrant.mutateAsync(formState.managedAuth.grantId);
779 if (!result.removed) {
780 throw new Error("Shared provider grant was not removed.");
781 }
782
783 setStatusBanner({
784 tone: "success",
785 text: "Shared provider grant revoked. Linked sources now require authentication again.",
786 });
787 } catch (error) {
788 setStatusBanner({
789 tone: "error",
790 text: error instanceof Error ? error.message : "Failed revoking shared provider grant.",
791 });
792 }
793 };
794
733 const backLink = props.mode === "edit" && props.source795 const backLink = props.mode === "edit" && props.source
734 ? { to: "/sources/$sourceId" as const, params: { sourceId: props.source.id }, search: { tab: "model" as const } }796 ? { to: "/sources/$sourceId" as const, params: { sourceId: props.source.id }, search: { tab: "model" as const } }
735 : { to: "/" as const };797 : { to: "/" as const };
@@ -944,40 +1006,49 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 208 unmodified lines
944 <Section title="Authentication">1006 <Section title="Authentication">
945 <div className="grid gap-4 sm:grid-cols-2">1007 <div className="grid gap-4 sm:grid-cols-2">
946 {formState.kind === "mcp" && (1008 {formState.kind === "mcp" && (
947 <div className="sm:col-span-2 rounded-lg border border-border bg-card/70 px-4 py-3">1009 <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-card/90 to-card/50 overflow-hidden">
948 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">1010 <div className="flex items-start gap-3 px-4 py-3.5">
949 <div className="space-y-1">1011 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
950 <p className="text-[13px] font-medium text-foreground">Sign in with OAuth</p>1012 <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
951 <p className="text-[12px] text-muted-foreground">1013 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
952 Starts the same MCP connection flow used by `executor.sources.add`.1014 <path d="M7 11V7a5 5 0 0110 0v4" />
953 </p>1015 </svg>
1016 </div>
1017 <div className="flex-1 min-w-0 space-y-2">
1018 <div className="space-y-0.5">
1019 <p className="text-[12px] font-medium text-foreground">MCP OAuth</p>
1020 <p className="text-[11px] text-muted-foreground">
1021 Opens the server's built-in OAuth flow to authenticate this source.
1022 </p>
1023 </div>
1024 <Button
1025 type="button"
1026 variant="outline"
1027 size="sm"
1028 onClick={handleMcpOAuthConnect}
1029 disabled={isSubmitting || isDeleting || isOAuthSubmitting}
1030 >
1031 {isOAuthSubmitting ? <IconSpinner className="size-3" /> : null}
1032 {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0
1033 ? "Reconnect"
1034 : "Sign in with OAuth"}
1035 </Button>
954 </div>1036 </div>
955 <Button
956 type="button"
957 variant="outline"
958 onClick={handleMcpOAuthConnect}
959 disabled={isSubmitting || isDeleting || isOAuthSubmitting}
960 >
961 {isOAuthSubmitting ? <IconSpinner className="size-3.5" /> : null}
962 {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0
963 ? "Reconnect with OAuth"
964 : "Sign in with OAuth"}
965 </Button>
966 </div>1037 </div>
967 {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0 && (1038 {formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0 && (
968 <div className="mt-3 flex items-center justify-between gap-3 border-t border-border pt-3">1039 <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5">
969 <p className="text-[12px] text-muted-foreground">1040 <p className="text-[11px] text-muted-foreground/70">
970 OAuth tokens are attached to this draft and stay hidden unless you need the raw refs.1041 Token refs attached to this draft
971 </p>1042 </p>
972 <button1043 <button
973 type="button"1044 type="button"
974 className="text-[12px] font-medium text-muted-foreground transition-colors hover:text-foreground"1045 className="text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground"
975 onClick={() =>1046 onClick={() =>
976 setExpandedOauthSecretRefTarget((current) =>1047 setExpandedOauthSecretRefTarget((current) =>
977 current === oauthSecretRefTarget ? null : oauthSecretRefTarget,1048 current === oauthSecretRefTarget ? null : oauthSecretRefTarget,
978 )}1049 )}
979 >1050 >
980 {showOauthSecretRefs ? "Hide token refs" : "Show token refs"}1051 {showOauthSecretRefs ? "Hide refs" : "Show refs"}
981 </button>1052 </button>
982 </div>1053 </div>
983 )}1054 )}
@@ -987,10 +1058,57 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 3 unmodified lines
987 <SelectInput1058 <SelectInput
988 value={formState.authKind}1059 value={formState.authKind}
989 onChange={(value) => setField("authKind", value as Source["auth"]["kind"])}1060 onChange={(value) => setField("authKind", value as Source["auth"]["kind"])}
1061 disabled={formState.managedAuth !== null}
990 options={authOptions.map((value) => ({ value, label: value }))}1062 options={authOptions.map((value) => ({ value, label: value }))}
991 />1063 />
992 </Field>1064 </Field>
993 {formState.authKind !== "none" && (1065 {formState.managedAuth !== null && (
1066 <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-muted/40 to-transparent overflow-hidden">
1067 <div className="flex items-start gap-3 px-4 py-3.5">
1068 <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1069 {formState.managedAuth.kind === "provider_grant_ref" ? (
1070 <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1071 <path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" />
1072 <path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" />
1073 </svg>
1074 ) : (
1075 <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1076 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
1077 <path d="M7 11V7a5 5 0 0110 0v4" />
1078 </svg>
1079 )}
1080 </div>
1081 <div className="flex-1 min-w-0 space-y-1">
1082 <p className="text-[12px] font-medium text-foreground">
1083 {formState.managedAuth.kind === "provider_grant_ref"
1084 ? "Shared provider grant"
1085 : "Managed MCP OAuth"}
1086 </p>
1087 <p className="text-[11px] leading-relaxed text-muted-foreground">
1088 {formState.managedAuth.kind === "provider_grant_ref"
1089 ? "This source uses a shared Google auth grant. Reconnect from Add Source to change the linked account or scopes."
1090 : "Authenticated through a persisted MCP OAuth session. Reconnect the source to refresh or replace the binding."}
1091 </p>
1092 </div>
1093 </div>
1094 {formState.managedAuth.kind === "provider_grant_ref" && props.mode === "edit" && (
1095 <div className="flex items-center justify-end border-t border-border/50 px-4 py-2.5">
1096 <Button
1097 type="button"
1098 variant="ghost"
1099 size="sm"
1100 className="text-destructive/70 hover:text-destructive hover:bg-destructive/8"
1101 onClick={handleRevokeProviderGrant}
1102 disabled={isRevokingGrant}
1103 >
1104 <IconTrash className="size-3" />
1105 {isRevokingGrant ? "Revoking\u2026" : "Revoke shared auth"}
1106 </Button>
1107 </div>
1108 )}
1109 </div>
1110 )}
1111 {formState.authKind !== "none" && formState.managedAuth === null && (
994 <>1112 <>
995 <Field label="Header name">1113 <Field label="Header name">
996 <TextInput1114 <TextInput
@@ -1009,7 +1127,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines
1009 </>1127 </>
1010 )}1128 )}
1011 1129
1012 {formState.authKind === "bearer" && (1130 {formState.authKind === "bearer" && formState.managedAuth === null && (
1013 <Field label="Token" className="sm:col-span-2">1131 <Field label="Token" className="sm:col-span-2">
1014 <SecretPicker1132 <SecretPicker
1015 instanceConfig={instanceConfig}1133 instanceConfig={instanceConfig}
@@ -1025,6 +1143,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 9 unmodified lines
1025 )}1143 )}
1026 1144
1027 {formState.authKind === "oauth2"1145 {formState.authKind === "oauth2"
1146 && formState.managedAuth === null
1028 && (formState.kind !== "mcp"1147 && (formState.kind !== "mcp"
1029 || formState.oauthAccessHandle.trim().length === 01148 || formState.oauthAccessHandle.trim().length === 0
1030 || showOauthSecretRefs) && (1149 || showOauthSecretRefs) && (
@@ -1066,7 +1185,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 35 unmodified lines
1066 variant="outline"1185 variant="outline"
1067 className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"1186 className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
1068 onClick={handleRemove}1187 onClick={handleRemove}
1069 disabled={isDeleting}1188 disabled={isDeleting || isRevokingGrant}
1070 >1189 >
1071 <IconTrash className="size-3.5" />1190 <IconTrash className="size-3.5" />
1072 {isDeleting ? "Removing\u2026" : "Remove source"}1191 {isDeleting ? "Removing\u2026" : "Remove source"}
@@ -1079,7 +1198,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 6 unmodified lines
1079 <Link {...backLink} className="inline-flex">1198 <Link {...backLink} className="inline-flex">
1080 <Button variant="ghost" type="button">Cancel</Button>1199 <Button variant="ghost" type="button">Cancel</Button>
1081 </Link>1200 </Link>
1082 <Button onClick={handleSubmit} disabled={isSubmitting}>1201 <Button onClick={handleSubmit} disabled={isSubmitting || isRevokingGrant}>
1083 {props.mode === "edit" ? <IconPencil className="size-3.5" /> : <IconPlus className="size-3.5" />}1202 {props.mode === "edit" ? <IconPencil className="size-3.5" /> : <IconPlus className="size-3.5" />}
1084 {isSubmitting1203 {isSubmitting
1085 ? props.mode === "edit"1204 ? props.mode === "edit"
@@ -1147,10 +1266,12 @@ function SelectInput(props: { · 61 unmodified lines
1147 value: string;1266 value: string;
1148 onChange: (value: string) => void;1267 onChange: (value: string) => void;
1149 options: ReadonlyArray<{ value: string; label: string }>;1268 options: ReadonlyArray<{ value: string; label: string }>;
1269 disabled?: boolean;
1150}) {1270}) {
1151 return (1271 return (
1152 <select1272 <select
1153 value={props.value}1273 value={props.value}
1274 disabled={props.disabled}
1154 onChange={(event) => props.onChange(event.target.value)}1275 onChange={(event) => props.onChange(event.target.value)}
1155 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"1276 className="h-9 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25"
1156 >1277 >
apps/web/src/views/source-templates.ts+12−0
@@ -5,6 +5,9 @@ type SourceTemplateBase = { · 4 unmodified lines
5 name: string;5 name: string;
6 summary: string;6 summary: string;
7 endpoint: string;7 endpoint: string;
8 groupId?: string;
9 groupLabel?: string;
10 batchable?: boolean;
8};11};
9 12
10export type OpenApiSourceTemplate = SourceTemplateBase & {13export type OpenApiSourceTemplate = SourceTemplateBase & {
@@ -46,6 +49,9 @@ const googleDiscoveryTemplate = (input: { · 35 unmodified lines
46 summary: input.summary,49 summary: input.summary,
47 kind: "google_discovery",50 kind: "google_discovery",
48 endpoint: discoveryUrl,51 endpoint: discoveryUrl,
52 groupId: "google_workspace",
53 groupLabel: "Google Workspace",
54 batchable: true,
49 service: input.service,55 service: input.service,
50 version: input.version,56 version: input.version,
51 discoveryUrl,57 discoveryUrl,
@@ -89,6 +95,9 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 37 unmodified lines
89 kind: "openapi",95 kind: "openapi",
90 endpoint: "https://api.github.com",96 endpoint: "https://api.github.com",
91 specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",97 specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml",
98 groupId: "github",
99 groupLabel: "GitHub",
100 batchable: false,
92 },101 },
93 {102 {
94 id: "github-graphql",103 id: "github-graphql",
@@ -96,6 +105,9 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
96 summary: "Issues, pull requests, discussions, and repository objects via GraphQL.",105 summary: "Issues, pull requests, discussions, and repository objects via GraphQL.",
97 kind: "graphql",106 kind: "graphql",
98 endpoint: "https://api.github.com/graphql",107 endpoint: "https://api.github.com/graphql",
108 groupId: "github",
109 groupLabel: "GitHub",
110 batchable: false,
99 },111 },
100 {112 {
101 id: "gitlab-graphql",113 id: "gitlab-graphql",
bun.lock+6−0
@@ -162,6 +162,7 @@ · 161 unmodified lines
162 "name": "@executor/control-plane",162 "name": "@executor/control-plane",
163 "dependencies": {163 "dependencies": {
164 "@effect/platform": "catalog:",164 "@effect/platform": "catalog:",
165 "@electric-sql/pglite": "^0.3.15",
165 "@executor/codemode-core": "workspace:*",166 "@executor/codemode-core": "workspace:*",
166 "@executor/codemode-google-discovery": "workspace:*",167 "@executor/codemode-google-discovery": "workspace:*",
167 "@executor/codemode-mcp": "workspace:*",168 "@executor/codemode-mcp": "workspace:*",
@@ -172,6 +173,7 @@ · 4 unmodified lines
172 "effect": "catalog:",173 "effect": "catalog:",
173 "graphql": "^16.12.0",174 "graphql": "^16.12.0",
174 "jsonc-parser": "3.3.1",175 "jsonc-parser": "3.3.1",
176 "postgres": "^3.4.8",
175 "prettier": "^3.8.1",177 "prettier": "^3.8.1",
176 "typescript": "^5.9.3",178 "typescript": "^5.9.3",
177 },179 },
@@ -442,6 +444,8 @@ · 264 unmodified lines
442 444
443 "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="],445 "@effect/workflow": ["@effect/workflow@0.16.0", "", { "peerDependencies": { "@effect/experimental": "^0.58.0", "@effect/platform": "^0.94.0", "@effect/rpc": "^0.73.0", "effect": "^3.19.13" } }, "sha512-MiAdlxx3TixkgHdbw+Yf1Z3tHAAE0rOQga12kIydJqj05Fnod+W/I+kQGRMY/XWRg+QUsVxhmh1qTr7Ype6lrw=="],
444 446
447 "@electric-sql/pglite": ["@electric-sql/pglite@0.3.16", "", {}, "sha512-mZkZfOd9OqTMHsK+1cje8OSzfAQcpD7JmILXTl5ahdempjUDdmg4euf1biDex5/LfQIDJ3gvCu6qDgdnDxfJmA=="],
448
445 "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],449 "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
446 450
447 "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],451 "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -1486,6 +1490,8 @@ · 1038 unmodified lines
1486 1490
1487 "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],1491 "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
1488 1492
1493 "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
1494
1489 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],1495 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
1490 1496
1491 "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],1497 "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
packages/codemode-mcp/src/mcp-connection.ts+12−2
@@ -1,3 +1,4 @@
1import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
1import { Client } from "@modelcontextprotocol/sdk/client/index.js";2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";3import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -11,6 +12,7 @@ export type CreateSdkMcpConnectorInput = { · 7 unmodified lines
11 transport?: McpTransportPreference;12 transport?: McpTransportPreference;
12 queryParams?: Record<string, string>;13 queryParams?: Record<string, string>;
13 headers?: Record<string, string>;14 headers?: Record<string, string>;
15 authProvider?: OAuthClientProvider;
14 clientName?: string;16 clientName?: string;
15 clientVersion?: string;17 clientVersion?: string;
16};18};
@@ -76,12 +78,16 @@ export const createSdkMcpConnector = ( · 59 unmodified lines
76 const client = createClient();78 const client = createClient();
77 79
78 if (transport === "streamable-http") {80 if (transport === "streamable-http") {
79 await client.connect(new StreamableHTTPClientTransport(endpoint, { requestInit }));81 await client.connect(new StreamableHTTPClientTransport(endpoint, {
82 requestInit,
83 authProvider: input.authProvider,
84 }));
80 return connectionFromClient(client);85 return connectionFromClient(client);
81 }86 }
82 87
83 if (transport === "sse") {88 if (transport === "sse") {
84 await client.connect(new SSEClientTransport(endpoint, {89 await client.connect(new SSEClientTransport(endpoint, {
90 authProvider: input.authProvider,
85 requestInit,91 requestInit,
86 eventSourceInit: requestInit92 eventSourceInit: requestInit
87 ? {93 ? {
@@ -94,10 +100,14 @@ export const createSdkMcpConnector = ( · 6 unmodified lines
94 }100 }
95 101
96 try {102 try {
97 await client.connect(new StreamableHTTPClientTransport(endpoint, { requestInit }));103 await client.connect(new StreamableHTTPClientTransport(endpoint, {
104 requestInit,
105 authProvider: input.authProvider,
106 }));
98 return connectionFromClient(client);107 return connectionFromClient(client);
99 } catch {108 } catch {
100 await client.connect(new SSEClientTransport(endpoint, {109 await client.connect(new SSEClientTransport(endpoint, {
110 authProvider: input.authProvider,
101 requestInit,111 requestInit,
102 eventSourceInit: requestInit112 eventSourceInit: requestInit
103 ? {113 ? {
packages/control-plane/package.json+2−0
@@ -21,6 +21,7 @@ · 20 unmodified lines
21 },21 },
22 "dependencies": {22 "dependencies": {
23 "@effect/platform": "catalog:",23 "@effect/platform": "catalog:",
24 "@electric-sql/pglite": "^0.3.15",
24 "@executor/codemode-core": "workspace:*",25 "@executor/codemode-core": "workspace:*",
25 "@executor/codemode-google-discovery": "workspace:*",26 "@executor/codemode-google-discovery": "workspace:*",
26 "@executor/codemode-mcp": "workspace:*",27 "@executor/codemode-mcp": "workspace:*",
@@ -31,6 +32,7 @@ · 4 unmodified lines
31 "effect": "catalog:",32 "effect": "catalog:",
32 "graphql": "^16.12.0",33 "graphql": "^16.12.0",
33 "jsonc-parser": "3.3.1",34 "jsonc-parser": "3.3.1",
35 "postgres": "^3.4.8",
34 "prettier": "^3.8.1",36 "prettier": "^3.8.1",
35 "typescript": "^5.9.3"37 "typescript": "^5.9.3"
36 },38 },
packages/control-plane/src/api/index.ts+6−0
@@ -50,13 +50,19 @@ export { · 49 unmodified lines
50} from "./oauth/api";50} from "./oauth/api";
51 51
52export {52export {
53 ConnectSourceBatchPayloadSchema,
54 ConnectSourceBatchResultSchema,
53 ConnectSourcePayloadSchema,55 ConnectSourcePayloadSchema,
54 ConnectSourceResultSchema,56 ConnectSourceResultSchema,
57 CreateWorkspaceOauthClientPayloadSchema,
55 CreateSourcePayloadSchema,58 CreateSourcePayloadSchema,
56 DiscoverSourcePayloadSchema,59 DiscoverSourcePayloadSchema,
57 UpdateSourcePayloadSchema,60 UpdateSourcePayloadSchema,
61 type ConnectSourceBatchPayload,
62 type ConnectSourceBatchResult,
58 type ConnectSourcePayload,63 type ConnectSourcePayload,
59 type ConnectSourceResult,64 type ConnectSourceResult,
65 type CreateWorkspaceOauthClientPayload,
60 type CreateSourcePayload,66 type CreateSourcePayload,
61 type DiscoverSourcePayload,67 type DiscoverSourcePayload,
62 type UpdateSourcePayload,68 type UpdateSourcePayload,
packages/control-plane/src/api/sources/api.ts+105−0
@@ -1,7 +1,9 @@
1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";1import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2import {2import {
3 SourceOauthClientInputSchema,
3 ExecutionInteractionIdSchema,4 ExecutionInteractionIdSchema,
4 JsonObjectSchema,5 JsonObjectSchema,
6 ProviderAuthGrantIdSchema,
5 SourceAuthSchema,7 SourceAuthSchema,
6 SourceAuthSessionIdSchema,8 SourceAuthSessionIdSchema,
7 SourceDiscoveryResultSchema,9 SourceDiscoveryResultSchema,
@@ -15,6 +17,8 @@ import { · 7 unmodified lines
15 SourceProbeAuthSchema,17 SourceProbeAuthSchema,
16 SourceSchema,18 SourceSchema,
17 SourceStatusSchema,19 SourceStatusSchema,
20 WorkspaceOauthClientIdSchema,
21 WorkspaceOauthClientSchema,
18 WorkspaceIdSchema,22 WorkspaceIdSchema,
19} from "#schema";23} from "#schema";
20import * as Schema from "effect/Schema";24import * as Schema from "effect/Schema";
@@ -59,6 +63,8 @@ export const CreateSourcePayloadSchema = Schema.extend( · 38 unmodified lines
59);63);
60 64
61export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type;65export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type;
66export type CreateWorkspaceOauthClientPayload =
67 typeof CreateWorkspaceOauthClientPayloadSchema.Type;
62 68
63export const UpdateSourcePayloadSchema = Schema.Struct({69export const UpdateSourcePayloadSchema = Schema.Struct({
64 name: OptionalTrimmedNonEmptyStringSchema,70 name: OptionalTrimmedNonEmptyStringSchema,
@@ -96,6 +102,49 @@ const CredentialOauthCompleteUrlParamsSchema = Schema.Struct({ · 31 unmodified lines
96 error_description: Schema.optional(Schema.String),102 error_description: Schema.optional(Schema.String),
97});103});
98 104
105const WorkspaceOauthClientQuerySchema = Schema.Struct({
106 providerKey: Schema.String,
107});
108
109const CreateWorkspaceOauthClientPayloadSchema = Schema.Struct({
110 providerKey: Schema.String,
111 label: Schema.optional(Schema.NullOr(Schema.String)),
112 oauthClient: SourceOauthClientInputSchema,
113});
114
115const oauthClientIdParam = HttpApiSchema.param("oauthClientId", WorkspaceOauthClientIdSchema);
116const grantIdParam = HttpApiSchema.param("grantId", ProviderAuthGrantIdSchema);
117
118const ConnectGoogleDiscoveryBatchSourceSchema = Schema.Struct({
119 service: TrimmedNonEmptyStringSchema,
120 version: TrimmedNonEmptyStringSchema,
121 discoveryUrl: Schema.optional(Schema.NullOr(TrimmedNonEmptyStringSchema)),
122 scopes: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)),
123 name: Schema.optional(Schema.NullOr(Schema.String)),
124 namespace: Schema.optional(Schema.NullOr(Schema.String)),
125});
126
127const ConnectSourceBatchPayloadSchema = Schema.Struct({
128 workspaceOauthClientId: WorkspaceOauthClientIdSchema,
129 sources: Schema.Array(ConnectGoogleDiscoveryBatchSourceSchema),
130});
131
132const ConnectSourceBatchResultSchema = Schema.Struct({
133 results: Schema.Array(
134 Schema.Struct({
135 source: SourceSchema,
136 status: Schema.Literal("connected", "pending_oauth"),
137 }),
138 ),
139 providerOauthSession: Schema.NullOr(
140 Schema.Struct({
141 sessionId: SourceAuthSessionIdSchema,
142 authorizationUrl: Schema.String,
143 sourceIds: Schema.Array(SourceIdSchema),
144 }),
145 ),
146});
147
99const DiscoverSourcePayloadSchema = Schema.Struct({148const DiscoverSourcePayloadSchema = Schema.Struct({
100 url: TrimmedNonEmptyStringSchema,149 url: TrimmedNonEmptyStringSchema,
101 probeAuth: Schema.optional(SourceProbeAuthSchema),150 probeAuth: Schema.optional(SourceProbeAuthSchema),
@@ -122,10 +171,15 @@ const ConnectSourceResultSchema = Schema.Union( · 20 unmodified lines
122);171);
123 172
124export type ConnectSourceResult = typeof ConnectSourceResultSchema.Type;173export type ConnectSourceResult = typeof ConnectSourceResultSchema.Type;
174export type ConnectSourceBatchPayload = typeof ConnectSourceBatchPayloadSchema.Type;
175export type ConnectSourceBatchResult = typeof ConnectSourceBatchResultSchema.Type;
125 176
126export {177export {
127 ConnectSourcePayloadSchema,178 ConnectSourcePayloadSchema,
179 ConnectSourceBatchPayloadSchema,
180 ConnectSourceBatchResultSchema,
128 ConnectSourceResultSchema,181 ConnectSourceResultSchema,
182 CreateWorkspaceOauthClientPayloadSchema,
129 DiscoverSourcePayloadSchema,183 DiscoverSourcePayloadSchema,
130};184};
131 185
@@ -153,6 +207,49 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 21 unmodified lines
153 .addError(ControlPlaneForbiddenError)207 .addError(ControlPlaneForbiddenError)
154 .addError(ControlPlaneStorageError),208 .addError(ControlPlaneStorageError),
155 )209 )
210 .add(
211 HttpApiEndpoint.post("connectBatch")`/workspaces/${workspaceIdParam}/sources/connect-batch`
212 .setPayload(ConnectSourceBatchPayloadSchema)
213 .addSuccess(ConnectSourceBatchResultSchema)
214 .addError(ControlPlaneBadRequestError)
215 .addError(ControlPlaneUnauthorizedError)
216 .addError(ControlPlaneForbiddenError)
217 .addError(ControlPlaneStorageError),
218 )
219 .add(
220 HttpApiEndpoint.get("listWorkspaceOauthClients")`/workspaces/${workspaceIdParam}/oauth-clients`
221 .setUrlParams(WorkspaceOauthClientQuerySchema)
222 .addSuccess(Schema.Array(WorkspaceOauthClientSchema))
223 .addError(ControlPlaneBadRequestError)
224 .addError(ControlPlaneUnauthorizedError)
225 .addError(ControlPlaneForbiddenError)
226 .addError(ControlPlaneStorageError),
227 )
228 .add(
229 HttpApiEndpoint.post("createWorkspaceOauthClient")`/workspaces/${workspaceIdParam}/oauth-clients`
230 .setPayload(CreateWorkspaceOauthClientPayloadSchema)
231 .addSuccess(WorkspaceOauthClientSchema)
232 .addError(ControlPlaneBadRequestError)
233 .addError(ControlPlaneUnauthorizedError)
234 .addError(ControlPlaneForbiddenError)
235 .addError(ControlPlaneStorageError),
236 )
237 .add(
238 HttpApiEndpoint.del("removeWorkspaceOauthClient")`/workspaces/${workspaceIdParam}/oauth-clients/${oauthClientIdParam}`
239 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
240 .addError(ControlPlaneBadRequestError)
241 .addError(ControlPlaneUnauthorizedError)
242 .addError(ControlPlaneForbiddenError)
243 .addError(ControlPlaneStorageError),
244 )
245 .add(
246 HttpApiEndpoint.del("removeProviderAuthGrant")`/workspaces/${workspaceIdParam}/provider-grants/${grantIdParam}`
247 .addSuccess(Schema.Struct({ removed: Schema.Boolean }))
248 .addError(ControlPlaneBadRequestError)
249 .addError(ControlPlaneUnauthorizedError)
250 .addError(ControlPlaneForbiddenError)
251 .addError(ControlPlaneStorageError),
252 )
156 .add(253 .add(
157 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`254 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`
158 .addSuccess(Schema.Array(SourceSchema))255 .addSuccess(Schema.Array(SourceSchema))
@@ -222,6 +319,14 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 63 unmodified lines
222 .addError(ControlPlaneNotFoundError)319 .addError(ControlPlaneNotFoundError)
223 .addError(ControlPlaneStorageError),320 .addError(ControlPlaneStorageError),
224 )321 )
322 .add(
323 HttpApiEndpoint.get("providerOauthComplete")`/workspaces/${workspaceIdParam}/oauth/provider/callback`
324 .setUrlParams(CredentialOauthCompleteUrlParamsSchema)
325 .addSuccess(HtmlSchema)
326 .addError(ControlPlaneBadRequestError)
327 .addError(ControlPlaneNotFoundError)
328 .addError(ControlPlaneStorageError),
329 )
225 .add(330 .add(
226 HttpApiEndpoint.get("inspection")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection`331 HttpApiEndpoint.get("inspection")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection`
227 .addSuccess(SourceInspectionSchema)332 .addSuccess(SourceInspectionSchema)
packages/control-plane/src/api/sources/http.ts+186−6
@@ -93,6 +93,8 @@ const serializeJsonForScript = (value: unknown): string => · 92 unmodified lines
93 .replaceAll(">", "\\u003e")93 .replaceAll(">", "\\u003e")
94 .replaceAll("&", "\\u0026");94 .replaceAll("&", "\\u0026");
95 95
96const SOURCE_OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
97
96const credentialPageDocument = (input: {98const credentialPageDocument = (input: {
97 title: string;99 title: string;
98 eyebrow: string;100 eyebrow: string;
@@ -460,10 +462,11 @@ const sourceOAuthPopupResultDocument = (input: { · 361 unmodified lines
460 title: string;462 title: string;
461 message: string;463 message: string;
462 state: "stored" | "cancelled";464 state: "stored" | "cancelled";
465 sessionId?: string | null;
463 payload:466 payload:
464 | {467 | {
465 type: "executor:source-oauth-result";468 type: "executor:source-oauth-result";
466 ok: true;469 ok: true;
467 sourceId: Source["id"];470 sourceId: Source["id"];
468 }471 }
469 | {472 | {
@@ -560,12 +563,22 @@ const sourceOAuthPopupResultDocument = (input: { · 90 unmodified lines
560 <script>563 <script>
561 (() => {564 (() => {
562 const payload = ${serializeJsonForScript(input.payload)};565 const payload = ${serializeJsonForScript(input.payload)};
566 const storageKey = ${serializeJsonForScript(
567 input.sessionId
568 ? `${SOURCE_OAUTH_RESULT_STORAGE_KEY_PREFIX}${input.sessionId}`
569 : null,
570 )};
571 try {
572 if (storageKey) {
573 window.localStorage.setItem(storageKey, JSON.stringify(payload));
574 }
575 } catch {}
563 try {576 try {
564 if (window.opener) {577 if (window.opener) {
565 window.opener.postMessage(payload, window.location.origin);578 window.opener.postMessage(payload, window.location.origin);
566 }579 }
567 } finally {580 } finally {
568 window.setTimeout(() => window.close(), 60);581 window.setTimeout(() => window.close(), 120);
569 }582 }
570 })();583 })();
571 </script>584 </script>
@@ -696,6 +709,105 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 124 unmodified lines
696 ),709 ),
697 ),710 ),
698 )711 )
712 .handle("connectBatch", ({ path, payload }) =>
713 resolveRequestedLocalWorkspace("sources.connectBatch", path.workspaceId).pipe(
714 Effect.flatMap((runtimeLocalWorkspace) =>
715 Effect.gen(function* () {
716 const request = yield* HttpServerRequest.HttpServerRequest;
717 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
718 const baseUrl = resolveRequestOrigin(request);
719
720 return yield* sourceAuthService.connectGoogleDiscoveryBatch({
721 workspaceId: path.workspaceId,
722 actorAccountId: runtimeLocalWorkspace.installation.accountId,
723 executionId: null,
724 interactionId: null,
725 workspaceOauthClientId: payload.workspaceOauthClientId,
726 sources: payload.sources,
727 baseUrl,
728 });
729 }).pipe(
730 Effect.catchAll((cause) =>
731 Effect.fail(toBadRequestError("sources.connectBatch", cause)),
732 ),
733 ),
734 ),
735 ),
736 )
737 .handle("listWorkspaceOauthClients", ({ path, urlParams }) =>
738 resolveRequestedLocalWorkspace("sources.listWorkspaceOauthClients", path.workspaceId).pipe(
739 Effect.flatMap(() =>
740 Effect.gen(function* () {
741 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
742 return yield* sourceAuthService.listWorkspaceOauthClients({
743 workspaceId: path.workspaceId,
744 providerKey: urlParams.providerKey,
745 });
746 }).pipe(
747 Effect.catchAll((cause) =>
748 Effect.fail(toBadRequestError("sources.listWorkspaceOauthClients", cause)),
749 ),
750 ),
751 ),
752 ),
753 )
754 .handle("createWorkspaceOauthClient", ({ path, payload }) =>
755 resolveRequestedLocalWorkspace("sources.createWorkspaceOauthClient", path.workspaceId).pipe(
756 Effect.flatMap(() =>
757 Effect.gen(function* () {
758 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
759 return yield* sourceAuthService.createWorkspaceOauthClient({
760 workspaceId: path.workspaceId,
761 providerKey: payload.providerKey,
762 label: payload.label,
763 oauthClient: payload.oauthClient,
764 });
765 }).pipe(
766 Effect.catchAll((cause) =>
767 Effect.fail(toBadRequestError("sources.createWorkspaceOauthClient", cause)),
768 ),
769 ),
770 ),
771 ),
772 )
773 .handle("removeWorkspaceOauthClient", ({ path }) =>
774 resolveRequestedLocalWorkspace("sources.removeWorkspaceOauthClient", path.workspaceId).pipe(
775 Effect.flatMap(() =>
776 Effect.gen(function* () {
777 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
778 const removed = yield* sourceAuthService.removeWorkspaceOauthClient({
779 workspaceId: path.workspaceId,
780 oauthClientId: path.oauthClientId,
781 });
782
783 return { removed };
784 }).pipe(
785 Effect.catchAll((cause) =>
786 Effect.fail(toBadRequestError("sources.removeWorkspaceOauthClient", cause)),
787 ),
788 ),
789 ),
790 ),
791 )
792 .handle("removeProviderAuthGrant", ({ path }) =>
793 resolveRequestedLocalWorkspace("sources.removeProviderAuthGrant", path.workspaceId).pipe(
794 Effect.flatMap(() =>
795 Effect.gen(function* () {
796 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
797 const removed = yield* sourceAuthService.removeProviderAuthGrant({
798 workspaceId: path.workspaceId,
799 grantId: path.grantId,
800 }).pipe(
801 Effect.catchAll((cause) =>
802 Effect.fail(toBadRequestError("sources.removeProviderAuthGrant", cause)),
803 ),
804 );
805
806 return { removed };
807 }),
808 ),
809 ),
810 )
699 .handle("list", ({ path }) =>811 .handle("list", ({ path }) =>
700 resolveRequestedLocalWorkspace("sources.list", path.workspaceId).pipe(812 resolveRequestedLocalWorkspace("sources.list", path.workspaceId).pipe(
701 Effect.flatMap((runtimeLocalWorkspace) =>813 Effect.flatMap((runtimeLocalWorkspace) =>
@@ -899,16 +1011,17 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 197 unmodified lines
899 error: urlParams.error,1011 error: urlParams.error,
900 errorDescription: urlParams.error_description,1012 errorDescription: urlParams.error_description,
901 }).pipe(1013 }).pipe(
902 Effect.map((source) =>1014 Effect.map((completed) =>
903 htmlResponse(1015 htmlResponse(
904 sourceOAuthPopupResultDocument({1016 sourceOAuthPopupResultDocument({
905 title: "Source connected",1017 title: "Source connected",
906 message: `Source connected: ${source.id}. You can close this window.`,1018 message: `Source connected: ${completed.source.id}. You can close this window.`,
907 state: "stored",1019 state: "stored",
1020 sessionId: completed.sessionId,
908 payload: {1021 payload: {
909 type: "executor:source-oauth-result",1022 type: "executor:source-oauth-result",
910 ok: true,1023 ok: true,
911 sourceId: source.id,1024 sourceId: completed.source.id,
912 },1025 },
913 }),1026 }),
914 ),1027 ),
@@ -931,5 +1044,72 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 16 unmodified lines
931 ),1044 ),
932 ),1045 ),
933 ),1046 ),
1047 )
1048 .handle("providerOauthComplete", ({ path, urlParams }) =>
1049 resolveRequestedLocalWorkspace("sources.providerOauthComplete", path.workspaceId).pipe(
1050 Effect.flatMap((runtimeLocalWorkspace) =>
1051 Effect.gen(function* () {
1052 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
1053 const completed = yield* sourceAuthService.completeProviderOauthCallback({
1054 workspaceId: path.workspaceId,
1055 actorAccountId: runtimeLocalWorkspace.installation.accountId,
1056 state: urlParams.state,
1057 code: urlParams.code,
1058 error: urlParams.error,
1059 errorDescription: urlParams.error_description,
1060 });
1061 const primarySource = completed.sources[0] ?? null;
1062
1063 return htmlResponse(
1064 sourceOAuthPopupResultDocument({
1065 title: "Sources connected",
1066 message: `Connected ${completed.sources.length} source${completed.sources.length === 1 ? "" : "s"}. You can close this window.`,
1067 state: "stored",
1068 sessionId: completed.sessionId,
1069 payload: {
1070 type: "executor:source-oauth-result",
1071 ok: true,
1072 sourceId: primarySource?.id ?? ("src_oauth_complete" as Source["id"]),
1073 },
1074 }),
1075 );
1076 }).pipe(
1077 Effect.catchAll((error) =>
1078 Effect.succeed(
1079 htmlResponse(
1080 sourceOAuthPopupResultDocument({
1081 title: "OAuth failed",
1082 message: error instanceof Error ? error.message : "Failed completing OAuth",
1083 state: "cancelled",
1084 payload: {
1085 type: "executor:source-oauth-result",
1086 ok: false,
1087 error: error instanceof Error ? error.message : "Failed completing OAuth",
1088 },
1089 }),
1090 500,
1091 ),
1092 ),
1093 ),
1094 ),
1095 ),
1096 Effect.catchAll((error) =>
1097 Effect.succeed(
1098 htmlResponse(
1099 sourceOAuthPopupResultDocument({
1100 title: "OAuth failed",
1101 message: error instanceof Error ? error.message : "Failed completing OAuth",
1102 state: "cancelled",
1103 payload: {
1104 type: "executor:source-oauth-result",
1105 ok: false,
1106 error: error instanceof Error ? error.message : "Failed completing OAuth",
1107 },
1108 }),
1109 500,
1110 ),
1111 ),
1112 ),
1113 ),
934 ),1114 ),
935);1115);
packages/control-plane/src/runtime/auth-artifacts.ts+111−0
@@ -1,6 +1,13 @@
1import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
1import {2import {
2 type AccountId,3 type AccountId,
3 AuthGrantSetJsonSchema,4 AuthGrantSetJsonSchema,
5 type McpOAuthAuthArtifactConfig,
6 McpOAuthAuthArtifactConfigJsonSchema,
7 McpOAuthAuthArtifactKind,
8 type ProviderGrantRefAuthArtifactConfig,
9 ProviderGrantRefAuthArtifactConfigJsonSchema,
10 ProviderGrantRefAuthArtifactKind,
4 RequestPlacementTemplatesJsonSchema,11 RequestPlacementTemplatesJsonSchema,
5 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,12 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
6 RefreshableOAuth2AuthorizedUserAuthArtifactKind,13 RefreshableOAuth2AuthorizedUserAuthArtifactKind,
@@ -13,6 +20,8 @@ import { · 6 unmodified lines
13 authArtifactSecretRefs,20 authArtifactSecretRefs,
14 decodeAuthGrantSet,21 decodeAuthGrantSet,
15 decodeBuiltInAuthArtifactConfig,22 decodeBuiltInAuthArtifactConfig,
23 decodeMcpOAuthAuthArtifactConfig,
24 decodeProviderGrantRefAuthArtifactConfig,
16 type AuthArtifact,25 type AuthArtifact,
17 type CredentialSlot,26 type CredentialSlot,
18 type RequestPlacement,27 type RequestPlacement,
@@ -37,6 +46,7 @@ export type ResolvedSourceAuthMaterial = { · 18 unmodified lines
37 bodyValues: Readonly<Record<string, string>>;46 bodyValues: Readonly<Record<string, string>>;
38 expiresAt: number | null;47 expiresAt: number | null;
39 refreshAfter: number | null;48 refreshAfter: number | null;
49 authProvider?: OAuthClientProvider | undefined;
40};50};
41 51
42const emptyResolvedAuthMaterial: ResolvedSourceAuthMaterial = {52const emptyResolvedAuthMaterial: ResolvedSourceAuthMaterial = {
@@ -47,6 +57,7 @@ const emptyResolvedAuthMaterial: ResolvedSourceAuthMaterial = { · 4 unmodified lines
47 bodyValues: {},57 bodyValues: {},
48 expiresAt: null,58 expiresAt: null,
49 refreshAfter: null,59 refreshAfter: null,
60 authProvider: undefined,
50};61};
51 62
52const encodeStaticBearerAuthArtifactConfig = Schema.encodeSync(63const encodeStaticBearerAuthArtifactConfig = Schema.encodeSync(
@@ -63,6 +74,12 @@ const encodeStaticPlacementsAuthArtifactConfig = Schema.encodeSync( · 10 unmodified lines
63const encodeRefreshableOAuth2AuthorizedUserAuthArtifactConfig = Schema.encodeSync(74const encodeRefreshableOAuth2AuthorizedUserAuthArtifactConfig = Schema.encodeSync(
64 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,75 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
65);76);
77const encodeProviderGrantRefAuthArtifactConfig = Schema.encodeSync(
78 ProviderGrantRefAuthArtifactConfigJsonSchema,
79);
80const encodeMcpOAuthAuthArtifactConfig = Schema.encodeSync(
81 McpOAuthAuthArtifactConfigJsonSchema,
82);
66 83
67const decodeRequestPlacementTemplates = Schema.decodeUnknownEither(84const decodeRequestPlacementTemplates = Schema.decodeUnknownEither(
68 RequestPlacementTemplatesJsonSchema,85 RequestPlacementTemplatesJsonSchema,
@@ -192,6 +209,54 @@ export const authArtifactFromSourceAuth = (input: { · 123 unmodified lines
192 };209 };
193 }210 }
194 211
212 if (input.auth.kind === "provider_grant_ref") {
213 return {
214 id,
215 workspaceId: input.source.workspaceId,
216 sourceId: input.source.id,
217 actorAccountId: input.actorAccountId ?? null,
218 slot: input.slot,
219 artifactKind: ProviderGrantRefAuthArtifactKind,
220 configJson: encodeProviderGrantRefAuthArtifactConfig({
221 grantId: input.auth.grantId,
222 providerKey: input.auth.providerKey,
223 requiredScopes: [...input.auth.requiredScopes],
224 headerName: input.auth.headerName,
225 prefix: input.auth.prefix,
226 }),
227 grantSetJson,
228 createdAt: input.source.createdAt,
229 updatedAt: input.source.updatedAt,
230 };
231 }
232
233 if (input.auth.kind === "mcp_oauth") {
234 return {
235 id,
236 workspaceId: input.source.workspaceId,
237 sourceId: input.source.id,
238 actorAccountId: input.actorAccountId ?? null,
239 slot: input.slot,
240 artifactKind: McpOAuthAuthArtifactKind,
241 configJson: encodeMcpOAuthAuthArtifactConfig({
242 redirectUri: input.auth.redirectUri,
243 accessToken: input.auth.accessToken,
244 refreshToken: input.auth.refreshToken,
245 tokenType: input.auth.tokenType,
246 expiresIn: input.auth.expiresIn,
247 scope: input.auth.scope,
248 resourceMetadataUrl: input.auth.resourceMetadataUrl,
249 authorizationServerUrl: input.auth.authorizationServerUrl,
250 resourceMetadataJson: input.auth.resourceMetadataJson,
251 authorizationServerMetadataJson: input.auth.authorizationServerMetadataJson,
252 clientInformationJson: input.auth.clientInformationJson,
253 }),
254 grantSetJson,
255 createdAt: input.source.createdAt,
256 updatedAt: input.source.updatedAt,
257 };
258 }
259
195 return {260 return {
196 id,261 id,
197 workspaceId: input.source.workspaceId,262 workspaceId: input.source.workspaceId,
@@ -216,6 +281,36 @@ export const sourceAuthFromAuthArtifact = (artifact: AuthArtifact | null): Sourc · 18 unmodified lines
216 return { kind: "none" };281 return { kind: "none" };
217 }282 }
218 283
284 const providerGrantRefConfig = decodeProviderGrantRefAuthArtifactConfig(artifact);
285 if (providerGrantRefConfig !== null) {
286 return {
287 kind: "provider_grant_ref",
288 grantId: providerGrantRefConfig.grantId,
289 providerKey: providerGrantRefConfig.providerKey,
290 requiredScopes: [...providerGrantRefConfig.requiredScopes],
291 headerName: providerGrantRefConfig.headerName,
292 prefix: providerGrantRefConfig.prefix,
293 };
294 }
295
296 const mcpOAuthConfig = decodeMcpOAuthAuthArtifactConfig(artifact);
297 if (mcpOAuthConfig !== null) {
298 return {
299 kind: "mcp_oauth",
300 redirectUri: mcpOAuthConfig.redirectUri,
301 accessToken: mcpOAuthConfig.accessToken,
302 refreshToken: mcpOAuthConfig.refreshToken,
303 tokenType: mcpOAuthConfig.tokenType,
304 expiresIn: mcpOAuthConfig.expiresIn,
305 scope: mcpOAuthConfig.scope,
306 resourceMetadataUrl: mcpOAuthConfig.resourceMetadataUrl,
307 authorizationServerUrl: mcpOAuthConfig.authorizationServerUrl,
308 resourceMetadataJson: mcpOAuthConfig.resourceMetadataJson,
309 authorizationServerMetadataJson: mcpOAuthConfig.authorizationServerMetadataJson,
310 clientInformationJson: mcpOAuthConfig.clientInformationJson,
311 };
312 }
313
219 const decoded = decodeBuiltInAuthArtifactConfig(artifact);314 const decoded = decodeBuiltInAuthArtifactConfig(artifact);
220 if (decoded === null) {315 if (decoded === null) {
221 return { kind: "none" };316 return { kind: "none" };
@@ -360,6 +455,22 @@ export const resolveAuthArtifactMaterial = (input: { · 138 unmodified lines
360 }455 }
361 }456 }
362 457
458 const providerGrantRefConfig = decodeProviderGrantRefAuthArtifactConfig(input.artifact);
459 if (providerGrantRefConfig !== null) {
460 if (input.lease === null || input.lease === undefined) {
461 return yield* Effect.fail(
462 new Error(`Provider grant auth artifact ${input.artifact.id} requires a lease`),
463 );
464 }
465 }
466
467 const mcpOAuthConfig = decodeMcpOAuthAuthArtifactConfig(input.artifact);
468 if (mcpOAuthConfig !== null) {
469 return {
470 ...emptyResolvedAuthMaterial,
471 };
472 }
473
363 if (input.lease === null || input.lease === undefined) {474 if (input.lease === null || input.lease === undefined) {
364 return yield* Effect.fail(475 return yield* Effect.fail(
365 new Error(`Unsupported auth artifact kind: ${input.artifact.artifactKind}`),476 new Error(`Unsupported auth artifact kind: ${input.artifact.artifactKind}`),
packages/control-plane/src/runtime/auth-leases.ts+193−0
@@ -1,11 +1,14 @@
1import type {1import type {
2 AuthArtifact,2 AuthArtifact,
3 AuthLease,3 AuthLease,
4 ProviderAuthGrant,
4 RequestPlacementTemplate,5 RequestPlacementTemplate,
5 SecretRef,6 SecretRef,
6} from "#schema";7} from "#schema";
7import {8import {
8 AuthLeaseIdSchema,9 AuthLeaseIdSchema,
10 decodeMcpOAuthAuthArtifactConfig,
11 decodeProviderGrantRefAuthArtifactConfig,
9 RefreshableOAuth2AuthorizedUserAuthArtifactKind,12 RefreshableOAuth2AuthorizedUserAuthArtifactKind,
10 authLeaseSecretRefs,13 authLeaseSecretRefs,
11 decodeBuiltInAuthArtifactConfig,14 decodeBuiltInAuthArtifactConfig,
@@ -23,6 +26,7 @@ import { · 11 unmodified lines
23} from "./secret-material-providers";26} from "./secret-material-providers";
24import { refreshOAuth2AccessToken } from "./oauth2-pkce";27import { refreshOAuth2AccessToken } from "./oauth2-pkce";
25import type { OAuth2TokenResponse } from "./oauth2-pkce";28import type { OAuth2TokenResponse } from "./oauth2-pkce";
29import { createPersistedMcpAuthProvider } from "./mcp-auth-provider";
26 30
27const LEASE_REFRESH_SKEW_MS = 60_000;31const LEASE_REFRESH_SKEW_MS = 60_000;
28 32
@@ -168,6 +172,155 @@ const refreshRefreshableOauth2AuthorizedUserArtifact = (input: { · 139 unmodified lines
168 return nextLease;172 return nextLease;
169 });173 });
170 174
175const workspaceOauthClientSecretRef = (input: {
176 clientSecretProviderId: string | null;
177 clientSecretHandle: string | null;
178}): SecretRef | null =>
179 input.clientSecretProviderId && input.clientSecretHandle
180 ? {
181 providerId: input.clientSecretProviderId,
182 handle: input.clientSecretHandle,
183 }
184 : null;
185
186const cleanupProviderGrantRefreshToken = (rows: ControlPlaneStoreShape, input: {
187 previous: SecretRef;
188 next: SecretRef;
189}) =>
190 secretRefKey(input.previous) === secretRefKey(input.next)
191 ? Effect.void
192 : createDefaultSecretMaterialDeleter({ rows })(input.previous).pipe(
193 Effect.either,
194 Effect.ignore,
195 );
196
197const refreshProviderGrantRefArtifact = (input: {
198 rows: ControlPlaneStoreShape;
199 artifact: AuthArtifact;
200 lease: AuthLease | null;
201 resolveSecretMaterial: ResolveSecretMaterial;
202 context?: SecretMaterialResolveContext;
203}): Effect.Effect<AuthLease, Error, never> =>
204 Effect.gen(function* () {
205 const config = decodeProviderGrantRefAuthArtifactConfig(input.artifact);
206 if (config === null) {
207 return yield* Effect.fail(
208 new Error(`Unsupported auth artifact kind: ${input.artifact.artifactKind}`),
209 );
210 }
211
212 const grantOption = yield* input.rows.providerAuthGrants.getById(config.grantId);
213 if (Option.isNone(grantOption)) {
214 return yield* Effect.fail(
215 new Error(`Provider auth grant not found: ${config.grantId}`),
216 );
217 }
218 const grant = grantOption.value;
219
220 const oauthClientOption = yield* input.rows.workspaceOauthClients.getById(grant.oauthClientId);
221 if (Option.isNone(oauthClientOption)) {
222 return yield* Effect.fail(
223 new Error(`Workspace OAuth client not found: ${grant.oauthClientId}`),
224 );
225 }
226 const oauthClient = oauthClientOption.value;
227
228 const refreshToken = yield* input.resolveSecretMaterial({
229 ref: grant.refreshToken,
230 context: input.context,
231 });
232 const clientSecretRef = workspaceOauthClientSecretRef(oauthClient);
233 const clientSecret = clientSecretRef
234 ? yield* input.resolveSecretMaterial({
235 ref: clientSecretRef,
236 context: input.context,
237 })
238 : null;
239
240 const tokenResponse = yield* refreshOAuth2AccessToken({
241 tokenEndpoint: grant.tokenEndpoint,
242 clientId: oauthClient.clientId,
243 clientAuthentication: grant.clientAuthentication,
244 clientSecret,
245 refreshToken,
246 });
247
248 const storeSecretMaterial = createDefaultSecretMaterialStorer({
249 rows: input.rows,
250 });
251 const accessTokenRef = yield* storeSecretMaterial({
252 purpose: "oauth_access_token",
253 value: tokenResponse.access_token,
254 name: `${grant.providerKey} Access Token`,
255 });
256
257 const rotatedRefreshTokenRef = tokenResponse.refresh_token
258 ? yield* storeSecretMaterial({
259 purpose: "oauth_refresh_token",
260 value: tokenResponse.refresh_token,
261 name: `${grant.providerKey} Refresh Token`,
262 })
263 : grant.refreshToken;
264
265 const now = Date.now();
266 const nextGrant: ProviderAuthGrant = {
267 ...grant,
268 refreshToken: rotatedRefreshTokenRef,
269 lastRefreshedAt: now,
270 updatedAt: now,
271 };
272 yield* input.rows.providerAuthGrants.upsert(nextGrant);
273 yield* cleanupProviderGrantRefreshToken(input.rows, {
274 previous: grant.refreshToken,
275 next: rotatedRefreshTokenRef,
276 });
277
278 const expiresInMs =
279 typeof tokenResponse.expires_in === "number" && Number.isFinite(tokenResponse.expires_in)
280 ? Math.max(0, tokenResponse.expires_in * 1000)
281 : null;
282 const expiresAt = expiresInMs === null ? null : now + expiresInMs;
283 const refreshAfter =
284 expiresAt === null ? null : Math.max(now, expiresAt - LEASE_REFRESH_SKEW_MS);
285
286 const nextLease: AuthLease = {
287 id: input.lease?.id ?? AuthLeaseIdSchema.make(`auth_lease_${crypto.randomUUID()}`),
288 authArtifactId: input.artifact.id,
289 workspaceId: input.artifact.workspaceId,
290 sourceId: input.artifact.sourceId,
291 actorAccountId: input.artifact.actorAccountId,
292 slot: input.artifact.slot,
293 placementsTemplateJson: encodeLeasePlacementTemplatesJson([
294 {
295 location: "header",
296 name: config.headerName,
297 parts: [
298 {
299 kind: "literal",
300 value: config.prefix,
301 },
302 {
303 kind: "secret_ref",
304 ref: accessTokenRef,
305 },
306 ],
307 },
308 ]),
309 expiresAt,
310 refreshAfter,
311 createdAt: input.lease?.createdAt ?? now,
312 updatedAt: now,
313 };
314
315 yield* input.rows.authLeases.upsert(nextLease);
316 yield* cleanupAuthLeaseSecretRefs(input.rows, {
317 previous: input.lease,
318 next: nextLease,
319 });
320
321 return nextLease;
322 });
323
171export const removeAuthLeaseAndSecrets = (rows: ControlPlaneStoreShape, input: {324export const removeAuthLeaseAndSecrets = (rows: ControlPlaneStoreShape, input: {
172 authArtifactId: AuthArtifact["id"];325 authArtifactId: AuthArtifact["id"];
173}): Effect.Effect<void, Error, never> =>326}): Effect.Effect<void, Error, never> =>
@@ -273,6 +426,8 @@ export const resolveAuthArtifactMaterialWithLeases = (input: { · 99 unmodified lines
273 const existingLeaseOption = yield* input.rows.authLeases.getByAuthArtifactId(input.artifact.id);426 const existingLeaseOption = yield* input.rows.authLeases.getByAuthArtifactId(input.artifact.id);
274 const existingLease = Option.isSome(existingLeaseOption) ? existingLeaseOption.value : null;427 const existingLease = Option.isSome(existingLeaseOption) ? existingLeaseOption.value : null;
275 const decoded = decodeBuiltInAuthArtifactConfig(input.artifact);428 const decoded = decodeBuiltInAuthArtifactConfig(input.artifact);
429 const providerGrantConfig = decodeProviderGrantRefAuthArtifactConfig(input.artifact);
430 const mcpOAuthConfig = decodeMcpOAuthAuthArtifactConfig(input.artifact);
276 431
277 if (432 if (
278 decoded !== null433 decoded !== null
@@ -296,6 +451,44 @@ export const resolveAuthArtifactMaterialWithLeases = (input: { · 17 unmodified lines
296 });451 });
297 }452 }
298 453
454 if (providerGrantConfig !== null) {
455 const lease = leaseIsFresh(existingLease, Date.now())
456 ? existingLease
457 : yield* refreshProviderGrantRefArtifact({
458 rows: input.rows,
459 artifact: input.artifact,
460 lease: existingLease,
461 resolveSecretMaterial: input.resolveSecretMaterial,
462 context: input.context,
463 });
464
465 return yield* resolveAuthArtifactMaterial({
466 artifact: input.artifact,
467 lease,
468 resolveSecretMaterial: input.resolveSecretMaterial,
469 context: input.context,
470 });
471 }
472
473 if (mcpOAuthConfig !== null) {
474 const material = yield* resolveAuthArtifactMaterial({
475 artifact: input.artifact,
476 resolveSecretMaterial: input.resolveSecretMaterial,
477 context: input.context,
478 });
479
480 return {
481 ...material,
482 authProvider: createPersistedMcpAuthProvider({
483 rows: input.rows,
484 artifact: input.artifact,
485 config: mcpOAuthConfig,
486 resolveSecretMaterial: input.resolveSecretMaterial,
487 context: input.context,
488 }),
489 };
490 }
491
299 return yield* resolveAuthArtifactMaterial({492 return yield* resolveAuthArtifactMaterial({
300 artifact: input.artifact,493 artifact: input.artifact,
301 lease: existingLease,494 lease: existingLease,
packages/control-plane/src/runtime/control-plane-runtime.test.ts+836−0
@@ -15,12 +15,16 @@ import { · 14 unmodified lines
15 ExecutionIdSchema,15 ExecutionIdSchema,
16 ExecutionInteractionIdSchema,16 ExecutionInteractionIdSchema,
17 type ExecutionInteraction,17 type ExecutionInteraction,
18 ProviderAuthGrantIdSchema,
18 SecretMaterialIdSchema,19 SecretMaterialIdSchema,
20 SourceAuthSessionIdSchema,
19 SourceIdSchema,21 SourceIdSchema,
22 WorkspaceOauthClientIdSchema,
20} from "#schema";23} from "#schema";
21import type { ToolPath } from "@executor/codemode-core";24import type { ToolPath } from "@executor/codemode-core";
22 25
23import {26import {
27 type ControlPlaneRuntime,
24 createControlPlaneRuntime,28 createControlPlaneRuntime,
25 LiveExecutionManagerService,29 LiveExecutionManagerService,
26 provideControlPlaneRuntime,30 provideControlPlaneRuntime,
@@ -54,6 +58,25 @@ type OpenApiSpecServer = { · 27 unmodified lines
54 close: () => Promise<void>;58 close: () => Promise<void>;
55};59};
56 60
61type GoogleWorkspaceTestServer = {
62 baseUrl: string;
63 tokenEndpoint: string;
64 discoveryUrl: (input: {
65 service: string;
66 version: string;
67 scope: string;
68 }) => string;
69 queueTokenResponse: (response: {
70 access_token: string;
71 refresh_token?: string;
72 expires_in?: number;
73 scope?: string;
74 }) => void;
75 tokenRequests: URLSearchParams[];
76 discoveryAuthorizations: string[];
77 close: () => Promise<void>;
78};
79
57const makeOpenApiSpecServer = Effect.acquireRelease(80const makeOpenApiSpecServer = Effect.acquireRelease(
58 Effect.promise<OpenApiSpecServer>(81 Effect.promise<OpenApiSpecServer>(
59 () =>82 () =>
@@ -122,6 +145,232 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 62 unmodified lines
122 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),145 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
123);146);
124 147
148const makeGoogleWorkspaceTestServer = Effect.acquireRelease(
149 Effect.promise<GoogleWorkspaceTestServer>(
150 () =>
151 new Promise<GoogleWorkspaceTestServer>((resolve, reject) => {
152 const discoveryScopes = new Map<string, string>();
153 const tokenResponses: Array<{
154 access_token: string;
155 refresh_token?: string;
156 expires_in?: number;
157 scope?: string;
158 }> = [];
159 const tokenRequests: URLSearchParams[] = [];
160 const discoveryAuthorizations: string[] = [];
161 let baseUrl = "";
162 let currentAccessToken: string | null = null;
163
164 const server = createServer(async (request, response) => {
165 const requestUrl = new URL(
166 request.url ?? "/",
167 `http://${request.headers.host ?? "127.0.0.1"}`,
168 );
169
170 if (request.method === "POST" && requestUrl.pathname === "/oauth/token") {
171 const chunks: Buffer[] = [];
172 for await (const chunk of request) {
173 chunks.push(Buffer.from(chunk));
174 }
175
176 const params = new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
177 tokenRequests.push(params);
178 const nextResponse = tokenResponses.shift();
179 if (!nextResponse) {
180 response.statusCode = 500;
181 response.setHeader("content-type", "application/json");
182 response.end(JSON.stringify({
183 error: "missing_token_response",
184 }));
185 return;
186 }
187
188 currentAccessToken = nextResponse.access_token;
189 response.statusCode = 200;
190 response.setHeader("content-type", "application/json");
191 response.end(JSON.stringify({
192 token_type: "Bearer",
193 expires_in: 3600,
194 ...nextResponse,
195 }));
196 return;
197 }
198
199 const discoveryMatch = /^\/([^/]+)\/\$discovery\/rest$/.exec(requestUrl.pathname);
200 if (request.method === "GET" && discoveryMatch) {
201 const service = discoveryMatch[1]!;
202 const version = requestUrl.searchParams.get("version") ?? "v1";
203 const authorizationHeader =
204 typeof request.headers.authorization === "string"
205 ? request.headers.authorization
206 : Array.isArray(request.headers.authorization)
207 ? (request.headers.authorization[0] ?? "")
208 : "";
209 discoveryAuthorizations.push(authorizationHeader);
210
211 if (authorizationHeader !== `Bearer ${currentAccessToken ?? ""}`) {
212 response.statusCode = 401;
213 response.end("Unauthorized");
214 return;
215 }
216
217 const scope = discoveryScopes.get(`${service}:${version}`)
218 ?? `https://example.test/auth/${service}`;
219 response.statusCode = 200;
220 response.setHeader("content-type", "application/json");
221 response.end(JSON.stringify({
222 name: service,
223 version,
224 title: `${service} API`,
225 description: `Test ${service} API`,
226 rootUrl: `${baseUrl}/`,
227 servicePath: `${service}/${version}/`,
228 auth: {
229 oauth2: {
230 scopes: {
231 [scope]: {
232 description: `${service} scope`,
233 },
234 },
235 },
236 },
237 methods: {
238 list: {
239 id: `${service}.list`,
240 path: "items",
241 httpMethod: "GET",
242 response: {
243 $ref: "ListResponse",
244 },
245 },
246 },
247 schemas: {
248 ListResponse: {
249 id: "ListResponse",
250 type: "object",
251 properties: {
252 items: {
253 type: "array",
254 items: {
255 type: "string",
256 },
257 },
258 },
259 },
260 },
261 }));
262 return;
263 }
264
265 response.statusCode = 404;
266 response.end();
267 });
268
269 server.once("error", reject);
270 server.listen(0, "127.0.0.1", () => {
271 const address = server.address();
272 if (!address || typeof address === "string") {
273 reject(new Error("Failed to bind Google workspace test server"));
274 return;
275 }
276
277 baseUrl = `http://127.0.0.1:${address.port}`;
278 resolve({
279 baseUrl,
280 tokenEndpoint: `${baseUrl}/oauth/token`,
281 discoveryUrl: ({ service, version, scope }) => {
282 discoveryScopes.set(`${service}:${version}`, scope);
283 return `${baseUrl}/${service}/$discovery/rest?version=${encodeURIComponent(version)}`;
284 },
285 queueTokenResponse: (tokenResponse) => {
286 tokenResponses.push(tokenResponse);
287 },
288 tokenRequests,
289 discoveryAuthorizations,
290 close: () =>
291 new Promise<void>((closeResolve, closeReject) => {
292 server.close((error) => {
293 if (error) {
294 closeReject(error);
295 return;
296 }
297
298 closeResolve();
299 });
300 }),
301 });
302 });
303 }),
304 ),
305 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
306);
307
308const upsertLocalSecret = (runtime: ControlPlaneRuntime, input: {
309 id: string;
310 name: string;
311 purpose: "oauth_refresh_token" | "oauth_client_info" | "oauth_access_token";
312 value: string;
313}) =>
314 runtime.persistence.rows.secretMaterials.upsert({
315 id: SecretMaterialIdSchema.make(input.id),
316 providerId: "local",
317 handle: input.id,
318 name: input.name,
319 purpose: input.purpose,
320 value: input.value,
321 createdAt: Date.now(),
322 updatedAt: Date.now(),
323 });
324
325const createPersistedGoogleSource = (input: {
326 runtime: ControlPlaneRuntime;
327 name: string;
328 namespace: string;
329 service: string;
330 version: string;
331 discoveryUrl: string;
332 scopes: ReadonlyArray<string>;
333 auth: {
334 kind: "none";
335 } | {
336 kind: "provider_grant_ref";
337 grantId: ReturnType<typeof ProviderAuthGrantIdSchema.make>;
338 providerKey: string;
339 requiredScopes: ReadonlyArray<string>;
340 headerName: string;
341 prefix: string;
342 };
343}) =>
344 Effect.gen(function* () {
345 const installation = input.runtime.localInstallation;
346 const source = yield* createSourceFromPayload({
347 workspaceId: installation.workspaceId,
348 sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
349 payload: {
350 name: input.name,
351 kind: "google_discovery",
352 endpoint: input.discoveryUrl,
353 namespace: input.namespace,
354 binding: {
355 service: input.service,
356 version: input.version,
357 discoveryUrl: input.discoveryUrl,
358 scopes: [...input.scopes],
359 },
360 importAuthPolicy: "reuse_runtime",
361 importAuth: { kind: "none" },
362 auth: input.auth as any,
363 status: "connected",
364 enabled: true,
365 },
366 now: Date.now(),
367 });
368
369 return yield* persistSource(input.runtime.persistence.rows, source, {
370 actorAccountId: installation.accountId,
371 }).pipe((effect) => provideControlPlaneRuntime(effect, input.runtime));
372 });
373
125const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) =>374const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) =>
126 Effect.either(effect).pipe(375 Effect.either(effect).pipe(
127 Effect.flatMap((result) =>376 Effect.flatMap((result) =>
@@ -689,4 +938,591 @@ describe("control-plane-runtime", () => { · 561 unmodified lines
689 }),938 }),
690 );939 );
691 940
941 it.scoped("reuses existing Google provider grants for batch connects and clears orphan state", () =>
942 Effect.gen(function* () {
943 const runtime = yield* makeRuntime;
944 const googleServer = yield* makeGoogleWorkspaceTestServer;
945 const installation = runtime.localInstallation;
946
947 const oauthClient = yield* withControlPlaneClient(
948 { runtime },
949 (client) =>
950 client.sources.createWorkspaceOauthClient({
951 path: {
952 workspaceId: installation.workspaceId,
953 },
954 payload: {
955 providerKey: "google_workspace",
956 label: "Local Google Workspace Client",
957 oauthClient: {
958 clientId: "google-client-id",
959 clientSecret: "google-client-secret",
960 },
961 },
962 }),
963 );
964
965 const refreshSecretId = "sec_google_refresh_reuse";
966 yield* upsertLocalSecret(runtime, {
967 id: refreshSecretId,
968 name: "Google Refresh Token",
969 purpose: "oauth_refresh_token",
970 value: "refresh-token-reuse",
971 });
972
973 const grantId = ProviderAuthGrantIdSchema.make(`provider_grant_${crypto.randomUUID()}`);
974 yield* runtime.persistence.rows.providerAuthGrants.upsert({
975 id: grantId,
976 workspaceId: installation.workspaceId,
977 actorAccountId: installation.accountId,
978 providerKey: "google_workspace",
979 oauthClientId: oauthClient.id,
980 tokenEndpoint: googleServer.tokenEndpoint,
981 clientAuthentication: "client_secret_post",
982 headerName: "Authorization",
983 prefix: "Bearer ",
984 refreshToken: {
985 providerId: "local",
986 handle: refreshSecretId,
987 },
988 grantedScopes: [
989 "scope:gmail.readonly",
990 "scope:calendar.readonly",
991 ],
992 lastRefreshedAt: null,
993 orphanedAt: 123,
994 createdAt: Date.now(),
995 updatedAt: Date.now(),
996 });
997
998 googleServer.queueTokenResponse({
999 access_token: "reuse-access-token",
1000 scope: "scope:gmail.readonly scope:calendar.readonly",
1001 });
1002 googleServer.queueTokenResponse({
1003 access_token: "reuse-access-token",
1004 scope: "scope:gmail.readonly scope:calendar.readonly",
1005 });
1006
1007 const result = yield* withControlPlaneClient(
1008 { runtime },
1009 (client) =>
1010 client.sources.connectBatch({
1011 path: {
1012 workspaceId: installation.workspaceId,
1013 },
1014 payload: {
1015 workspaceOauthClientId: oauthClient.id,
1016 sources: [
1017 {
1018 service: "gmail",
1019 version: "v1",
1020 discoveryUrl: googleServer.discoveryUrl({
1021 service: "gmail",
1022 version: "v1",
1023 scope: "scope:gmail.readonly",
1024 }),
1025 scopes: ["scope:gmail.readonly"],
1026 name: "Gmail",
1027 namespace: "google.gmail",
1028 },
1029 {
1030 service: "calendar",
1031 version: "v3",
1032 discoveryUrl: googleServer.discoveryUrl({
1033 service: "calendar",
1034 version: "v3",
1035 scope: "scope:calendar.readonly",
1036 }),
1037 scopes: ["scope:calendar.readonly"],
1038 name: "Calendar",
1039 namespace: "google.calendar",
1040 },
1041 ],
1042 },
1043 }),
1044 );
1045
1046 expect(result.providerOauthSession).toBeNull();
1047 expect(result.results).toHaveLength(2);
1048 expect(result.results.every((entry) => entry.status === "connected")).toBe(true);
1049 expect(result.results.every((entry) => entry.source.auth.kind === "provider_grant_ref")).toBe(true);
1050
1051 const storedGrant = yield* runtime.persistence.rows.providerAuthGrants.getById(grantId);
1052 assertTrue(Option.isSome(storedGrant));
1053 expect(storedGrant.value.orphanedAt).toBeNull();
1054 expect(googleServer.tokenRequests.length).toBeGreaterThanOrEqual(1);
1055 expect(
1056 googleServer.discoveryAuthorizations.every(
1057 (authorization) => authorization === "Bearer reuse-access-token",
1058 ),
1059 ).toBe(true);
1060 }),
1061 60_000,
1062 );
1063
1064 it.scoped("uses the app callback redirect for browser-started Google batch OAuth", () =>
1065 Effect.gen(function* () {
1066 const runtime = yield* makeRuntime;
1067 const googleServer = yield* makeGoogleWorkspaceTestServer;
1068 const installation = runtime.localInstallation;
1069
1070 const oauthClient = yield* withControlPlaneClient(
1071 { runtime },
1072 (client) =>
1073 client.sources.createWorkspaceOauthClient({
1074 path: {
1075 workspaceId: installation.workspaceId,
1076 },
1077 payload: {
1078 providerKey: "google_workspace",
1079 label: "Local Google Workspace Client",
1080 oauthClient: {
1081 clientId: "google-client-id",
1082 clientSecret: "google-client-secret",
1083 },
1084 },
1085 }),
1086 );
1087
1088 const result = yield* withControlPlaneClient(
1089 { runtime },
1090 (client) =>
1091 client.sources.connectBatch({
1092 path: {
1093 workspaceId: installation.workspaceId,
1094 },
1095 payload: {
1096 workspaceOauthClientId: oauthClient.id,
1097 sources: [{
1098 service: "gmail",
1099 version: "v1",
1100 discoveryUrl: googleServer.discoveryUrl({
1101 service: "gmail",
1102 version: "v1",
1103 scope: "scope:gmail.readonly",
1104 }),
1105 scopes: ["scope:gmail.readonly"],
1106 name: "Gmail",
1107 namespace: "google.gmail",
1108 }],
1109 },
1110 }),
1111 );
1112
1113 expect(result.providerOauthSession).not.toBeNull();
1114 const redirectUri = new URL(
1115 result.providerOauthSession!.authorizationUrl,
1116 ).searchParams.get("redirect_uri");
1117
1118 expect(redirectUri).not.toBeNull();
1119 expect(new URL(redirectUri!).pathname).toBe(
1120 `/v1/workspaces/${encodeURIComponent(installation.workspaceId)}/oauth/provider/callback`,
1121 );
1122 }),
1123 60_000,
1124 );
1125
1126 it.scoped("preserves the existing provider refresh token when callback expansion omits refresh_token", () =>
1127 Effect.gen(function* () {
1128 const runtime = yield* makeRuntime;
1129 const googleServer = yield* makeGoogleWorkspaceTestServer;
1130 const installation = runtime.localInstallation;
1131
1132 const oauthClient = yield* withControlPlaneClient(
1133 { runtime },
1134 (client) =>
1135 client.sources.createWorkspaceOauthClient({
1136 path: {
1137 workspaceId: installation.workspaceId,
1138 },
1139 payload: {
1140 providerKey: "google_workspace",
1141 label: "Local Google Workspace Client",
1142 oauthClient: {
1143 clientId: "google-client-id",
1144 clientSecret: "google-client-secret",
1145 },
1146 },
1147 }),
1148 );
1149
1150 const preservedRefreshSecretId = "sec_google_refresh_preserved";
1151 yield* upsertLocalSecret(runtime, {
1152 id: preservedRefreshSecretId,
1153 name: "Preserved Google Refresh Token",
1154 purpose: "oauth_refresh_token",
1155 value: "refresh-token-preserved",
1156 });
1157
1158 const source = yield* createPersistedGoogleSource({
1159 runtime,
1160 name: "Drive",
1161 namespace: "google.drive",
1162 service: "drive",
1163 version: "v3",
1164 discoveryUrl: googleServer.discoveryUrl({
1165 service: "drive",
1166 version: "v3",
1167 scope: "scope:drive.readonly",
1168 }),
1169 scopes: ["scope:drive.readonly"],
1170 auth: {
1171 kind: "none",
1172 },
1173 });
1174
1175 const grantId = ProviderAuthGrantIdSchema.make(`provider_grant_${crypto.randomUUID()}`);
1176 yield* runtime.persistence.rows.providerAuthGrants.upsert({
1177 id: grantId,
1178 workspaceId: installation.workspaceId,
1179 actorAccountId: installation.accountId,
1180 providerKey: "google_workspace",
1181 oauthClientId: oauthClient.id,
1182 tokenEndpoint: googleServer.tokenEndpoint,
1183 clientAuthentication: "client_secret_post",
1184 headerName: "Authorization",
1185 prefix: "Bearer ",
1186 refreshToken: {
1187 providerId: "local",
1188 handle: preservedRefreshSecretId,
1189 },
1190 grantedScopes: [
1191 "scope:gmail.readonly",
1192 ],
1193 lastRefreshedAt: null,
1194 orphanedAt: null,
1195 createdAt: Date.now(),
1196 updatedAt: Date.now(),
1197 });
1198
1199 const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);
1200 const state = `provider-state-${crypto.randomUUID()}`;
1201 yield* runtime.persistence.rows.sourceAuthSessions.upsert({
1202 id: sessionId,
1203 workspaceId: installation.workspaceId,
1204 sourceId: SourceIdSchema.make(`oauth_provider_${crypto.randomUUID()}`),
1205 actorAccountId: installation.accountId,
1206 credentialSlot: "runtime",
1207 executionId: null,
1208 interactionId: null,
1209 providerKind: "oauth2_provider_batch",
1210 status: "pending",
1211 state,
1212 sessionDataJson: JSON.stringify({
1213 kind: "provider_oauth_batch",
1214 providerKey: "google_workspace",
1215 authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
1216 tokenEndpoint: googleServer.tokenEndpoint,
1217 redirectUri: `${googleServer.baseUrl}/oauth/callback`,
1218 oauthClientId: oauthClient.id,
1219 clientAuthentication: "client_secret_post",
1220 scopes: [
1221 "scope:gmail.readonly",
1222 "scope:drive.readonly",
1223 ],
1224 headerName: "Authorization",
1225 prefix: "Bearer ",
1226 authorizationParams: {
1227 access_type: "offline",
1228 prompt: "consent",
1229 include_granted_scopes: "true",
1230 },
1231 targetSources: [{
1232 sourceId: source.id,
1233 requiredScopes: ["scope:drive.readonly"],
1234 }],
1235 codeVerifier: "pkce-verifier",
1236 authorizationUrl: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(state)}`,
1237 }),
1238 errorText: null,
1239 completedAt: null,
1240 createdAt: Date.now(),
1241 updatedAt: Date.now(),
1242 });
1243
1244 googleServer.queueTokenResponse({
1245 access_token: "authorization-code-access-token",
1246 scope: "scope:gmail.readonly scope:drive.readonly",
1247 });
1248 googleServer.queueTokenResponse({
1249 access_token: "post-callback-sync-access-token",
1250 scope: "scope:gmail.readonly scope:drive.readonly",
1251 });
1252
1253 const callbackPage = yield* withControlPlaneClient(
1254 { runtime },
1255 (client) =>
1256 client.sources.providerOauthComplete({
1257 path: {
1258 workspaceId: installation.workspaceId,
1259 },
1260 urlParams: {
1261 state,
1262 code: "authorization-code",
1263 },
1264 }),
1265 );
1266
1267 expect(callbackPage).toContain("Connected 1 source");
1268
1269 const storedGrant = yield* runtime.persistence.rows.providerAuthGrants.getById(grantId);
1270 assertTrue(Option.isSome(storedGrant));
1271 expect(storedGrant.value.refreshToken).toEqual({
1272 providerId: "local",
1273 handle: preservedRefreshSecretId,
1274 });
1275 expect(storedGrant.value.grantedScopes).toEqual([
1276 "scope:gmail.readonly",
1277 "scope:drive.readonly",
1278 ]);
1279
1280 const refreshedSource = yield* withControlPlaneClient(
1281 { runtime },
1282 (client) =>
1283 client.sources.get({
1284 path: {
1285 workspaceId: installation.workspaceId,
1286 sourceId: source.id,
1287 },
1288 }),
1289 );
1290
1291 expect(refreshedSource.status).toBe("connected");
1292 expect(refreshedSource.auth).toEqual({
1293 kind: "provider_grant_ref",
1294 grantId,
1295 providerKey: "google_workspace",
1296 requiredScopes: ["scope:drive.readonly"],
1297 headerName: "Authorization",
1298 prefix: "Bearer ",
1299 });
1300
1301 expect(googleServer.tokenRequests.map((request) => request.get("grant_type"))).toEqual([
1302 "authorization_code",
1303 "refresh_token",
1304 ]);
1305 expect(googleServer.tokenRequests[1]?.get("refresh_token")).toBe("refresh-token-preserved");
1306 expect(googleServer.discoveryAuthorizations).toContain("Bearer post-callback-sync-access-token");
1307 }),
1308 60_000,
1309 );
1310
1311 it.scoped("marks provider grants as orphaned when the last referencing source is removed", () =>
1312 Effect.gen(function* () {
1313 const runtime = yield* makeRuntime;
1314 const installation = runtime.localInstallation;
1315 const oauthClientId = WorkspaceOauthClientIdSchema.make(`ws_oauth_client_${crypto.randomUUID()}`);
1316 const refreshSecretId = "sec_google_refresh_orphan";
1317
1318 yield* runtime.persistence.rows.workspaceOauthClients.upsert({
1319 id: oauthClientId,
1320 workspaceId: installation.workspaceId,
1321 providerKey: "google_workspace",
1322 label: "Local Google Workspace Client",
1323 clientId: "google-client-id",
1324 clientSecretProviderId: null,
1325 clientSecretHandle: null,
1326 clientMetadataJson: null,
1327 createdAt: Date.now(),
1328 updatedAt: Date.now(),
1329 });
1330 yield* upsertLocalSecret(runtime, {
1331 id: refreshSecretId,
1332 name: "Google Refresh Token",
1333 purpose: "oauth_refresh_token",
1334 value: "refresh-token-orphan",
1335 });
1336
1337 const grantId = ProviderAuthGrantIdSchema.make(`provider_grant_${crypto.randomUUID()}`);
1338 yield* runtime.persistence.rows.providerAuthGrants.upsert({
1339 id: grantId,
1340 workspaceId: installation.workspaceId,
1341 actorAccountId: installation.accountId,
1342 providerKey: "google_workspace",
1343 oauthClientId,
1344 tokenEndpoint: "https://example.test/oauth/token",
1345 clientAuthentication: "client_secret_post",
1346 headerName: "Authorization",
1347 prefix: "Bearer ",
1348 refreshToken: {
1349 providerId: "local",
1350 handle: refreshSecretId,
1351 },
1352 grantedScopes: ["scope:drive.readonly"],
1353 lastRefreshedAt: null,
1354 orphanedAt: null,
1355 createdAt: Date.now(),
1356 updatedAt: Date.now(),
1357 });
1358
1359 const source = yield* createPersistedGoogleSource({
1360 runtime,
1361 name: "Drive",
1362 namespace: "google.drive",
1363 service: "drive",
1364 version: "v3",
1365 discoveryUrl: "https://example.test/drive/$discovery/rest?version=v3",
1366 scopes: ["scope:drive.readonly"],
1367 auth: {
1368 kind: "provider_grant_ref",
1369 grantId,
1370 providerKey: "google_workspace",
1371 requiredScopes: ["scope:drive.readonly"],
1372 headerName: "Authorization",
1373 prefix: "Bearer ",
1374 },
1375 });
1376
1377 const removed = yield* withControlPlaneClient(
1378 { runtime },
1379 (client) =>
1380 client.sources.remove({
1381 path: {
1382 workspaceId: installation.workspaceId,
1383 sourceId: source.id,
1384 },
1385 }),
1386 );
1387
1388 expect(removed.removed).toBe(true);
1389
1390 const storedGrant = yield* runtime.persistence.rows.providerAuthGrants.getById(grantId);
1391 assertTrue(Option.isSome(storedGrant));
1392 expect(storedGrant.value.orphanedAt).not.toBeNull();
1393 }),
1394 );
1395
1396 it.scoped("revokes shared provider grants through the API and disconnects linked sources", () =>
1397 Effect.gen(function* () {
1398 const runtime = yield* makeRuntime;
1399 const installation = runtime.localInstallation;
1400 const oauthClientId = WorkspaceOauthClientIdSchema.make(`ws_oauth_client_${crypto.randomUUID()}`);
1401 const refreshSecretId = "sec_google_refresh_revoke";
1402
1403 yield* runtime.persistence.rows.workspaceOauthClients.upsert({
1404 id: oauthClientId,
1405 workspaceId: installation.workspaceId,
1406 providerKey: "google_workspace",
1407 label: "Local Google Workspace Client",
1408 clientId: "google-client-id",
1409 clientSecretProviderId: null,
1410 clientSecretHandle: null,
1411 clientMetadataJson: null,
1412 createdAt: Date.now(),
1413 updatedAt: Date.now(),
1414 });
1415 yield* upsertLocalSecret(runtime, {
1416 id: refreshSecretId,
1417 name: "Google Refresh Token",
1418 purpose: "oauth_refresh_token",
1419 value: "refresh-token-revoke",
1420 });
1421
1422 const grantId = ProviderAuthGrantIdSchema.make(`provider_grant_${crypto.randomUUID()}`);
1423 yield* runtime.persistence.rows.providerAuthGrants.upsert({
1424 id: grantId,
1425 workspaceId: installation.workspaceId,
1426 actorAccountId: installation.accountId,
1427 providerKey: "google_workspace",
1428 oauthClientId,
1429 tokenEndpoint: "https://example.test/oauth/token",
1430 clientAuthentication: "client_secret_post",
1431 headerName: "Authorization",
1432 prefix: "Bearer ",
1433 refreshToken: {
1434 providerId: "local",
1435 handle: refreshSecretId,
1436 },
1437 grantedScopes: ["scope:drive.readonly", "scope:gmail.readonly"],
1438 lastRefreshedAt: null,
1439 orphanedAt: null,
1440 createdAt: Date.now(),
1441 updatedAt: Date.now(),
1442 });
1443
1444 const driveSource = yield* createPersistedGoogleSource({
1445 runtime,
1446 name: "Drive",
1447 namespace: "google.drive",
1448 service: "drive",
1449 version: "v3",
1450 discoveryUrl: "https://example.test/drive/$discovery/rest?version=v3",
1451 scopes: ["scope:drive.readonly"],
1452 auth: {
1453 kind: "provider_grant_ref",
1454 grantId,
1455 providerKey: "google_workspace",
1456 requiredScopes: ["scope:drive.readonly"],
1457 headerName: "Authorization",
1458 prefix: "Bearer ",
1459 },
1460 });
1461 const gmailSource = yield* createPersistedGoogleSource({
1462 runtime,
1463 name: "Gmail",
1464 namespace: "google.gmail",
1465 service: "gmail",
1466 version: "v1",
1467 discoveryUrl: "https://example.test/gmail/$discovery/rest?version=v1",
1468 scopes: ["scope:gmail.readonly"],
1469 auth: {
1470 kind: "provider_grant_ref",
1471 grantId,
1472 providerKey: "google_workspace",
1473 requiredScopes: ["scope:gmail.readonly"],
1474 headerName: "Authorization",
1475 prefix: "Bearer ",
1476 },
1477 });
1478
1479 const removed = yield* withControlPlaneClient(
1480 { runtime },
1481 (client) =>
1482 client.sources.removeProviderAuthGrant({
1483 path: {
1484 workspaceId: installation.workspaceId,
1485 grantId,
1486 },
1487 }),
1488 );
1489
1490 expect(removed.removed).toBe(true);
1491
1492 const storedGrant = yield* runtime.persistence.rows.providerAuthGrants.getById(grantId);
1493 assertTrue(Option.isNone(storedGrant));
1494
1495 const storedRefreshSecret = yield* runtime.persistence.rows.secretMaterials.getById(
1496 SecretMaterialIdSchema.make(refreshSecretId),
1497 );
1498 assertTrue(Option.isNone(storedRefreshSecret));
1499
1500 const disconnectedDrive = yield* withControlPlaneClient(
1501 { runtime },
1502 (client) =>
1503 client.sources.get({
1504 path: {
1505 workspaceId: installation.workspaceId,
1506 sourceId: driveSource.id,
1507 },
1508 }),
1509 );
1510 const disconnectedGmail = yield* withControlPlaneClient(
1511 { runtime },
1512 (client) =>
1513 client.sources.get({
1514 path: {
1515 workspaceId: installation.workspaceId,
1516 sourceId: gmailSource.id,
1517 },
1518 }),
1519 );
1520
1521 expect(disconnectedDrive.status).toBe("auth_required");
1522 expect(disconnectedDrive.auth).toEqual({ kind: "none" });
1523 expect(disconnectedGmail.status).toBe("auth_required");
1524 expect(disconnectedGmail.auth).toEqual({ kind: "none" });
1525 }),
1526 );
1527
692});1528});
packages/control-plane/src/runtime/index.ts+15−0
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
17 createLocalControlPlanePersistence,17 createLocalControlPlanePersistence,
18 type LocalControlPlanePersistence,18 type LocalControlPlanePersistence,
19} from "./local-control-plane-store";19} from "./local-control-plane-store";
20import { migrateLegacyPostgresWorkspaceIfNeeded } from "./legacy-postgres-migration";
20import {21import {
21 resolveLocalWorkspaceContext,22 resolveLocalWorkspaceContext,
22} from "./local-config";23} from "./local-config";
@@ -68,6 +69,9 @@ export type RuntimeControlPlaneOptions = { · 45 unmodified lines
68 executionResolver?: ResolveExecutionEnvironment;69 executionResolver?: ResolveExecutionEnvironment;
69 resolveSecretMaterial?: ResolveSecretMaterial;70 resolveSecretMaterial?: ResolveSecretMaterial;
70 getLocalServerBaseUrl?: () => string | undefined;71 getLocalServerBaseUrl?: () => string | undefined;
72 localDataDir?: string;
73 legacyLocalDataDir?: string;
74 legacyDatabaseUrl?: string;
71 workspaceRoot?: string;75 workspaceRoot?: string;
72 homeConfigPath?: string;76 homeConfigPath?: string;
73 homeStateDirectory?: string;77 homeStateDirectory?: string;
@@ -212,6 +216,17 @@ export const createControlPlaneRuntime = ( · 138 unmodified lines
212 ),216 ),
213 );217 );
214 218
219 yield* migrateLegacyPostgresWorkspaceIfNeeded({
220 context: localWorkspaceContext,
221 legacyLocalDataDir: options.legacyLocalDataDir ?? options.localDataDir,
222 legacyDatabaseUrl: options.legacyDatabaseUrl,
223 }).pipe(
224 Effect.mapError(toLocalRuntimeBootstrapError),
225 Effect.catchAll((error) =>
226 closeScope(scope).pipe(Effect.zipRight(Effect.fail(error))),
227 ),
228 );
229
215 const installationStore = LocalInstallationStore;230 const installationStore = LocalInstallationStore;
216 const workspaceConfigStore = LocalWorkspaceConfigStore;231 const workspaceConfigStore = LocalWorkspaceConfigStore;
217 232
packages/control-plane/src/runtime/ir-execution.ts+3−0
@@ -97,6 +97,7 @@ type McpInvocationRequestPlan = { · 96 unmodified lines
97 transport?: "auto" | "streamable-http" | "sse";97 transport?: "auto" | "streamable-http" | "sse";
98 queryParams: Record<string, string>;98 queryParams: Record<string, string>;
99 headers: Record<string, string>;99 headers: Record<string, string>;
100 authProvider?: ResolvedSourceAuthMaterial["authProvider"];
100 toolName: string;101 toolName: string;
101 payload: unknown;102 payload: unknown;
102 inputSchema: unknown;103 inputSchema: unknown;
@@ -600,6 +601,7 @@ const planMcpInvocation = (input: { · 497 unmodified lines
600 },601 },
601 cookies: input.auth.cookies,602 cookies: input.auth.cookies,
602 }),603 }),
604 authProvider: input.auth.authProvider,
603 toolName: input.executable.toolName,605 toolName: input.executable.toolName,
604 payload,606 payload,
605 inputSchema: input.tool.descriptor.inputSchema,607 inputSchema: input.tool.descriptor.inputSchema,
@@ -624,6 +626,7 @@ const executeMcpPlan = (input: { · 18 unmodified lines
624 transport: input.requestPlan.transport,626 transport: input.requestPlan.transport,
625 queryParams: input.requestPlan.queryParams,627 queryParams: input.requestPlan.queryParams,
626 headers: input.requestPlan.headers,628 headers: input.requestPlan.headers,
629 authProvider: input.requestPlan.authProvider,
627 });630 });
628 const tools = createMcpToolsFromManifest({631 const tools = createMcpToolsFromManifest({
629 manifest: {632 manifest: {
packages/control-plane/src/runtime/legacy-postgres-migration.test.ts+639−0
@@ -0,0 +1,639 @@
1import { existsSync, mkdtempSync } from "node:fs";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4
5import { PGlite } from "@electric-sql/pglite";
6import { NodeFileSystem } from "@effect/platform-node";
7import { describe, expect, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9
10import { createControlPlaneRuntime } from "./index";
11import {
12 loadLocalExecutorConfig,
13 resolveLocalWorkspaceContext,
14} from "./local-config";
15import {
16 loadLocalControlPlaneState,
17 localControlPlaneStatePath,
18} from "./local-control-plane-store";
19import { readLocalSourceArtifact } from "./local-source-artifacts";
20import {
21 loadLocalWorkspaceState,
22 localWorkspaceStatePath,
23} from "./local-workspace-state";
24
25const LEGACY_INSTALLATION_ID = "local_default";
26const LEGACY_WORKSPACE_ID = "ws_legacy_default";
27const LEGACY_ACCOUNT_ID = "acc_legacy_default";
28const LEGACY_SOURCE_ID = "src_legacy_mcp";
29const LEGACY_SECRET_ID = "sec_legacy_token";
30const LEGACY_POLICY_ID = "pol_legacy_allow";
31const LEGACY_EXECUTION_ID = "exec_legacy_run";
32const LEGACY_INTERACTION_ID = "int_legacy_prompt";
33const LEGACY_SESSION_ID = "sas_legacy_oauth";
34const SECOND_LEGACY_SOURCE_ID = "src_should_not_migrate";
35
36const CREATED_AT = 1_700_000_000_000;
37const UPDATED_AT = 1_700_000_000_500;
38
39const makeTempDir = (prefix: string) =>
40 mkdtempSync(join(tmpdir(), prefix));
41
42const createLegacySchema = async (db: PGlite) => {
43 await db.exec(`
44 create table local_installations (
45 id text primary key,
46 account_id text not null,
47 workspace_id text not null
48 );
49 create table workspaces (
50 id text primary key,
51 name text
52 );
53 create table sources (
54 workspace_id text not null,
55 source_id text primary key,
56 name text not null,
57 kind text not null,
58 endpoint text not null,
59 status text not null,
60 enabled boolean not null,
61 namespace text,
62 transport text,
63 query_params_json text,
64 headers_json text,
65 spec_url text,
66 default_headers_json text,
67 source_hash text,
68 source_document_text text,
69 last_error text,
70 created_at bigint not null,
71 updated_at bigint not null
72 );
73 create table credentials (
74 id text primary key,
75 workspace_id text not null,
76 auth_kind text not null,
77 auth_header_name text not null,
78 auth_prefix text not null,
79 token_provider_id text not null,
80 token_handle text not null,
81 refresh_token_provider_id text,
82 refresh_token_handle text,
83 created_at bigint not null,
84 updated_at bigint not null
85 );
86 create table source_credential_bindings (
87 id text primary key,
88 workspace_id text not null,
89 source_id text not null,
90 credential_id text not null
91 );
92 create table secret_materials (
93 id text primary key,
94 name text,
95 purpose text not null,
96 value text not null,
97 created_at bigint not null,
98 updated_at bigint not null
99 );
100 create table policies (
101 id text primary key,
102 scope_type text not null,
103 workspace_id text,
104 target_account_id text,
105 client_id text,
106 resource_type text not null,
107 resource_pattern text not null,
108 match_type text not null,
109 effect text not null,
110 approval_mode text not null,
111 priority integer not null,
112 enabled boolean not null,
113 argument_conditions_json text,
114 created_at bigint not null,
115 updated_at bigint not null
116 );
117 create table executions (
118 id text primary key,
119 workspace_id text not null,
120 created_by_account_id text not null,
121 status text not null,
122 code text not null,
123 result_json text,
124 error_text text,
125 logs_json text,
126 started_at bigint,
127 completed_at bigint,
128 created_at bigint not null,
129 updated_at bigint not null
130 );
131 create table execution_interactions (
132 id text primary key,
133 execution_id text not null,
134 status text not null,
135 kind text not null,
136 purpose text not null,
137 payload_json text not null,
138 response_json text,
139 created_at bigint not null,
140 updated_at bigint not null
141 );
142 create table source_auth_sessions (
143 id text primary key,
144 workspace_id text not null,
145 source_id text not null,
146 execution_id text,
147 interaction_id text,
148 strategy text not null,
149 status text not null,
150 endpoint text not null,
151 state text not null,
152 redirect_uri text not null,
153 scope text,
154 resource_metadata_url text,
155 authorization_server_url text,
156 resource_metadata_json text,
157 authorization_server_metadata_json text,
158 client_information_json text,
159 code_verifier text,
160 authorization_url text,
161 error_text text,
162 completed_at bigint,
163 created_at bigint not null,
164 updated_at bigint not null
165 );
166 create table tool_artifacts (
167 workspace_id text not null,
168 path text not null,
169 tool_id text not null,
170 source_id text not null,
171 title text,
172 description text,
173 search_namespace text not null,
174 search_text text not null,
175 input_schema_json text,
176 output_schema_json text,
177 provider_kind text not null,
178 mcp_tool_name text,
179 created_at bigint not null,
180 updated_at bigint not null
181 );
182 `);
183};
184
185const seedInitialLegacyWorkspace = async (legacyLocalDataDir: string) => {
186 const db = new PGlite(legacyLocalDataDir);
187 await db.waitReady;
188
189 try {
190 await createLegacySchema(db);
191
192 await db.query(
193 "insert into local_installations (id, account_id, workspace_id) values ($1, $2, $3)",
194 [LEGACY_INSTALLATION_ID, LEGACY_ACCOUNT_ID, LEGACY_WORKSPACE_ID],
195 );
196 await db.query(
197 "insert into workspaces (id, name) values ($1, $2)",
198 [LEGACY_WORKSPACE_ID, "Legacy Workspace"],
199 );
200 await db.query(
201 `insert into sources (
202 workspace_id,
203 source_id,
204 name,
205 kind,
206 endpoint,
207 status,
208 enabled,
209 namespace,
210 transport,
211 query_params_json,
212 headers_json,
213 spec_url,
214 default_headers_json,
215 source_hash,
216 source_document_text,
217 last_error,
218 created_at,
219 updated_at
220 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`,
221 [
222 LEGACY_WORKSPACE_ID,
223 LEGACY_SOURCE_ID,
224 "Legacy MCP",
225 "mcp",
226 "https://legacy.example.test/mcp",
227 "connected",
228 true,
229 "legacy",
230 "streamable-http",
231 null,
232 JSON.stringify({ "x-legacy": "1" }),
233 null,
234 null,
235 "hash_legacy_mcp",
236 null,
237 null,
238 CREATED_AT,
239 UPDATED_AT,
240 ],
241 );
242 await db.query(
243 `insert into credentials (
244 id,
245 workspace_id,
246 auth_kind,
247 auth_header_name,
248 auth_prefix,
249 token_provider_id,
250 token_handle,
251 refresh_token_provider_id,
252 refresh_token_handle,
253 created_at,
254 updated_at
255 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
256 [
257 "cred_legacy_bearer",
258 LEGACY_WORKSPACE_ID,
259 "bearer",
260 "Authorization",
261 "Bearer ",
262 "postgres",
263 LEGACY_SECRET_ID,
264 null,
265 null,
266 CREATED_AT,
267 UPDATED_AT,
268 ],
269 );
270 await db.query(
271 "insert into source_credential_bindings (id, workspace_id, source_id, credential_id) values ($1, $2, $3, $4)",
272 ["bind_legacy_bearer", LEGACY_WORKSPACE_ID, LEGACY_SOURCE_ID, "cred_legacy_bearer"],
273 );
274 await db.query(
275 "insert into secret_materials (id, name, purpose, value, created_at, updated_at) values ($1, $2, $3, $4, $5, $6)",
276 [LEGACY_SECRET_ID, "Legacy bearer token", "auth_material", "top-secret", CREATED_AT, UPDATED_AT],
277 );
278 await db.query(
279 `insert into policies (
280 id,
281 scope_type,
282 workspace_id,
283 target_account_id,
284 client_id,
285 resource_type,
286 resource_pattern,
287 match_type,
288 effect,
289 approval_mode,
290 priority,
291 enabled,
292 argument_conditions_json,
293 created_at,
294 updated_at
295 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
296 [
297 LEGACY_POLICY_ID,
298 "workspace",
299 LEGACY_WORKSPACE_ID,
300 null,
301 null,
302 "namespace",
303 "legacy",
304 "exact",
305 "allow",
306 "required",
307 7,
308 true,
309 null,
310 CREATED_AT,
311 UPDATED_AT,
312 ],
313 );
314 await db.query(
315 `insert into executions (
316 id,
317 workspace_id,
318 created_by_account_id,
319 status,
320 code,
321 result_json,
322 error_text,
323 logs_json,
324 started_at,
325 completed_at,
326 created_at,
327 updated_at
328 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
329 [
330 LEGACY_EXECUTION_ID,
331 LEGACY_WORKSPACE_ID,
332 LEGACY_ACCOUNT_ID,
333 "completed",
334 "export default 'legacy';",
335 JSON.stringify({
336 workspaceId: LEGACY_WORKSPACE_ID,
337 accountId: LEGACY_ACCOUNT_ID,
338 }),
339 null,
340 JSON.stringify(["legacy-log"]),
341 CREATED_AT,
342 UPDATED_AT,
343 CREATED_AT,
344 UPDATED_AT,
345 ],
346 );
347 await db.query(
348 `insert into execution_interactions (
349 id,
350 execution_id,
351 status,
352 kind,
353 purpose,
354 payload_json,
355 response_json,
356 created_at,
357 updated_at
358 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
359 [
360 LEGACY_INTERACTION_ID,
361 LEGACY_EXECUTION_ID,
362 "resolved",
363 "credential",
364 "connect",
365 JSON.stringify({
366 workspaceId: LEGACY_WORKSPACE_ID,
367 accountId: LEGACY_ACCOUNT_ID,
368 }),
369 JSON.stringify({ ok: true }),
370 CREATED_AT,
371 UPDATED_AT,
372 ],
373 );
374 await db.query(
375 `insert into source_auth_sessions (
376 id,
377 workspace_id,
378 source_id,
379 execution_id,
380 interaction_id,
381 strategy,
382 status,
383 endpoint,
384 state,
385 redirect_uri,
386 scope,
387 resource_metadata_url,
388 authorization_server_url,
389 resource_metadata_json,
390 authorization_server_metadata_json,
391 client_information_json,
392 code_verifier,
393 authorization_url,
394 error_text,
395 completed_at,
396 created_at,
397 updated_at
398 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
399 [
400 LEGACY_SESSION_ID,
401 LEGACY_WORKSPACE_ID,
402 LEGACY_SOURCE_ID,
403 LEGACY_EXECUTION_ID,
404 LEGACY_INTERACTION_ID,
405 "oauth2_authorization_code",
406 "pending",
407 "https://legacy.example.test/mcp",
408 "legacy-state",
409 "http://127.0.0.1/callback",
410 "tools:read",
411 "https://legacy.example.test/resource",
412 "https://legacy.example.test/oauth",
413 JSON.stringify({ resource: "metadata" }),
414 JSON.stringify({ issuer: "legacy-auth" }),
415 JSON.stringify({ client_id: "legacy-client" }),
416 "verifier-123",
417 "https://legacy.example.test/oauth/authorize",
418 null,
419 null,
420 CREATED_AT,
421 UPDATED_AT,
422 ],
423 );
424 await db.query(
425 `insert into tool_artifacts (
426 workspace_id,
427 path,
428 tool_id,
429 source_id,
430 title,
431 description,
432 search_namespace,
433 search_text,
434 input_schema_json,
435 output_schema_json,
436 provider_kind,
437 mcp_tool_name,
438 created_at,
439 updated_at
440 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
441 [
442 LEGACY_WORKSPACE_ID,
443 "legacy.echo",
444 "legacy.echo",
445 LEGACY_SOURCE_ID,
446 "Legacy Echo",
447 "Echo a string from the legacy MCP source",
448 "legacy",
449 "legacy echo mcp tool",
450 JSON.stringify({
451 type: "object",
452 properties: {
453 value: { type: "string" },
454 },
455 }),
456 JSON.stringify({
457 type: "object",
458 properties: {
459 echoed: { type: "string" },
460 },
461 }),
462 "mcp",
463 "echo",
464 CREATED_AT,
465 UPDATED_AT,
466 ],
467 );
468 } finally {
469 await db.close();
470 }
471};
472
473const appendUnmigratedLegacySource = async (legacyLocalDataDir: string) => {
474 const db = new PGlite(legacyLocalDataDir);
475 await db.waitReady;
476
477 try {
478 await db.query(
479 `insert into sources (
480 workspace_id,
481 source_id,
482 name,
483 kind,
484 endpoint,
485 status,
486 enabled,
487 namespace,
488 transport,
489 query_params_json,
490 headers_json,
491 spec_url,
492 default_headers_json,
493 source_hash,
494 source_document_text,
495 last_error,
496 created_at,
497 updated_at
498 ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)`,
499 [
500 LEGACY_WORKSPACE_ID,
501 SECOND_LEGACY_SOURCE_ID,
502 "Should Not Migrate",
503 "mcp",
504 "https://legacy.example.test/should-not-migrate",
505 "connected",
506 true,
507 "skip",
508 "streamable-http",
509 null,
510 null,
511 null,
512 null,
513 "hash_second_source",
514 null,
515 null,
516 CREATED_AT,
517 UPDATED_AT,
518 ],
519 );
520 } finally {
521 await db.close();
522 }
523};
524
525describe("legacy-postgres-migration", () => {
526 it.scoped("migrates the legacy postgres workspace into local files once on startup", () =>
527 Effect.gen(function* () {
528 const workspaceRoot = makeTempDir("executor-legacy-workspace-");
529 const legacyLocalDataDir = makeTempDir("executor-legacy-db-");
530 const homeConfigPath = join(workspaceRoot, ".executor-home.jsonc");
531 const homeStateDirectory = join(workspaceRoot, ".executor-home-state");
532
533 yield* Effect.promise(() => seedInitialLegacyWorkspace(legacyLocalDataDir));
534
535 const firstRuntime = yield* createControlPlaneRuntime({
536 workspaceRoot,
537 homeConfigPath,
538 homeStateDirectory,
539 localDataDir: legacyLocalDataDir,
540 });
541
542 const context = yield* resolveLocalWorkspaceContext({
543 workspaceRoot,
544 homeConfigPath,
545 homeStateDirectory,
546 });
547 const loadedConfig = yield* loadLocalExecutorConfig(context);
548 const workspaceState = yield* loadLocalWorkspaceState(context);
549 const controlPlaneState = yield* loadLocalControlPlaneState(context);
550 const sourceArtifact = yield* readLocalSourceArtifact({
551 context,
552 sourceId: LEGACY_SOURCE_ID,
553 });
554
555 expect(existsSync(context.projectConfigPath)).toBe(true);
556 expect(existsSync(localWorkspaceStatePath(context))).toBe(true);
557 expect(existsSync(localControlPlaneStatePath(context))).toBe(true);
558
559 expect(loadedConfig.projectConfig?.sources?.[LEGACY_SOURCE_ID]?.kind).toBe("mcp");
560 expect(
561 loadedConfig.projectConfig?.sources?.[LEGACY_SOURCE_ID]?.connection.endpoint,
562 ).toBe("https://legacy.example.test/mcp");
563 expect(
564 Object.values(loadedConfig.projectConfig?.policies ?? {}).some((policy) =>
565 policy.match === "legacy.*"
566 && policy.action === "allow"
567 && policy.approval === "manual"
568 && policy.priority === 7),
569 ).toBe(true);
570
571 expect(workspaceState.sources[LEGACY_SOURCE_ID]?.status).toBe("connected");
572 expect(Object.values(workspaceState.policies)).toHaveLength(1);
573
574 expect(controlPlaneState.secretMaterials).toEqual([
575 {
576 id: LEGACY_SECRET_ID,
577 name: "Legacy bearer token",
578 purpose: "auth_material",
579 providerId: "local",
580 handle: LEGACY_SECRET_ID,
581 value: "top-secret",
582 createdAt: CREATED_AT,
583 updatedAt: UPDATED_AT,
584 },
585 ]);
586 expect(controlPlaneState.authArtifacts).toHaveLength(1);
587 expect(JSON.parse(controlPlaneState.authArtifacts[0]!.configJson)).toMatchObject({
588 headerName: "Authorization",
589 prefix: "Bearer",
590 token: {
591 providerId: "local",
592 handle: LEGACY_SECRET_ID,
593 },
594 });
595 expect(controlPlaneState.sourceAuthSessions).toHaveLength(1);
596 expect(controlPlaneState.executions).toHaveLength(1);
597 expect(controlPlaneState.executions[0]?.workspaceId).toBe(
598 firstRuntime.localInstallation.workspaceId,
599 );
600 expect(controlPlaneState.executions[0]?.createdByAccountId).toBe(
601 firstRuntime.localInstallation.accountId,
602 );
603 expect(controlPlaneState.executions[0]?.resultJson).toContain(
604 firstRuntime.localInstallation.workspaceId,
605 );
606 expect(controlPlaneState.executions[0]?.resultJson).not.toContain(
607 LEGACY_WORKSPACE_ID,
608 );
609 expect(controlPlaneState.executionInteractions[0]?.payloadJson).not.toContain(
610 LEGACY_ACCOUNT_ID,
611 );
612
613 expect(sourceArtifact).not.toBeNull();
614 expect(sourceArtifact?.sourceId).toBe(LEGACY_SOURCE_ID);
615 expect(Object.values(sourceArtifact?.snapshot.catalog.executables ?? {})).toHaveLength(1);
616
617 yield* Effect.promise(() => firstRuntime.close());
618
619 yield* Effect.promise(() => appendUnmigratedLegacySource(legacyLocalDataDir));
620
621 const secondRuntime = yield* createControlPlaneRuntime({
622 workspaceRoot,
623 homeConfigPath,
624 homeStateDirectory,
625 localDataDir: legacyLocalDataDir,
626 });
627 yield* Effect.promise(() => secondRuntime.close());
628
629 const loadedConfigAfterSecondStart = yield* loadLocalExecutorConfig(context);
630 const workspaceStateAfterSecondStart = yield* loadLocalWorkspaceState(context);
631
632 expect(loadedConfigAfterSecondStart.projectConfig?.sources?.[SECOND_LEGACY_SOURCE_ID]).toBeUndefined();
633 expect(workspaceStateAfterSecondStart.sources[SECOND_LEGACY_SOURCE_ID]).toBeUndefined();
634 expect(secondRuntime.localInstallation.workspaceId).toBe(
635 firstRuntime.localInstallation.workspaceId,
636 );
637 }).pipe(Effect.provide(NodeFileSystem.layer)),
638 );
639});
packages/control-plane/src/runtime/legacy-postgres-migration.ts+1578−0
@@ -0,0 +1,1578 @@
1import { existsSync, readFileSync } from "node:fs";
2import { mkdir, unlink } from "node:fs/promises";
3import { join, resolve } from "node:path";
4
5import { FileSystem } from "@effect/platform";
6import { PGlite } from "@electric-sql/pglite";
7import {
8 buildOpenApiToolPresentation,
9 compileOpenApiToolDefinitions,
10 extractOpenApiManifest,
11 type OpenApiRefHintTable,
12 type OpenApiToolProviderData,
13} from "@executor/codemode-openapi";
14import {
15 AuthArtifactIdSchema,
16 type JsonObject,
17 McpSourceAuthSessionDataJsonSchema,
18 type Execution,
19 type ExecutionInteraction,
20 type LocalExecutorConfig,
21 type SecretMaterial,
22 type Source,
23 type SourceAuth,
24 type SourceTransport,
25 type SourceAuthSession,
26} from "#schema";
27import * as Effect from "effect/Effect";
28import * as Schema from "effect/Schema";
29import postgres from "postgres";
30
31import {
32 buildGraphqlToolPresentation,
33 compileGraphqlToolDefinitions,
34 extractGraphqlManifest,
35 type GraphqlToolProviderData,
36} from "./graphql-tools";
37import {
38 type LocalControlPlaneState,
39 writeLocalControlPlaneState,
40} from "./local-control-plane-store";
41import {
42 type ResolvedLocalWorkspaceContext,
43 writeProjectLocalExecutorConfig,
44} from "./local-config";
45import { deriveLocalInstallation } from "./local-installation";
46import { derivePolicyConfigKey } from "./local-workspace-sync";
47import {
48 type LocalWorkspaceState,
49 writeLocalWorkspaceState,
50} from "./local-workspace-state";
51import {
52 buildLocalSourceArtifact,
53 writeLocalSourceArtifact,
54 type LocalSourceArtifact,
55} from "./local-source-artifacts";
56import { namespaceFromSourceName } from "./source-names";
57import {
58 createCatalogImportMetadata,
59 createGraphqlCatalogFragment,
60 createMcpCatalogFragment,
61 createOpenApiCatalogFragment,
62 type GraphqlCatalogOperationInput,
63 type McpCatalogOperationInput,
64 type OpenApiCatalogOperationInput,
65} from "./source-catalog-snapshot";
66import { authArtifactFromSourceAuth } from "./auth-artifacts";
67import { createSourceCatalogSyncResult, contentHash } from "./source-catalog-support";
68import { getSourceAdapter } from "./source-adapters";
69
70const LOCAL_CONTROL_PLANE_STATE_BASENAME = "control-plane-state.json";
71const WORKSPACE_STATE_BASENAME = "workspace-state.json";
72const LEGACY_LOCAL_INSTALLATION_ID = "local_default";
73const LEGACY_POSTGRES_SECRET_PROVIDER_ID = "postgres";
74const LOCAL_SECRET_PROVIDER_ID = "local";
75const MIGRATABLE_LEGACY_SOURCE_KINDS = ["mcp", "openapi", "graphql"] as const;
76
77type MigratableLegacySourceKind = (typeof MIGRATABLE_LEGACY_SOURCE_KINDS)[number];
78type LocalConfigSourceEntry = NonNullable<LocalExecutorConfig["sources"]>[string];
79type LocalWorkspaceSourceState = LocalWorkspaceState["sources"][string];
80
81type LegacyRow = Record<string, unknown>;
82
83type LegacyLocalInstallationRow = {
84 id: string;
85 account_id: string;
86 workspace_id: string;
87};
88
89type LegacyWorkspaceRow = {
90 id: string;
91 name: string | null;
92};
93
94type LegacySourceRow = {
95 workspace_id: string;
96 source_id: string;
97 name: string;
98 kind: string;
99 endpoint: string;
100 status: string;
101 enabled: boolean;
102 namespace: string | null;
103 transport: string | null;
104 query_params_json: string | null;
105 headers_json: string | null;
106 spec_url: string | null;
107 default_headers_json: string | null;
108 source_hash: string | null;
109 source_document_text: string | null;
110 last_error: string | null;
111 created_at: number;
112 updated_at: number;
113};
114
115type LegacyCredentialRow = {
116 id: string;
117 workspace_id: string;
118 auth_kind: string;
119 auth_header_name: string;
120 auth_prefix: string;
121 token_provider_id: string;
122 token_handle: string;
123 refresh_token_provider_id: string | null;
124 refresh_token_handle: string | null;
125 created_at: number;
126 updated_at: number;
127};
128
129type LegacySourceCredentialBindingRow = {
130 id: string;
131 workspace_id: string;
132 source_id: string;
133 credential_id: string;
134};
135
136type LegacySecretMaterialRow = {
137 id: string;
138 name: string | null;
139 purpose: SecretMaterial["purpose"];
140 value: string;
141 created_at: number;
142 updated_at: number;
143};
144
145type LegacyPolicyRow = {
146 id: string;
147 scope_type: string;
148 workspace_id: string | null;
149 target_account_id: string | null;
150 client_id: string | null;
151 resource_type: string;
152 resource_pattern: string;
153 match_type: string;
154 effect: string;
155 approval_mode: string;
156 priority: number;
157 enabled: boolean;
158 argument_conditions_json: string | null;
159 created_at: number;
160 updated_at: number;
161};
162
163type LegacyExecutionRow = {
164 id: string;
165 workspace_id: string;
166 created_by_account_id: string;
167 status: Execution["status"];
168 code: string;
169 result_json: string | null;
170 error_text: string | null;
171 logs_json: string | null;
172 started_at: number | null;
173 completed_at: number | null;
174 created_at: number;
175 updated_at: number;
176};
177
178type LegacyExecutionInteractionRow = {
179 id: string;
180 execution_id: string;
181 status: ExecutionInteraction["status"];
182 kind: string;
183 purpose: string;
184 payload_json: string;
185 response_json: string | null;
186 created_at: number;
187 updated_at: number;
188};
189
190type LegacySourceAuthSessionRow = {
191 id: string;
192 workspace_id: string;
193 source_id: string;
194 execution_id: string | null;
195 interaction_id: string | null;
196 strategy: string;
197 status: SourceAuthSession["status"];
198 endpoint: string;
199 state: string;
200 redirect_uri: string;
201 scope: string | null;
202 resource_metadata_url: string | null;
203 authorization_server_url: string | null;
204 resource_metadata_json: string | null;
205 authorization_server_metadata_json: string | null;
206 client_information_json: string | null;
207 code_verifier: string | null;
208 authorization_url: string | null;
209 error_text: string | null;
210 completed_at: number | null;
211 created_at: number;
212 updated_at: number;
213};
214
215type LegacyToolArtifactRow = {
216 workspace_id: string;
217 path: string;
218 tool_id: string;
219 source_id: string;
220 title: string | null;
221 description: string | null;
222 search_namespace: string;
223 search_text: string;
224 input_schema_json: string | null;
225 output_schema_json: string | null;
226 provider_kind: string;
227 mcp_tool_name: string | null;
228 created_at: number;
229 updated_at: number;
230};
231
232type LegacyWorkspaceSnapshot = {
233 installation: LegacyLocalInstallationRow;
234 workspace: LegacyWorkspaceRow | null;
235 sources: LegacySourceRow[];
236 credentials: LegacyCredentialRow[];
237 sourceCredentialBindings: LegacySourceCredentialBindingRow[];
238 secretMaterials: LegacySecretMaterialRow[];
239 policies: LegacyPolicyRow[];
240 executions: LegacyExecutionRow[];
241 executionInteractions: LegacyExecutionInteractionRow[];
242 sourceAuthSessions: LegacySourceAuthSessionRow[];
243 toolArtifacts: LegacyToolArtifactRow[];
244};
245
246type LegacyQueryClient = {
247 label: string;
248 query: (sql: string) => Promise<LegacyRow[]>;
249 close: () => Promise<void>;
250};
251
252type MigratedPolicyEntry = {
253 key: string;
254 config: NonNullable<LocalExecutorConfig["policies"]>[string];
255 state: LocalWorkspaceState["policies"][string];
256};
257
258type MigratedWorkspace = {
259 projectConfig: LocalExecutorConfig;
260 workspaceState: LocalWorkspaceState;
261 controlPlaneState: LocalControlPlaneState;
262 sourceArtifacts: Array<{
263 sourceId: Source["id"];
264 artifact: LocalSourceArtifact;
265 }>;
266 sourceCount: number;
267 policyCount: number;
268};
269
270const encodeMcpSourceAuthSessionData = Schema.encodeSync(
271 McpSourceAuthSessionDataJsonSchema,
272);
273
274const trimOrNull = (value: unknown): string | null => {
275 if (typeof value !== "string") {
276 return null;
277 }
278
279 const trimmed = value.trim();
280 return trimmed.length > 0 ? trimmed : null;
281};
282
283const toError = (cause: unknown): Error =>
284 cause instanceof Error ? cause : new Error(String(cause));
285
286const asRecord = (value: unknown): Record<string, unknown> =>
287 value !== null && typeof value === "object" && !Array.isArray(value)
288 ? (value as Record<string, unknown>)
289 : {};
290
291const asString = (value: unknown): string => {
292 const trimmed = trimOrNull(value);
293 return trimmed ?? "";
294};
295
296const asNullableString = (value: unknown): string | null => trimOrNull(value);
297
298const asNumber = (value: unknown): number => {
299 if (typeof value === "number" && Number.isFinite(value)) {
300 return value;
301 }
302 if (typeof value === "bigint") {
303 return Number(value);
304 }
305 if (typeof value === "string" && value.trim().length > 0) {
306 const parsed = Number(value);
307 return Number.isFinite(parsed) ? parsed : 0;
308 }
309 return 0;
310};
311
312const asNullableNumber = (value: unknown): number | null => {
313 if (value === null || value === undefined) {
314 return null;
315 }
316 return asNumber(value);
317};
318
319const asBoolean = (value: unknown): boolean => {
320 if (typeof value === "boolean") {
321 return value;
322 }
323 if (typeof value === "number") {
324 return value !== 0;
325 }
326 if (typeof value === "string") {
327 const normalized = value.trim().toLowerCase();
328 return normalized === "true" || normalized === "t" || normalized === "1";
329 }
330 return false;
331};
332
333const asStringMap = (value: string | null): Record<string, string> | null => {
334 if (value === null) {
335 return null;
336 }
337
338 try {
339 const parsed = JSON.parse(value) as unknown;
340 const record = asRecord(parsed);
341 const normalized: Record<string, string> = {};
342 for (const [key, entry] of Object.entries(record)) {
343 if (typeof entry === "string") {
344 normalized[key] = entry;
345 }
346 }
347 return normalized;
348 } catch {
349 return null;
350 }
351};
352
353const safeJsonParse = <T>(value: string | null): T | undefined => {
354 if (value === null) {
355 return undefined;
356 }
357
358 try {
359 return JSON.parse(value) as T;
360 } catch {
361 return undefined;
362 }
363};
364
365const stableSourceNamespace = (row: Pick<LegacySourceRow, "name" | "namespace">): string =>
366 trimOrNull(row.namespace) ?? namespaceFromSourceName(row.name);
367
368const normalizeLegacyRows = (value: unknown): LegacyRow[] => {
369 if (Array.isArray(value)) {
370 return value.map(asRecord);
371 }
372
373 const record = asRecord(value);
374 if (Array.isArray(record.rows)) {
375 return record.rows.map(asRecord);
376 }
377
378 return [];
379};
380
381const cloneJson = <T>(value: T): T =>
382 JSON.parse(JSON.stringify(value)) as T;
383
384const isMigratableLegacySourceKind = (
385 value: string,
386): value is MigratableLegacySourceKind =>
387 (MIGRATABLE_LEGACY_SOURCE_KINDS as readonly string[]).includes(value);
388
389const decodeLegacyLocalInstallationRow = (row: LegacyRow): LegacyLocalInstallationRow => ({
390 id: asString(row.id),
391 account_id: asString(row.account_id),
392 workspace_id: asString(row.workspace_id),
393});
394
395const decodeLegacyWorkspaceRow = (row: LegacyRow): LegacyWorkspaceRow => ({
396 id: asString(row.id),
397 name: asNullableString(row.name),
398});
399
400const decodeLegacySourceRow = (row: LegacyRow): LegacySourceRow => ({
401 workspace_id: asString(row.workspace_id),
402 source_id: asString(row.source_id),
403 name: asString(row.name),
404 kind: asString(row.kind),
405 endpoint: asString(row.endpoint),
406 status: asString(row.status),
407 enabled: asBoolean(row.enabled),
408 namespace: asNullableString(row.namespace),
409 transport: asNullableString(row.transport),
410 query_params_json: asNullableString(row.query_params_json),
411 headers_json: asNullableString(row.headers_json),
412 spec_url: asNullableString(row.spec_url),
413 default_headers_json: asNullableString(row.default_headers_json),
414 source_hash: asNullableString(row.source_hash),
415 source_document_text: asNullableString(row.source_document_text),
416 last_error: asNullableString(row.last_error),
417 created_at: asNumber(row.created_at),
418 updated_at: asNumber(row.updated_at),
419});
420
421const decodeLegacyCredentialRow = (row: LegacyRow): LegacyCredentialRow => ({
422 id: asString(row.id),
423 workspace_id: asString(row.workspace_id),
424 auth_kind: asString(row.auth_kind),
425 auth_header_name: asString(row.auth_header_name),
426 auth_prefix: asString(row.auth_prefix),
427 token_provider_id: asString(row.token_provider_id),
428 token_handle: asString(row.token_handle),
429 refresh_token_provider_id: asNullableString(row.refresh_token_provider_id),
430 refresh_token_handle: asNullableString(row.refresh_token_handle),
431 created_at: asNumber(row.created_at),
432 updated_at: asNumber(row.updated_at),
433});
434
435const decodeLegacySourceCredentialBindingRow = (
436 row: LegacyRow,
437): LegacySourceCredentialBindingRow => ({
438 id: asString(row.id),
439 workspace_id: asString(row.workspace_id),
440 source_id: asString(row.source_id),
441 credential_id: asString(row.credential_id),
442});
443
444const decodeLegacySecretMaterialRow = (row: LegacyRow): LegacySecretMaterialRow => ({
445 id: asString(row.id),
446 name: asNullableString(row.name),
447 purpose:
448 asString(row.purpose) as LegacySecretMaterialRow["purpose"],
449 value: asString(row.value),
450 created_at: asNumber(row.created_at),
451 updated_at: asNumber(row.updated_at),
452});
453
454const decodeLegacyPolicyRow = (row: LegacyRow): LegacyPolicyRow => ({
455 id: asString(row.id),
456 scope_type: asString(row.scope_type),
457 workspace_id: asNullableString(row.workspace_id),
458 target_account_id: asNullableString(row.target_account_id),
459 client_id: asNullableString(row.client_id),
460 resource_type: asString(row.resource_type),
461 resource_pattern: asString(row.resource_pattern),
462 match_type: asString(row.match_type),
463 effect: asString(row.effect),
464 approval_mode: asString(row.approval_mode),
465 priority: asNumber(row.priority),
466 enabled: row.enabled === undefined ? true : asBoolean(row.enabled),
467 argument_conditions_json: asNullableString(row.argument_conditions_json),
468 created_at: asNumber(row.created_at),
469 updated_at: asNumber(row.updated_at),
470});
471
472const decodeLegacyExecutionRow = (row: LegacyRow): LegacyExecutionRow => ({
473 id: asString(row.id),
474 workspace_id: asString(row.workspace_id),
475 created_by_account_id: asString(row.created_by_account_id),
476 status: asString(row.status) as LegacyExecutionRow["status"],
477 code: asString(row.code),
478 result_json: asNullableString(row.result_json),
479 error_text: asNullableString(row.error_text),
480 logs_json: asNullableString(row.logs_json),
481 started_at: asNullableNumber(row.started_at),
482 completed_at: asNullableNumber(row.completed_at),
483 created_at: asNumber(row.created_at),
484 updated_at: asNumber(row.updated_at),
485});
486
487const decodeLegacyExecutionInteractionRow = (
488 row: LegacyRow,
489): LegacyExecutionInteractionRow => ({
490 id: asString(row.id),
491 execution_id: asString(row.execution_id),
492 status: asString(row.status) as LegacyExecutionInteractionRow["status"],
493 kind: asString(row.kind),
494 purpose: asString(row.purpose),
495 payload_json: asString(row.payload_json),
496 response_json: asNullableString(row.response_json),
497 created_at: asNumber(row.created_at),
498 updated_at: asNumber(row.updated_at),
499});
500
501const decodeLegacySourceAuthSessionRow = (
502 row: LegacyRow,
503): LegacySourceAuthSessionRow => ({
504 id: asString(row.id),
505 workspace_id: asString(row.workspace_id),
506 source_id: asString(row.source_id),
507 execution_id: asNullableString(row.execution_id),
508 interaction_id: asNullableString(row.interaction_id),
509 strategy: asString(row.strategy),
510 status: asString(row.status) as LegacySourceAuthSessionRow["status"],
511 endpoint: asString(row.endpoint),
512 state: asString(row.state),
513 redirect_uri: asString(row.redirect_uri),
514 scope: asNullableString(row.scope),
515 resource_metadata_url: asNullableString(row.resource_metadata_url),
516 authorization_server_url: asNullableString(row.authorization_server_url),
517 resource_metadata_json: asNullableString(row.resource_metadata_json),
518 authorization_server_metadata_json: asNullableString(row.authorization_server_metadata_json),
519 client_information_json: asNullableString(row.client_information_json),
520 code_verifier: asNullableString(row.code_verifier),
521 authorization_url: asNullableString(row.authorization_url),
522 error_text: asNullableString(row.error_text),
523 completed_at: asNullableNumber(row.completed_at),
524 created_at: asNumber(row.created_at),
525 updated_at: asNumber(row.updated_at),
526});
527
528const decodeLegacyToolArtifactRow = (row: LegacyRow): LegacyToolArtifactRow => ({
529 workspace_id: asString(row.workspace_id),
530 path: asString(row.path),
531 tool_id: asString(row.tool_id),
532 source_id: asString(row.source_id),
533 title: asNullableString(row.title),
534 description: asNullableString(row.description),
535 search_namespace: asString(row.search_namespace),
536 search_text: asString(row.search_text),
537 input_schema_json: asNullableString(row.input_schema_json),
538 output_schema_json: asNullableString(row.output_schema_json),
539 provider_kind: asString(row.provider_kind),
540 mcp_tool_name: asNullableString(row.mcp_tool_name),
541 created_at: asNumber(row.created_at),
542 updated_at: asNumber(row.updated_at),
543});
544
545const isPostgresUrl = (value: string | undefined): boolean => {
546 const trimmed = trimOrNull(value);
547 return trimmed !== null
548 && (trimmed.startsWith("postgres://") || trimmed.startsWith("postgresql://"));
549};
550
551const cleanupStalePGliteLock = async (dataDir: string): Promise<void> => {
552 const lockPath = join(dataDir, "postmaster.pid");
553 if (!existsSync(lockPath)) {
554 return;
555 }
556
557 try {
558 const firstLine = readFileSync(lockPath, "utf8").split("\n")[0]?.trim();
559 const pid = Number(firstLine);
560 if (!Number.isNaN(pid) && pid > 0) {
561 return;
562 }
563 } catch {
564 // Best effort only.
565 }
566
567 try {
568 await unlink(lockPath);
569 } catch {
570 // Best effort only.
571 }
572};
573
574const openLegacyPGliteClient = async (
575 localDataDir: string,
576): Promise<LegacyQueryClient | null> => {
577 if (localDataDir.trim() === ":memory:") {
578 return null;
579 }
580
581 const resolvedDataDir = resolve(localDataDir);
582 if (!existsSync(resolvedDataDir)) {
583 return null;
584 }
585
586 await mkdir(resolvedDataDir, { recursive: true });
587 await cleanupStalePGliteLock(resolvedDataDir);
588 const client = new PGlite(resolvedDataDir);
589
590 return {
591 label: `pglite:${resolvedDataDir}`,
592 query: async (sql) => normalizeLegacyRows(await client.query(sql)),
593 close: () => client.close(),
594 };
595};
596
597const openLegacyPostgresClient = (
598 databaseUrl: string,
599): LegacyQueryClient => {
600 const client = postgres(databaseUrl, {
601 prepare: false,
602 max: 1,
603 });
604
605 return {
606 label: "postgres",
607 query: async (sql) => normalizeLegacyRows(await client.unsafe(sql)),
608 close: () => client.end({ timeout: 5 }).then(() => undefined),
609 };
610};
611
612const queryLegacyTable = async (
613 client: LegacyQueryClient,
614 tableName: string,
615): Promise<LegacyRow[]> =>
616 client.query(`select * from ${tableName}`);
617
618const hasLegacySchema = async (client: LegacyQueryClient): Promise<boolean> => {
619 try {
620 await client.query("select * from local_installations limit 1");
621 return true;
622 } catch {
623 return false;
624 }
625};
626
627const loadLegacyWorkspaceSnapshot = async (
628 client: LegacyQueryClient,
629): Promise<LegacyWorkspaceSnapshot | null> => {
630 const installationRows = (await queryLegacyTable(client, "local_installations"))
631 .map(decodeLegacyLocalInstallationRow);
632 const installation = installationRows.find((row) => row.id === LEGACY_LOCAL_INSTALLATION_ID)
633 ?? installationRows[0]
634 ?? null;
635 if (installation === null) {
636 return null;
637 }
638
639 const [
640 workspaces,
641 sources,
642 credentials,
643 sourceCredentialBindings,
644 secretMaterials,
645 policies,
646 executions,
647 executionInteractions,
648 sourceAuthSessions,
649 toolArtifacts,
650 ] = await Promise.all([
651 queryLegacyTable(client, "workspaces"),
652 queryLegacyTable(client, "sources"),
653 queryLegacyTable(client, "credentials"),
654 queryLegacyTable(client, "source_credential_bindings"),
655 queryLegacyTable(client, "secret_materials"),
656 queryLegacyTable(client, "policies"),
657 queryLegacyTable(client, "executions"),
658 queryLegacyTable(client, "execution_interactions"),
659 queryLegacyTable(client, "source_auth_sessions"),
660 queryLegacyTable(client, "tool_artifacts"),
661 ]);
662
663 const workspaceId = installation.workspace_id;
664 const sourceRows = sources
665 .map(decodeLegacySourceRow)
666 .filter((row) => row.workspace_id === workspaceId);
667 const credentialRows = credentials
668 .map(decodeLegacyCredentialRow)
669 .filter((row) => row.workspace_id === workspaceId);
670 const bindingRows = sourceCredentialBindings
671 .map(decodeLegacySourceCredentialBindingRow)
672 .filter((row) => row.workspace_id === workspaceId);
673 const policyRows = policies
674 .map(decodeLegacyPolicyRow)
675 .filter((row) => row.workspace_id === workspaceId);
676 const executionRows = executions
677 .map(decodeLegacyExecutionRow)
678 .filter((row) => row.workspace_id === workspaceId);
679 const executionIds = new Set(executionRows.map((row) => row.id));
680 const sessionRows = sourceAuthSessions
681 .map(decodeLegacySourceAuthSessionRow)
682 .filter((row) => row.workspace_id === workspaceId);
683 const toolArtifactRows = toolArtifacts
684 .map(decodeLegacyToolArtifactRow)
685 .filter((row) => row.workspace_id === workspaceId);
686 const secretIds = new Set<string>();
687
688 for (const credential of credentialRows) {
689 if (
690 credential.token_provider_id === LEGACY_POSTGRES_SECRET_PROVIDER_ID
691 && credential.token_handle.length > 0
692 ) {
693 secretIds.add(credential.token_handle);
694 }
695
696 if (
697 credential.refresh_token_provider_id === LEGACY_POSTGRES_SECRET_PROVIDER_ID
698 && credential.refresh_token_handle
699 ) {
700 secretIds.add(credential.refresh_token_handle);
701 }
702 }
703
704 const secretMaterialRows = secretMaterials
705 .map(decodeLegacySecretMaterialRow)
706 .filter((row) => secretIds.has(row.id));
707
708 return {
709 installation,
710 workspace:
711 workspaces
712 .map(decodeLegacyWorkspaceRow)
713 .find((row) => row.id === workspaceId)
714 ?? null,
715 sources: sourceRows,
716 credentials: credentialRows,
717 sourceCredentialBindings: bindingRows,
718 secretMaterials: secretMaterialRows,
719 policies: policyRows,
720 executions: executionRows,
721 executionInteractions: executionInteractions
722 .map(decodeLegacyExecutionInteractionRow)
723 .filter((row) => executionIds.has(row.execution_id)),
724 sourceAuthSessions: sessionRows,
725 toolArtifacts: toolArtifactRows,
726 };
727};
728
729const tryLoadLegacyWorkspaceSnapshot = async (input: {
730 localDataDir?: string;
731 databaseUrl?: string;
732}): Promise<LegacyWorkspaceSnapshot | null> => {
733 const clients: LegacyQueryClient[] = [];
734
735 try {
736 if (trimOrNull(input.localDataDir) !== null) {
737 const localClient = await openLegacyPGliteClient(input.localDataDir!);
738 if (localClient !== null) {
739 clients.push(localClient);
740 }
741 }
742
743 if (clients.length === 0 && isPostgresUrl(input.databaseUrl)) {
744 clients.push(openLegacyPostgresClient(input.databaseUrl!));
745 }
746
747 for (const client of clients) {
748 try {
749 if (!(await hasLegacySchema(client))) {
750 continue;
751 }
752
753 return await loadLegacyWorkspaceSnapshot(client);
754 } catch {
755 continue;
756 }
757 }
758
759 return null;
760 } finally {
761 await Promise.all(
762 clients.map((client) =>
763 client.close().catch(() => undefined)),
764 );
765 }
766};
767
768const targetControlPlaneStatePath = (context: ResolvedLocalWorkspaceContext): string =>
769 join(
770 context.homeStateDirectory,
771 "workspaces",
772 deriveLocalInstallation(context).workspaceId,
773 LOCAL_CONTROL_PLANE_STATE_BASENAME,
774 );
775
776const targetWorkspaceStatePath = (context: ResolvedLocalWorkspaceContext): string =>
777 join(context.stateDirectory, WORKSPACE_STATE_BASENAME);
778
779const workspaceAlreadyInitialized = (
780 context: ResolvedLocalWorkspaceContext,
781): boolean =>
782 existsSync(context.projectConfigPath)
783 || existsSync(targetWorkspaceStatePath(context))
784 || existsSync(targetControlPlaneStatePath(context));
785
786const mapLegacySecretRef = (input: {
787 providerId: string;
788 handle: string;
789}) => ({
790 providerId:
791 input.providerId === LEGACY_POSTGRES_SECRET_PROVIDER_ID
792 ? LOCAL_SECRET_PROVIDER_ID
793 : input.providerId,
794 handle: input.handle,
795});
796
797const matchesLegacyPattern = (
798 pattern: string,
799 value: string,
800 matchType: string,
801): boolean => {
802 if (matchType === "exact") {
803 return pattern === value;
804 }
805
806 const escaped = pattern
807 .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
808 .replace(/\*/g, ".*");
809 return new RegExp(`^${escaped}$`).test(value);
810};
811
812const namespacePatternToToolPattern = (
813 pattern: string,
814 matchType: string,
815): string | null => {
816 const trimmed = pattern.trim();
817 if (trimmed.length === 0) {
818 return null;
819 }
820
821 if (trimmed === "*") {
822 return "*";
823 }
824
825 if (matchType === "exact" || !trimmed.includes("*")) {
826 return `${trimmed}.*`;
827 }
828
829 return trimmed;
830};
831
832const buildSourceAuth = (
833 credential: LegacyCredentialRow | null,
834): SourceAuth => {
835 if (credential === null) {
836 return { kind: "none" };
837 }
838
839 if (credential.auth_kind === "oauth2") {
840 return {
841 kind: "oauth2",
842 headerName: credential.auth_header_name,
843 prefix: credential.auth_prefix,
844 accessToken: mapLegacySecretRef({
845 providerId: credential.token_provider_id,
846 handle: credential.token_handle,
847 }),
848 refreshToken:
849 credential.refresh_token_provider_id && credential.refresh_token_handle
850 ? mapLegacySecretRef({
851 providerId: credential.refresh_token_provider_id,
852 handle: credential.refresh_token_handle,
853 })
854 : null,
855 };
856 }
857
858 return {
859 kind: "bearer",
860 headerName: credential.auth_header_name,
861 prefix: credential.auth_prefix,
862 token: mapLegacySecretRef({
863 providerId: credential.token_provider_id,
864 handle: credential.token_handle,
865 }),
866 };
867};
868
869const validateMigratedSource = (input: {
870 workspaceId: Source["workspaceId"];
871 row: LegacySourceRow;
872 credential: LegacyCredentialRow | null;
873}): Source | null => {
874 if (!isMigratableLegacySourceKind(input.row.kind)) {
875 return null;
876 }
877
878 const kind = input.row.kind;
879 const namespace = stableSourceNamespace(input.row);
880 const binding: Source["binding"] =
881 kind === "mcp"
882 ? {
883 transport: input.row.transport,
884 queryParams: asStringMap(input.row.query_params_json) ?? null,
885 headers: asStringMap(input.row.headers_json) ?? null,
886 }
887 : kind === "openapi"
888 ? {
889 specUrl: input.row.spec_url ?? input.row.endpoint,
890 defaultHeaders: asStringMap(input.row.default_headers_json) ?? null,
891 }
892 : {
893 defaultHeaders: asStringMap(input.row.default_headers_json) ?? null,
894 };
895 const baseSource = {
896 id: input.row.source_id as Source["id"],
897 workspaceId: input.workspaceId,
898 name: input.row.name,
899 kind,
900 endpoint: input.row.endpoint,
901 status: input.row.status as Source["status"],
902 enabled: input.row.enabled,
903 namespace,
904 bindingVersion: getSourceAdapter(kind).bindingConfigVersion,
905 binding,
906 importAuthPolicy: "reuse_runtime" as const,
907 importAuth: { kind: "none" } as const,
908 auth: buildSourceAuth(input.credential),
909 sourceHash: input.row.source_hash,
910 lastError: input.row.last_error,
911 createdAt: input.row.created_at,
912 updatedAt: input.row.updated_at,
913 } satisfies Source;
914
915 return Effect.runSync(
916 getSourceAdapter(kind).validateSource(baseSource),
917 );
918};
919
920const configSourceFromMigratedSource = (
921 source: Source,
922): LocalConfigSourceEntry => {
923 const binding = cloneJson(source.binding) as Record<string, unknown>;
924 const common = {
925 ...(trimOrNull(source.name) !== trimOrNull(source.id) ? { name: source.name } : {}),
926 ...(trimOrNull(source.namespace) !== trimOrNull(source.id)
927 ? { namespace: source.namespace ?? undefined }
928 : {}),
929 ...(source.enabled === false ? { enabled: false } : {}),
930 connection: {
931 endpoint: source.endpoint,
932 },
933 };
934
935 switch (source.kind) {
936 case "mcp":
937 return {
938 kind: "mcp",
939 ...common,
940 binding: {
941 transport: (binding.transport as SourceTransport | null | undefined) ?? null,
942 queryParams:
943 (binding.queryParams as Record<string, string> | null | undefined) ?? null,
944 headers:
945 (binding.headers as Record<string, string> | null | undefined) ?? null,
946 },
947 };
948 case "openapi":
949 return {
950 kind: "openapi",
951 ...common,
952 binding: {
953 specUrl: String(binding.specUrl ?? source.endpoint),
954 defaultHeaders:
955 (binding.defaultHeaders as Record<string, string> | null | undefined) ?? null,
956 },
957 };
958 case "graphql":
959 return {
960 kind: "graphql",
961 ...common,
962 binding: {
963 defaultHeaders:
964 (binding.defaultHeaders as Record<string, string> | null | undefined) ?? null,
965 },
966 };
967 default:
968 throw new Error(`Unsupported migrated source kind: ${source.kind}`);
969 }
970};
971
972const buildMigratedPolicyEntries = (input: {
973 policies: readonly LegacyPolicyRow[];
974 workspaceId: Source["workspaceId"];
975 sources: readonly Source[];
976}): MigratedPolicyEntry[] => {
977 const usedKeys = new Set<string>();
978 const entries: MigratedPolicyEntry[] = [];
979 const seenPatterns = new Set<string>();
980
981 for (const policy of input.policies) {
982 if (
983 policy.scope_type !== "workspace"
984 || policy.target_account_id !== null
985 || policy.client_id !== null
986 || policy.argument_conditions_json !== null
987 ) {
988 continue;
989 }
990
991 const patterns = (() => {
992 if (policy.resource_type === "all_tools") {
993 return ["*"];
994 }
995
996 if (policy.resource_type === "tool_path") {
997 return policy.resource_pattern.trim().length > 0
998 ? [policy.resource_pattern.trim()]
999 : [];
1000 }
1001
1002 if (policy.resource_type === "namespace") {
1003 const pattern = namespacePatternToToolPattern(
1004 policy.resource_pattern,
1005 policy.match_type,
1006 );
1007 return pattern ? [pattern] : [];
1008 }
1009
1010 if (policy.resource_type === "source") {
1011 const matchedNamespaces = input.sources
1012 .filter((source) =>
1013 matchesLegacyPattern(policy.resource_pattern, source.id, policy.match_type)
1014 || matchesLegacyPattern(
1015 policy.resource_pattern,
1016 `source:${source.id}`,
1017 policy.match_type,
1018 ))
1019 .map((source) => `${source.namespace ?? source.id}.*`);
1020
1021 return [...new Set(matchedNamespaces)];
1022 }
1023
1024 return [];
1025 })();
1026
1027 for (const resourcePattern of patterns) {
1028 const dedupeKey = [
1029 resourcePattern,
1030 policy.effect,
1031 policy.approval_mode,
1032 String(policy.priority),
1033 String(policy.enabled),
1034 String(policy.id),
1035 ].join("::");
1036 if (seenPatterns.has(dedupeKey)) {
1037 continue;
1038 }
1039 seenPatterns.add(dedupeKey);
1040
1041 const key = derivePolicyConfigKey(
1042 {
1043 resourcePattern,
1044 effect: policy.effect === "deny" ? "deny" : "allow",
1045 approvalMode: policy.approval_mode === "required" ? "required" : "auto",
1046 },
1047 usedKeys,
1048 );
1049
1050 entries.push({
1051 key,
1052 config: {
1053 match: resourcePattern,
1054 action: policy.effect === "deny" ? "deny" : "allow",
1055 approval: policy.approval_mode === "required" ? "manual" : "auto",
1056 ...(policy.enabled === false ? { enabled: false } : {}),
1057 ...(policy.priority !== 0 ? { priority: policy.priority } : {}),
1058 },
1059 state: {
1060 id: policy.id as LocalWorkspaceState["policies"][string]["id"],
1061 createdAt: policy.created_at,
1062 updatedAt: policy.updated_at,
1063 },
1064 });
1065 }
1066 }
1067
1068 return entries;
1069};
1070
1071const rewriteLegacyScopedJson = (input: {
1072 value: string | null;
1073 oldWorkspaceId: string;
1074 newWorkspaceId: string;
1075 oldAccountId: string;
1076 newAccountId: string;
1077}): string | null => {
1078 if (input.value === null) {
1079 return null;
1080 }
1081
1082 return input.value
1083 .replaceAll(input.oldWorkspaceId, input.newWorkspaceId)
1084 .replaceAll(input.oldAccountId, input.newAccountId);
1085};
1086
1087const buildMigratedMcpArtifact = (input: {
1088 source: Source;
1089 row: LegacySourceRow;
1090 toolArtifacts: readonly LegacyToolArtifactRow[];
1091}): LocalSourceArtifact | null => {
1092 const sourceArtifacts = input.toolArtifacts.filter((artifact) => artifact.source_id === input.source.id);
1093 if (sourceArtifacts.length === 0) {
1094 return null;
1095 }
1096
1097 const operations: McpCatalogOperationInput[] = sourceArtifacts.map((artifact) => ({
1098 toolId: artifact.tool_id,
1099 title: artifact.title ?? artifact.mcp_tool_name ?? artifact.tool_id,
1100 description: artifact.description,
1101 // Legacy MCP descriptors did not store readOnly/destructive hints. Preserve
1102 // allow-by-default behavior by treating them as generic reads.
1103 effect: "read",
1104 inputSchema: safeJsonParse<unknown>(artifact.input_schema_json),
1105 outputSchema: safeJsonParse<unknown>(artifact.output_schema_json),
1106 providerData: {
1107 toolId: artifact.tool_id,
1108 toolName: artifact.mcp_tool_name ?? artifact.title ?? artifact.tool_id,
1109 displayTitle: artifact.title ?? artifact.mcp_tool_name ?? artifact.tool_id,
1110 title: artifact.title,
1111 description: artifact.description,
1112 annotations: null,
1113 execution: null,
1114 icons: null,
1115 meta: null,
1116 rawTool: null,
1117 server: null,
1118 },
1119 }));
1120
1121 const manifestLikeDocument = JSON.stringify({
1122 tools: sourceArtifacts.map((artifact) => ({
1123 toolId: artifact.tool_id,
1124 toolName: artifact.mcp_tool_name ?? artifact.title ?? artifact.tool_id,
1125 title: artifact.title,
1126 description: artifact.description,
1127 inputSchema: safeJsonParse<unknown>(artifact.input_schema_json) ?? null,
1128 outputSchema: safeJsonParse<unknown>(artifact.output_schema_json) ?? null,
1129 })),
1130 }, null, 2);
1131
1132 const artifactSource = {
1133 ...input.source,
1134 sourceHash: input.row.source_hash ?? contentHash(manifestLikeDocument),
1135 };
1136
1137 return buildLocalSourceArtifact({
1138 source: artifactSource,
1139 syncResult: createSourceCatalogSyncResult({
1140 fragment: createMcpCatalogFragment({
1141 source: artifactSource,
1142 documents: [{
1143 documentKind: "mcp_manifest",
1144 documentKey: input.source.endpoint,
1145 contentText: manifestLikeDocument,
1146 fetchedAt: input.row.updated_at,
1147 }],
1148 operations,
1149 }),
1150 importMetadata: createCatalogImportMetadata({
1151 source: artifactSource,
1152 adapterKey: "mcp",
1153 }),
1154 sourceHash: artifactSource.sourceHash,
1155 }),
1156 });
1157};
1158
1159const buildMigratedOpenApiArtifact = async (input: {
1160 source: Source;
1161 row: LegacySourceRow;
1162}): Promise<LocalSourceArtifact | null> => {
1163 if (input.row.source_document_text === null) {
1164 return null;
1165 }
1166
1167 const manifest = await Effect.runPromise(
1168 extractOpenApiManifest(input.source.name, input.row.source_document_text),
1169 );
1170 const refHintTable = (manifest as { refHintTable?: Readonly<OpenApiRefHintTable> }).refHintTable;
1171 const operations: OpenApiCatalogOperationInput[] = compileOpenApiToolDefinitions(manifest).map(
1172 (definition) => {
1173 const presentation = buildOpenApiToolPresentation({
1174 definition,
1175 refHintTable,
1176 });
1177 const method = definition.method.toUpperCase();
1178
1179 return {
1180 toolId: definition.toolId,
1181 title: definition.name,
1182 description: definition.description,
1183 effect:
1184 method === "GET" || method === "HEAD"
1185 ? "read"
1186 : method === "DELETE"
1187 ? "delete"
1188 : "write",
1189 inputSchema: presentation.inputSchema,
1190 outputSchema: presentation.outputSchema,
1191 providerData: presentation.providerData as OpenApiToolProviderData,
1192 };
1193 },
1194 );
1195
1196 const artifactSource = {
1197 ...input.source,
1198 sourceHash: manifest.sourceHash,
1199 };
1200
1201 return buildLocalSourceArtifact({
1202 source: artifactSource,
1203 syncResult: createSourceCatalogSyncResult({
1204 fragment: createOpenApiCatalogFragment({
1205 source: artifactSource,
1206 documents: [{
1207 documentKind: "openapi",
1208 documentKey: input.row.spec_url ?? input.source.endpoint,
1209 contentText: input.row.source_document_text,
1210 fetchedAt: input.row.updated_at,
1211 }],
1212 operations,
1213 }),
1214 importMetadata: createCatalogImportMetadata({
1215 source: artifactSource,
1216 adapterKey: "openapi",
1217 }),
1218 sourceHash: manifest.sourceHash,
1219 }),
1220 });
1221};
1222
1223const buildMigratedGraphqlArtifact = async (input: {
1224 source: Source;
1225 row: LegacySourceRow;
1226}): Promise<LocalSourceArtifact | null> => {
1227 if (input.row.source_document_text === null) {
1228 return null;
1229 }
1230
1231 const manifest = await Effect.runPromise(
1232 extractGraphqlManifest(input.source.name, input.row.source_document_text),
1233 );
1234 const operations: GraphqlCatalogOperationInput[] = compileGraphqlToolDefinitions(manifest).map(
1235 (definition) => {
1236 const presentation = buildGraphqlToolPresentation({
1237 manifest,
1238 definition,
1239 });
1240
1241 return {
1242 toolId: definition.toolId,
1243 title: definition.name,
1244 description: definition.description,
1245 effect: definition.operationType === "query" ? "read" : "write",
1246 inputSchema: presentation.inputSchema,
1247 outputSchema: presentation.outputSchema,
1248 providerData: presentation.providerData as GraphqlToolProviderData,
1249 };
1250 },
1251 );
1252
1253 const artifactSource = {
1254 ...input.source,
1255 sourceHash: manifest.sourceHash,
1256 };
1257
1258 return buildLocalSourceArtifact({
1259 source: artifactSource,
1260 syncResult: createSourceCatalogSyncResult({
1261 fragment: createGraphqlCatalogFragment({
1262 source: artifactSource,
1263 documents: [{
1264 documentKind: "graphql_introspection",
1265 documentKey: input.source.endpoint,
1266 contentText: input.row.source_document_text,
1267 fetchedAt: input.row.updated_at,
1268 }],
1269 operations,
1270 }),
1271 importMetadata: createCatalogImportMetadata({
1272 source: artifactSource,
1273 adapterKey: "graphql",
1274 }),
1275 sourceHash: manifest.sourceHash,
1276 }),
1277 });
1278};
1279
1280const buildMigratedSourceArtifact = async (input: {
1281 source: Source;
1282 row: LegacySourceRow;
1283 toolArtifacts: readonly LegacyToolArtifactRow[];
1284}): Promise<LocalSourceArtifact | null> => {
1285 switch (input.source.kind) {
1286 case "mcp":
1287 return buildMigratedMcpArtifact(input);
1288 case "openapi":
1289 return buildMigratedOpenApiArtifact(input);
1290 case "graphql":
1291 return buildMigratedGraphqlArtifact(input);
1292 default:
1293 return null;
1294 }
1295};
1296
1297const migrateLegacyWorkspace = async (input: {
1298 context: ResolvedLocalWorkspaceContext;
1299 snapshot: LegacyWorkspaceSnapshot;
1300}): Promise<MigratedWorkspace> => {
1301 const installation = deriveLocalInstallation(input.context);
1302 const targetWorkspaceId = installation.workspaceId;
1303 const targetAccountId = installation.accountId;
1304 const bindingBySourceId = new Map(
1305 input.snapshot.sourceCredentialBindings.map((binding) => [binding.source_id, binding] as const),
1306 );
1307 const credentialsById = new Map(
1308 input.snapshot.credentials.map((credential) => [credential.id, credential] as const),
1309 );
1310 const migratedSources: Source[] = [];
1311 const projectConfigSources: Record<string, LocalConfigSourceEntry> = {};
1312 const workspaceStateSources: Record<string, LocalWorkspaceSourceState> = {};
1313
1314 for (const row of input.snapshot.sources) {
1315 const binding = bindingBySourceId.get(row.source_id) ?? null;
1316 const credential = binding ? credentialsById.get(binding.credential_id) ?? null : null;
1317 const source = validateMigratedSource({
1318 workspaceId: targetWorkspaceId,
1319 row,
1320 credential,
1321 });
1322 if (source === null) {
1323 continue;
1324 }
1325
1326 migratedSources.push(source);
1327 projectConfigSources[source.id] = configSourceFromMigratedSource(source);
1328 workspaceStateSources[source.id] = {
1329 status: source.status,
1330 lastError: source.lastError,
1331 sourceHash: source.sourceHash,
1332 createdAt: source.createdAt,
1333 updatedAt: source.updatedAt,
1334 };
1335 }
1336
1337 const migratedPolicies = buildMigratedPolicyEntries({
1338 policies: input.snapshot.policies,
1339 workspaceId: targetWorkspaceId,
1340 sources: migratedSources,
1341 });
1342
1343 const projectConfig: LocalExecutorConfig = {
1344 ...(Object.keys(projectConfigSources).length > 0 ? { sources: projectConfigSources } : {}),
1345 ...(migratedPolicies.length > 0
1346 ? {
1347 policies: Object.fromEntries(
1348 migratedPolicies.map((policy) => [policy.key, policy.config] as const),
1349 ),
1350 }
1351 : {}),
1352 };
1353
1354 const workspaceState: LocalWorkspaceState = {
1355 version: 1,
1356 sources: workspaceStateSources,
1357 policies: Object.fromEntries(
1358 migratedPolicies.map((policy) => [policy.key, policy.state] as const),
1359 ),
1360 };
1361
1362 const secretMaterials: SecretMaterial[] = input.snapshot.secretMaterials.map((material) => ({
1363 id: material.id as SecretMaterial["id"],
1364 name: material.name,
1365 purpose: material.purpose,
1366 providerId: LOCAL_SECRET_PROVIDER_ID,
1367 handle: material.id,
1368 value: material.value,
1369 createdAt: material.created_at,
1370 updatedAt: material.updated_at,
1371 }));
1372
1373 const authArtifacts = migratedSources
1374 .map((source) =>
1375 authArtifactFromSourceAuth({
1376 source,
1377 auth: source.auth,
1378 slot: "runtime",
1379 actorAccountId: null,
1380 existingAuthArtifactId: AuthArtifactIdSchema.make(
1381 `auth_art_migrated_${source.id}_runtime`,
1382 ),
1383 }))
1384 .filter((artifact): artifact is NonNullable<typeof artifact> => artifact !== null);
1385
1386 const executionIdSet = new Set(input.snapshot.executions.map((execution) => execution.id));
1387
1388 const controlPlaneState: LocalControlPlaneState = {
1389 version: 1,
1390 authArtifacts,
1391 authLeases: [],
1392 sourceOauthClients: [],
1393 workspaceOauthClients: [],
1394 providerAuthGrants: [],
1395 sourceAuthSessions: input.snapshot.sourceAuthSessions
1396 .filter((session) => session.strategy === "oauth2_authorization_code")
1397 .filter((session) => migratedSources.some((source) => source.id === session.source_id))
1398 .map((session) => ({
1399 id: session.id as SourceAuthSession["id"],
1400 workspaceId: targetWorkspaceId,
1401 sourceId: session.source_id as SourceAuthSession["sourceId"],
1402 actorAccountId: null,
1403 credentialSlot: "runtime",
1404 executionId: session.execution_id as SourceAuthSession["executionId"],
1405 interactionId: session.interaction_id as SourceAuthSession["interactionId"],
1406 providerKind: "mcp_oauth",
1407 status: session.status,
1408 state: session.state,
1409 sessionDataJson: encodeMcpSourceAuthSessionData({
1410 kind: "mcp_oauth",
1411 endpoint: session.endpoint,
1412 redirectUri: session.redirect_uri,
1413 scope: session.scope,
1414 resourceMetadataUrl: session.resource_metadata_url,
1415 authorizationServerUrl: session.authorization_server_url,
1416 resourceMetadata:
1417 safeJsonParse<JsonObject>(session.resource_metadata_json) ?? null,
1418 authorizationServerMetadata:
1419 safeJsonParse<JsonObject>(session.authorization_server_metadata_json) ?? null,
1420 clientInformation:
1421 safeJsonParse<JsonObject>(session.client_information_json) ?? null,
1422 codeVerifier: session.code_verifier,
1423 authorizationUrl: session.authorization_url,
1424 }),
1425 errorText: session.error_text,
1426 completedAt: session.completed_at,
1427 createdAt: session.created_at,
1428 updatedAt: session.updated_at,
1429 })),
1430 secretMaterials,
1431 executions: input.snapshot.executions.map((execution) => ({
1432 id: execution.id as Execution["id"],
1433 workspaceId: targetWorkspaceId,
1434 createdByAccountId: targetAccountId,
1435 status: execution.status,
1436 code: execution.code,
1437 resultJson: rewriteLegacyScopedJson({
1438 value: execution.result_json,
1439 oldWorkspaceId: input.snapshot.installation.workspace_id,
1440 newWorkspaceId: targetWorkspaceId,
1441 oldAccountId: input.snapshot.installation.account_id,
1442 newAccountId: targetAccountId,
1443 }),
1444 errorText: execution.error_text,
1445 logsJson: execution.logs_json,
1446 startedAt: execution.started_at,
1447 completedAt: execution.completed_at,
1448 createdAt: execution.created_at,
1449 updatedAt: execution.updated_at,
1450 })),
1451 executionInteractions: input.snapshot.executionInteractions
1452 .filter((interaction) => executionIdSet.has(interaction.execution_id))
1453 .map((interaction) => ({
1454 id: interaction.id as ExecutionInteraction["id"],
1455 executionId: interaction.execution_id as ExecutionInteraction["executionId"],
1456 status: interaction.status,
1457 kind: interaction.kind,
1458 purpose: interaction.purpose,
1459 payloadJson: rewriteLegacyScopedJson({
1460 value: interaction.payload_json,
1461 oldWorkspaceId: input.snapshot.installation.workspace_id,
1462 newWorkspaceId: targetWorkspaceId,
1463 oldAccountId: input.snapshot.installation.account_id,
1464 newAccountId: targetAccountId,
1465 }) ?? interaction.payload_json,
1466 responseJson: rewriteLegacyScopedJson({
1467 value: interaction.response_json,
1468 oldWorkspaceId: input.snapshot.installation.workspace_id,
1469 newWorkspaceId: targetWorkspaceId,
1470 oldAccountId: input.snapshot.installation.account_id,
1471 newAccountId: targetAccountId,
1472 }),
1473 responsePrivateJson: null,
1474 createdAt: interaction.created_at,
1475 updatedAt: interaction.updated_at,
1476 })),
1477 executionSteps: [],
1478 };
1479
1480 const legacySourceById = new Map(
1481 input.snapshot.sources.map((row) => [row.source_id, row] as const),
1482 );
1483 const sourceArtifacts: Array<{
1484 sourceId: Source["id"];
1485 artifact: LocalSourceArtifact;
1486 }> = [];
1487
1488 for (const source of migratedSources) {
1489 const legacyRow = legacySourceById.get(source.id);
1490 if (!legacyRow) {
1491 continue;
1492 }
1493
1494 const artifact = await buildMigratedSourceArtifact({
1495 source,
1496 row: legacyRow,
1497 toolArtifacts: input.snapshot.toolArtifacts,
1498 });
1499 if (artifact === null) {
1500 continue;
1501 }
1502
1503 sourceArtifacts.push({
1504 sourceId: source.id,
1505 artifact,
1506 });
1507 }
1508
1509 return {
1510 projectConfig,
1511 workspaceState,
1512 controlPlaneState,
1513 sourceArtifacts,
1514 sourceCount: migratedSources.length,
1515 policyCount: migratedPolicies.length,
1516 };
1517};
1518
1519export const migrateLegacyPostgresWorkspaceIfNeeded = (input: {
1520 context: ResolvedLocalWorkspaceContext;
1521 legacyLocalDataDir?: string;
1522 legacyDatabaseUrl?: string;
1523}): Effect.Effect<void, Error, FileSystem.FileSystem> =>
1524 Effect.gen(function* () {
1525 if (workspaceAlreadyInitialized(input.context)) {
1526 return;
1527 }
1528
1529 const snapshot = yield* Effect.tryPromise({
1530 try: () =>
1531 tryLoadLegacyWorkspaceSnapshot({
1532 localDataDir: input.legacyLocalDataDir,
1533 databaseUrl: input.legacyDatabaseUrl,
1534 }),
1535 catch: toError,
1536 });
1537 if (snapshot === null) {
1538 return;
1539 }
1540
1541 const migrated = yield* Effect.tryPromise({
1542 try: () =>
1543 migrateLegacyWorkspace({
1544 context: input.context,
1545 snapshot,
1546 }),
1547 catch: toError,
1548 });
1549
1550 yield* writeProjectLocalExecutorConfig({
1551 context: input.context,
1552 config: migrated.projectConfig,
1553 });
1554 yield* writeLocalWorkspaceState({
1555 context: input.context,
1556 state: migrated.workspaceState,
1557 });
1558 yield* writeLocalControlPlaneState({
1559 context: input.context,
1560 state: migrated.controlPlaneState,
1561 });
1562 yield* Effect.forEach(
1563 migrated.sourceArtifacts,
1564 ({ sourceId, artifact }) =>
1565 writeLocalSourceArtifact({
1566 context: input.context,
1567 sourceId,
1568 artifact,
1569 }),
1570 { discard: true },
1571 );
1572
1573 yield* Effect.sync(() => {
1574 console.warn(
1575 `[executor] Migrated ${String(migrated.sourceCount)} legacy source(s) and ${String(migrated.policyCount)} policy/policies into ${input.context.projectConfigPath}`,
1576 );
1577 });
1578 });
packages/control-plane/src/runtime/local-config.ts+1−1
@@ -5,7 +5,7 @@ import { · 4 unmodified lines
5 type ParseError as JsoncParseError,5 type ParseError as JsoncParseError,
6 parse as parseJsoncDocument,6 parse as parseJsoncDocument,
7 printParseErrorCode,7 printParseErrorCode,
8} from "jsonc-parser";8} from "jsonc-parser/lib/esm/main.js";
9 9
10import {10import {
11 LocalExecutorConfigSchema,11 LocalExecutorConfigSchema,
packages/control-plane/src/runtime/local-control-plane-store.test.ts+33−0
@@ -41,6 +41,8 @@ describe("local-control-plane-store", () => { · 40 unmodified lines
41 authArtifacts: [],41 authArtifacts: [],
42 authLeases: [],42 authLeases: [],
43 sourceOauthClients: [],43 sourceOauthClients: [],
44 workspaceOauthClients: [],
45 providerAuthGrants: [],
44 sourceAuthSessions: [],46 sourceAuthSessions: [],
45 secretMaterials: [],47 secretMaterials: [],
46 executions: [],48 executions: [],
@@ -76,6 +78,8 @@ describe("local-control-plane-store", () => { · 29 unmodified lines
76 authArtifacts: [],78 authArtifacts: [],
77 authLeases: [],79 authLeases: [],
78 sourceOauthClients: [],80 sourceOauthClients: [],
81 workspaceOauthClients: [],
82 providerAuthGrants: [],
79 sourceAuthSessions: [],83 sourceAuthSessions: [],
80 secretMaterials: [84 secretMaterials: [
81 {85 {
@@ -108,4 +112,33 @@ describe("local-control-plane-store", () => { · 26 unmodified lines
108 ]);112 ]);
109 }),113 }),
110 );114 );
115
116 it.effect("backfills newly added top-level state arrays when loading older version 1 files", () =>
117 Effect.gen(function* () {
118 const context = makeContext();
119 const expectedPath = localControlPlaneStatePath(context);
120 mkdirSync(dirname(expectedPath), { recursive: true });
121
122 writeFileSync(
123 expectedPath,
124 JSON.stringify({
125 version: 1,
126 authArtifacts: [],
127 authLeases: [],
128 sourceOauthClients: [],
129 sourceAuthSessions: [],
130 secretMaterials: [],
131 executions: [],
132 executionInteractions: [],
133 executionSteps: [],
134 }),
135 );
136
137 const loaded = yield* loadLocalControlPlaneState(context);
138 expect(loaded.workspaceOauthClients).toEqual([]);
139 expect(loaded.providerAuthGrants).toEqual([]);
140 expect(loaded.sourceOauthClients).toEqual([]);
141 expect(loaded.secretMaterials).toEqual([]);
142 }),
143 );
111});144});
packages/control-plane/src/runtime/local-control-plane-store.ts+198−1
@@ -14,10 +14,14 @@ import { · 13 unmodified lines
14 ExecutionInteractionSchema,14 ExecutionInteractionSchema,
15 ExecutionSchema,15 ExecutionSchema,
16 ExecutionStepSchema,16 ExecutionStepSchema,
17 type ProviderAuthGrant,
18 ProviderAuthGrantSchema,
17 SecretMaterialSchema,19 SecretMaterialSchema,
18 type SecretMaterial,20 type SecretMaterial,
19 type SourceAuthSession,21 type SourceAuthSession,
20 SourceAuthSessionSchema,22 SourceAuthSessionSchema,
23 type WorkspaceOauthClient,
24 WorkspaceOauthClientSchema,
21 type WorkspaceSourceOauthClient,25 type WorkspaceSourceOauthClient,
22 WorkspaceSourceOauthClientSchema,26 WorkspaceSourceOauthClientSchema,
23} from "#schema";27} from "#schema";
@@ -40,6 +44,8 @@ const LocalControlPlaneStateSchema = Schema.Struct({ · 16 unmodified lines
40 authArtifacts: Schema.Array(AuthArtifactSchema),44 authArtifacts: Schema.Array(AuthArtifactSchema),
41 authLeases: Schema.Array(AuthLeaseSchema),45 authLeases: Schema.Array(AuthLeaseSchema),
42 sourceOauthClients: Schema.Array(WorkspaceSourceOauthClientSchema),46 sourceOauthClients: Schema.Array(WorkspaceSourceOauthClientSchema),
47 workspaceOauthClients: Schema.Array(WorkspaceOauthClientSchema),
48 providerAuthGrants: Schema.Array(ProviderAuthGrantSchema),
43 sourceAuthSessions: Schema.Array(SourceAuthSessionSchema),49 sourceAuthSessions: Schema.Array(SourceAuthSessionSchema),
44 secretMaterials: Schema.Array(SecretMaterialSchema),50 secretMaterials: Schema.Array(SecretMaterialSchema),
45 executions: Schema.Array(ExecutionSchema),51 executions: Schema.Array(ExecutionSchema),
@@ -63,6 +69,8 @@ const defaultLocalControlPlaneState = (): LocalControlPlaneState => ({ · 17 unmodified lines
63 authArtifacts: [],69 authArtifacts: [],
64 authLeases: [],70 authLeases: [],
65 sourceOauthClients: [],71 sourceOauthClients: [],
72 workspaceOauthClients: [],
73 providerAuthGrants: [],
66 sourceAuthSessions: [],74 sourceAuthSessions: [],
67 secretMaterials: [],75 secretMaterials: [],
68 executions: [],76 executions: [],
@@ -70,6 +78,30 @@ const defaultLocalControlPlaneState = (): LocalControlPlaneState => ({ · 1 unmodified line
70 executionSteps: [],78 executionSteps: [],
71});79});
72 80
81const normalizeLocalControlPlaneStateInput = (raw: unknown): unknown => {
82 if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
83 return raw;
84 }
85
86 const record = raw as Record<string, unknown>;
87 const defaults = defaultLocalControlPlaneState();
88
89 return {
90 ...record,
91 version: record.version ?? defaults.version,
92 authArtifacts: record.authArtifacts ?? defaults.authArtifacts,
93 authLeases: record.authLeases ?? defaults.authLeases,
94 sourceOauthClients: record.sourceOauthClients ?? defaults.sourceOauthClients,
95 workspaceOauthClients: record.workspaceOauthClients ?? defaults.workspaceOauthClients,
96 providerAuthGrants: record.providerAuthGrants ?? defaults.providerAuthGrants,
97 sourceAuthSessions: record.sourceAuthSessions ?? defaults.sourceAuthSessions,
98 secretMaterials: record.secretMaterials ?? defaults.secretMaterials,
99 executions: record.executions ?? defaults.executions,
100 executionInteractions: record.executionInteractions ?? defaults.executionInteractions,
101 executionSteps: record.executionSteps ?? defaults.executionSteps,
102 };
103};
104
73const cloneValue = <T>(value: T): T =>105const cloneValue = <T>(value: T): T =>
74 JSON.parse(JSON.stringify(value)) as T;106 JSON.parse(JSON.stringify(value)) as T;
75 107
@@ -119,7 +151,9 @@ const readStateFromDisk = async ( · 43 unmodified lines
119 }151 }
120 152
121 const content = await readFile(path, "utf8");153 const content = await readFile(path, "utf8");
122 return decodeLocalControlPlaneState(JSON.parse(content) as unknown);154 return decodeLocalControlPlaneState(
155 normalizeLocalControlPlaneStateInput(JSON.parse(content) as unknown),
156 );
123};157};
124 158
125const writeStateToDisk = async (159const writeStateToDisk = async (
@@ -253,6 +287,34 @@ const mergeSourceOauthClients = ( · 127 unmodified lines
253 return [...merged.values()];287 return [...merged.values()];
254};288};
255 289
290const mergeWorkspaceOauthClients = (
291 current: readonly WorkspaceOauthClient[],
292 imported: readonly WorkspaceOauthClient[],
293): WorkspaceOauthClient[] => {
294 const merged = new Map<string, WorkspaceOauthClient>();
295
296 for (const oauthClient of imported) {
297 merged.set(
298 [oauthClient.workspaceId, oauthClient.providerKey, oauthClient.id].join("::"),
299 cloneValue(oauthClient),
300 );
301 }
302
303 for (const oauthClient of current) {
304 merged.set(
305 [oauthClient.workspaceId, oauthClient.providerKey, oauthClient.id].join("::"),
306 cloneValue(oauthClient),
307 );
308 }
309
310 return [...merged.values()];
311};
312
313const mergeProviderAuthGrants = (
314 current: readonly ProviderAuthGrant[],
315 imported: readonly ProviderAuthGrant[],
316): ProviderAuthGrant[] => mergeById(current, imported);
317
256export const mergeImportedLocalControlPlaneState = (input: {318export const mergeImportedLocalControlPlaneState = (input: {
257 current: LocalControlPlaneState;319 current: LocalControlPlaneState;
258 imported: Partial<Omit<LocalControlPlaneState, "version">>;320 imported: Partial<Omit<LocalControlPlaneState, "version">>;
@@ -270,6 +332,14 @@ export const mergeImportedLocalControlPlaneState = (input: { · 11 unmodified lines
270 input.current.sourceOauthClients,332 input.current.sourceOauthClients,
271 input.imported.sourceOauthClients ?? [],333 input.imported.sourceOauthClients ?? [],
272 ),334 ),
335 workspaceOauthClients: mergeWorkspaceOauthClients(
336 input.current.workspaceOauthClients,
337 input.imported.workspaceOauthClients ?? [],
338 ),
339 providerAuthGrants: mergeProviderAuthGrants(
340 input.current.providerAuthGrants,
341 input.imported.providerAuthGrants ?? [],
342 ),
273 sourceAuthSessions: mergeById(343 sourceAuthSessions: mergeById(
274 input.current.sourceAuthSessions,344 input.current.sourceAuthSessions,
275 input.imported.sourceAuthSessions ?? [],345 input.imported.sourceAuthSessions ?? [],
@@ -581,6 +651,133 @@ export const createLocalControlPlaneStore = ( · 305 unmodified lines
581 }),651 }),
582 },652 },
583 653
654 workspaceOauthClients: {
655 listByWorkspaceAndProvider: (input: {
656 workspaceId: WorkspaceOauthClient["workspaceId"];
657 providerKey: string;
658 }) =>
659 stateManager.read((state) =>
660 sortByUpdatedAtAndIdAsc(
661 state.workspaceOauthClients.filter(
662 (candidate) =>
663 candidate.workspaceId === input.workspaceId
664 && candidate.providerKey === input.providerKey,
665 ),
666 ),
667 ),
668
669 getById: (id: WorkspaceOauthClient["id"]) =>
670 stateManager.read((state) => {
671 const oauthClient = state.workspaceOauthClients.find(
672 (candidate) => candidate.id === id,
673 );
674
675 return oauthClient
676 ? Option.some(cloneValue(oauthClient))
677 : Option.none<WorkspaceOauthClient>();
678 }),
679
680 upsert: (oauthClient: WorkspaceOauthClient) =>
681 stateManager.mutate((state) => {
682 const nextOauthClients = state.workspaceOauthClients.filter(
683 (candidate) => candidate.id !== oauthClient.id,
684 );
685 nextOauthClients.push(cloneValue(oauthClient));
686
687 return {
688 state: {
689 ...state,
690 workspaceOauthClients: nextOauthClients,
691 },
692 value: undefined,
693 } satisfies StateMutationResult<void>;
694 }),
695
696 removeById: (id: WorkspaceOauthClient["id"]) =>
697 stateManager.mutate((state) => {
698 const nextOauthClients = state.workspaceOauthClients.filter(
699 (candidate) => candidate.id !== id,
700 );
701
702 return {
703 state: {
704 ...state,
705 workspaceOauthClients: nextOauthClients,
706 },
707 value: nextOauthClients.length !== state.workspaceOauthClients.length,
708 } satisfies StateMutationResult<boolean>;
709 }),
710 },
711
712 providerAuthGrants: {
713 listByWorkspaceId: (workspaceId: ProviderAuthGrant["workspaceId"]) =>
714 stateManager.read((state) =>
715 sortByUpdatedAtAndIdAsc(
716 state.providerAuthGrants.filter(
717 (grant) => grant.workspaceId === workspaceId,
718 ),
719 ),
720 ),
721
722 listByWorkspaceActorAndProvider: (input: {
723 workspaceId: ProviderAuthGrant["workspaceId"];
724 actorAccountId: ProviderAuthGrant["actorAccountId"];
725 providerKey: string;
726 }) =>
727 stateManager.read((state) =>
728 sortByUpdatedAtAndIdAsc(
729 state.providerAuthGrants.filter(
730 (grant) =>
731 grant.workspaceId === input.workspaceId
732 && grant.providerKey === input.providerKey
733 && actorEquals(grant.actorAccountId, input.actorAccountId),
734 ),
735 ),
736 ),
737
738 getById: (id: ProviderAuthGrant["id"]) =>
739 stateManager.read((state) => {
740 const grant = state.providerAuthGrants.find(
741 (candidate) => candidate.id === id,
742 );
743
744 return grant
745 ? Option.some(cloneValue(grant))
746 : Option.none<ProviderAuthGrant>();
747 }),
748
749 upsert: (grant: ProviderAuthGrant) =>
750 stateManager.mutate((state) => {
751 const nextGrants = state.providerAuthGrants.filter(
752 (candidate) => candidate.id !== grant.id,
753 );
754 nextGrants.push(cloneValue(grant));
755
756 return {
757 state: {
758 ...state,
759 providerAuthGrants: nextGrants,
760 },
761 value: undefined,
762 } satisfies StateMutationResult<void>;
763 }),
764
765 removeById: (id: ProviderAuthGrant["id"]) =>
766 stateManager.mutate((state) => {
767 const nextGrants = state.providerAuthGrants.filter(
768 (candidate) => candidate.id !== id,
769 );
770
771 return {
772 state: {
773 ...state,
774 providerAuthGrants: nextGrants,
775 },
776 value: nextGrants.length !== state.providerAuthGrants.length,
777 } satisfies StateMutationResult<boolean>;
778 }),
779 },
780
584 sourceAuthSessions: {781 sourceAuthSessions: {
585 listAll: () =>782 listAll: () =>
586 stateManager.read((state) => sortByUpdatedAtAndIdAsc(state.sourceAuthSessions)),783 stateManager.read((state) => sortByUpdatedAtAndIdAsc(state.sourceAuthSessions)),
packages/control-plane/src/runtime/mcp-auth-provider.ts+262−0
@@ -0,0 +1,262 @@
1import type {
2 OAuthClientInformationMixed,
3 OAuthTokens,
4} from "@modelcontextprotocol/sdk/shared/auth.js";
5import type {
6 OAuthClientMetadata,
7} from "@modelcontextprotocol/sdk/shared/auth.js";
8import type {
9 OAuthClientProvider,
10 OAuthDiscoveryState,
11} from "@modelcontextprotocol/sdk/client/auth.js";
12import type {
13 AuthArtifact,
14 McpOAuthAuthArtifactConfig,
15 SecretRef,
16} from "#schema";
17import {
18 McpOAuthAuthArtifactConfigJsonSchema,
19} from "#schema";
20import * as Effect from "effect/Effect";
21import * as Schema from "effect/Schema";
22
23import type { ControlPlaneStoreShape } from "./store";
24import {
25 createDefaultSecretMaterialDeleter,
26 createDefaultSecretMaterialStorer,
27 type ResolveSecretMaterial,
28 type SecretMaterialResolveContext,
29} from "./secret-material-providers";
30
31const encodeMcpOAuthAuthArtifactConfig = Schema.encodeSync(
32 McpOAuthAuthArtifactConfigJsonSchema,
33);
34
35const parseJsonObject = <T>(value: string | null): T | undefined => {
36 if (value === null) {
37 return undefined;
38 }
39
40 try {
41 const parsed = JSON.parse(value) as unknown;
42 return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
43 ? parsed as T
44 : undefined;
45 } catch {
46 return undefined;
47 }
48};
49
50const stringifyJsonObject = (value: unknown): string | null =>
51 value === null || value === undefined ? null : JSON.stringify(value);
52
53const createClientMetadata = (redirectUrl: string): OAuthClientMetadata => ({
54 redirect_uris: [redirectUrl],
55 grant_types: ["authorization_code", "refresh_token"],
56 response_types: ["code"],
57 token_endpoint_auth_method: "none",
58 client_name: "Executor Local",
59});
60
61const secretRefEquals = (left: SecretRef | null, right: SecretRef | null): boolean =>
62 (left?.providerId ?? null) === (right?.providerId ?? null)
63 && (left?.handle ?? null) === (right?.handle ?? null);
64
65const cleanupReplacedSecretRefs = (rows: ControlPlaneStoreShape, input: {
66 previousAccessToken: SecretRef;
67 nextAccessToken: SecretRef;
68 previousRefreshToken: SecretRef | null;
69 nextRefreshToken: SecretRef | null;
70}) =>
71 Effect.gen(function* () {
72 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows });
73
74 if (!secretRefEquals(input.previousAccessToken, input.nextAccessToken)) {
75 yield* Effect.either(deleteSecretMaterial(input.previousAccessToken));
76 }
77
78 if (
79 input.previousRefreshToken !== null
80 && !secretRefEquals(input.previousRefreshToken, input.nextRefreshToken)
81 ) {
82 yield* Effect.either(deleteSecretMaterial(input.previousRefreshToken));
83 }
84 });
85
86export const createPersistedMcpOAuthSourceAuth = (input: {
87 redirectUri: string;
88 accessToken: SecretRef;
89 refreshToken: SecretRef | null;
90 tokenType: string;
91 expiresIn: number | null;
92 scope: string | null;
93 resourceMetadataUrl: string | null;
94 authorizationServerUrl: string | null;
95 resourceMetadata: unknown;
96 authorizationServerMetadata: unknown;
97 clientInformation: unknown;
98}) => ({
99 kind: "mcp_oauth" as const,
100 redirectUri: input.redirectUri,
101 accessToken: input.accessToken,
102 refreshToken: input.refreshToken,
103 tokenType: input.tokenType,
104 expiresIn: input.expiresIn,
105 scope: input.scope,
106 resourceMetadataUrl: input.resourceMetadataUrl,
107 authorizationServerUrl: input.authorizationServerUrl,
108 resourceMetadataJson: stringifyJsonObject(input.resourceMetadata),
109 authorizationServerMetadataJson: stringifyJsonObject(input.authorizationServerMetadata),
110 clientInformationJson: stringifyJsonObject(input.clientInformation),
111});
112
113export const createPersistedMcpAuthProvider = (input: {
114 rows: ControlPlaneStoreShape;
115 artifact: AuthArtifact;
116 config: McpOAuthAuthArtifactConfig;
117 resolveSecretMaterial: ResolveSecretMaterial;
118 context?: SecretMaterialResolveContext;
119}): OAuthClientProvider => {
120 const storeSecretMaterial = createDefaultSecretMaterialStorer({
121 rows: input.rows,
122 });
123
124 let currentArtifact = input.artifact;
125 let currentConfig = input.config;
126
127 const persistConfig = (nextConfig: McpOAuthAuthArtifactConfig) =>
128 Effect.gen(function* () {
129 const nextArtifact: AuthArtifact = {
130 ...currentArtifact,
131 configJson: encodeMcpOAuthAuthArtifactConfig(nextConfig),
132 updatedAt: Date.now(),
133 };
134 yield* input.rows.authArtifacts.upsert(nextArtifact);
135 currentArtifact = nextArtifact;
136 currentConfig = nextConfig;
137 });
138
139 return {
140 get redirectUrl() {
141 return currentConfig.redirectUri;
142 },
143
144 get clientMetadata() {
145 return createClientMetadata(currentConfig.redirectUri);
146 },
147
148 clientInformation: () =>
149 parseJsonObject<OAuthClientInformationMixed>(currentConfig.clientInformationJson),
150
151 saveClientInformation: (clientInformation) =>
152 Effect.runPromise(
153 persistConfig({
154 ...currentConfig,
155 clientInformationJson: stringifyJsonObject(clientInformation),
156 }),
157 ).then(() => undefined),
158
159 tokens: async () => {
160 const accessToken = await Effect.runPromise(
161 input.resolveSecretMaterial({
162 ref: currentConfig.accessToken,
163 context: input.context,
164 }),
165 );
166 const refreshToken = currentConfig.refreshToken
167 ? await Effect.runPromise(
168 input.resolveSecretMaterial({
169 ref: currentConfig.refreshToken,
170 context: input.context,
171 }),
172 )
173 : undefined;
174
175 return {
176 access_token: accessToken,
177 token_type: currentConfig.tokenType,
178 ...(typeof currentConfig.expiresIn === "number"
179 ? { expires_in: currentConfig.expiresIn }
180 : {}),
181 ...(currentConfig.scope ? { scope: currentConfig.scope } : {}),
182 ...(refreshToken ? { refresh_token: refreshToken } : {}),
183 } satisfies OAuthTokens;
184 },
185
186 saveTokens: (tokens) =>
187 Effect.runPromise(Effect.gen(function* () {
188 const previousConfig = currentConfig;
189 const accessTokenRef = yield* storeSecretMaterial({
190 purpose: "oauth_access_token",
191 value: tokens.access_token,
192 name: `${currentArtifact.sourceId} MCP Access Token`,
193 });
194 const refreshTokenRef = tokens.refresh_token
195 ? yield* storeSecretMaterial({
196 purpose: "oauth_refresh_token",
197 value: tokens.refresh_token,
198 name: `${currentArtifact.sourceId} MCP Refresh Token`,
199 })
200 : currentConfig.refreshToken;
201
202 const nextConfig: McpOAuthAuthArtifactConfig = {
203 ...currentConfig,
204 accessToken: accessTokenRef,
205 refreshToken: refreshTokenRef,
206 tokenType: tokens.token_type ?? currentConfig.tokenType,
207 expiresIn:
208 typeof tokens.expires_in === "number" && Number.isFinite(tokens.expires_in)
209 ? tokens.expires_in
210 : currentConfig.expiresIn,
211 scope: tokens.scope ?? currentConfig.scope,
212 };
213
214 yield* persistConfig(nextConfig);
215 yield* cleanupReplacedSecretRefs(input.rows, {
216 previousAccessToken: previousConfig.accessToken,
217 nextAccessToken: accessTokenRef,
218 previousRefreshToken: previousConfig.refreshToken,
219 nextRefreshToken: refreshTokenRef,
220 });
221 })).then(() => undefined),
222
223 redirectToAuthorization: async (authorizationUrl) => {
224 throw new Error(
225 `MCP OAuth re-authorization is required for ${currentArtifact.sourceId}: ${authorizationUrl.toString()}`,
226 );
227 },
228
229 saveCodeVerifier: () => undefined,
230
231 codeVerifier: () => {
232 throw new Error("Persisted MCP OAuth sessions do not retain an active PKCE verifier");
233 },
234
235 saveDiscoveryState: (state) =>
236 Effect.runPromise(
237 persistConfig({
238 ...currentConfig,
239 resourceMetadataUrl: state.resourceMetadataUrl ?? null,
240 authorizationServerUrl: state.authorizationServerUrl ?? null,
241 resourceMetadataJson: stringifyJsonObject(state.resourceMetadata),
242 authorizationServerMetadataJson: stringifyJsonObject(
243 state.authorizationServerMetadata,
244 ),
245 }),
246 ).then(() => undefined),
247
248 discoveryState: () =>
249 currentConfig.authorizationServerUrl === null
250 ? undefined
251 : {
252 resourceMetadataUrl: currentConfig.resourceMetadataUrl ?? undefined,
253 authorizationServerUrl: currentConfig.authorizationServerUrl,
254 resourceMetadata: parseJsonObject<OAuthDiscoveryState["resourceMetadata"]>(
255 currentConfig.resourceMetadataJson,
256 ),
257 authorizationServerMetadata: parseJsonObject<
258 OAuthDiscoveryState["authorizationServerMetadata"]
259 >(currentConfig.authorizationServerMetadataJson),
260 },
261 };
262};
packages/control-plane/src/runtime/provider-grant-lifecycle.ts+93−0
@@ -0,0 +1,93 @@
1import type {
2 AuthArtifact,
3 ProviderAuthGrant,
4 WorkspaceId,
5} from "#schema";
6import {
7 decodeProviderGrantRefAuthArtifactConfig,
8} from "#schema";
9import * as Effect from "effect/Effect";
10import * as Option from "effect/Option";
11
12import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
13import type { ControlPlaneStoreShape } from "./store";
14
15const providerGrantRefFromArtifact = (
16 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
17) => decodeProviderGrantRefAuthArtifactConfig(artifact);
18
19export const providerGrantIdFromArtifact = (
20 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
21): ProviderAuthGrant["id"] | null =>
22 providerGrantRefFromArtifact(artifact)?.grantId ?? null;
23
24export const listProviderGrantRefArtifacts = (rows: ControlPlaneStoreShape, input: {
25 workspaceId: WorkspaceId;
26 grantId?: ProviderAuthGrant["id"] | null;
27}): Effect.Effect<readonly AuthArtifact[], Error, never> =>
28 Effect.map(
29 rows.authArtifacts.listByWorkspaceId(input.workspaceId),
30 (artifacts) =>
31 artifacts.filter((artifact) => {
32 const grantId = providerGrantIdFromArtifact(artifact);
33 return grantId !== null && (input.grantId == null || grantId === input.grantId);
34 }),
35 );
36
37export const clearProviderGrantOrphanedAt = (rows: ControlPlaneStoreShape, input: {
38 grantId: ProviderAuthGrant["id"];
39}): Effect.Effect<boolean, Error, never> =>
40 Effect.gen(function* () {
41 const grantOption = yield* rows.providerAuthGrants.getById(input.grantId);
42 if (Option.isNone(grantOption) || grantOption.value.orphanedAt === null) {
43 return false;
44 }
45
46 yield* rows.providerAuthGrants.upsert({
47 ...grantOption.value,
48 orphanedAt: null,
49 updatedAt: Date.now(),
50 });
51 return true;
52 });
53
54export const markProviderGrantOrphanedIfUnused = (rows: ControlPlaneStoreShape, input: {
55 workspaceId: WorkspaceId;
56 grantId: ProviderAuthGrant["id"];
57}): Effect.Effect<boolean, Error, never> =>
58 Effect.gen(function* () {
59 const references = yield* listProviderGrantRefArtifacts(rows, input);
60 if (references.length > 0) {
61 return false;
62 }
63
64 const grantOption = yield* rows.providerAuthGrants.getById(input.grantId);
65 if (Option.isNone(grantOption) || grantOption.value.workspaceId !== input.workspaceId) {
66 return false;
67 }
68
69 const grant = grantOption.value;
70 if (grant.orphanedAt !== null) {
71 return false;
72 }
73
74 yield* rows.providerAuthGrants.upsert({
75 ...grant,
76 orphanedAt: Date.now(),
77 updatedAt: Date.now(),
78 });
79 return true;
80 });
81
82export const removeProviderAuthGrantSecret = (rows: ControlPlaneStoreShape, input: {
83 grant: ProviderAuthGrant;
84}): Effect.Effect<void, Error, never> =>
85 Effect.gen(function* () {
86 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({
87 rows,
88 });
89 yield* deleteSecretMaterial(input.grant.refreshToken).pipe(
90 Effect.either,
91 Effect.ignore,
92 );
93 });
packages/control-plane/src/runtime/source-adapters/google-discovery.ts+4−1
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
10 type GoogleDiscoveryToolProviderData,10 type GoogleDiscoveryToolProviderData,
11} from "@executor/codemode-google-discovery";11} from "@executor/codemode-google-discovery";
12import type { Source } from "#schema";12import type { Source } from "#schema";
13import { StringMapSchema } from "#schema";13import { StringMapSchema, WorkspaceOauthClientIdSchema } from "#schema";
14import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
15import * as Schema from "effect/Schema";15import * as Schema from "effect/Schema";
16 16
@@ -46,6 +46,9 @@ const GoogleDiscoveryConnectPayloadSchema = Schema.extend( · 29 unmodified lines
46 scopes: Schema.optional(46 scopes: Schema.optional(
47 Schema.Array(Schema.Trim.pipe(Schema.nonEmptyString())),47 Schema.Array(Schema.Trim.pipe(Schema.nonEmptyString())),
48 ),48 ),
49 workspaceOauthClientId: Schema.optional(
50 Schema.NullOr(WorkspaceOauthClientIdSchema),
51 ),
49 oauthClient: ConnectOauthClientSchema,52 oauthClient: ConnectOauthClientSchema,
50 name: OptionalNullableStringSchema,53 name: OptionalNullableStringSchema,
51 namespace: OptionalNullableStringSchema,54 namespace: OptionalNullableStringSchema,
packages/control-plane/src/runtime/source-adapters/mcp.test.ts+227−0
@@ -1,6 +1,7 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2 2
3import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";3import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
4import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";5import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";6import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
6import { describe, expect, it } from "@effect/vitest";7import { describe, expect, it } from "@effect/vitest";
@@ -215,6 +216,151 @@ const makeRealMcpServer = Effect.acquireRelease( · 208 unmodified lines
215 }).pipe(Effect.orDie),216 }).pipe(Effect.orDie),
216);217);
217 218
219const makeAuthenticatedMcpServer = (expectedAuthorization: string) =>
220 Effect.acquireRelease(
221 Effect.promise<RealMcpServer>(
222 () =>
223 new Promise<RealMcpServer>((resolve, reject) => {
224 const createServerForRequest = () => {
225 const mcp = new McpServer(
226 {
227 name: "mcp-auth-adapter-test-server",
228 version: "1.0.0",
229 },
230 {
231 capabilities: {
232 tools: {
233 listChanged: true,
234 },
235 },
236 },
237 );
238
239 mcp.registerTool(
240 "secure_echo",
241 {
242 title: "Secure Echo",
243 description: "Echoes a string after auth succeeds",
244 inputSchema: {
245 value: z.string(),
246 },
247 },
248 async ({ value }: { value: string }) => ({
249 content: [{
250 type: "text",
251 text: `secure:${value}`,
252 }],
253 }),
254 );
255
256 const transport = new StreamableHTTPServerTransport({
257 sessionIdGenerator: undefined,
258 });
259
260 return {
261 mcp,
262 transport,
263 };
264 };
265
266 const app = createMcpExpressApp({ host: "127.0.0.1" });
267
268 const handle = async (req: any, res: any, parsedBody?: unknown) => {
269 if (req.headers.authorization !== expectedAuthorization) {
270 res.status(401).json({
271 jsonrpc: "2.0",
272 error: {
273 code: -32001,
274 message: "Unauthorized",
275 },
276 id: null,
277 });
278 return;
279 }
280
281 const { mcp, transport } = createServerForRequest();
282 try {
283 await mcp.connect(transport);
284 await transport.handleRequest(req, res, parsedBody);
285 } finally {
286 await transport.close().catch(() => undefined);
287 await mcp.close().catch(() => undefined);
288 }
289 };
290
291 app.post("/mcp", async (req: any, res: any) => {
292 await handle(req, res, req.body);
293 });
294
295 app.get("/mcp", async (req: any, res: any) => {
296 await handle(req, res);
297 });
298
299 app.delete("/mcp", async (req: any, res: any) => {
300 await handle(req, res, req.body);
301 });
302
303 const listener = app.listen(0, "127.0.0.1", () => {
304 const address = listener.address();
305 if (!address || typeof address === "string") {
306 reject(new Error("failed to resolve authenticated MCP test server address"));
307 return;
308 }
309
310 resolve({
311 endpoint: `http://127.0.0.1:${address.port}/mcp`,
312 close: async () => {
313 await new Promise<void>((closeResolve, closeReject) => {
314 listener.close((error: Error | undefined) => {
315 if (error) {
316 closeReject(error);
317 return;
318 }
319 closeResolve();
320 });
321 });
322 },
323 });
324 });
325
326 listener.once("error", reject);
327 }),
328 ),
329 (server: RealMcpServer) =>
330 Effect.tryPromise({
331 try: () => server.close(),
332 catch: (error: unknown) =>
333 error instanceof Error ? error : new Error(String(error)),
334 }).pipe(Effect.orDie),
335 );
336
337const makeStaticAuthProvider = (accessToken: string): OAuthClientProvider => ({
338 get redirectUrl() {
339 return "http://127.0.0.1/oauth/callback";
340 },
341 get clientMetadata() {
342 return {
343 redirect_uris: ["http://127.0.0.1/oauth/callback"],
344 grant_types: ["authorization_code", "refresh_token"],
345 response_types: ["code"],
346 token_endpoint_auth_method: "none",
347 client_name: "Executor MCP Test",
348 };
349 },
350 clientInformation: () => undefined,
351 saveClientInformation: async () => undefined,
352 tokens: async () => ({
353 access_token: accessToken,
354 token_type: "Bearer",
355 }),
356 saveTokens: async () => undefined,
357 redirectToAuthorization: async () => undefined,
358 saveCodeVerifier: () => undefined,
359 codeVerifier: () => "unused",
360 saveDiscoveryState: async () => undefined,
361 discoveryState: () => undefined,
362});
363
218describe("mcp source adapter", () => {364describe("mcp source adapter", () => {
219 it.scoped("syncs MCP annotations and introspection metadata into snapshot", () =>365 it.scoped("syncs MCP annotations and introspection metadata into snapshot", () =>
220 Effect.gen(function* () {366 Effect.gen(function* () {
@@ -389,4 +535,85 @@ describe("mcp source adapter", () => { · 168 unmodified lines
389 });535 });
390 }),536 }),
391 );537 );
538
539 it.scoped("passes OAuth client providers through MCP sync and execution reconnects", () =>
540 Effect.gen(function* () {
541 const realServer = yield* makeAuthenticatedMcpServer("Bearer mcp-auth-token");
542 const authProvider = makeStaticAuthProvider("mcp-auth-token");
543 const source = yield* createSourceFromPayload({
544 workspaceId: "ws_test" as any,
545 sourceId: SourceIdSchema.make(`src_${randomUUID()}`),
546 payload: {
547 name: "Authenticated MCP Demo",
548 kind: "mcp",
549 endpoint: realServer.endpoint,
550 namespace: "mcp.auth.demo",
551 binding: {
552 transport: "streamable-http",
553 queryParams: null,
554 headers: null,
555 },
556 importAuthPolicy: "reuse_runtime",
557 importAuth: { kind: "none" },
558 auth: { kind: "none" },
559 status: "connected",
560 enabled: true,
561 },
562 now: Date.now(),
563 });
564
565 const resolvedAuth = {
566 placements: [],
567 headers: {},
568 queryParams: {},
569 cookies: {},
570 bodyValues: {},
571 expiresAt: null,
572 refreshAfter: null,
573 authProvider,
574 } as const;
575
576 const syncResult = yield* mcpSourceAdapter.syncCatalog({
577 source,
578 resolveSecretMaterial: () =>
579 Effect.fail(new Error("unexpected secret lookup")),
580 resolveAuthMaterialForSlot: () => Effect.succeed(resolvedAuth),
581 });
582 const snapshot = snapshotFromSourceCatalogSyncResult(syncResult);
583
584 const tool = yield* expandCatalogToolByPath({
585 catalogs: [makeLoadedCatalog({
586 source,
587 snapshot,
588 })],
589 path: "mcp.auth.demo.secure_echo",
590 });
591
592 if (!tool) {
593 throw new Error("Expected authenticated MCP tool to resolve");
594 }
595
596 const result = yield* invokeIrTool({
597 workspaceId: source.workspaceId,
598 accountId: "acct_test" as any,
599 tool,
600 auth: resolvedAuth,
601 args: {
602 value: "ok",
603 },
604 });
605
606 expect(result).toEqual({
607 data: {
608 content: [{
609 type: "text",
610 text: "secure:ok",
611 }],
612 },
613 error: null,
614 headers: {},
615 status: null,
616 });
617 }),
618 );
392});619});
packages/control-plane/src/runtime/source-adapters/mcp.ts+1−0
@@ -272,6 +272,7 @@ export const mcpSourceAdapter: SourceAdapter = { · 271 unmodified lines
272 authHeaders: auth.headers,272 authHeaders: auth.headers,
273 authCookies: auth.cookies,273 authCookies: auth.cookies,
274 }),274 }),
275 authProvider: auth.authProvider,
275 }),276 }),
276 catch: (cause) =>277 catch: (cause) =>
277 cause instanceof Error ? cause : new Error(String(cause)),278 cause instanceof Error ? cause : new Error(String(cause)),
packages/control-plane/src/runtime/source-auth-material.test.ts+94−0
@@ -1,7 +1,9 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
3 4
4import {5import {
6 AuthArtifactIdSchema,
5 SourceIdSchema,7 SourceIdSchema,
6 WorkspaceIdSchema,8 WorkspaceIdSchema,
7 type Source,9 type Source,
@@ -150,5 +152,97 @@ describe("source-auth-material", () => { · 142 unmodified lines
150 });152 });
151 }));153 }));
152 });154 });
155
156 it("reconstructs persisted MCP OAuth auth providers from stored auth artifacts", async () => {
157 const calls: string[] = [];
158
159 await Effect.runPromise(Effect.gen(function* () {
160 const auth = yield* resolveSourceAuthMaterialWithDeps({
161 source: makeSource({
162 kind: "mcp",
163 endpoint: "https://example.com/mcp",
164 binding: {
165 transport: "streamable-http",
166 queryParams: null,
167 headers: null,
168 },
169 auth: { kind: "none" },
170 }),
171 actorAccountId: null,
172 rows: {
173 authArtifacts: {
174 getByWorkspaceSourceAndActor: () =>
175 Effect.succeed(
176 Option.some({
177 id: AuthArtifactIdSchema.make("auth_artifact_mcp"),
178 workspaceId: WorkspaceIdSchema.make("ws_tool_artifacts"),
179 sourceId: SourceIdSchema.make("src_tool_artifacts"),
180 actorAccountId: null,
181 slot: "runtime",
182 artifactKind: "mcp_oauth",
183 configJson: JSON.stringify({
184 redirectUri: "http://127.0.0.1/oauth/callback",
185 accessToken: {
186 providerId: "local",
187 handle: "sec_mcp_access",
188 },
189 refreshToken: {
190 providerId: "local",
191 handle: "sec_mcp_refresh",
192 },
193 tokenType: "Bearer",
194 expiresIn: 3600,
195 scope: "mcp",
196 resourceMetadataUrl: "https://example.com/.well-known/oauth-protected-resource",
197 authorizationServerUrl: "https://example.com/oauth",
198 resourceMetadataJson: JSON.stringify({ resource: "mcp" }),
199 authorizationServerMetadataJson: JSON.stringify({
200 issuer: "https://example.com/oauth",
201 token_endpoint: "https://example.com/oauth/token",
202 }),
203 clientInformationJson: JSON.stringify({
204 client_id: "client-123",
205 }),
206 }),
207 grantSetJson: null,
208 createdAt: 1,
209 updatedAt: 1,
210 }),
211 ),
212 },
213 authLeases: {
214 getByAuthArtifactId: () => Effect.succeed(Option.none()),
215 },
216 } as any,
217 resolveSecretMaterial: ({ ref }) => {
218 calls.push(`${ref.providerId}:${ref.handle}`);
219 return Effect.succeed(
220 ref.handle === "sec_mcp_access" ? "persisted-access-token" : "persisted-refresh-token",
221 );
222 },
223 });
224
225 expect(auth.headers).toEqual({});
226 expect(auth.authProvider).toBeDefined();
227
228 const tokens = yield* Effect.tryPromise({
229 try: () => auth.authProvider!.tokens(),
230 catch: (error) =>
231 error instanceof Error ? error : new Error(String(error)),
232 });
233
234 expect(tokens).toEqual({
235 access_token: "persisted-access-token",
236 refresh_token: "persisted-refresh-token",
237 token_type: "Bearer",
238 expires_in: 3600,
239 scope: "mcp",
240 });
241 expect(calls).toEqual([
242 "local:sec_mcp_access",
243 "local:sec_mcp_refresh",
244 ]);
245 }));
246 });
153 });247 });
154});248});
packages/control-plane/src/runtime/source-auth-material.ts+5−1
@@ -109,7 +109,11 @@ export const resolveSourceAuthMaterialWithDeps = (input: { · 108 unmodified lines
109 });109 });
110 }110 }
111 111
112 if (artifact?.artifactKind === "oauth2_authorized_user") {112 if (
113 artifact?.artifactKind === "oauth2_authorized_user"
114 || artifact?.artifactKind === "provider_grant_ref"
115 || artifact?.artifactKind === "mcp_oauth"
116 ) {
113 return yield* Effect.fail(117 return yield* Effect.fail(
114 new Error("Dynamic auth artifacts require persistence-backed lease resolution"),118 new Error("Dynamic auth artifacts require persistence-backed lease resolution"),
115 );119 );
packages/control-plane/src/runtime/source-auth-service.ts+1312−77
@@ -11,6 +11,10 @@ import { · 10 unmodified lines
11 type McpSourceAuthSessionData,11 type McpSourceAuthSessionData,
12 OAuth2PkceSourceAuthSessionDataJsonSchema,12 OAuth2PkceSourceAuthSessionDataJsonSchema,
13 type OAuth2PkceSourceAuthSessionData,13 type OAuth2PkceSourceAuthSessionData,
14 type ProviderAuthGrant,
15 ProviderAuthGrantIdSchema,
16 ProviderOauthBatchSourceAuthSessionDataJsonSchema,
17 type ProviderOauthBatchSourceAuthSessionData,
14 type SecretMaterialPurpose,18 type SecretMaterialPurpose,
15 Source,19 Source,
16 type SourceImportAuthPolicy,20 type SourceImportAuthPolicy,
@@ -22,8 +26,10 @@ import { · 5 unmodified lines
22 SourceSchema,26 SourceSchema,
23 type SecretRef,27 type SecretRef,
24 type StringMap,28 type StringMap,
29 WorkspaceOauthClientIdSchema,
25 WorkspaceSourceOauthClientIdSchema,30 WorkspaceSourceOauthClientIdSchema,
26 WorkspaceSourceOauthClientMetadataJsonSchema,31 WorkspaceSourceOauthClientMetadataJsonSchema,
32 type WorkspaceOauthClient,
27 type WorkspaceSourceOauthClientRedirectMode,33 type WorkspaceSourceOauthClientRedirectMode,
28 type WorkspaceId,34 type WorkspaceId,
29} from "#schema";35} from "#schema";
@@ -49,15 +55,18 @@ import { · 19 unmodified lines
49 exchangeMcpOAuthAuthorizationCode,55 exchangeMcpOAuthAuthorizationCode,
50 startMcpOAuthAuthorization,56 startMcpOAuthAuthorization,
51} from "./mcp-oauth";57} from "./mcp-oauth";
58import { createPersistedMcpOAuthSourceAuth } from "./mcp-auth-provider";
52import {59import {
53 createSourceFromPayload,60 createSourceFromPayload,
54 updateSourceFromPayload,61 updateSourceFromPayload,
55} from "./source-definitions";62} from "./source-definitions";
56import {63import {
64 getSourceAdapter,
57 getSourceAdapterForSource,65 getSourceAdapterForSource,
58 hasSourceAdapterFamily,66 hasSourceAdapterFamily,
59 sourceBindingStateFromSource,67 sourceBindingStateFromSource,
60} from "./source-adapters";68} from "./source-adapters";
69import type { SourceAdapterOauth2SetupConfig } from "./source-adapters/types";
61import { isSourceCredentialRequiredError } from "./source-adapters/shared";70import { isSourceCredentialRequiredError } from "./source-adapters/shared";
62import {71import {
63 createDefaultSecretMaterialDeleter,72 createDefaultSecretMaterialDeleter,
@@ -66,7 +75,14 @@ import { · 2 unmodified lines
66 SecretMaterialStorerService,75 SecretMaterialStorerService,
67 type StoreSecretMaterial,76 type StoreSecretMaterial,
68} from "./secret-material-providers";77} from "./secret-material-providers";
69import { upsertOauth2AuthorizedUserLeaseFromTokenResponse } from "./auth-leases";78import {
79 removeAuthLeaseAndSecrets,
80 upsertOauth2AuthorizedUserLeaseFromTokenResponse,
81} from "./auth-leases";
82import {
83 listProviderGrantRefArtifacts,
84 removeProviderAuthGrantSecret,
85} from "./provider-grant-lifecycle";
70import {86import {
71 RuntimeSourceCatalogSyncService,87 RuntimeSourceCatalogSyncService,
72 type RuntimeSourceCatalogSyncShape,88 type RuntimeSourceCatalogSyncShape,
@@ -129,6 +145,15 @@ const resolveSourceOAuthCallbackUrl = (input: { · 56 unmodified lines
129 input.baseUrl,145 input.baseUrl,
130 ).toString();146 ).toString();
131 147
148const resolveWorkspaceProviderOauthCompleteUrl = (input: {
149 baseUrl: string;
150 workspaceId: WorkspaceId;
151}): string =>
152 new URL(
153 `/v1/workspaces/${encodeURIComponent(input.workspaceId)}/oauth/provider/callback`,
154 input.baseUrl,
155 ).toString();
156
132const normalizeEndpoint = (endpoint: string): string => {157const normalizeEndpoint = (endpoint: string): string => {
133 const url = new URL(endpoint.trim());158 const url = new URL(endpoint.trim());
134 return url.toString();159 return url.toString();
@@ -301,6 +326,42 @@ const mergeOauth2PkceSourceAuthSessionData = (input: { · 166 unmodified lines
301 });326 });
302};327};
303 328
329const encodeProviderOauthBatchSourceAuthSessionData = (
330 sessionData: ProviderOauthBatchSourceAuthSessionData,
331): string => Schema.encodeSync(ProviderOauthBatchSourceAuthSessionDataJsonSchema)(sessionData);
332
333const decodeProviderOauthBatchSourceAuthSessionDataJson = Schema.decodeUnknownEither(
334 ProviderOauthBatchSourceAuthSessionDataJsonSchema,
335);
336
337const decodeProviderOauthBatchSourceAuthSessionData = (
338 session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">,
339): ProviderOauthBatchSourceAuthSessionData => {
340 if (session.providerKind !== "oauth2_provider_batch") {
341 throw new Error(`Unsupported source auth provider for session ${session.id}`);
342 }
343
344 const decoded = decodeProviderOauthBatchSourceAuthSessionDataJson(session.sessionDataJson);
345 if (Either.isLeft(decoded)) {
346 throw new Error(
347 `Invalid source auth session data for ${session.id}: ${ParseResult.TreeFormatter.formatErrorSync(decoded.left)}`,
348 );
349 }
350
351 return decoded.right;
352};
353
354const mergeProviderOauthBatchSourceAuthSessionData = (input: {
355 session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">;
356 patch: Partial<ProviderOauthBatchSourceAuthSessionData>;
357}): string => {
358 const existing = decodeProviderOauthBatchSourceAuthSessionData(input.session);
359 return encodeProviderOauthBatchSourceAuthSessionData({
360 ...existing,
361 ...input.patch,
362 });
363};
364
304 365
305const completeLiveInteraction = (input: {366const completeLiveInteraction = (input: {
306 rows: ControlPlaneStoreShape;367 rows: ControlPlaneStoreShape;
@@ -479,6 +540,7 @@ export type ExecutorAddSourceInput = · 172 unmodified lines
479 version: string;540 version: string;
480 discoveryUrl?: string | null;541 discoveryUrl?: string | null;
481 scopes?: ReadonlyArray<string> | null;542 scopes?: ReadonlyArray<string> | null;
543 workspaceOauthClientId?: WorkspaceOauthClient["id"] | null;
482 oauthClient?: SourceOauthClientInput | null;544 oauthClient?: SourceOauthClientInput | null;
483 name?: string | null;545 name?: string | null;
484 namespace?: string | null;546 namespace?: string | null;
@@ -501,6 +563,42 @@ export type ConnectMcpSourceInput = { · 16 unmodified lines
501 baseUrl?: string | null;563 baseUrl?: string | null;
502};564};
503 565
566export type ConnectGoogleDiscoveryBatchInput = {
567 workspaceId: WorkspaceId;
568 actorAccountId?: AccountId | null;
569 executionId: SourceAuthSession["executionId"];
570 interactionId: SourceAuthSession["interactionId"];
571 workspaceOauthClientId: WorkspaceOauthClient["id"];
572 sources: ReadonlyArray<{
573 service: string;
574 version: string;
575 discoveryUrl?: string | null;
576 scopes?: ReadonlyArray<string> | null;
577 name?: string | null;
578 namespace?: string | null;
579 }>;
580 baseUrl?: string | null;
581};
582
583export type ConnectGoogleDiscoveryBatchResult = {
584 results: ReadonlyArray<{
585 source: Source;
586 status: "connected" | "pending_oauth";
587 }>;
588 providerOauthSession: {
589 sessionId: SourceAuthSession["id"];
590 authorizationUrl: string;
591 sourceIds: ReadonlyArray<Source["id"]>;
592 } | null;
593};
594
595export type CreateWorkspaceOauthClientInput = {
596 workspaceId: WorkspaceId;
597 providerKey: string;
598 label?: string | null;
599 oauthClient: SourceOauthClientInput;
600};
601
504export type McpSourceConnectResult = Extract<ExecutorSourceAddResult, {602export type McpSourceConnectResult = Extract<ExecutorSourceAddResult, {
505 kind: "connected" | "oauth_required";603 kind: "connected" | "oauth_required";
506}>;604}>;
@@ -531,6 +629,16 @@ export type CompleteSourceOAuthSessionResult = { · 24 unmodified lines
531 auth: Extract<Source["auth"], { kind: "oauth2" }>;629 auth: Extract<Source["auth"], { kind: "oauth2" }>;
532};630};
533 631
632export type CompleteSourceCredentialSetupResult = {
633 sessionId: SourceAuthSession["id"];
634 source: Source;
635};
636
637export type CompleteProviderOauthCallbackResult = {
638 sessionId: SourceAuthSession["id"];
639 sources: ReadonlyArray<Source>;
640};
641
534const materializeSecretRefInput = (input: {642const materializeSecretRefInput = (input: {
535 rawValue?: string | null;643 rawValue?: string | null;
536 ref?: SecretRef | null;644 ref?: SecretRef | null;
@@ -713,6 +821,11 @@ type ResolvedSourceOauthClient = { · 176 unmodified lines
713 redirectMode: WorkspaceSourceOauthClientRedirectMode;821 redirectMode: WorkspaceSourceOauthClientRedirectMode;
714};822};
715 823
824type ResolvedWorkspaceOauthClient = ResolvedSourceOauthClient & {
825 id: WorkspaceOauthClient["id"];
826 label: string | null;
827};
828
716const decodeWorkspaceSourceOauthClientMetadataOption = Schema.decodeUnknownOption(829const decodeWorkspaceSourceOauthClientMetadataOption = Schema.decodeUnknownOption(
717 WorkspaceSourceOauthClientMetadataJsonSchema,830 WorkspaceSourceOauthClientMetadataJsonSchema,
718);831);
@@ -864,6 +977,235 @@ const resolveExistingSourceOauthClient = (input: { · 145 unmodified lines
864 };977 };
865 });978 });
866 979
980const createWorkspaceOauthClient = (input: {
981 rows: ControlPlaneStoreShape;
982 workspaceId: WorkspaceId;
983 providerKey: string;
984 oauthClient: SourceOauthClientInput;
985 label?: string | null;
986 normalizeOauthClient?: (
987 input: SourceOauthClientInput,
988 ) => Effect.Effect<SourceOauthClientInput, Error, never>;
989 storeSecretMaterial: StoreSecretMaterial;
990}): Effect.Effect<ResolvedWorkspaceOauthClient, Error, never> =>
991 Effect.gen(function* () {
992 const normalizedOauthClient = input.normalizeOauthClient
993 ? yield* input.normalizeOauthClient(input.oauthClient)
994 : input.oauthClient;
995 const clientSecretRef = normalizedOauthClient.clientSecret
996 ? yield* input.storeSecretMaterial({
997 purpose: "oauth_client_info",
998 value: normalizedOauthClient.clientSecret,
999 })
1000 : null;
1001 const now = Date.now();
1002 const id = WorkspaceOauthClientIdSchema.make(
1003 `ws_oauth_client_${crypto.randomUUID()}`,
1004 );
1005
1006 yield* input.rows.workspaceOauthClients.upsert({
1007 id,
1008 workspaceId: input.workspaceId,
1009 providerKey: input.providerKey,
1010 label: trimOrNull(input.label) ?? null,
1011 clientId: normalizedOauthClient.clientId,
1012 clientSecretProviderId: clientSecretRef?.providerId ?? null,
1013 clientSecretHandle: clientSecretRef?.handle ?? null,
1014 clientMetadataJson: encodeWorkspaceSourceOauthClientMetadataJson({
1015 redirectMode: normalizedOauthClient.redirectMode ?? "app_callback",
1016 }),
1017 createdAt: now,
1018 updatedAt: now,
1019 });
1020
1021 return {
1022 id,
1023 providerKey: input.providerKey,
1024 label: trimOrNull(input.label) ?? null,
1025 clientId: normalizedOauthClient.clientId,
1026 clientSecret: clientSecretRef,
1027 redirectMode: normalizedOauthClient.redirectMode ?? "app_callback",
1028 };
1029 });
1030
1031const resolveWorkspaceOauthClientById = (input: {
1032 rows: ControlPlaneStoreShape;
1033 workspaceId: WorkspaceId;
1034 oauthClientId: WorkspaceOauthClient["id"];
1035 providerKey: string;
1036}): Effect.Effect<ResolvedWorkspaceOauthClient | null, Error, never> =>
1037 Effect.gen(function* () {
1038 const existing = yield* input.rows.workspaceOauthClients.getById(input.oauthClientId);
1039 if (Option.isNone(existing)) {
1040 return null;
1041 }
1042
1043 if (
1044 existing.value.workspaceId !== input.workspaceId
1045 || existing.value.providerKey !== input.providerKey
1046 ) {
1047 return yield* Effect.fail(
1048 new Error(`Workspace OAuth client ${input.oauthClientId} is not valid for ${input.providerKey}`),
1049 );
1050 }
1051
1052 return {
1053 id: existing.value.id,
1054 providerKey: existing.value.providerKey,
1055 label: existing.value.label,
1056 clientId: existing.value.clientId,
1057 clientSecret: sourceOauthClientSecretRef(existing.value),
1058 redirectMode: sourceOauthClientRedirectMode(existing.value),
1059 };
1060 });
1061
1062const promoteLegacySourceOauthClientToWorkspaceClient = (input: {
1063 rows: ControlPlaneStoreShape;
1064 workspaceId: WorkspaceId;
1065 legacyOauthClient: ResolvedSourceOauthClient;
1066 label?: string | null;
1067}): Effect.Effect<ResolvedWorkspaceOauthClient, Error, never> =>
1068 Effect.gen(function* () {
1069 const id = WorkspaceOauthClientIdSchema.make(
1070 `ws_oauth_client_${crypto.randomUUID()}`,
1071 );
1072 const now = Date.now();
1073
1074 yield* input.rows.workspaceOauthClients.upsert({
1075 id,
1076 workspaceId: input.workspaceId,
1077 providerKey: input.legacyOauthClient.providerKey,
1078 label: trimOrNull(input.label) ?? null,
1079 clientId: input.legacyOauthClient.clientId,
1080 clientSecretProviderId: input.legacyOauthClient.clientSecret?.providerId ?? null,
1081 clientSecretHandle: input.legacyOauthClient.clientSecret?.handle ?? null,
1082 clientMetadataJson: encodeWorkspaceSourceOauthClientMetadataJson({
1083 redirectMode: input.legacyOauthClient.redirectMode,
1084 }),
1085 createdAt: now,
1086 updatedAt: now,
1087 });
1088
1089 return {
1090 id,
1091 providerKey: input.legacyOauthClient.providerKey,
1092 label: trimOrNull(input.label) ?? null,
1093 clientId: input.legacyOauthClient.clientId,
1094 clientSecret: input.legacyOauthClient.clientSecret,
1095 redirectMode: input.legacyOauthClient.redirectMode,
1096 };
1097 });
1098
1099const providerGrantCoversScopes = (
1100 grantedScopes: ReadonlyArray<string>,
1101 requiredScopes: ReadonlyArray<string>,
1102): boolean => {
1103 const granted = new Set(grantedScopes);
1104 return requiredScopes.every((scope) => granted.has(scope));
1105};
1106
1107const normalizeScopes = (scopes: ReadonlyArray<string>): ReadonlyArray<string> =>
1108 [...new Set(scopes.map((scope) => scope.trim()).filter((scope) => scope.length > 0))];
1109
1110const findReusableProviderGrant = (input: {
1111 rows: ControlPlaneStoreShape;
1112 workspaceId: WorkspaceId;
1113 actorAccountId?: AccountId | null;
1114 providerKey: string;
1115 oauthClientId: WorkspaceOauthClient["id"];
1116 requiredScopes: ReadonlyArray<string>;
1117}): Effect.Effect<import("effect/Option").Option<import("#schema").ProviderAuthGrant>, Error, never> =>
1118 Effect.map(
1119 input.rows.providerAuthGrants.listByWorkspaceActorAndProvider({
1120 workspaceId: input.workspaceId,
1121 actorAccountId: input.actorAccountId ?? null,
1122 providerKey: input.providerKey,
1123 }),
1124 (grants) =>
1125 Option.fromNullable(
1126 grants.find(
1127 (grant) =>
1128 grant.oauthClientId === input.oauthClientId
1129 && providerGrantCoversScopes(grant.grantedScopes, input.requiredScopes),
1130 ),
1131 ),
1132 );
1133
1134const upsertProviderAuthGrant = (input: {
1135 rows: ControlPlaneStoreShape;
1136 workspaceId: WorkspaceId;
1137 actorAccountId?: AccountId | null;
1138 providerKey: string;
1139 oauthClientId: WorkspaceOauthClient["id"];
1140 tokenEndpoint: string;
1141 clientAuthentication: "none" | "client_secret_post";
1142 headerName: string;
1143 prefix: string;
1144 grantedScopes: ReadonlyArray<string>;
1145 refreshToken: string | null;
1146 storeSecretMaterial: StoreSecretMaterial;
1147 existingGrant?: import("#schema").ProviderAuthGrant | null;
1148}): Effect.Effect<import("#schema").ProviderAuthGrant, Error, never> =>
1149 Effect.gen(function* () {
1150 const existingGrant = input.existingGrant ?? null;
1151 let refreshTokenRef = existingGrant?.refreshToken ?? null;
1152 if (input.refreshToken !== null) {
1153 refreshTokenRef = yield* input.storeSecretMaterial({
1154 purpose: "oauth_refresh_token",
1155 value: input.refreshToken,
1156 name: `${input.providerKey} Refresh`,
1157 });
1158 }
1159
1160 if (refreshTokenRef === null) {
1161 return yield* Effect.fail(
1162 new Error(`Provider auth grant for ${input.providerKey} is missing a refresh token`),
1163 );
1164 }
1165
1166 const now = Date.now();
1167 const nextGrant = {
1168 id: existingGrant?.id ?? ProviderAuthGrantIdSchema.make(`provider_grant_${crypto.randomUUID()}`),
1169 workspaceId: input.workspaceId,
1170 actorAccountId: input.actorAccountId ?? null,
1171 providerKey: input.providerKey,
1172 oauthClientId: input.oauthClientId,
1173 tokenEndpoint: input.tokenEndpoint,
1174 clientAuthentication: input.clientAuthentication,
1175 headerName: input.headerName,
1176 prefix: input.prefix,
1177 refreshToken: refreshTokenRef,
1178 grantedScopes: [...normalizeScopes([
1179 ...(existingGrant?.grantedScopes ?? []),
1180 ...input.grantedScopes,
1181 ])],
1182 lastRefreshedAt: existingGrant?.lastRefreshedAt ?? null,
1183 orphanedAt: null,
1184 createdAt: existingGrant?.createdAt ?? now,
1185 updatedAt: now,
1186 } satisfies import("#schema").ProviderAuthGrant;
1187
1188 yield* input.rows.providerAuthGrants.upsert(nextGrant);
1189
1190 if (
1191 existingGrant?.refreshToken
1192 && (
1193 existingGrant.refreshToken.providerId !== refreshTokenRef.providerId
1194 || existingGrant.refreshToken.handle !== refreshTokenRef.handle
1195 )
1196 ) {
1197 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({
1198 rows: input.rows,
1199 });
1200 yield* deleteSecretMaterial(existingGrant.refreshToken).pipe(
1201 Effect.either,
1202 Effect.ignore,
1203 );
1204 }
1205
1206 return nextGrant;
1207 });
1208
867const startOauth2PkceSourceCredentialSetup = (input: {1209const startOauth2PkceSourceCredentialSetup = (input: {
868 rows: ControlPlaneStoreShape;1210 rows: ControlPlaneStoreShape;
869 sourceStore: RuntimeSourceStore;1211 sourceStore: RuntimeSourceStore;
@@ -982,82 +1324,311 @@ const startOauth2PkceSourceCredentialSetup = (input: { · 112 unmodified lines
982 );1324 );
983 });1325 });
984 1326
985const connectMcpSourceInternal = (input: {1327const startProviderOauthBatchCredentialSetup = (input: {
986 rows: ControlPlaneStoreShape;1328 rows: ControlPlaneStoreShape;
987 sourceStore: RuntimeSourceStore;1329 sourceStore: RuntimeSourceStore;
988 sourceCatalogSync: RuntimeSourceCatalogSyncShape;
989 getLocalServerBaseUrl?: () => string | undefined;
990 baseUrl?: string | null;
991 workspaceId: WorkspaceId;1330 workspaceId: WorkspaceId;
992 actorAccountId?: AccountId | null;1331 actorAccountId?: AccountId | null;
993 sourceId?: Source["id"] | null;
994 executionId?: SourceAuthSession["executionId"];1332 executionId?: SourceAuthSession["executionId"];
995 interactionId?: SourceAuthSession["interactionId"];1333 interactionId?: SourceAuthSession["interactionId"];
996 endpoint: string;1334 baseUrl: string;
997 name?: string | null;1335 redirectModeOverride?: WorkspaceSourceOauthClientRedirectMode;
998 namespace?: string | null;1336 workspaceOauthClient: ResolvedWorkspaceOauthClient;
999 enabled?: boolean;1337 setupConfig: SourceAdapterOauth2SetupConfig;
1000 transport?: SourceTransport;1338 targetSources: ReadonlyArray<{
1001 queryParams?: StringMap | null;1339 source: Source;
1002 headers?: StringMap | null;1340 requiredScopes: ReadonlyArray<string>;
1003 mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;1341 }>;
1004 resolveSecretMaterial: ResolveSecretMaterial;1342}): Effect.Effect<{
1005}): Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices> =>1343 sessionId: SourceAuthSession["id"];
1344 authorizationUrl: string;
1345 source: Source;
1346}, Error, WorkspaceStorageServices> =>
1006 Effect.gen(function* () {1347 Effect.gen(function* () {
1007 const normalizedEndpoint = normalizeEndpoint(input.endpoint);1348 if (input.targetSources.length === 0) {
1008 const existing = yield* (1349 return yield* Effect.fail(new Error("Provider OAuth setup requires at least one target source"));
1009 input.sourceId1350 }
1010 ? input.sourceStore.loadSourceById({
1011 workspaceId: input.workspaceId,
1012 sourceId: input.sourceId,
1013 actorAccountId: input.actorAccountId,
1014 }).pipe(
1015 Effect.flatMap((source) =>
1016 hasSourceAdapterFamily(source.kind, "mcp")
1017 ? Effect.succeed(source)
1018 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),
1019 ),
1020 )
1021 : input.sourceStore.loadSourcesInWorkspace(input.workspaceId, {
1022 actorAccountId: input.actorAccountId,
1023 }).pipe(
1024 Effect.map((sources) =>
1025 sources.find(
1026 (source) =>
1027 hasSourceAdapterFamily(source.kind, "mcp")
1028 && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
1029 ),
1030 ),
1031 )
1032 );
1033 1351
1034 const chosenName =1352 const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);
1035 trimOrNull(input.name) ?? existing?.name ?? defaultSourceNameFromEndpoint(normalizedEndpoint);1353 const state = crypto.randomUUID();
1036 const chosenNamespace =1354 const completionUrl = resolveWorkspaceProviderOauthCompleteUrl({
1037 trimOrNull(input.namespace)1355 baseUrl: input.baseUrl,
1038 ?? existing?.namespace1356 workspaceId: input.workspaceId,
1039 ?? defaultNamespaceFromName(chosenName);1357 });
1040 const chosenEnabled = input.enabled ?? existing?.enabled ?? true;1358 const redirectMode = input.redirectModeOverride ?? input.workspaceOauthClient.redirectMode;
1041 const existingBinding = existing1359 const redirectServer = redirectMode === "loopback"
1042 ? yield* sourceBindingStateFromSource(existing)1360 ? yield* startOauthLoopbackRedirectServer({
1361 completionUrl,
1362 })
1043 : null;1363 : null;
1044 const chosenTransport = input.transport ?? existingBinding?.transport ?? "auto";1364 const redirectUri = redirectServer?.redirectUri ?? completionUrl;
1045 const chosenQueryParams =1365 const codeVerifier = createPkceCodeVerifier();
1046 input.queryParams !== undefined ? input.queryParams : (existingBinding?.queryParams ?? null);
1047 const chosenHeaders =
1048 input.headers !== undefined ? input.headers : (existingBinding?.headers ?? null);
1049 const now = Date.now();
1050 1366
1051 const draftSource = existing1367 return yield* Effect.gen(function* () {
1052 ? yield* updateSourceFromPayload({1368 const authorizationUrl = buildOAuth2AuthorizationUrl({
1053 source: existing,1369 authorizationEndpoint: input.setupConfig.authorizationEndpoint,
1054 payload: {1370 clientId: input.workspaceOauthClient.clientId,
1055 name: chosenName,1371 redirectUri,
1056 endpoint: normalizedEndpoint,1372 scopes: [...normalizeScopes(input.setupConfig.scopes)],
1057 namespace: chosenNamespace,1373 state,
1058 status: "probing",1374 codeVerifier,
1059 enabled: chosenEnabled,1375 extraParams: input.setupConfig.authorizationParams,
1060 binding: {1376 });
1377 const now = Date.now();
1378
1379 yield* input.rows.sourceAuthSessions.upsert({
1380 id: sessionId,
1381 workspaceId: input.workspaceId,
1382 sourceId: SourceIdSchema.make(`oauth_provider_${crypto.randomUUID()}`),
1383 actorAccountId: input.actorAccountId ?? null,
1384 credentialSlot: "runtime",
1385 executionId: input.executionId ?? null,
1386 interactionId: input.interactionId ?? null,
1387 providerKind: "oauth2_provider_batch",
1388 status: "pending",
1389 state,
1390 sessionDataJson: encodeProviderOauthBatchSourceAuthSessionData({
1391 kind: "provider_oauth_batch",
1392 providerKey: input.setupConfig.providerKey,
1393 authorizationEndpoint: input.setupConfig.authorizationEndpoint,
1394 tokenEndpoint: input.setupConfig.tokenEndpoint,
1395 redirectUri,
1396 oauthClientId: input.workspaceOauthClient.id,
1397 clientAuthentication: input.setupConfig.clientAuthentication,
1398 scopes: [...normalizeScopes(input.setupConfig.scopes)],
1399 headerName: input.setupConfig.headerName,
1400 prefix: input.setupConfig.prefix,
1401 authorizationParams: {
1402 ...(input.setupConfig.authorizationParams ?? {}),
1403 },
1404 targetSources: input.targetSources.map((target) => ({
1405 sourceId: target.source.id,
1406 requiredScopes: [...normalizeScopes(target.requiredScopes)],
1407 })),
1408 codeVerifier,
1409 authorizationUrl,
1410 }),
1411 errorText: null,
1412 completedAt: null,
1413 createdAt: now,
1414 updatedAt: now,
1415 });
1416
1417 const primarySource = yield* updateSourceStatus(
1418 input.sourceStore,
1419 input.targetSources[0]!.source,
1420 {
1421 actorAccountId: input.actorAccountId,
1422 status: "auth_required",
1423 lastError: null,
1424 },
1425 );
1426
1427 yield* Effect.forEach(
1428 input.targetSources.slice(1),
1429 (target) =>
1430 updateSourceStatus(input.sourceStore, target.source, {
1431 actorAccountId: input.actorAccountId,
1432 status: "auth_required",
1433 lastError: null,
1434 }).pipe(Effect.asVoid),
1435 { discard: true },
1436 );
1437
1438 return {
1439 sessionId,
1440 authorizationUrl,
1441 source: primarySource,
1442 };
1443 }).pipe(
1444 Effect.onError(() =>
1445 redirectServer
1446 ? redirectServer.close.pipe(Effect.orDie)
1447 : Effect.void
1448 ),
1449 );
1450 });
1451
1452const attachProviderGrantToSources = (input: {
1453 sourceStore: RuntimeSourceStore;
1454 sourceCatalogSync: RuntimeSourceCatalogSyncShape;
1455 actorAccountId?: AccountId | null;
1456 grantId: ProviderAuthGrant["id"];
1457 providerKey: string;
1458 headerName: string;
1459 prefix: string;
1460 targets: ReadonlyArray<{
1461 source: Source;
1462 requiredScopes: ReadonlyArray<string>;
1463 }>;
1464}): Effect.Effect<ReadonlyArray<Source>, Error, WorkspaceStorageServices> =>
1465 Effect.forEach(
1466 input.targets,
1467 (target) =>
1468 Effect.gen(function* () {
1469 const connectedSource = yield* updateSourceStatus(input.sourceStore, target.source, {
1470 actorAccountId: input.actorAccountId,
1471 status: "connected",
1472 lastError: null,
1473 auth: {
1474 kind: "provider_grant_ref",
1475 grantId: input.grantId,
1476 providerKey: input.providerKey,
1477 requiredScopes: [...normalizeScopes(target.requiredScopes)],
1478 headerName: input.headerName,
1479 prefix: input.prefix,
1480 },
1481 });
1482
1483 yield* input.sourceCatalogSync.sync({
1484 source: connectedSource,
1485 actorAccountId: input.actorAccountId,
1486 });
1487
1488 return connectedSource;
1489 }),
1490 { discard: false },
1491 );
1492
1493const removeProviderAuthGrantInternal = (input: {
1494 rows: ControlPlaneStoreShape;
1495 sourceStore: RuntimeSourceStore;
1496 workspaceId: WorkspaceId;
1497 grantId: ProviderAuthGrant["id"];
1498}): Effect.Effect<boolean, Error, WorkspaceStorageServices> =>
1499 Effect.gen(function* () {
1500 const grantOption = yield* input.rows.providerAuthGrants.getById(input.grantId);
1501 if (Option.isNone(grantOption) || grantOption.value.workspaceId !== input.workspaceId) {
1502 return false;
1503 }
1504
1505 const references = yield* listProviderGrantRefArtifacts(input.rows, {
1506 workspaceId: input.workspaceId,
1507 grantId: input.grantId,
1508 });
1509
1510 yield* Effect.forEach(
1511 references,
1512 (artifact) =>
1513 Effect.gen(function* () {
1514 const latestSource = yield* input.sourceStore.loadSourceById({
1515 workspaceId: artifact.workspaceId,
1516 sourceId: artifact.sourceId,
1517 actorAccountId: artifact.actorAccountId,
1518 }).pipe(
1519 Effect.catchAll(() => Effect.succeed(null)),
1520 );
1521
1522 if (latestSource === null) {
1523 yield* removeAuthLeaseAndSecrets(input.rows, {
1524 authArtifactId: artifact.id,
1525 });
1526 yield* input.rows.authArtifacts.removeByWorkspaceSourceAndActor({
1527 workspaceId: artifact.workspaceId,
1528 sourceId: artifact.sourceId,
1529 actorAccountId: artifact.actorAccountId,
1530 slot: artifact.slot,
1531 });
1532 return;
1533 }
1534
1535 yield* input.sourceStore.persistSource({
1536 ...latestSource,
1537 status: "auth_required",
1538 lastError: null,
1539 auth: artifact.slot === "runtime" ? { kind: "none" } : latestSource.auth,
1540 importAuth: artifact.slot === "import" ? { kind: "none" } : latestSource.importAuth,
1541 updatedAt: Date.now(),
1542 }, {
1543 actorAccountId: artifact.actorAccountId,
1544 }).pipe(Effect.asVoid);
1545 }),
1546 { discard: true },
1547 );
1548
1549 yield* removeProviderAuthGrantSecret(input.rows, {
1550 grant: grantOption.value,
1551 });
1552 yield* input.rows.providerAuthGrants.removeById(input.grantId);
1553 return true;
1554 });
1555
1556const connectMcpSourceInternal = (input: {
1557 rows: ControlPlaneStoreShape;
1558 sourceStore: RuntimeSourceStore;
1559 sourceCatalogSync: RuntimeSourceCatalogSyncShape;
1560 getLocalServerBaseUrl?: () => string | undefined;
1561 baseUrl?: string | null;
1562 workspaceId: WorkspaceId;
1563 actorAccountId?: AccountId | null;
1564 sourceId?: Source["id"] | null;
1565 executionId?: SourceAuthSession["executionId"];
1566 interactionId?: SourceAuthSession["interactionId"];
1567 endpoint: string;
1568 name?: string | null;
1569 namespace?: string | null;
1570 enabled?: boolean;
1571 transport?: SourceTransport;
1572 queryParams?: StringMap | null;
1573 headers?: StringMap | null;
1574 mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
1575 resolveSecretMaterial: ResolveSecretMaterial;
1576}): Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices> =>
1577 Effect.gen(function* () {
1578 const normalizedEndpoint = normalizeEndpoint(input.endpoint);
1579 const existing = yield* (
1580 input.sourceId
1581 ? input.sourceStore.loadSourceById({
1582 workspaceId: input.workspaceId,
1583 sourceId: input.sourceId,
1584 actorAccountId: input.actorAccountId,
1585 }).pipe(
1586 Effect.flatMap((source) =>
1587 hasSourceAdapterFamily(source.kind, "mcp")
1588 ? Effect.succeed(source)
1589 : Effect.fail(new Error(`Expected MCP source, received ${source.kind}`)),
1590 ),
1591 )
1592 : input.sourceStore.loadSourcesInWorkspace(input.workspaceId, {
1593 actorAccountId: input.actorAccountId,
1594 }).pipe(
1595 Effect.map((sources) =>
1596 sources.find(
1597 (source) =>
1598 hasSourceAdapterFamily(source.kind, "mcp")
1599 && normalizeEndpoint(source.endpoint) === normalizedEndpoint,
1600 ),
1601 ),
1602 )
1603 );
1604
1605 const chosenName =
1606 trimOrNull(input.name) ?? existing?.name ?? defaultSourceNameFromEndpoint(normalizedEndpoint);
1607 const chosenNamespace =
1608 trimOrNull(input.namespace)
1609 ?? existing?.namespace
1610 ?? defaultNamespaceFromName(chosenName);
1611 const chosenEnabled = input.enabled ?? existing?.enabled ?? true;
1612 const existingBinding = existing
1613 ? yield* sourceBindingStateFromSource(existing)
1614 : null;
1615 const chosenTransport = input.transport ?? existingBinding?.transport ?? "auto";
1616 const chosenQueryParams =
1617 input.queryParams !== undefined ? input.queryParams : (existingBinding?.queryParams ?? null);
1618 const chosenHeaders =
1619 input.headers !== undefined ? input.headers : (existingBinding?.headers ?? null);
1620 const now = Date.now();
1621
1622 const draftSource = existing
1623 ? yield* updateSourceFromPayload({
1624 source: existing,
1625 payload: {
1626 name: chosenName,
1627 endpoint: normalizedEndpoint,
1628 namespace: chosenNamespace,
1629 status: "probing",
1630 enabled: chosenEnabled,
1631 binding: {
1061 transport: chosenTransport,1632 transport: chosenTransport,
1062 queryParams: chosenQueryParams,1633 queryParams: chosenQueryParams,
1063 headers: chosenHeaders,1634 headers: chosenHeaders,
@@ -1545,6 +2116,121 @@ const addExecutorGoogleDiscoverySource = (input: { · 481 unmodified lines
1545 const persistedDraft = yield* input.sourceStore.persistSource(draftSource, {2116 const persistedDraft = yield* input.sourceStore.persistSource(draftSource, {
1546 actorAccountId: input.sourceInput.actorAccountId,2117 actorAccountId: input.sourceInput.actorAccountId,
1547 });2118 });
2119 const googleAdapter = getSourceAdapterForSource(persistedDraft);
2120 const googleSetupConfig = googleAdapter.getOauth2SetupConfig
2121 ? yield* googleAdapter.getOauth2SetupConfig({
2122 source: persistedDraft,
2123 slot: "runtime",
2124 })
2125 : null;
2126
2127 if (
2128 googleSetupConfig !== null
2129 && input.sourceInput.auth === undefined
2130 && persistedDraft.auth.kind === "none"
2131 ) {
2132 let workspaceOauthClient: ResolvedWorkspaceOauthClient | null = null;
2133 if (input.sourceInput.workspaceOauthClientId) {
2134 workspaceOauthClient = yield* resolveWorkspaceOauthClientById({
2135 rows: input.rows,
2136 workspaceId: persistedDraft.workspaceId,
2137 oauthClientId: input.sourceInput.workspaceOauthClientId,
2138 providerKey: googleSetupConfig.providerKey,
2139 });
2140 } else if (input.sourceInput.oauthClient) {
2141 workspaceOauthClient = yield* createWorkspaceOauthClient({
2142 rows: input.rows,
2143 workspaceId: persistedDraft.workspaceId,
2144 providerKey: googleSetupConfig.providerKey,
2145 oauthClient: input.sourceInput.oauthClient,
2146 label: `${chosenName} OAuth Client`,
2147 normalizeOauthClient: googleAdapter.normalizeOauthClientInput,
2148 storeSecretMaterial: input.storeSecretMaterial,
2149 });
2150 } else {
2151 const legacyOauthClient = yield* resolveExistingSourceOauthClient({
2152 rows: input.rows,
2153 source: persistedDraft,
2154 });
2155 if (legacyOauthClient !== null) {
2156 workspaceOauthClient = yield* promoteLegacySourceOauthClientToWorkspaceClient({
2157 rows: input.rows,
2158 workspaceId: persistedDraft.workspaceId,
2159 legacyOauthClient,
2160 label: `${chosenName} Imported Client`,
2161 });
2162 }
2163 }
2164
2165 if (workspaceOauthClient === null) {
2166 return yield* Effect.fail(
2167 new Error("Google shared auth requires a workspace OAuth client"),
2168 );
2169 }
2170
2171 const requiredScopes = normalizeScopes(googleSetupConfig.scopes);
2172 const reusableGrant = yield* findReusableProviderGrant({
2173 rows: input.rows,
2174 workspaceId: persistedDraft.workspaceId,
2175 actorAccountId: input.sourceInput.actorAccountId,
2176 providerKey: googleSetupConfig.providerKey,
2177 oauthClientId: workspaceOauthClient.id,
2178 requiredScopes,
2179 });
2180
2181 if (Option.isSome(reusableGrant)) {
2182 const [connectedSource] = yield* attachProviderGrantToSources({
2183 sourceStore: input.sourceStore,
2184 sourceCatalogSync: input.sourceCatalogSync,
2185 actorAccountId: input.sourceInput.actorAccountId,
2186 grantId: reusableGrant.value.id,
2187 providerKey: reusableGrant.value.providerKey,
2188 headerName: reusableGrant.value.headerName,
2189 prefix: reusableGrant.value.prefix,
2190 targets: [{
2191 source: persistedDraft,
2192 requiredScopes,
2193 }],
2194 });
2195
2196 return {
2197 kind: "connected",
2198 source: connectedSource!,
2199 } satisfies ExecutorSourceAddResult;
2200 }
2201
2202 const requestBaseUrl = trimOrNull(input.baseUrl);
2203 const baseUrl = requestBaseUrl ?? input.getLocalServerBaseUrl?.() ?? null;
2204 if (baseUrl === null) {
2205 return yield* Effect.fail(
2206 new Error("Local executor server base URL is unavailable for Google OAuth setup"),
2207 );
2208 }
2209
2210 const oauthRequired = yield* startProviderOauthBatchCredentialSetup({
2211 rows: input.rows,
2212 sourceStore: input.sourceStore,
2213 workspaceId: persistedDraft.workspaceId,
2214 actorAccountId: input.sourceInput.actorAccountId,
2215 executionId: input.sourceInput.executionId,
2216 interactionId: input.sourceInput.interactionId,
2217 baseUrl,
2218 redirectModeOverride: requestBaseUrl ? "app_callback" : undefined,
2219 workspaceOauthClient,
2220 setupConfig: googleSetupConfig,
2221 targetSources: [{
2222 source: persistedDraft,
2223 requiredScopes,
2224 }],
2225 });
2226
2227 return {
2228 kind: "oauth_required",
2229 source: oauthRequired.source,
2230 sessionId: oauthRequired.sessionId,
2231 authorizationUrl: oauthRequired.authorizationUrl,
2232 } satisfies ExecutorSourceAddResult;
2233 }
1548 2234
1549 if (input.sourceInput.oauthClient) {2235 if (input.sourceInput.oauthClient) {
1550 yield* upsertSourceOauthClient({2236 yield* upsertSourceOauthClient({
@@ -1641,6 +2327,237 @@ const addExecutorGoogleDiscoverySource = (input: { · 90 unmodified lines
1641 });2327 });
1642 });2328 });
1643 2329
2330const connectGoogleDiscoveryBatchInternal = (input: {
2331 rows: ControlPlaneStoreShape;
2332 sourceStore: RuntimeSourceStore;
2333 sourceCatalogSync: RuntimeSourceCatalogSyncShape;
2334 sourceInput: ConnectGoogleDiscoveryBatchInput;
2335 getLocalServerBaseUrl?: () => string | undefined;
2336}): Effect.Effect<ConnectGoogleDiscoveryBatchResult, Error, WorkspaceStorageServices> =>
2337 Effect.gen(function* () {
2338 if (input.sourceInput.sources.length === 0) {
2339 return yield* Effect.fail(new Error("Google batch connect requires at least one source"));
2340 }
2341
2342 const existingSources = yield* input.sourceStore.loadSourcesInWorkspace(
2343 input.sourceInput.workspaceId,
2344 {
2345 actorAccountId: input.sourceInput.actorAccountId,
2346 },
2347 );
2348
2349 const persistedTargets = yield* Effect.forEach(
2350 input.sourceInput.sources,
2351 (sourceInput) =>
2352 Effect.gen(function* () {
2353 const normalizedService = sourceInput.service.trim();
2354 const normalizedVersion = sourceInput.version.trim();
2355 const normalizedDiscoveryUrl = normalizeEndpoint(
2356 trimOrNull(sourceInput.discoveryUrl)
2357 ?? defaultGoogleDiscoveryUrl(normalizedService, normalizedVersion),
2358 );
2359 const existing = existingSources.find((source) => {
2360 if (source.kind !== "google_discovery") {
2361 return false;
2362 }
2363
2364 const binding = source.binding;
2365 return typeof binding.service === "string"
2366 && typeof binding.version === "string"
2367 && binding.service.trim() === normalizedService
2368 && binding.version.trim() === normalizedVersion;
2369 });
2370 const chosenName =
2371 trimOrNull(sourceInput.name)
2372 ?? existing?.name
2373 ?? defaultGoogleDiscoverySourceName(normalizedService, normalizedVersion);
2374 const chosenNamespace =
2375 trimOrNull(sourceInput.namespace)
2376 ?? existing?.namespace
2377 ?? defaultGoogleDiscoveryNamespace(normalizedService);
2378 const chosenScopes = normalizeScopes(
2379 sourceInput.scopes
2380 ?? (Array.isArray(existing?.binding.scopes)
2381 ? existing?.binding.scopes as ReadonlyArray<string>
2382 : []),
2383 );
2384 const existingBinding = existing
2385 ? yield* sourceBindingStateFromSource(existing)
2386 : null;
2387 const now = Date.now();
2388
2389 const draftSource = existing
2390 ? yield* updateSourceFromPayload({
2391 source: existing,
2392 payload: {
2393 name: chosenName,
2394 endpoint: normalizedDiscoveryUrl,
2395 namespace: chosenNamespace,
2396 status: "probing",
2397 enabled: true,
2398 binding: {
2399 service: normalizedService,
2400 version: normalizedVersion,
2401 discoveryUrl: normalizedDiscoveryUrl,
2402 defaultHeaders: existingBinding?.defaultHeaders ?? null,
2403 scopes: [...chosenScopes],
2404 },
2405 importAuthPolicy: "reuse_runtime",
2406 importAuth: { kind: "none" },
2407 auth: existing.auth.kind === "provider_grant_ref" ? existing.auth : { kind: "none" },
2408 lastError: null,
2409 },
2410 now,
2411 })
2412 : yield* createSourceFromPayload({
2413 workspaceId: input.sourceInput.workspaceId,
2414 sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
2415 payload: {
2416 name: chosenName,
2417 kind: "google_discovery",
2418 endpoint: normalizedDiscoveryUrl,
2419 namespace: chosenNamespace,
2420 status: "probing",
2421 enabled: true,
2422 binding: {
2423 service: normalizedService,
2424 version: normalizedVersion,
2425 discoveryUrl: normalizedDiscoveryUrl,
2426 defaultHeaders: existingBinding?.defaultHeaders ?? null,
2427 scopes: [...chosenScopes],
2428 },
2429 importAuthPolicy: "reuse_runtime",
2430 importAuth: { kind: "none" },
2431 auth: { kind: "none" },
2432 },
2433 now,
2434 });
2435
2436 const persistedDraft = yield* input.sourceStore.persistSource(draftSource, {
2437 actorAccountId: input.sourceInput.actorAccountId,
2438 });
2439 const adapter = getSourceAdapterForSource(persistedDraft);
2440 const setupConfig = adapter.getOauth2SetupConfig
2441 ? yield* adapter.getOauth2SetupConfig({
2442 source: persistedDraft,
2443 slot: "runtime",
2444 })
2445 : null;
2446 if (setupConfig === null) {
2447 return yield* Effect.fail(
2448 new Error(`Source ${persistedDraft.id} does not support Google shared auth`),
2449 );
2450 }
2451
2452 return {
2453 source: persistedDraft,
2454 requiredScopes: normalizeScopes(setupConfig.scopes),
2455 setupConfig,
2456 };
2457 }),
2458 { discard: false },
2459 );
2460
2461 const baseSetupConfig = persistedTargets[0]!.setupConfig;
2462 const unionScopes = normalizeScopes(
2463 persistedTargets.flatMap((target) => [...target.requiredScopes]),
2464 );
2465 const workspaceOauthClient = yield* resolveWorkspaceOauthClientById({
2466 rows: input.rows,
2467 workspaceId: input.sourceInput.workspaceId,
2468 oauthClientId: input.sourceInput.workspaceOauthClientId,
2469 providerKey: baseSetupConfig.providerKey,
2470 });
2471 if (workspaceOauthClient === null) {
2472 return yield* Effect.fail(
2473 new Error(`Workspace OAuth client not found: ${input.sourceInput.workspaceOauthClientId}`),
2474 );
2475 }
2476
2477 const reusableGrant = yield* findReusableProviderGrant({
2478 rows: input.rows,
2479 workspaceId: input.sourceInput.workspaceId,
2480 actorAccountId: input.sourceInput.actorAccountId,
2481 providerKey: baseSetupConfig.providerKey,
2482 oauthClientId: workspaceOauthClient.id,
2483 requiredScopes: unionScopes,
2484 });
2485
2486 if (Option.isSome(reusableGrant)) {
2487 const connectedSources = yield* attachProviderGrantToSources({
2488 sourceStore: input.sourceStore,
2489 sourceCatalogSync: input.sourceCatalogSync,
2490 actorAccountId: input.sourceInput.actorAccountId,
2491 grantId: reusableGrant.value.id,
2492 providerKey: reusableGrant.value.providerKey,
2493 headerName: reusableGrant.value.headerName,
2494 prefix: reusableGrant.value.prefix,
2495 targets: persistedTargets.map((target) => ({
2496 source: target.source,
2497 requiredScopes: target.requiredScopes,
2498 })),
2499 });
2500
2501 return {
2502 results: connectedSources.map((source) => ({
2503 source,
2504 status: "connected" as const,
2505 })),
2506 providerOauthSession: null,
2507 };
2508 }
2509
2510 const baseUrl = trimOrNull(input.sourceInput.baseUrl) ?? input.getLocalServerBaseUrl?.() ?? null;
2511 if (baseUrl === null) {
2512 return yield* Effect.fail(
2513 new Error("Local executor server base URL is unavailable for Google OAuth setup"),
2514 );
2515 }
2516
2517 const oauthRequired = yield* startProviderOauthBatchCredentialSetup({
2518 rows: input.rows,
2519 sourceStore: input.sourceStore,
2520 workspaceId: input.sourceInput.workspaceId,
2521 actorAccountId: input.sourceInput.actorAccountId,
2522 executionId: input.sourceInput.executionId,
2523 interactionId: input.sourceInput.interactionId,
2524 baseUrl,
2525 redirectModeOverride: trimOrNull(input.sourceInput.baseUrl) ? "app_callback" : undefined,
2526 workspaceOauthClient,
2527 setupConfig: {
2528 ...baseSetupConfig,
2529 scopes: unionScopes,
2530 },
2531 targetSources: persistedTargets.map((target) => ({
2532 source: target.source,
2533 requiredScopes: target.requiredScopes,
2534 })),
2535 });
2536
2537 const pendingSources = yield* Effect.forEach(
2538 persistedTargets,
2539 (target) =>
2540 input.sourceStore.loadSourceById({
2541 workspaceId: input.sourceInput.workspaceId,
2542 sourceId: target.source.id,
2543 actorAccountId: input.sourceInput.actorAccountId,
2544 }),
2545 { discard: false },
2546 );
2547
2548 return {
2549 results: pendingSources.map((source) => ({
2550 source,
2551 status: "pending_oauth" as const,
2552 })),
2553 providerOauthSession: {
2554 sessionId: oauthRequired.sessionId,
2555 authorizationUrl: oauthRequired.authorizationUrl,
2556 sourceIds: pendingSources.map((source) => source.id),
2557 },
2558 };
2559 });
2560
1644type RuntimeSourceAuthServiceShape = {2561type RuntimeSourceAuthServiceShape = {
1645 getSourceById: (input: {2562 getSourceById: (input: {
1646 workspaceId: WorkspaceId;2563 workspaceId: WorkspaceId;
@@ -1659,9 +2576,27 @@ type RuntimeSourceAuthServiceShape = { · 12 unmodified lines
1659 baseUrl?: string | null;2576 baseUrl?: string | null;
1660 },2577 },
1661 ) => Effect.Effect<ExecutorSourceAddResult, Error, WorkspaceStorageServices>;2578 ) => Effect.Effect<ExecutorSourceAddResult, Error, WorkspaceStorageServices>;
2579 connectGoogleDiscoveryBatch: (
2580 input: ConnectGoogleDiscoveryBatchInput,
2581 ) => Effect.Effect<ConnectGoogleDiscoveryBatchResult, Error, WorkspaceStorageServices>;
1662 connectMcpSource: (2582 connectMcpSource: (
1663 input: ConnectMcpSourceInput,2583 input: ConnectMcpSourceInput,
1664 ) => Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices>;2584 ) => Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices>;
2585 listWorkspaceOauthClients: (input: {
2586 workspaceId: WorkspaceId;
2587 providerKey: string;
2588 }) => Effect.Effect<readonly WorkspaceOauthClient[], Error, WorkspaceStorageServices>;
2589 createWorkspaceOauthClient: (
2590 input: CreateWorkspaceOauthClientInput,
2591 ) => Effect.Effect<WorkspaceOauthClient, Error, WorkspaceStorageServices>;
2592 removeWorkspaceOauthClient: (input: {
2593 workspaceId: WorkspaceId;
2594 oauthClientId: WorkspaceOauthClient["id"];
2595 }) => Effect.Effect<boolean, Error, WorkspaceStorageServices>;
2596 removeProviderAuthGrant: (input: {
2597 workspaceId: WorkspaceId;
2598 grantId: ProviderAuthGrant["id"];
2599 }) => Effect.Effect<boolean, Error, WorkspaceStorageServices>;
1665 startSourceOAuthSession: (2600 startSourceOAuthSession: (
1666 input: StartSourceOAuthSessionInput,2601 input: StartSourceOAuthSessionInput,
1667 ) => Effect.Effect<StartSourceOAuthSessionResult, Error, never>;2602 ) => Effect.Effect<StartSourceOAuthSessionResult, Error, never>;
@@ -1671,6 +2606,14 @@ type RuntimeSourceAuthServiceShape = { · 3 unmodified lines
1671 error?: string | null;2606 error?: string | null;
1672 errorDescription?: string | null;2607 errorDescription?: string | null;
1673 }) => Effect.Effect<CompleteSourceOAuthSessionResult, Error, WorkspaceStorageServices>;2608 }) => Effect.Effect<CompleteSourceOAuthSessionResult, Error, WorkspaceStorageServices>;
2609 completeProviderOauthCallback: (input: {
2610 workspaceId: WorkspaceId;
2611 actorAccountId?: AccountId | null;
2612 state: string;
2613 code?: string | null;
2614 error?: string | null;
2615 errorDescription?: string | null;
2616 }) => Effect.Effect<CompleteProviderOauthCallbackResult, Error, WorkspaceStorageServices>;
1674 completeSourceCredentialSetup: (input: {2617 completeSourceCredentialSetup: (input: {
1675 workspaceId: WorkspaceId;2618 workspaceId: WorkspaceId;
1676 sourceId: Source["id"];2619 sourceId: Source["id"];
@@ -1679,7 +2622,7 @@ type RuntimeSourceAuthServiceShape = { · 2 unmodified lines
1679 code?: string | null;2622 code?: string | null;
1680 error?: string | null;2623 error?: string | null;
1681 errorDescription?: string | null;2624 errorDescription?: string | null;
1682 }) => Effect.Effect<Source, Error, WorkspaceStorageServices>;2625 }) => Effect.Effect<CompleteSourceCredentialSetupResult, Error, WorkspaceStorageServices>;
1683};2626};
1684 2627
1685type RuntimeSourceAuthDependencies = {2628type RuntimeSourceAuthDependencies = {
@@ -1699,12 +2642,22 @@ type ProvideLocalWorkspace = <A, E, R>( · 13 unmodified lines
1699 2642
1700type RuntimeSourceConnectionServiceShape = Pick<2643type RuntimeSourceConnectionServiceShape = Pick<
1701 RuntimeSourceAuthServiceShape,2644 RuntimeSourceAuthServiceShape,
1702 "getSourceById" | "addExecutorSource" | "connectMcpSource"2645 | "getSourceById"
2646 | "addExecutorSource"
2647 | "connectGoogleDiscoveryBatch"
2648 | "connectMcpSource"
2649 | "listWorkspaceOauthClients"
2650 | "createWorkspaceOauthClient"
2651 | "removeWorkspaceOauthClient"
2652 | "removeProviderAuthGrant"
1703>;2653>;
1704 2654
1705type RuntimeSourceOAuthSessionServiceShape = Pick<2655type RuntimeSourceOAuthSessionServiceShape = Pick<
1706 RuntimeSourceAuthServiceShape,2656 RuntimeSourceAuthServiceShape,
1707 "startSourceOAuthSession" | "completeSourceOAuthSession" | "completeSourceCredentialSetup"2657 | "startSourceOAuthSession"
2658 | "completeSourceOAuthSession"
2659 | "completeProviderOauthCallback"
2660 | "completeSourceCredentialSetup"
1708>;2661>;
1709 2662
1710const createRuntimeSourceConnectionService = (2663const createRuntimeSourceConnectionService = (
@@ -1777,6 +2730,17 @@ const createRuntimeSourceConnectionService = ( · 66 unmodified lines
1777 ),2730 ),
1778 ),2731 ),
1779 2732
2733 connectGoogleDiscoveryBatch: (sourceInput) =>
2734 provideLocalWorkspace(
2735 connectGoogleDiscoveryBatchInternal({
2736 rows: input.rows,
2737 sourceStore: input.sourceStore,
2738 sourceCatalogSync: input.sourceCatalogSync,
2739 sourceInput,
2740 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
2741 }),
2742 ),
2743
1780 connectMcpSource: (sourceInput) =>2744 connectMcpSource: (sourceInput) =>
1781 provideLocalWorkspace(2745 provideLocalWorkspace(
1782 connectMcpSourceInternal({2746 connectMcpSourceInternal({
@@ -1802,6 +2766,83 @@ const createRuntimeSourceConnectionService = ( · 19 unmodified lines
1802 Effect.flatMap(mirrorLocalMcpSourceResult),2766 Effect.flatMap(mirrorLocalMcpSourceResult),
1803 ),2767 ),
1804 ),2768 ),
2769
2770 listWorkspaceOauthClients: ({ workspaceId, providerKey }) =>
2771 provideLocalWorkspace(
2772 input.rows.workspaceOauthClients.listByWorkspaceAndProvider({
2773 workspaceId,
2774 providerKey,
2775 }),
2776 ),
2777
2778 createWorkspaceOauthClient: ({ workspaceId, providerKey, label, oauthClient }) =>
2779 provideLocalWorkspace(
2780 Effect.gen(function* () {
2781 const created = yield* createWorkspaceOauthClient({
2782 rows: input.rows,
2783 workspaceId,
2784 providerKey,
2785 oauthClient,
2786 label,
2787 normalizeOauthClient: providerKey === "google_workspace"
2788 ? getSourceAdapter("google_discovery").normalizeOauthClientInput
2789 : undefined,
2790 storeSecretMaterial: input.storeSecretMaterial,
2791 });
2792 const stored = yield* input.rows.workspaceOauthClients.getById(created.id);
2793 if (Option.isNone(stored)) {
2794 return yield* Effect.fail(
2795 new Error(`Workspace OAuth client ${created.id} was not persisted`),
2796 );
2797 }
2798
2799 return stored.value;
2800 }),
2801 ),
2802
2803 removeWorkspaceOauthClient: ({ workspaceId, oauthClientId }) =>
2804 provideLocalWorkspace(
2805 Effect.gen(function* () {
2806 const oauthClient = yield* input.rows.workspaceOauthClients.getById(oauthClientId);
2807 if (Option.isNone(oauthClient) || oauthClient.value.workspaceId !== workspaceId) {
2808 return false;
2809 }
2810
2811 const grants = yield* input.rows.providerAuthGrants.listByWorkspaceId(workspaceId);
2812 const dependentGrant = grants.find((grant) => grant.oauthClientId === oauthClientId);
2813 if (dependentGrant) {
2814 return yield* Effect.fail(
2815 new Error(
2816 `Workspace OAuth client ${oauthClientId} is still referenced by provider grant ${dependentGrant.id}`,
2817 ),
2818 );
2819 }
2820
2821 const secretRef = sourceOauthClientSecretRef(oauthClient.value);
2822 const removed = yield* input.rows.workspaceOauthClients.removeById(oauthClientId);
2823 if (removed && secretRef) {
2824 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({
2825 rows: input.rows,
2826 });
2827 yield* deleteSecretMaterial(secretRef).pipe(
2828 Effect.either,
2829 Effect.ignore,
2830 );
2831 }
2832
2833 return removed;
2834 }),
2835 ),
2836
2837 removeProviderAuthGrant: ({ workspaceId, grantId }) =>
2838 provideLocalWorkspace(
2839 removeProviderAuthGrantInternal({
2840 rows: input.rows,
2841 sourceStore: input.sourceStore,
2842 workspaceId,
2843 grantId,
2844 }),
2845 ),
1805 };2846 };
1806};2847};
1807 2848
@@ -1981,6 +3022,184 @@ const createRuntimeSourceOAuthSessionService = ( · 173 unmodified lines
1981 } satisfies CompleteSourceOAuthSessionResult;3022 } satisfies CompleteSourceOAuthSessionResult;
1982 })),3023 })),
1983 3024
3025 completeProviderOauthCallback: ({ workspaceId, actorAccountId, state, code, error, errorDescription }) =>
3026 provideLocalWorkspace(Effect.gen(function* () {
3027 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
3028 if (Option.isNone(sessionOption)) {
3029 return yield* Effect.fail(new Error(`Source auth session not found for state ${state}`));
3030 }
3031
3032 const session = sessionOption.value;
3033 if (session.workspaceId !== workspaceId) {
3034 return yield* Effect.fail(
3035 new Error(`Source auth session ${session.id} does not match workspaceId=${workspaceId}`),
3036 );
3037 }
3038 if ((session.actorAccountId ?? null) !== (actorAccountId ?? null)) {
3039 return yield* Effect.fail(
3040 new Error(`Source auth session ${session.id} does not match the active account`),
3041 );
3042 }
3043 if (session.providerKind !== "oauth2_provider_batch") {
3044 return yield* Effect.fail(
3045 new Error(`Source auth session ${session.id} is not a provider batch OAuth session`),
3046 );
3047 }
3048 if (session.status === "completed") {
3049 const sessionData = decodeProviderOauthBatchSourceAuthSessionData(session);
3050 const sources = yield* Effect.forEach(
3051 sessionData.targetSources,
3052 (target) =>
3053 input.sourceStore.loadSourceById({
3054 workspaceId,
3055 sourceId: target.sourceId,
3056 actorAccountId,
3057 }),
3058 { discard: false },
3059 );
3060 return {
3061 sessionId: session.id,
3062 sources,
3063 } satisfies CompleteProviderOauthCallbackResult;
3064 }
3065 if (session.status !== "pending") {
3066 return yield* Effect.fail(
3067 new Error(`Source auth session ${session.id} is not pending`),
3068 );
3069 }
3070
3071 const sessionData = decodeProviderOauthBatchSourceAuthSessionData(session);
3072 if (trimOrNull(error) !== null) {
3073 const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed";
3074 yield* input.rows.sourceAuthSessions.update(
3075 session.id,
3076 createTerminalSourceAuthSessionPatch({
3077 sessionDataJson: session.sessionDataJson,
3078 status: "failed",
3079 now: Date.now(),
3080 errorText: reason,
3081 }),
3082 );
3083 return yield* Effect.fail(new Error(reason));
3084 }
3085
3086 const authorizationCode = trimOrNull(code);
3087 if (authorizationCode === null) {
3088 return yield* Effect.fail(new Error("Missing OAuth authorization code"));
3089 }
3090 if (sessionData.codeVerifier === null) {
3091 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));
3092 }
3093
3094 const oauthClient = yield* resolveWorkspaceOauthClientById({
3095 rows: input.rows,
3096 workspaceId,
3097 oauthClientId: sessionData.oauthClientId,
3098 providerKey: sessionData.providerKey,
3099 });
3100 if (oauthClient === null) {
3101 return yield* Effect.fail(
3102 new Error(`Workspace OAuth client not found: ${sessionData.oauthClientId}`),
3103 );
3104 }
3105
3106 let clientSecret: string | null = null;
3107 if (oauthClient.clientSecret) {
3108 clientSecret = yield* input.resolveSecretMaterial({
3109 ref: oauthClient.clientSecret,
3110 });
3111 }
3112
3113 const exchanged = yield* exchangeOAuth2AuthorizationCode({
3114 tokenEndpoint: sessionData.tokenEndpoint,
3115 clientId: oauthClient.clientId,
3116 clientAuthentication: sessionData.clientAuthentication,
3117 clientSecret,
3118 redirectUri: sessionData.redirectUri,
3119 codeVerifier: sessionData.codeVerifier ?? "",
3120 code: authorizationCode,
3121 });
3122
3123 const availableGrants = yield* input.rows.providerAuthGrants.listByWorkspaceActorAndProvider({
3124 workspaceId,
3125 actorAccountId: actorAccountId ?? null,
3126 providerKey: sessionData.providerKey,
3127 });
3128 const existingGrant = availableGrants.find(
3129 (grant) => grant.oauthClientId === sessionData.oauthClientId,
3130 ) ?? null;
3131
3132 const grantedScopes = normalizeScopes(
3133 trimOrNull(exchanged.scope)
3134 ? exchanged.scope!.split(/\s+/)
3135 : sessionData.scopes,
3136 );
3137 const nextGrant = yield* upsertProviderAuthGrant({
3138 rows: input.rows,
3139 workspaceId,
3140 actorAccountId,
3141 providerKey: sessionData.providerKey,
3142 oauthClientId: sessionData.oauthClientId,
3143 tokenEndpoint: sessionData.tokenEndpoint,
3144 clientAuthentication: sessionData.clientAuthentication,
3145 headerName: sessionData.headerName,
3146 prefix: sessionData.prefix,
3147 grantedScopes,
3148 refreshToken: trimOrNull(exchanged.refresh_token),
3149 existingGrant,
3150 storeSecretMaterial: input.storeSecretMaterial,
3151 });
3152
3153 const targets = yield* Effect.forEach(
3154 sessionData.targetSources,
3155 (target) =>
3156 Effect.map(
3157 input.sourceStore.loadSourceById({
3158 workspaceId,
3159 sourceId: target.sourceId,
3160 actorAccountId,
3161 }),
3162 (source) => ({
3163 source,
3164 requiredScopes: target.requiredScopes,
3165 }),
3166 ),
3167 { discard: false },
3168 );
3169
3170 const connectedSources = yield* attachProviderGrantToSources({
3171 sourceStore: input.sourceStore,
3172 sourceCatalogSync: input.sourceCatalogSync,
3173 actorAccountId,
3174 grantId: nextGrant.id,
3175 providerKey: nextGrant.providerKey,
3176 headerName: nextGrant.headerName,
3177 prefix: nextGrant.prefix,
3178 targets,
3179 });
3180
3181 yield* input.rows.sourceAuthSessions.update(
3182 session.id,
3183 createTerminalSourceAuthSessionPatch({
3184 sessionDataJson: mergeProviderOauthBatchSourceAuthSessionData({
3185 session,
3186 patch: {
3187 codeVerifier: null,
3188 authorizationUrl: null,
3189 },
3190 }),
3191 status: "completed",
3192 now: Date.now(),
3193 errorText: null,
3194 }),
3195 );
3196
3197 return {
3198 sessionId: session.id,
3199 sources: connectedSources,
3200 } satisfies CompleteProviderOauthCallbackResult;
3201 })),
3202
1984 completeSourceCredentialSetup: ({ workspaceId, sourceId, actorAccountId, state, code, error, errorDescription }) =>3203 completeSourceCredentialSetup: ({ workspaceId, sourceId, actorAccountId, state, code, error, errorDescription }) =>
1985 provideLocalWorkspace(Effect.gen(function* () {3204 provideLocalWorkspace(Effect.gen(function* () {
1986 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);3205 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
@@ -2012,7 +3231,10 @@ const createRuntimeSourceOAuthSessionService = ( · 25 unmodified lines
2012 });3231 });
2013 3232
2014 if (session.status === "completed") {3233 if (session.status === "completed") {
2015 return source;3234 return {
3235 sessionId: session.id,
3236 source,
3237 } satisfies CompleteSourceCredentialSetupResult;
2016 }3238 }
2017 3239
2018 if (session.status !== "pending") {3240 if (session.status !== "pending") {
@@ -2186,13 +3408,23 @@ const createRuntimeSourceOAuthSessionService = ( · 167 unmodified lines
2186 actorAccountId: session.actorAccountId,3408 actorAccountId: session.actorAccountId,
2187 status: "connected",3409 status: "connected",
2188 lastError: null,3410 lastError: null,
2189 auth: {3411 auth: createPersistedMcpOAuthSourceAuth({
2190 kind: "oauth2",3412 redirectUri: mcpSessionData.redirectUri,
2191 headerName: "Authorization",
2192 prefix: "Bearer ",
2193 accessToken: accessTokenRef,3413 accessToken: accessTokenRef,
2194 refreshToken: refreshTokenRef,3414 refreshToken: refreshTokenRef,
2195 },3415 tokenType: exchanged.tokens.token_type,
3416 expiresIn:
3417 typeof exchanged.tokens.expires_in === "number"
3418 && Number.isFinite(exchanged.tokens.expires_in)
3419 ? exchanged.tokens.expires_in
3420 : null,
3421 scope: exchanged.tokens.scope ?? null,
3422 resourceMetadataUrl: exchanged.resourceMetadataUrl,
3423 authorizationServerUrl: exchanged.authorizationServerUrl,
3424 resourceMetadata: exchanged.resourceMetadata,
3425 authorizationServerMetadata: exchanged.authorizationServerMetadata,
3426 clientInformation: exchanged.clientInformation,
3427 }),
2196 });3428 });
2197 3429
2198 yield* input.rows.sourceAuthSessions.update(3430 yield* input.rows.sourceAuthSessions.update(
@@ -2242,7 +3474,10 @@ const createRuntimeSourceOAuthSessionService = ( · 43 unmodified lines
2242 },3474 },
2243 });3475 });
2244 3476
2245 return connectedSource;3477 return {
3478 sessionId: session.id,
3479 source: connectedSource,
3480 } satisfies CompleteSourceCredentialSetupResult;
2246 })),3481 })),
2247});3482});
2248 3483
packages/control-plane/src/runtime/source-definitions.ts+77−4
@@ -22,6 +22,7 @@ import type { · 21 unmodified lines
22} from "#schema";22} from "#schema";
23import {23import {
24 AuthArtifactIdSchema,24 AuthArtifactIdSchema,
25 ProviderAuthGrantIdSchema,
25 SourceCatalogIdSchema,26 SourceCatalogIdSchema,
26 SourceCatalogRevisionIdSchema,27 SourceCatalogRevisionIdSchema,
27} from "#schema";28} from "#schema";
@@ -90,10 +91,9 @@ const normalizeAuth = ( · 62 unmodified lines
90 return { kind: "none" } satisfies SourceAuth;91 return { kind: "none" } satisfies SourceAuth;
91 }92 }
92 93
93 const headerName = trimOrNull(auth.headerName) ?? "Authorization";94 if (auth.kind === "bearer") {
94 const prefix = auth.prefix ?? "Bearer ";95 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
95 96 const prefix = auth.prefix ?? "Bearer ";
96 if (auth.kind === "bearer") {
97 const providerId = trimOrNull(auth.token.providerId);97 const providerId = trimOrNull(auth.token.providerId);
98 const handle = trimOrNull(auth.token.handle);98 const handle = trimOrNull(auth.token.handle);
99 if (providerId === null || handle === null) {99 if (providerId === null || handle === null) {
@@ -112,6 +112,8 @@ const normalizeAuth = ( · 12 unmodified lines
112 }112 }
113 113
114 if (auth.kind === "oauth2_authorized_user") {114 if (auth.kind === "oauth2_authorized_user") {
115 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
116 const prefix = auth.prefix ?? "Bearer ";
115 const refreshProviderId = trimOrNull(auth.refreshToken.providerId);117 const refreshProviderId = trimOrNull(auth.refreshToken.providerId);
116 const refreshHandle = trimOrNull(auth.refreshToken.handle);118 const refreshHandle = trimOrNull(auth.refreshToken.handle);
117 if (refreshProviderId === null || refreshHandle === null) {119 if (refreshProviderId === null || refreshHandle === null) {
@@ -159,6 +161,77 @@ const normalizeAuth = ( · 41 unmodified lines
159 } satisfies SourceAuth;161 } satisfies SourceAuth;
160 }162 }
161 163
164 if (auth.kind === "provider_grant_ref") {
165 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
166 const prefix = auth.prefix ?? "Bearer ";
167 const grantId = trimOrNull(auth.grantId);
168 if (grantId === null) {
169 return yield* Effect.fail(
170 new Error("Provider grant auth requires a grantId"),
171 );
172 }
173
174 return {
175 kind: "provider_grant_ref",
176 grantId: ProviderAuthGrantIdSchema.make(grantId),
177 providerKey: trimOrNull(auth.providerKey) ?? "",
178 requiredScopes: auth.requiredScopes
179 .map((scope) => scope.trim())
180 .filter((scope) => scope.length > 0),
181 headerName,
182 prefix,
183 } satisfies SourceAuth;
184 }
185
186 if (auth.kind === "mcp_oauth") {
187 const redirectUri = trimOrNull(auth.redirectUri);
188 const accessProviderId = trimOrNull(auth.accessToken.providerId);
189 const accessHandle = trimOrNull(auth.accessToken.handle);
190 if (redirectUri === null || accessProviderId === null || accessHandle === null) {
191 return yield* Effect.fail(
192 new Error("MCP OAuth auth requires redirectUri and access token secret ref"),
193 );
194 }
195
196 let refreshToken: { providerId: string; handle: string } | null = null;
197 if (auth.refreshToken !== null) {
198 const refreshProviderId = trimOrNull(auth.refreshToken.providerId);
199 const refreshHandle = trimOrNull(auth.refreshToken.handle);
200 if (refreshProviderId === null || refreshHandle === null) {
201 return yield* Effect.fail(
202 new Error("MCP OAuth refresh token ref must include providerId and handle"),
203 );
204 }
205
206 refreshToken = {
207 providerId: refreshProviderId,
208 handle: refreshHandle,
209 };
210 }
211
212 const tokenType = trimOrNull(auth.tokenType) ?? "Bearer";
213
214 return {
215 kind: "mcp_oauth",
216 redirectUri,
217 accessToken: {
218 providerId: accessProviderId,
219 handle: accessHandle,
220 },
221 refreshToken,
222 tokenType,
223 expiresIn: auth.expiresIn ?? null,
224 scope: trimOrNull(auth.scope),
225 resourceMetadataUrl: trimOrNull(auth.resourceMetadataUrl),
226 authorizationServerUrl: trimOrNull(auth.authorizationServerUrl),
227 resourceMetadataJson: trimOrNull(auth.resourceMetadataJson),
228 authorizationServerMetadataJson: trimOrNull(auth.authorizationServerMetadataJson),
229 clientInformationJson: trimOrNull(auth.clientInformationJson),
230 } satisfies SourceAuth;
231 }
232
233 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
234 const prefix = auth.prefix ?? "Bearer ";
162 const accessProviderId = trimOrNull(auth.accessToken.providerId);235 const accessProviderId = trimOrNull(auth.accessToken.providerId);
163 const accessHandle = trimOrNull(auth.accessToken.handle);236 const accessHandle = trimOrNull(auth.accessToken.handle);
164 if (accessProviderId === null || accessHandle === null) {237 if (accessProviderId === null || accessHandle === null) {
packages/control-plane/src/runtime/source-store.ts+59−1
@@ -3,12 +3,16 @@ import type { · 2 unmodified lines
3 AuthArtifact,3 AuthArtifact,
4 CredentialSlot,4 CredentialSlot,
5 LocalConfigSecretInput,5 LocalConfigSecretInput,
6 ProviderAuthGrant,
6 LocalConfigSource,7 LocalConfigSource,
7 Source,8 Source,
8 SourceId,9 SourceId,
9 WorkspaceId,10 WorkspaceId,
10} from "#schema";11} from "#schema";
11import { SourceIdSchema } from "#schema";12import {
13 decodeProviderGrantRefAuthArtifactConfig,
14 SourceIdSchema,
15} from "#schema";
12import * as Context from "effect/Context";16import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";17import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
@@ -60,6 +64,10 @@ import { · 45 unmodified lines
60import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";64import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
61import { authArtifactSecretMaterialRefs } from "./auth-artifacts";65import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
62import { removeAuthLeaseAndSecrets } from "./auth-leases";66import { removeAuthLeaseAndSecrets } from "./auth-leases";
67import {
68 clearProviderGrantOrphanedAt,
69 markProviderGrantOrphanedIfUnused,
70} from "./provider-grant-lifecycle";
63import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";71import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
64import { getSourceAdapter } from "./source-adapters";72import { getSourceAdapter } from "./source-adapters";
65import { refreshWorkspaceSourceTypeDeclarationsInBackground } from "./source-type-declarations";73import { refreshWorkspaceSourceTypeDeclarationsInBackground } from "./source-type-declarations";
@@ -92,6 +100,15 @@ const cleanupAuthArtifactSecretRefs = (rows: ControlPlaneStoreShape, input: { · 26 unmodified lines
92 );100 );
93 });101 });
94 102
103const providerGrantIdsFromArtifacts = (
104 artifacts: ReadonlyArray<Pick<AuthArtifact, "artifactKind" | "configJson"> | null>,
105): ReadonlySet<ProviderAuthGrant["id"]> =>
106 new Set(
107 artifacts
108 .flatMap((artifact) => artifact ? [decodeProviderGrantRefAuthArtifactConfig(artifact)] : [])
109 .flatMap((config) => config ? [config.grantId] : []),
110 );
111
95const selectPreferredAuthArtifact = (input: {112const selectPreferredAuthArtifact = (input: {
96 authArtifacts: ReadonlyArray<AuthArtifact>;113 authArtifacts: ReadonlyArray<AuthArtifact>;
97 actorAccountId?: AccountId | null;114 actorAccountId?: AccountId | null;
@@ -804,6 +821,11 @@ const removeSourceByIdWithDeps = (deps: RuntimeSourceStoreDeps, input: { · 706 unmodified lines
804 context: localWorkspace.context,821 context: localWorkspace.context,
805 sourceId: input.sourceId,822 sourceId: input.sourceId,
806 });823 });
824 const existingAuthArtifacts = yield* deps.rows.authArtifacts.listByWorkspaceAndSourceId({
825 workspaceId: input.workspaceId,
826 sourceId: input.sourceId,
827 });
828 const removedGrantIds = providerGrantIdsFromArtifacts(existingAuthArtifacts);
807 829
808 yield* deps.rows.sourceAuthSessions.removeByWorkspaceAndSourceId(830 yield* deps.rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
809 input.workspaceId,831 input.workspaceId,
@@ -814,6 +836,15 @@ const removeSourceByIdWithDeps = (deps: RuntimeSourceStoreDeps, input: { · 4 unmodified lines
814 sourceId: input.sourceId,836 sourceId: input.sourceId,
815 });837 });
816 yield* removeAuthArtifactsForSource(deps.rows, input);838 yield* removeAuthArtifactsForSource(deps.rows, input);
839 yield* Effect.forEach(
840 [...removedGrantIds],
841 (grantId) =>
842 markProviderGrantOrphanedIfUnused(deps.rows, {
843 workspaceId: input.workspaceId,
844 grantId,
845 }),
846 { discard: true },
847 );
817 yield* syncWorkspaceSourceTypeDeclarationsWithDeps(deps, input.workspaceId);848 yield* syncWorkspaceSourceTypeDeclarationsWithDeps(deps, input.workspaceId);
818 849
819 return true;850 return true;
@@ -947,6 +978,33 @@ const persistSourceWithDeps = ( · 127 unmodified lines
947 next: importAuthArtifact,978 next: importAuthArtifact,
948 });979 });
949 980
981 const previousGrantIds = providerGrantIdsFromArtifacts([
982 existingRuntimeAuthArtifact,
983 existingImportAuthArtifact,
984 ]);
985 const nextGrantIds = providerGrantIdsFromArtifacts([
986 runtimeAuthArtifact,
987 importAuthArtifact,
988 ]);
989
990 yield* Effect.forEach(
991 [...nextGrantIds],
992 (grantId) =>
993 clearProviderGrantOrphanedAt(deps.rows, {
994 grantId,
995 }),
996 { discard: true },
997 );
998 yield* Effect.forEach(
999 [...previousGrantIds].filter((grantId) => !nextGrantIds.has(grantId)),
1000 (grantId) =>
1001 markProviderGrantOrphanedIfUnused(deps.rows, {
1002 workspaceId: nextSource.workspaceId,
1003 grantId,
1004 }),
1005 { discard: true },
1006 );
1007
950 const existingSourceState = localWorkspace.workspaceState.sources[nextSource.id];1008 const existingSourceState = localWorkspace.workspaceState.sources[nextSource.id];
951 const workspaceState: LocalWorkspaceState = {1009 const workspaceState: LocalWorkspaceState = {
952 ...localWorkspace.workspaceState,1010 ...localWorkspace.workspaceState,
packages/control-plane/src/runtime/store.ts+28−0
@@ -7,8 +7,10 @@ import type { · 6 unmodified lines
7 Execution,7 Execution,
8 ExecutionInteraction,8 ExecutionInteraction,
9 ExecutionStep,9 ExecutionStep,
10 ProviderAuthGrant,
10 SecretMaterial,11 SecretMaterial,
11 SourceAuthSession,12 SourceAuthSession,
13 WorkspaceOauthClient,
12 WorkspaceSourceOauthClient,14 WorkspaceSourceOauthClient,
13} from "#schema";15} from "#schema";
14 16
@@ -76,6 +78,32 @@ export type ControlPlaneStoreShape = { · 61 unmodified lines
76 sourceId: WorkspaceSourceOauthClient["sourceId"];78 sourceId: WorkspaceSourceOauthClient["sourceId"];
77 }) => Effect.Effect<number, Error, never>;79 }) => Effect.Effect<number, Error, never>;
78 };80 };
81 workspaceOauthClients: {
82 listByWorkspaceAndProvider: (input: {
83 workspaceId: WorkspaceOauthClient["workspaceId"];
84 providerKey: string;
85 }) => Effect.Effect<readonly WorkspaceOauthClient[], Error, never>;
86 getById: (
87 id: WorkspaceOauthClient["id"],
88 ) => Effect.Effect<import("effect/Option").Option<WorkspaceOauthClient>, Error, never>;
89 upsert: (oauthClient: WorkspaceOauthClient) => Effect.Effect<void, Error, never>;
90 removeById: (id: WorkspaceOauthClient["id"]) => Effect.Effect<boolean, Error, never>;
91 };
92 providerAuthGrants: {
93 listByWorkspaceId: (
94 workspaceId: ProviderAuthGrant["workspaceId"],
95 ) => Effect.Effect<readonly ProviderAuthGrant[], Error, never>;
96 listByWorkspaceActorAndProvider: (input: {
97 workspaceId: ProviderAuthGrant["workspaceId"];
98 actorAccountId: ProviderAuthGrant["actorAccountId"];
99 providerKey: string;
100 }) => Effect.Effect<readonly ProviderAuthGrant[], Error, never>;
101 getById: (
102 id: ProviderAuthGrant["id"],
103 ) => Effect.Effect<import("effect/Option").Option<ProviderAuthGrant>, Error, never>;
104 upsert: (grant: ProviderAuthGrant) => Effect.Effect<void, Error, never>;
105 removeById: (id: ProviderAuthGrant["id"]) => Effect.Effect<boolean, Error, never>;
106 };
79 sourceAuthSessions: {107 sourceAuthSessions: {
80 listAll: () => Effect.Effect<readonly SourceAuthSession[], Error, never>;108 listAll: () => Effect.Effect<readonly SourceAuthSession[], Error, never>;
81 listByWorkspaceId: (109 listByWorkspaceId: (
packages/control-plane/src/schema/ids.ts+8−0
@@ -20,6 +20,12 @@ export const CredentialIdSchema = AuthArtifactIdSchema; · 19 unmodified lines
20export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(20export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(
21 Schema.brand("WorkspaceSourceOauthClientId"),21 Schema.brand("WorkspaceSourceOauthClientId"),
22);22);
23export const WorkspaceOauthClientIdSchema = Schema.String.pipe(
24 Schema.brand("WorkspaceOauthClientId"),
25);
26export const ProviderAuthGrantIdSchema = Schema.String.pipe(
27 Schema.brand("ProviderAuthGrantId"),
28);
23export const SecretMaterialIdSchema = Schema.String.pipe(29export const SecretMaterialIdSchema = Schema.String.pipe(
24 Schema.brand("SecretMaterialId"),30 Schema.brand("SecretMaterialId"),
25);31);
@@ -42,6 +48,8 @@ export type AuthArtifactId = typeof AuthArtifactIdSchema.Type; · 16 unmodified lines
42export type AuthLeaseId = typeof AuthLeaseIdSchema.Type;48export type AuthLeaseId = typeof AuthLeaseIdSchema.Type;
43export type CredentialId = typeof CredentialIdSchema.Type;49export type CredentialId = typeof CredentialIdSchema.Type;
44export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;50export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
51export type WorkspaceOauthClientId = typeof WorkspaceOauthClientIdSchema.Type;
52export type ProviderAuthGrantId = typeof ProviderAuthGrantIdSchema.Type;
45export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;53export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
46export type PolicyId = typeof PolicyIdSchema.Type;54export type PolicyId = typeof PolicyIdSchema.Type;
47export type ExecutionId = typeof ExecutionIdSchema.Type;55export type ExecutionId = typeof ExecutionIdSchema.Type;
packages/control-plane/src/schema/index.ts+2−0
@@ -8,6 +8,8 @@ export * from "./models/source-catalog"; · 7 unmodified lines
8export * from "./models/auth-artifact";8export * from "./models/auth-artifact";
9export * from "./models/auth-lease";9export * from "./models/auth-lease";
10export * from "./models/source-oauth-client";10export * from "./models/source-oauth-client";
11export * from "./models/workspace-oauth-client";
12export * from "./models/provider-auth-grant";
11export * from "./models/secret-material";13export * from "./models/secret-material";
12export * from "./models/source-auth-session";14export * from "./models/source-auth-session";
13export * from "./models/policy";15export * from "./models/policy";
packages/control-plane/src/schema/models/auth-artifact.ts+90−19
@@ -4,6 +4,7 @@ import { TimestampMsSchema } from "../common"; · 3 unmodified lines
4import {4import {
5 AccountIdSchema,5 AccountIdSchema,
6 AuthArtifactIdSchema,6 AuthArtifactIdSchema,
7 ProviderAuthGrantIdSchema,
7 SourceIdSchema,8 SourceIdSchema,
8 WorkspaceIdSchema,9 WorkspaceIdSchema,
9} from "../ids";10} from "../ids";
@@ -85,6 +86,8 @@ export const StaticOAuth2AuthArtifactKind = "static_oauth2" as const; · 75 unmodified lines
85export const StaticPlacementsAuthArtifactKind = "static_placements" as const;86export const StaticPlacementsAuthArtifactKind = "static_placements" as const;
86export const RefreshableOAuth2AuthorizedUserAuthArtifactKind =87export const RefreshableOAuth2AuthorizedUserAuthArtifactKind =
87 "oauth2_authorized_user" as const;88 "oauth2_authorized_user" as const;
89export const ProviderGrantRefAuthArtifactKind = "provider_grant_ref" as const;
90export const McpOAuthAuthArtifactKind = "mcp_oauth" as const;
88 91
89export const OAuth2ClientAuthenticationMethodSchema = Schema.Literal(92export const OAuth2ClientAuthenticationMethodSchema = Schema.Literal(
90 "none",93 "none",
@@ -125,6 +128,28 @@ export const RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema = Schema.St · 34 unmodified lines
125 refreshToken: SecretRefSchema,128 refreshToken: SecretRefSchema,
126});129});
127 130
131export const ProviderGrantRefAuthArtifactConfigSchema = Schema.Struct({
132 grantId: ProviderAuthGrantIdSchema,
133 providerKey: Schema.String,
134 requiredScopes: Schema.Array(Schema.String),
135 headerName: Schema.String,
136 prefix: Schema.String,
137});
138
139export const McpOAuthAuthArtifactConfigSchema = Schema.Struct({
140 redirectUri: Schema.String,
141 accessToken: SecretRefSchema,
142 refreshToken: Schema.NullOr(SecretRefSchema),
143 tokenType: Schema.String,
144 expiresIn: Schema.NullOr(Schema.Number),
145 scope: Schema.NullOr(Schema.String),
146 resourceMetadataUrl: Schema.NullOr(Schema.String),
147 authorizationServerUrl: Schema.NullOr(Schema.String),
148 resourceMetadataJson: Schema.NullOr(Schema.String),
149 authorizationServerMetadataJson: Schema.NullOr(Schema.String),
150 clientInformationJson: Schema.NullOr(Schema.String),
151});
152
128export const StaticBearerAuthArtifactConfigJsonSchema = Schema.parseJson(153export const StaticBearerAuthArtifactConfigJsonSchema = Schema.parseJson(
129 StaticBearerAuthArtifactConfigSchema,154 StaticBearerAuthArtifactConfigSchema,
130);155);
@@ -141,6 +166,14 @@ export const RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema = Schem · 10 unmodified lines
141 RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema,166 RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema,
142);167);
143 168
169export const ProviderGrantRefAuthArtifactConfigJsonSchema = Schema.parseJson(
170 ProviderGrantRefAuthArtifactConfigSchema,
171);
172
173export const McpOAuthAuthArtifactConfigJsonSchema = Schema.parseJson(
174 McpOAuthAuthArtifactConfigSchema,
175);
176
144export const RequestPlacementsJsonSchema = Schema.parseJson(177export const RequestPlacementsJsonSchema = Schema.parseJson(
145 Schema.Array(RequestPlacementSchema),178 Schema.Array(RequestPlacementSchema),
146);179);
@@ -162,6 +195,12 @@ const decodeStaticPlacementsAuthArtifactConfigOption = Schema.decodeUnknownOptio · 15 unmodified lines
162const decodeRefreshableOAuth2AuthorizedUserAuthArtifactConfigOption = Schema.decodeUnknownOption(195const decodeRefreshableOAuth2AuthorizedUserAuthArtifactConfigOption = Schema.decodeUnknownOption(
163 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,196 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
164);197);
198const decodeProviderGrantRefAuthArtifactConfigOption = Schema.decodeUnknownOption(
199 ProviderGrantRefAuthArtifactConfigJsonSchema,
200);
201const decodeMcpOAuthAuthArtifactConfigOption = Schema.decodeUnknownOption(
202 McpOAuthAuthArtifactConfigJsonSchema,
203);
165 204
166const decodeAuthGrantSetOption = Schema.decodeUnknownOption(AuthGrantSetJsonSchema);205const decodeAuthGrantSetOption = Schema.decodeUnknownOption(AuthGrantSetJsonSchema);
167 206
@@ -194,6 +233,9 @@ export type StaticOAuth2AuthArtifactConfig = typeof StaticOAuth2AuthArtifactConf · 26 unmodified lines
194export type StaticPlacementsAuthArtifactConfig = typeof StaticPlacementsAuthArtifactConfigSchema.Type;233export type StaticPlacementsAuthArtifactConfig = typeof StaticPlacementsAuthArtifactConfigSchema.Type;
195export type RefreshableOAuth2AuthorizedUserAuthArtifactConfig =234export type RefreshableOAuth2AuthorizedUserAuthArtifactConfig =
196 typeof RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema.Type;235 typeof RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema.Type;
236export type ProviderGrantRefAuthArtifactConfig =
237 typeof ProviderGrantRefAuthArtifactConfigSchema.Type;
238export type McpOAuthAuthArtifactConfig = typeof McpOAuthAuthArtifactConfigSchema.Type;
197export type AuthArtifact = typeof AuthArtifactSchema.Type;239export type AuthArtifact = typeof AuthArtifactSchema.Type;
198 240
199export type DecodedBuiltInAuthArtifactConfig =241export type DecodedBuiltInAuthArtifactConfig =
@@ -214,6 +256,28 @@ export type DecodedBuiltInAuthArtifactConfig = · 14 unmodified lines
214 config: RefreshableOAuth2AuthorizedUserAuthArtifactConfig;256 config: RefreshableOAuth2AuthorizedUserAuthArtifactConfig;
215 };257 };
216 258
259export const decodeProviderGrantRefAuthArtifactConfig = (
260 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
261): ProviderGrantRefAuthArtifactConfig | null => {
262 if (artifact.artifactKind !== ProviderGrantRefAuthArtifactKind) {
263 return null;
264 }
265
266 const decoded = decodeProviderGrantRefAuthArtifactConfigOption(artifact.configJson);
267 return decoded._tag === "Some" ? decoded.value : null;
268};
269
270export const decodeMcpOAuthAuthArtifactConfig = (
271 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
272): McpOAuthAuthArtifactConfig | null => {
273 if (artifact.artifactKind !== McpOAuthAuthArtifactKind) {
274 return null;
275 }
276
277 const decoded = decodeMcpOAuthAuthArtifactConfigOption(artifact.configJson);
278 return decoded._tag === "Some" ? decoded.value : null;
279};
280
217export const decodeBuiltInAuthArtifactConfig = (281export const decodeBuiltInAuthArtifactConfig = (
218 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,282 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
219): DecodedBuiltInAuthArtifactConfig | null => {283): DecodedBuiltInAuthArtifactConfig | null => {
@@ -273,28 +337,35 @@ export const authArtifactSecretRefs = ( · 53 unmodified lines
273 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,337 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
274): ReadonlyArray<SecretRef> => {338): ReadonlyArray<SecretRef> => {
275 const decoded = decodeBuiltInAuthArtifactConfig(artifact);339 const decoded = decodeBuiltInAuthArtifactConfig(artifact);
276 if (decoded === null) {340 if (decoded !== null) {
277 return [];341 switch (decoded.artifactKind) {
342 case StaticBearerAuthArtifactKind:
343 return [decoded.config.token];
344 case StaticOAuth2AuthArtifactKind:
345 return decoded.config.refreshToken
346 ? [decoded.config.accessToken, decoded.config.refreshToken]
347 : [decoded.config.accessToken];
348 case StaticPlacementsAuthArtifactKind:
349 return decoded.config.placements.flatMap((placement) =>
350 placement.parts.flatMap((part) =>
351 part.kind === "secret_ref" ? [part.ref] : [],
352 )
353 );
354 case RefreshableOAuth2AuthorizedUserAuthArtifactKind:
355 return decoded.config.clientSecret
356 ? [decoded.config.refreshToken, decoded.config.clientSecret]
357 : [decoded.config.refreshToken];
358 }
278 }359 }
279 360
280 switch (decoded.artifactKind) {361 const mcpOAuthConfig = decodeMcpOAuthAuthArtifactConfig(artifact);
281 case StaticBearerAuthArtifactKind:362 if (mcpOAuthConfig !== null) {
282 return [decoded.config.token];363 return mcpOAuthConfig.refreshToken
283 case StaticOAuth2AuthArtifactKind:364 ? [mcpOAuthConfig.accessToken, mcpOAuthConfig.refreshToken]
284 return decoded.config.refreshToken365 : [mcpOAuthConfig.accessToken];
285 ? [decoded.config.accessToken, decoded.config.refreshToken]
286 : [decoded.config.accessToken];
287 case StaticPlacementsAuthArtifactKind:
288 return decoded.config.placements.flatMap((placement) =>
289 placement.parts.flatMap((part) =>
290 part.kind === "secret_ref" ? [part.ref] : [],
291 )
292 );
293 case RefreshableOAuth2AuthorizedUserAuthArtifactKind:
294 return decoded.config.clientSecret
295 ? [decoded.config.refreshToken, decoded.config.clientSecret]
296 : [decoded.config.refreshToken];
297 }366 }
367
368 return [];
298};369};
299 370
300export const authArtifactSecretMaterialRefs = authArtifactSecretRefs;371export const authArtifactSecretMaterialRefs = authArtifactSecretRefs;
packages/control-plane/src/schema/models/provider-auth-grant.ts+34−0
@@ -0,0 +1,34 @@
1import { Schema } from "effect";
2
3import { TimestampMsSchema } from "../common";
4import {
5 AccountIdSchema,
6 ProviderAuthGrantIdSchema,
7 WorkspaceIdSchema,
8 WorkspaceOauthClientIdSchema,
9} from "../ids";
10
11import {
12 OAuth2ClientAuthenticationMethodSchema,
13 SecretRefSchema,
14} from "./auth-artifact";
15
16export const ProviderAuthGrantSchema = Schema.Struct({
17 id: ProviderAuthGrantIdSchema,
18 workspaceId: WorkspaceIdSchema,
19 actorAccountId: Schema.NullOr(AccountIdSchema),
20 providerKey: Schema.String,
21 oauthClientId: WorkspaceOauthClientIdSchema,
22 tokenEndpoint: Schema.String,
23 clientAuthentication: OAuth2ClientAuthenticationMethodSchema,
24 headerName: Schema.String,
25 prefix: Schema.String,
26 refreshToken: SecretRefSchema,
27 grantedScopes: Schema.Array(Schema.String),
28 lastRefreshedAt: Schema.NullOr(TimestampMsSchema),
29 orphanedAt: Schema.NullOr(TimestampMsSchema),
30 createdAt: TimestampMsSchema,
31 updatedAt: TimestampMsSchema,
32});
33
34export type ProviderAuthGrant = typeof ProviderAuthGrantSchema.Type;
packages/control-plane/src/schema/models/source-auth-session.ts+34−1
@@ -5,8 +5,9 @@ import { · 4 unmodified lines
5 AccountIdSchema,5 AccountIdSchema,
6 ExecutionIdSchema,6 ExecutionIdSchema,
7 ExecutionInteractionIdSchema,7 ExecutionInteractionIdSchema,
8 SourceAuthSessionIdSchema,
9 SourceIdSchema,8 SourceIdSchema,
9 SourceAuthSessionIdSchema,
10 WorkspaceOauthClientIdSchema,
10 WorkspaceIdSchema,11 WorkspaceIdSchema,
11} from "../ids";12} from "../ids";
12import {13import {
@@ -97,6 +98,35 @@ export const OAuth2PkceSourceAuthSessionDataJsonSchema = Schema.parseJson( · 84 unmodified lines
97 OAuth2PkceSourceAuthSessionDataSchema,98 OAuth2PkceSourceAuthSessionDataSchema,
98);99);
99 100
101export const ProviderOauthTargetSourceSchema = Schema.Struct({
102 sourceId: SourceIdSchema,
103 requiredScopes: Schema.Array(Schema.String),
104});
105
106export const ProviderOauthBatchSourceAuthSessionDataSchema = Schema.Struct({
107 kind: Schema.Literal("provider_oauth_batch"),
108 providerKey: Schema.String,
109 authorizationEndpoint: Schema.String,
110 tokenEndpoint: Schema.String,
111 redirectUri: Schema.String,
112 oauthClientId: WorkspaceOauthClientIdSchema,
113 clientAuthentication: OAuth2ClientAuthenticationMethodSchema,
114 scopes: Schema.Array(Schema.String),
115 headerName: Schema.String,
116 prefix: Schema.String,
117 authorizationParams: Schema.Record({
118 key: Schema.String,
119 value: Schema.String,
120 }),
121 targetSources: Schema.Array(ProviderOauthTargetSourceSchema),
122 codeVerifier: Schema.NullOr(Schema.String),
123 authorizationUrl: Schema.NullOr(Schema.String),
124});
125
126export const ProviderOauthBatchSourceAuthSessionDataJsonSchema = Schema.parseJson(
127 ProviderOauthBatchSourceAuthSessionDataSchema,
128);
129
100export const SourceAuthSessionSchema = Schema.Struct({130export const SourceAuthSessionSchema = Schema.Struct({
101 id: SourceAuthSessionIdSchema,131 id: SourceAuthSessionIdSchema,
102 workspaceId: WorkspaceIdSchema,132 workspaceId: WorkspaceIdSchema,
@@ -120,4 +150,7 @@ export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; · 17 unmodified lines
120export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type;150export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type;
121export type OAuth2PkceSourceAuthSessionData =151export type OAuth2PkceSourceAuthSessionData =
122 typeof OAuth2PkceSourceAuthSessionDataSchema.Type;152 typeof OAuth2PkceSourceAuthSessionDataSchema.Type;
153export type ProviderOauthTargetSource = typeof ProviderOauthTargetSourceSchema.Type;
154export type ProviderOauthBatchSourceAuthSessionData =
155 typeof ProviderOauthBatchSourceAuthSessionDataSchema.Type;
123export type SourceAuthSession = typeof SourceAuthSessionSchema.Type;156export type SourceAuthSession = typeof SourceAuthSessionSchema.Type;
packages/control-plane/src/schema/models/source.ts+23−0
@@ -2,6 +2,7 @@ import { Schema } from "effect"; · 1 unmodified line
2 2
3import { TimestampMsSchema } from "../common";3import { TimestampMsSchema } from "../common";
4import {4import {
5 ProviderAuthGrantIdSchema,
5 SourceIdSchema,6 SourceIdSchema,
6 SourceCatalogIdSchema,7 SourceCatalogIdSchema,
7 SourceCatalogRevisionIdSchema,8 SourceCatalogRevisionIdSchema,
@@ -60,6 +61,28 @@ export const SourceAuthSchema = Schema.Union( · 52 unmodified lines
60 refreshToken: SecretRefSchema,61 refreshToken: SecretRefSchema,
61 grantSet: Schema.NullOr(Schema.Array(Schema.String)),62 grantSet: Schema.NullOr(Schema.Array(Schema.String)),
62 }),63 }),
64 Schema.Struct({
65 kind: Schema.Literal("provider_grant_ref"),
66 grantId: ProviderAuthGrantIdSchema,
67 providerKey: Schema.String,
68 requiredScopes: Schema.Array(Schema.String),
69 headerName: Schema.String,
70 prefix: Schema.String,
71 }),
72 Schema.Struct({
73 kind: Schema.Literal("mcp_oauth"),
74 redirectUri: Schema.String,
75 accessToken: SecretRefSchema,
76 refreshToken: Schema.NullOr(SecretRefSchema),
77 tokenType: Schema.String,
78 expiresIn: Schema.NullOr(Schema.Number),
79 scope: Schema.NullOr(Schema.String),
80 resourceMetadataUrl: Schema.NullOr(Schema.String),
81 authorizationServerUrl: Schema.NullOr(Schema.String),
82 resourceMetadataJson: Schema.NullOr(Schema.String),
83 authorizationServerMetadataJson: Schema.NullOr(Schema.String),
84 clientInformationJson: Schema.NullOr(Schema.String),
85 }),
63);86);
64 87
65export const StringMapSchema = Schema.Record({88export const StringMapSchema = Schema.Record({
packages/control-plane/src/schema/models/workspace-oauth-client.ts+22−0
@@ -0,0 +1,22 @@
1import { Schema } from "effect";
2
3import { TimestampMsSchema } from "../common";
4import {
5 WorkspaceIdSchema,
6 WorkspaceOauthClientIdSchema,
7} from "../ids";
8
9export const WorkspaceOauthClientSchema = Schema.Struct({
10 id: WorkspaceOauthClientIdSchema,
11 workspaceId: WorkspaceIdSchema,
12 providerKey: Schema.String,
13 label: Schema.NullOr(Schema.String),
14 clientId: Schema.String,
15 clientSecretProviderId: Schema.NullOr(Schema.String),
16 clientSecretHandle: Schema.NullOr(Schema.String),
17 clientMetadataJson: Schema.NullOr(Schema.String),
18 createdAt: TimestampMsSchema,
19 updatedAt: TimestampMsSchema,
20});
21
22export type WorkspaceOauthClient = typeof WorkspaceOauthClientSchema.Type;
packages/react/src/index.ts+202−0
@@ -4,12 +4,15 @@ import { RegistryContext, RegistryProvider, useAtomValue } from "@effect-atom/at · 3 unmodified lines
4import { createControlPlaneClient } from "@executor/control-plane/client";4import { createControlPlaneClient } from "@executor/control-plane/client";
5import type {5import type {
6 CompleteSourceOAuthResult,6 CompleteSourceOAuthResult,
7 ConnectSourceBatchPayload,
8 ConnectSourceBatchResult,
7 ConnectSourcePayload,9 ConnectSourcePayload,
8 ConnectSourceResult,10 ConnectSourceResult,
9 ControlPlaneClient,11 ControlPlaneClient,
10 CreateSecretPayload,12 CreateSecretPayload,
11 CreateSecretResult,13 CreateSecretResult,
12 CreateSourcePayload,14 CreateSourcePayload,
15 CreateWorkspaceOauthClientPayload,
13 DeleteSecretResult,16 DeleteSecretResult,
14 DiscoverSourcePayload,17 DiscoverSourcePayload,
15 InstanceConfig,18 InstanceConfig,
@@ -25,6 +28,7 @@ import type { · 9 unmodified lines
25 UpdateSecretPayload,28 UpdateSecretPayload,
26 UpdateSecretResult,29 UpdateSecretResult,
27 UpdateSourcePayload,30 UpdateSourcePayload,
31 WorkspaceOauthClient,
28} from "@executor/control-plane";32} from "@executor/control-plane";
29import * as Cause from "effect/Cause";33import * as Cause from "effect/Cause";
30import * as Effect from "effect/Effect";34import * as Effect from "effect/Effect";
@@ -66,6 +70,12 @@ type SourceDiscoveryKeyParts = readonly [ · 35 unmodified lines
66 string,70 string,
67 number | null,71 number | null,
68];72];
73type WorkspaceOauthClientsKeyParts = readonly [
74 boolean,
75 Source["workspaceId"],
76 string,
77 string,
78];
69 79
70type InvalidationTarget = {80type InvalidationTarget = {
71 workspaceId?: Source["workspaceId"];81 workspaceId?: Source["workspaceId"];
@@ -76,6 +86,7 @@ type InvalidationTarget = { · 4 unmodified lines
76type ActiveQueryCollections = {86type ActiveQueryCollections = {
77 sourceLists: Set<string>;87 sourceLists: Set<string>;
78 sources: Set<string>;88 sources: Set<string>;
89 workspaceOauthClients: Set<string>;
79 inspections: Set<string>;90 inspections: Set<string>;
80 toolDetails: Set<string>;91 toolDetails: Set<string>;
81 discoveries: Set<string>;92 discoveries: Set<string>;
@@ -163,6 +174,16 @@ const encodeDiscoveryKey = ( · 81 unmodified lines
163): string =>174): string =>
164 encodeAtomKey([enabled, workspaceId, accountId, sourceId, query, limit] satisfies SourceDiscoveryKeyParts);175 encodeAtomKey([enabled, workspaceId, accountId, sourceId, query, limit] satisfies SourceDiscoveryKeyParts);
165 176
177const encodeWorkspaceOauthClientsKey = (
178 enabled: boolean,
179 workspaceId: Source["workspaceId"],
180 accountId: string,
181 providerKey: string,
182): string =>
183 encodeAtomKey(
184 [enabled, workspaceId, accountId, providerKey] satisfies WorkspaceOauthClientsKeyParts,
185 );
186
166const causeMessage = (cause: Cause.Cause<unknown>): Error =>187const causeMessage = (cause: Cause.Cause<unknown>): Error =>
167 new Error(Cause.pretty(cause));188 new Error(Cause.pretty(cause));
168 189
@@ -377,6 +398,26 @@ const sourceDiscoveryAtom = Atom.family((key: string) => { · 208 unmodified lines
377 ).pipe(Atom.keepAlive);398 ).pipe(Atom.keepAlive);
378});399});
379 400
401const workspaceOauthClientsAtom = Atom.family((key: string) => {
402 const [enabled, workspaceId, accountId, providerKey] = decodeAtomKey<WorkspaceOauthClientsKeyParts>(key);
403
404 return Atom.make(
405 enabled
406 ? controlPlaneRequest({
407 accountId,
408 execute: (client) => client.sources.listWorkspaceOauthClients({
409 path: {
410 workspaceId,
411 },
412 urlParams: {
413 providerKey,
414 },
415 }),
416 })
417 : Effect.never,
418 ).pipe(Atom.keepAlive);
419});
420
380export type Loadable<T> =421export type Loadable<T> =
381 | { status: "loading" }422 | { status: "loading" }
382 | { status: "error"; error: Error }423 | { status: "error"; error: Error }
@@ -506,6 +547,7 @@ const setCachedAtomValue = <A>( · 123 unmodified lines
506const createActiveQueryCollections = (): ActiveQueryCollections => ({547const createActiveQueryCollections = (): ActiveQueryCollections => ({
507 sourceLists: new Set(),548 sourceLists: new Set(),
508 sources: new Set(),549 sources: new Set(),
550 workspaceOauthClients: new Set(),
509 inspections: new Set(),551 inspections: new Set(),
510 toolDetails: new Set(),552 toolDetails: new Set(),
511 discoveries: new Set(),553 discoveries: new Set(),
@@ -548,6 +590,13 @@ const invalidateTrackedQueries = ( · 36 unmodified lines
548 }590 }
549 });591 });
550 592
593 activeQueries.workspaceOauthClients.forEach((key) => {
594 const [enabled, workspaceId, accountId] = decodeAtomKey<WorkspaceOauthClientsKeyParts>(key);
595 if (enabled && targetMatches(target, workspaceId, accountId)) {
596 registry.refresh(workspaceOauthClientsAtom(key));
597 }
598 });
599
551 activeQueries.inspections.forEach((key) => {600 activeQueries.inspections.forEach((key) => {
552 const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key);601 const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key);
553 if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {602 if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
@@ -905,6 +954,37 @@ export const useSource = (sourceId: string): Loadable<Source> => { · 351 unmodified lines
905 return workspace.enabled ? source : pendingLoadable(workspace.workspace);954 return workspace.enabled ? source : pendingLoadable(workspace.workspace);
906};955};
907 956
957export const useWorkspaceOauthClients = (
958 providerKey: string | null,
959): Loadable<ReadonlyArray<WorkspaceOauthClient>> => {
960 const workspace = useWorkspaceRequestContext();
961 const key = encodeWorkspaceOauthClientsKey(
962 workspace.enabled && providerKey !== null,
963 workspace.workspaceId,
964 workspace.accountId,
965 providerKey ?? "",
966 );
967 useTrackActiveKey(
968 "workspaceOauthClients",
969 key,
970 workspace.enabled && providerKey !== null,
971 );
972 const oauthClients = useLoadableAtom(workspaceOauthClientsAtom(key));
973
974 if (!workspace.enabled) {
975 return pendingLoadable(workspace.workspace);
976 }
977
978 if (providerKey === null) {
979 return {
980 status: "ready",
981 data: [],
982 };
983 }
984
985 return oauthClients;
986};
987
908export const useSourceInspection = (sourceId: string): Loadable<SourceInspection> => {988export const useSourceInspection = (sourceId: string): Loadable<SourceInspection> => {
909 const workspace = useWorkspaceRequestContext();989 const workspace = useWorkspaceRequestContext();
910 const requestedSourceId = workspace.enabled990 const requestedSourceId = workspace.enabled
@@ -1209,13 +1289,134 @@ export const useConnectSource = () => · 298 unmodified lines
1209 },1289 },
1210 );1290 );
1211 1291
1292export const useConnectSourceBatch = () =>
1293 useSourceMutation<ConnectSourceBatchPayload, ConnectSourceBatchResult>(
1294 React.useCallback(
1295 ({ workspaceId, accountId, payload }) =>
1296 runControlPlane({
1297 accountId,
1298 execute: (client) => client.sources.connectBatch({
1299 path: {
1300 workspaceId,
1301 },
1302 payload,
1303 } as any),
1304 }),
1305 [],
1306 ),
1307 {
1308 onSuccess: (context, _payload, result) => {
1309 const listAtom = sourcesAtom(encodeSourcesKey(true, context.workspaceId, context.accountId));
1310 const currentList = getCachedAtomValue(context.registry, listAtom);
1311 if (currentList !== undefined) {
1312 let nextList = currentList;
1313 for (const entry of result.results) {
1314 nextList = upsertSourceInList(nextList, entry.source);
1315 setCachedAtomValue(
1316 context.registry,
1317 sourceAtom(encodeSourceKey(true, context.workspaceId, context.accountId, entry.source.id)),
1318 entry.source,
1319 );
1320 }
1321 setCachedAtomValue(context.registry, listAtom, nextList);
1322 }
1323
1324 context.invalidateQueries({
1325 workspaceId: context.workspaceId,
1326 accountId: context.accountId,
1327 });
1328 },
1329 },
1330 );
1331
1332export const useCreateWorkspaceOauthClient = () =>
1333 useSourceMutation<CreateWorkspaceOauthClientPayload, WorkspaceOauthClient>(
1334 React.useCallback(
1335 ({ workspaceId, accountId, payload }) =>
1336 runControlPlane({
1337 accountId,
1338 execute: (client) => client.sources.createWorkspaceOauthClient({
1339 path: {
1340 workspaceId,
1341 },
1342 payload,
1343 }),
1344 }),
1345 [],
1346 ),
1347 {
1348 onSuccess: (context) => {
1349 context.invalidateQueries({
1350 workspaceId: context.workspaceId,
1351 accountId: context.accountId,
1352 });
1353 },
1354 },
1355 );
1356
1357export const useRemoveWorkspaceOauthClient = () =>
1358 useSourceMutation<WorkspaceOauthClient["id"], { removed: boolean }>(
1359 React.useCallback(
1360 ({ workspaceId, accountId, payload }) =>
1361 runControlPlane({
1362 accountId,
1363 execute: (client) => client.sources.removeWorkspaceOauthClient({
1364 path: {
1365 workspaceId,
1366 oauthClientId: payload,
1367 },
1368 }),
1369 }),
1370 [],
1371 ),
1372 {
1373 onSuccess: (context) => {
1374 context.invalidateQueries({
1375 workspaceId: context.workspaceId,
1376 accountId: context.accountId,
1377 });
1378 },
1379 },
1380 );
1381
1382export const useRemoveProviderAuthGrant = () =>
1383 useSourceMutation<
1384 Extract<Source["auth"], { kind: "provider_grant_ref" }>["grantId"],
1385 { removed: boolean }
1386 >(
1387 React.useCallback(
1388 ({ workspaceId, accountId, payload }) =>
1389 runControlPlane({
1390 accountId,
1391 execute: (client) => client.sources.removeProviderAuthGrant({
1392 path: {
1393 workspaceId,
1394 grantId: payload,
1395 },
1396 }),
1397 }),
1398 [],
1399 ),
1400 {
1401 onSuccess: (context) => {
1402 context.invalidateQueries({
1403 workspaceId: context.workspaceId,
1404 accountId: context.accountId,
1405 });
1406 },
1407 },
1408 );
1409
1212export type {1410export type {
1213 CompleteSourceOAuthResult,1411 CompleteSourceOAuthResult,
1412 ConnectSourceBatchPayload,
1413 ConnectSourceBatchResult,
1214 ConnectSourcePayload,1414 ConnectSourcePayload,
1215 ConnectSourceResult,1415 ConnectSourceResult,
1216 CreateSecretPayload,1416 CreateSecretPayload,
1217 CreateSecretResult,1417 CreateSecretResult,
1218 CreateSourcePayload,1418 CreateSourcePayload,
1419 CreateWorkspaceOauthClientPayload,
1219 DeleteSecretResult,1420 DeleteSecretResult,
1220 DiscoverSourcePayload,1421 DiscoverSourcePayload,
1221 InstanceConfig,1422 InstanceConfig,
@@ -1232,4 +1433,5 @@ export type { · 10 unmodified lines
1232 UpdateSecretPayload,1433 UpdateSecretPayload,
1233 UpdateSecretResult,1434 UpdateSecretResult,
1234 UpdateSourcePayload,1435 UpdateSourcePayload,
1436 WorkspaceOauthClient,
1235};1437};
packages/server/src/index.ts+1−0
@@ -115,6 +115,7 @@ const createRuntime = ( · 114 unmodified lines
115 executionResolver: options.executionResolver,115 executionResolver: options.executionResolver,
116 resolveSecretMaterial: options.resolveSecretMaterial,116 resolveSecretMaterial: options.resolveSecretMaterial,
117 getLocalServerBaseUrl,117 getLocalServerBaseUrl,
118 localDataDir,
118 }).pipe(119 }).pipe(
119 Effect.mapError((cause) =>120 Effect.mapError((cause) =>
120 cause instanceof Error ? cause : new Error(String(cause)),121 cause instanceof Error ? cause : new Error(String(cause)),