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+
5+add multiple sources at same time
apps/executor/src/cli/main.ts+3−1
@@ -273,7 +273,9 @@ const buildRunWorkflowText = ( · 272 unmodified lines
273273
274274 const loadRunWorkflowText = (): Effect.Effect<string, Error, never> =>
275275 Effect.acquireUseRelease(
276− createControlPlaneRuntime({}).pipe(Effect.mapError(toError)),
276+ createControlPlaneRuntime({
277+ localDataDir: DEFAULT_LOCAL_DATA_DIR,
278+ }).pipe(Effect.mapError(toError)),
277279 (runtime) =>
278280 Effect.gen(function* () {
279281 const environment = yield* Effect.gen(function* () {
apps/executor/src/distribution/artifact.ts+0−21
@@ -90,25 +90,6 @@ const resolveQuickJsWasmPath = (): string => { · 89 unmodified lines
9090 return wasmPath;
9191 };
9292
93−const 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−
11293 const createPackageJson = (input: {
11394 packageName: string;
11495 packageVersion: string;
@@ -169,7 +150,6 @@ export const buildDistributionPackage = async ( · 54 unmodified lines
169150 const bundlePath = join(binDir, "executor.mjs");
170151 const launcherPath = join(binDir, "executor.js");
171152 const quickJsWasmPath = resolveQuickJsWasmPath();
172− const jsoncParserUmdImplPath = await resolveJsoncParserUmdImplPath();
173153 const webDistDir = join(repoRoot, "apps/web/dist");
174154 const readmePath = join(repoRoot, "README.md");
175155 const packageName = options.packageName ?? defaults.name;
@@ -205,7 +185,6 @@ export const buildDistributionPackage = async ( · 29 unmodified lines
205185
206186 await cp(webDistDir, webDir, { recursive: true });
207187 await cp(quickJsWasmPath, join(binDir, "emscripten-module.wasm"));
208− await cp(jsoncParserUmdImplPath, join(binDir, "impl"), { recursive: true });
209188 await mkdir(join(binDir, "openapi-extractor-wasm"), { recursive: true });
210189 await cp(
211190 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
1515 Effect.gen(function* () {
1616 const harness = yield* DistributionHarness;
1717
18+ yield* harness.writeProjectConfig(`{
19+ // local workspace config
20+ "sources": {},
21+}
22+`);
23+
1824 const initialDoctor = yield* runCommand([
1925 "doctor",
2026 "--json",
apps/executor/src/distribution/harness.ts+19−1
@@ -1,5 +1,5 @@
11 import { spawn } from "node:child_process";
2−import { mkdir, mkdtemp, rm } from "node:fs/promises";
2+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
33 import { createServer } from "node:net";
44 import { tmpdir } from "node:os";
55 import { dirname, join } from "node:path";
@@ -26,6 +26,9 @@ export class DistributionHarness extends Context.Tag( · 20 unmodified lines
2626 readonly tarballPath: string;
2727 readonly executorHome: string;
2828 readonly baseUrl: string;
29+ readonly writeProjectConfig: (
30+ contents: string,
31+ ) => Effect.Effect<void, Error, never>;
2932 readonly run: (
3033 args: ReadonlyArray<string>,
3134 options?: {
@@ -185,6 +188,8 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 153 unmodified lines
185188 const prefixDir = join(tempRoot, "prefix");
186189 const homeDir = join(tempRoot, "home");
187190 const executorHome = join(homeDir, ".executor");
191+ const stagedWorkspaceRoot = packageDir;
192+ const installedWorkspaceRoot = tempRoot;
188193 const baseUrl = `http://127.0.0.1:${yield* allocatePort()}`;
189194
190195 yield* Effect.tryPromise({
@@ -256,12 +261,25 @@ export const LocalDistributionHarnessLive = Layer.scoped( · 65 unmodified lines
256261 catch: (cause) => cause instanceof Error ? cause : new Error(String(cause)),
257262 });
258263
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+
259276 return DistributionHarness.of({
260277 packageDir,
261278 launcherPath: artifact.launcherPath,
262279 tarballPath,
263280 executorHome,
264281 baseUrl,
282+ writeProjectConfig,
265283 run,
266284 runInstalled,
267285 fetchText,
apps/web/src/components/source-favicon.tsx+25−3
@@ -1,6 +1,6 @@
11 import { useMemo, useState } from "react";
22 import type { Source } from "@executor/react";
3−import { getSourceFaviconUrl } from "../lib/source-favicon";
3+import { getGoogleProductIconUrl, getSourceFaviconUrl } from "../lib/source-favicon";
44 import { cn } from "../lib/utils";
55
66 type SourceKind = Source["kind"] | string;
@@ -16,7 +16,14 @@ export function SourceFavicon({ · 9 unmodified lines
1616 className?: string;
1717 size?: number;
1818 }) {
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+
2027 const [failedUrl, setFailedUrl] = useState<string | null>(null);
2128 const isFailed = Boolean(faviconUrl && failedUrl === faviconUrl);
2229
@@ -31,7 +38,7 @@ export function SourceFavicon({ · 8 unmodified lines
3138 alt=""
3239 width={size}
3340 height={size}
34− className={cn("size-full rounded-full object-cover", className)}
41+ className={cn("size-full object-contain", className)}
3542 loading="lazy"
3643 referrerPolicy="no-referrer"
3744 onError={() => setFailedUrl(faviconUrl)}
@@ -70,6 +77,21 @@ export function DefaultSourceIcon({ · 32 unmodified lines
7077 <path d="M5 6h6M5 8h4M5 10h5" stroke="currentColor" strokeWidth="1.1" strokeLinecap="round" />
7178 </svg>
7279 );
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+ );
7395 default:
7496 return (
7597 <svg viewBox="0 0 16 16" fill="none" className={base}>
apps/web/src/globals.css+6−0
@@ -152,6 +152,12 @@ · 151 unmodified lines
152152 }
153153 }
154154
155+/* Indeterminate progress shimmer */
156+@keyframes indeterminate-progress {
157+ 0% { transform: translateX(-100%); }
158+ 100% { transform: translateX(400%); }
159+}
160+
155161 /* Streamdown code block overrides */
156162 [data-streamdown="code-block-body"] {
157163 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
77 "raw.github.com",
88 ]);
99
10+// ---------------------------------------------------------------------------
11+// Google product icon URLs (official CDN assets)
12+// ---------------------------------------------------------------------------
13+
14+const 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+ */
39+const 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+
1068 const parseUrl = (value: string | null | undefined): URL | null => {
1169 if (!value) {
1270 return null;
@@ -44,6 +102,8 @@ const normalizeSeedUrl = (value: string | null | undefined): string | null => { · 31 unmodified lines
44102 return parsed.origin;
45103 };
46104
105+export const getGoogleProductIconUrl = getGoogleServiceIconUrl;
106+
47107 export const getSourceFaviconUrl = (
48108 value: string | null | undefined,
49109 ): 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
22 import { Link, useNavigate } from "@tanstack/react-router";
33 import {
44 type CompleteSourceOAuthResult,
5+ type ConnectSourceBatchPayload,
6+ type ConnectSourceBatchResult,
57 type ConnectSourcePayload,
68 type ConnectSourceResult,
9+ type CreateWorkspaceOauthClientPayload,
710 type DiscoverSourcePayload,
811 type InstanceConfig,
912 type Loadable,
1013 type SecretListItem,
1114 type Source,
1215 type SourceDiscoveryResult,
16+ type WorkspaceOauthClient,
17+ useConnectSourceBatch,
1318 useConnectSource,
19+ useCreateWorkspaceOauthClient,
1420 useCreateSecret,
1521 useDiscoverSource,
22+ useInvalidateExecutorQueries,
1623 useInstanceConfig,
1724 useRefreshSecrets,
1825 useSecrets,
1926 useStartSourceOAuth,
27+ useWorkspaceOauthClients,
2028 } from "@executor/react";
2129 import { Badge } from "../components/ui/badge";
2230 import { Button } from "../components/ui/button";
@@ -74,6 +82,7 @@ type ConnectFormState = { · 51 unmodified lines
7482 bearerToken: string;
7583 bearerProviderId: string;
7684 bearerHandle: string;
85+ workspaceOauthClientId: string;
7786 oauthClientId: string;
7887 oauthClientSecret: string;
7988 queryParamsText: string;
@@ -86,6 +95,12 @@ type OAuthRequiredInfo = { · 6 unmodified lines
8695 authorizationUrl: string;
8796 };
8897
98+type BatchOAuthRequiredInfo = {
99+ sessionId: string;
100+ authorizationUrl: string;
101+ sourceIds: ReadonlyArray<string>;
102+};
103+
89104 // ---------------------------------------------------------------------------
90105 // Constants
91106 // ---------------------------------------------------------------------------
@@ -220,6 +235,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 128 unmodified lines
220235 bearerToken: "",
221236 bearerProviderId: "",
222237 bearerHandle: "",
238+ workspaceOauthClientId: "",
223239 oauthClientId: "",
224240 oauthClientSecret: "",
225241 queryParamsText: "",
@@ -272,6 +288,7 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 46 unmodified lines
272288 bearerToken: "",
273289 bearerProviderId: "",
274290 bearerHandle: "",
291+ workspaceOauthClientId: "",
275292 oauthClientId: "",
276293 oauthClientSecret: "",
277294 queryParamsText: "",
@@ -293,6 +310,7 @@ const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => · 15 unmodified lines
293310 : namespaceFromUrl(template.endpoint),
294311 transport: template.kind === "mcp" ? "auto" : "",
295312 authKind: template.kind === "google_discovery" ? "oauth2" : "none",
313+ workspaceOauthClientId: "",
296314 });
297315
298316 const authKindForSourceKind = (
@@ -366,8 +384,12 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 67 unmodified lines
366384 const version = form.version.trim();
367385 if (!service) throw new Error("Google Discovery sources require a service name.");
368386 if (!version) throw new Error("Google Discovery sources require a version.");
369− if (form.authKind === "oauth2" && form.oauthClientId.trim().length === 0) {
370− throw new Error("Google OAuth requires a client ID.");
387+ if (
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.");
371393 }
372394 return {
373395 kind: "google_discovery",
@@ -376,12 +398,17 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 2 unmodified lines
376398 discoveryUrl: trimToNull(form.discoveryUrl),
377399 name: trimToNull(form.name),
378400 namespace: trimToNull(form.namespace),
379− oauthClient: form.authKind === "oauth2"
380− ? {
381− clientId: form.oauthClientId.trim(),
382− clientSecret: trimToNull(form.oauthClientSecret),
383− }
384− : null,
401+ workspaceOauthClientId:
402+ form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length > 0
403+ ? form.workspaceOauthClientId.trim()
404+ : undefined,
405+ oauthClient:
406+ form.authKind === "oauth2" && form.workspaceOauthClientId.trim().length === 0
407+ ? {
408+ clientId: form.oauthClientId.trim(),
409+ clientSecret: trimToNull(form.oauthClientSecret),
410+ }
411+ : null,
385412 auth,
386413 };
387414 }
@@ -592,7 +619,11 @@ export function AddSourcePage() { · 204 unmodified lines
592619 const navigate = useNavigate();
593620 const discoverSource = useDiscoverSource();
594621 const connectSource = useConnectSource();
622+ const connectSourceBatch = useConnectSourceBatch();
623+ const createWorkspaceOauthClient = useCreateWorkspaceOauthClient();
624+ const workspaceOauthClients = useWorkspaceOauthClients("google_workspace");
595625 const instanceConfig = useInstanceConfig();
626+ const invalidateExecutorQueries = useInvalidateExecutorQueries();
596627 const secrets = useSecrets();
597628 const refreshSecrets = useRefreshSecrets();
598629 const startSourceOAuth = useStartSourceOAuth();
@@ -626,7 +657,10 @@ export function AddSourcePage() { · 27 unmodified lines
626657
627658 // OAuth required state
628659 const [oauthInfo, setOauthInfo] = useState<OAuthRequiredInfo | null>(null);
660+ const [batchOauthInfo, setBatchOauthInfo] = useState<BatchOAuthRequiredInfo | null>(null);
629661 const [oauthBusy, setOauthBusy] = useState(false);
662+ const [batchConnecting, setBatchConnecting] = useState(false);
663+ const [selectedGoogleTemplateIds, setSelectedGoogleTemplateIds] = useState<ReadonlyArray<string>>([]);
630664
631665 // Status banner
632666 const [statusBanner, setStatusBanner] = useState<{
@@ -643,6 +677,7 @@ export function AddSourcePage() { · 10 unmodified lines
643677 ...current,
644678 kind,
645679 authKind: authKindForSourceKind(current.authKind, kind),
680+ workspaceOauthClientId: kind === "google_discovery" ? current.workspaceOauthClientId : "",
646681 }));
647682 };
648683
@@ -650,6 +685,10 @@ export function AddSourcePage() { · 1 unmodified line
650685 setProbeAuth((current) => ({ ...current, [key]: value }));
651686 };
652687
688+ const googleTemplates = sourceTemplates.filter(
689+ (template) => template.groupId === "google_workspace" && template.batchable,
690+ );
691+
653692 // -------------------------------------------------------------------------
654693 // Handlers
655694 // -------------------------------------------------------------------------
@@ -704,6 +743,16 @@ export function AddSourcePage() { · 48 unmodified lines
704743 };
705744
706745 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+
707756 const discoveryUrl = "specUrl" in template ? template.specUrl : template.endpoint;
708757 setUrl(template.endpoint);
709758 setStatusBanner(null);
@@ -752,6 +801,7 @@ export function AddSourcePage() { · 42 unmodified lines
752801 setConnectResult(result);
753802
754803 if (result.kind === "connected") {
804+ setBatchOauthInfo(null);
755805 setPhase("connected");
756806 setStatusBanner({
757807 tone: "success",
@@ -766,6 +816,7 @@ export function AddSourcePage() { · 8 unmodified lines
766816 });
767817 }, 1200);
768818 } else if (result.kind === "credential_required") {
819+ setBatchOauthInfo(null);
769820 setPhase("credential_required");
770821 setStatusBanner({
771822 tone: "info",
@@ -776,6 +827,7 @@ export function AddSourcePage() { · 4 unmodified lines
776827 setFormField("authKind", "bearer");
777828 }
778829 } else if (result.kind === "oauth_required") {
830+ setBatchOauthInfo(null);
779831 setPhase("oauth_required");
780832 setOauthInfo({
781833 source: result.source,
@@ -797,28 +849,38 @@ export function AddSourcePage() { · 15 unmodified lines
797849 };
798850
799851 const handleOAuthPopup = async () => {
800− if (!oauthInfo) return;
852+ if (!oauthInfo && !batchOauthInfo) return;
801853 setStatusBanner(null);
802854 setOauthBusy(true);
803855
804856 try {
805857 await startSourceOAuthPopup({
806− authorizationUrl: oauthInfo.authorizationUrl,
807− sessionId: oauthInfo.sessionId,
858+ authorizationUrl: oauthInfo?.authorizationUrl ?? batchOauthInfo!.authorizationUrl,
859+ sessionId: oauthInfo?.sessionId ?? batchOauthInfo!.sessionId,
808860 });
809861
810862 refreshSecrets();
863+ invalidateExecutorQueries();
811864 setPhase("connected");
812865 setStatusBanner({
813866 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.`,
815870 });
816871
817872 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+
818882 void navigate({
819− to: "/sources/$sourceId",
820− params: { sourceId: oauthInfo.source.id },
821− search: { tab: "model" },
883+ to: "/",
822884 });
823885 }, 1200);
824886 } catch (error) {
@@ -831,6 +893,91 @@ export function AddSourcePage() { · 6 unmodified lines
831893 }
832894 };
833895
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+
834981 const handleCredentialConnect = async () => {
835982 // Re-run connect with the auth now configured
836983 await handleConnect();
@@ -845,7 +992,12 @@ export function AddSourcePage() { · 8 unmodified lines
845992
846993 const isDiscovering = phase === "discovering";
847994 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";
8491001
8501002 // -------------------------------------------------------------------------
8511003 // Render
@@ -870,11 +1022,53 @@ export function AddSourcePage() { · 18 unmodified lines
8701022 {phase === "editing" && (
8711023 <Badge variant="outline">{connectForm.kind}</Badge>
8721024 )}
873− {phase === "connected" && (
874− <Badge variant="default">connected</Badge>
875− )}
8761025 </div>
8771026
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+
8781072 {statusBanner && <StatusBanner state={statusBanner} className="mb-6" />}
8791073
8801074
@@ -895,22 +1089,29 @@ export function AddSourcePage() { · 14 unmodified lines
8951089 </p>
8961090
8971091 <Field label="URL">
898− <div className="flex gap-2">
899− <input
900− value={url}
901− onChange={(e) => setUrl(e.target.value)}
902− placeholder="https://api.example.com or https://mcp.example.com/mcp"
903− onKeyDown={(e) => {
904− if (e.key === "Enter" && !isDiscovering) {
905− void handleDiscover();
906− }
907− }}
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"
909− />
910− <Button onClick={handleDiscover} disabled={isBusy}>
911− {isDiscovering ? <IconSpinner className="size-3.5" /> : <IconDiscover className="size-3.5" />}
912− {isDiscovering ? "Discovering\u2026" : "Discover"}
913− </Button>
1092+ <div className="space-y-0">
1093+ <div className="flex gap-2">
1094+ <input
1095+ value={url}
1096+ onChange={(e) => setUrl(e.target.value)}
1097+ placeholder="https://api.example.com or https://mcp.example.com/mcp"
1098+ onKeyDown={(e) => {
1099+ if (e.key === "Enter" && !isDiscovering) {
1100+ void handleDiscover();
1101+ }
1102+ }}
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"
1104+ />
1105+ <Button onClick={handleDiscover} disabled={isBusy}>
1106+ {isDiscovering ? <IconSpinner className="size-3.5" /> : <IconDiscover className="size-3.5" />}
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+ )}
9141115 </div>
9151116 </Field>
9161117
@@ -1005,36 +1206,187 @@ export function AddSourcePage() { · 88 unmodified lines
10051206 </button>
10061207 </div>
10071208
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">
10091211 <div>
1010− <p className="text-[12px] font-medium text-foreground">Start from a known source</p>
1011− <p className="text-[11px] text-muted-foreground">
1012− Load a real MCP, OpenAPI, GraphQL, or Google Discovery template without running discovery first.
1013− </p>
1014− </div>
1015− <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1016− {sourceTemplates.map((template) => (
1017− <button
1018− key={template.id}
1019− type="button"
1020− onClick={() => applyTemplate(template)}
1021− disabled={isBusy}
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"
1023− >
1024− <div className="mb-1 flex items-center justify-between gap-2">
1025− <div className="flex min-w-0 items-center gap-2">
1026− <div className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
1027− <SourceFavicon endpoint={template.endpoint} kind={template.kind} className="size-4" />
1212+ <p className="text-[13px] font-medium text-foreground">Start from a template</p>
1213+ <p className="mt-0.5 text-[12px] text-muted-foreground">
1214+ Pick a known source to skip discovery, or select multiple Google APIs for batch connect.
1215+ </p>
1216+ </div>
1217+
1218+ {/* -- Popular / non-Google templates -- */}
1219+ {(() => {
1220+ const popularTemplates = sourceTemplates.filter((t) => t.groupId !== "google_workspace");
1221+ return popularTemplates.length > 0 && (
1222+ <div className="space-y-2.5">
1223+ <p className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/50">Popular</p>
1224+ <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1225+ {popularTemplates.map((template) => (
1226+ <button
1227+ key={template.id}
1228+ type="button"
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>
10281379 </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>
10301386 </div>
1031− <Badge variant="outline" className="text-[9px]">{template.kind}</Badge>
10321387 </div>
1033− <span className="line-clamp-2 text-[11px] text-muted-foreground">
1034− {template.summary}
1035− </span>
1036− </button>
1037− ))}
1388+ </div>
1389+ )}
10381390 </div>
10391391 </div>
10401392 </div>
@@ -1044,8 +1396,25 @@ export function AddSourcePage() { · 3 unmodified lines
10441396
10451397
10461398
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+
10471416 {/* Step 2-4: Editing / Connecting */}
1048− {(phase === "editing" || phase === "connecting" || phase === "credential_required") && (
1417+ {(phase === "editing" || (phase === "connecting" && !batchConnecting) || phase === "credential_required") && (
10491418 <div className="space-y-6">
10501419 <Section title="Configuration">
10511420 <div className="grid gap-4 sm:grid-cols-2">
@@ -1154,6 +1523,22 @@ export function AddSourcePage() { · 102 unmodified lines
11541523 )}
11551524
11561525 {/* 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+ )}
11571542 {connectForm.kind !== "mcp" && (
11581543 <Section title="Authentication">
11591544 <div className="grid gap-4 sm:grid-cols-2">
@@ -1167,28 +1552,47 @@ export function AddSourcePage() { · 7 unmodified lines
11671552 {connectForm.kind === "google_discovery" && (
11681553 <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">
11691554 {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."
11711556 : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}
11721557 </div>
11731558 )}
11741559 {connectForm.kind === "google_discovery" && connectForm.authKind === "oauth2" && (
11751560 <>
1176− <Field label="OAuth client ID" className="sm:col-span-2">
1177− <TextInput
1178− type="password"
1179− value={connectForm.oauthClientId}
1180− onChange={(v) => setFormField("oauthClientId", v)}
1181− placeholder="1234567890-abcdef.apps.googleusercontent.com"
1182− />
1183− </Field>
1184− <Field label="OAuth client secret" className="sm:col-span-2">
1185− <TextInput
1186− type="password"
1187− value={connectForm.oauthClientSecret}
1188− onChange={(v) => setFormField("oauthClientSecret", v)}
1189− placeholder="GOCSPX-..."
1561+ <Field label="Workspace OAuth client" className="sm:col-span-2">
1562+ <SelectInput
1563+ value={connectForm.workspaceOauthClientId}
1564+ onChange={(value) => setFormField("workspaceOauthClientId", value)}
1565+ options={[
1566+ { value: "", label: "Enter new client" },
1567+ ...(workspaceOauthClients.status === "ready"
1568+ ? workspaceOauthClients.data.map((client) => ({
1569+ value: client.id,
1570+ label: client.label ?? client.clientId,
1571+ }))
1572+ : []),
1573+ ]}
11901574 />
11911575 </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+ )}
11921596 </>
11931597 )}
11941598 {connectForm.authKind !== "none" && (
@@ -1256,45 +1660,78 @@ export function AddSourcePage() { · 61 unmodified lines
12561660 )}
12571661
12581662 {/* Step 5a: OAuth required */}
1259− {phase === "oauth_required" && oauthInfo && (
1260− <div className="space-y-6">
1261− <Section title="OAuth Authentication Required">
1262− <div className="space-y-4">
1263− <p className="text-[13px] text-muted-foreground">
1264− The source <strong className="text-foreground">{oauthInfo.source.name}</strong> requires OAuth.
1265− Click the button below to open a popup and complete authentication.
1663+ {phase === "oauth_required" && (oauthInfo || batchOauthInfo) && (
1664+ <div className="flex flex-col items-center justify-center py-16">
1665+ <div className="w-full max-w-sm space-y-6 text-center">
1666+ {/* Visual anchor */}
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">
1668+ {oauthBusy ? (
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+ }
12661693 </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>
12761694 </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>
12781712 </div>
12791713 )}
12801714
12811715 {/* Step 5b: Connected */}
1282− {phase === "connected" && connectResult && (
1283− <Section title="Connected">
1284− <div className="flex items-center gap-3">
1285− <div className="flex size-10 items-center justify-center rounded-full bg-primary/10 text-primary">
1286− <IconCheck className="size-5" />
1716+ {phase === "connected" && (
1717+ <div className="flex flex-col items-center justify-center py-20">
1718+ <div className="space-y-5 text-center">
1719+ <div className="mx-auto flex size-14 items-center justify-center rounded-full bg-primary/10 ring-4 ring-primary/5">
1720+ <IconCheck className="size-6 text-primary" />
12871721 </div>
1288− <div>
1289− <p className="text-[14px] font-medium text-foreground">
1290− {connectResult.source.name}
1291− </p>
1292− <p className="text-[12px] text-muted-foreground">
1293− Source connected. Redirecting...
1294− </p>
1722+ <div className="space-y-1.5">
1723+ <h2 className="font-display text-xl tracking-tight text-foreground">
1724+ {connectResult
1725+ ? connectResult.source.name
1726+ : batchOauthInfo
1727+ ? `${batchOauthInfo.sourceIds.length} source${batchOauthInfo.sourceIds.length === 1 ? "" : "s"}`
1728+ : "Source"}
1729+ {" "}connected
1730+ </h2>
1731+ <p className="text-[13px] text-muted-foreground/70">Redirecting\u2026</p>
12951732 </div>
12961733 </div>
1297− </Section>
1734+ </div>
12981735 )}
12991736 </div>
13001737 </div>
@@ -1390,14 +1827,27 @@ function StatusBanner(props: { state: { tone: "info" | "success" | "error"; text · 89 unmodified lines
13901827 return (
13911828 <div
13921829 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]",
13941831 props.state.tone === "success" && "border-primary/30 bg-primary/8 text-foreground",
13951832 props.state.tone === "info" && "border-border bg-card text-muted-foreground",
13961833 props.state.tone === "error" && "border-destructive/30 bg-destructive/8 text-destructive",
13971834 props.className,
13981835 )}
13991836 >
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>
14011851 </div>
14021852 );
14031853 }
apps/web/src/views/source-detail.tsx+20−8
@@ -89,6 +89,13 @@ const readBindingTransport = (source: Source): "auto" | "streamable-http" | "sse · 88 unmodified lines
8989 : undefined;
9090 };
9191
92+const 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+
9299 const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null => {
93100 switch (source.kind) {
94101 case "mcp":
@@ -114,8 +121,8 @@ const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null = · 19 unmodified lines
114121 name: source.name,
115122 namespace: source.namespace,
116123 importAuthPolicy: source.importAuthPolicy,
117− importAuth: source.importAuth,
118− auth: source.auth,
124+ importAuth: refreshableHttpAuth(source.importAuth),
125+ auth: refreshableHttpAuth(source.auth),
119126 };
120127 }
121128 case "graphql":
@@ -125,8 +132,8 @@ const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null = · 3 unmodified lines
125132 name: source.name,
126133 namespace: source.namespace,
127134 importAuthPolicy: source.importAuthPolicy,
128− importAuth: source.importAuth,
129− auth: source.auth,
135+ importAuth: refreshableHttpAuth(source.importAuth),
136+ auth: refreshableHttpAuth(source.auth),
130137 };
131138 case "google_discovery": {
132139 const service = readBindingString(source, "service");
@@ -145,8 +152,8 @@ const refreshPayloadFromSource = (source: Source): ConnectSourcePayload | null = · 12 unmodified lines
145152 name: source.name,
146153 namespace: source.namespace,
147154 importAuthPolicy: source.importAuthPolicy,
148− importAuth: source.importAuth,
149− auth: source.auth,
155+ importAuth: refreshableHttpAuth(source.importAuth),
156+ auth: refreshableHttpAuth(source.auth),
150157 };
151158 }
152159 }
@@ -250,11 +257,16 @@ export function SourceDetailPage(props: { · 97 unmodified lines
250257 {refreshFeedback && (
251258 <span
252259 className={cn(
253− "hidden max-w-72 truncate text-[11px] sm:block",
254− refreshFeedback.tone === "success" ? "text-emerald-600" : "text-destructive",
260+ "hidden max-w-72 truncate text-[11px] font-medium sm:inline-flex sm:items-center sm:gap-1.5",
261+ refreshFeedback.tone === "success"
262+ ? "text-primary"
263+ : "text-destructive",
255264 )}
256265 title={refreshFeedback.text}
257266 >
267+ {refreshFeedback.tone === "success" && (
268+ <IconCheck className="size-3 shrink-0" />
269+ )}
258270 {refreshFeedback.text}
259271 </span>
260272 )}
apps/web/src/views/source-editor.tsx+151−30
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 useCreateSource,
1414 useInstanceConfig,
1515 useRefreshSecrets,
16+ useRemoveProviderAuthGrant,
1617 useRemoveSource,
1718 useSecrets,
1819 useSources,
@@ -86,6 +87,7 @@ type SourceFormState = { · 67 unmodified lines
8687 oauthAccessHandle: string;
8788 oauthRefreshProviderId: string;
8889 oauthRefreshHandle: string;
90+ managedAuth: Extract<Source["auth"], { kind: "provider_grant_ref" | "mcp_oauth" }> | null;
8991 };
9092
9193 const kindOptions: ReadonlyArray<Source["kind"]> = [
@@ -102,7 +104,13 @@ const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [ · 10 unmodified lines
102104 "sse",
103105 ];
104106
105−const authOptions: ReadonlyArray<Source["auth"]["kind"]> = ["none", "bearer", "oauth2"];
107+const authOptions: ReadonlyArray<Source["auth"]["kind"]> = [
108+ "none",
109+ "bearer",
110+ "oauth2",
111+ "provider_grant_ref",
112+ "mcp_oauth",
113+];
106114
107115 const trimToNull = (value: string): string | null => {
108116 const trimmed = value.trim();
@@ -321,6 +329,7 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 212 unmodified lines
321329 oauthAccessHandle: "",
322330 oauthRefreshProviderId: "",
323331 oauthRefreshHandle: "",
332+ managedAuth: null,
324333 });
325334
326335 const formStateFromSource = (source: Source): SourceFormState => ({
@@ -337,8 +346,14 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 10 unmodified lines
337346 version: readBindingString(source, "version"),
338347 defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),
339348 authKind: source.auth.kind,
340− authHeaderName: source.auth.kind === "none" ? "Authorization" : source.auth.headerName,
341− authPrefix: source.auth.kind === "none" ? "Bearer " : source.auth.prefix,
349+ authHeaderName:
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,
342357 bearerProviderId: source.auth.kind === "bearer" ? source.auth.token.providerId : "",
343358 bearerHandle: source.auth.kind === "bearer" ? source.auth.token.handle : "",
344359 oauthAccessProviderId: source.auth.kind === "oauth2" ? source.auth.accessToken.providerId : "",
@@ -351,6 +366,10 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 6 unmodified lines
351366 source.auth.kind === "oauth2" && source.auth.refreshToken !== null
352367 ? source.auth.refreshToken.handle
353368 : "",
369+ managedAuth:
370+ source.auth.kind === "provider_grant_ref" || source.auth.kind === "mcp_oauth"
371+ ? source.auth
372+ : null,
354373 });
355374
356375 const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {
@@ -387,6 +406,10 @@ const buildAuthPayload = (state: SourceFormState): CreateSourcePayload["auth"] = · 30 unmodified lines
387406 return { kind: "none" };
388407 }
389408
409+ if ((state.authKind === "provider_grant_ref" || state.authKind === "mcp_oauth") && state.managedAuth !== null) {
410+ return state.managedAuth;
411+ }
412+
390413 const headerName = state.authHeaderName.trim() || "Authorization";
391414 const prefix = state.authPrefix;
392415
@@ -589,6 +612,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 196 unmodified lines
589612 const startSourceOAuth = useStartSourceOAuth();
590613 const updateSource = useUpdateSource();
591614 const removeSource = useRemoveSource();
615+ const removeProviderAuthGrant = useRemoveProviderAuthGrant();
592616 const instanceConfig = useInstanceConfig();
593617 const secrets = useSecrets();
594618 const refreshSecrets = useRefreshSecrets();
@@ -602,6 +626,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 7 unmodified lines
602626
603627 const isSubmitting = createSource.status === "pending" || updateSource.status === "pending";
604628 const isDeleting = removeSource.status === "pending";
629+ const isRevokingGrant = removeProviderAuthGrant.status === "pending";
605630 const isOAuthSubmitting = startSourceOAuth.status === "pending" || oauthPopupBusy;
606631 const oauthSecretRefTarget =
607632 formState.authKind === "oauth2" && formState.oauthAccessHandle.trim().length > 0
@@ -690,6 +715,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 82 unmodified lines
690715 oauthAccessHandle: auth.accessToken.handle,
691716 oauthRefreshProviderId: auth.refreshToken?.providerId ?? "",
692717 oauthRefreshHandle: auth.refreshToken?.handle ?? "",
718+ managedAuth: null,
693719 }));
694720 setStatusBanner({
695721 tone: "success",
@@ -730,6 +756,42 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 34 unmodified lines
730756 }
731757 };
732758
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+
733795 const backLink = props.mode === "edit" && props.source
734796 ? { to: "/sources/$sourceId" as const, params: { sourceId: props.source.id }, search: { tab: "model" as const } }
735797 : { to: "/" as const };
@@ -944,40 +1006,49 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 208 unmodified lines
9441006 <Section title="Authentication">
9451007 <div className="grid gap-4 sm:grid-cols-2">
9461008 {formState.kind === "mcp" && (
947− <div className="sm:col-span-2 rounded-lg border border-border bg-card/70 px-4 py-3">
948− <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
949− <div className="space-y-1">
950− <p className="text-[13px] font-medium text-foreground">Sign in with OAuth</p>
951− <p className="text-[12px] text-muted-foreground">
952− Starts the same MCP connection flow used by `executor.sources.add`.
953− </p>
1009+ <div className="sm:col-span-2 rounded-xl border border-border bg-gradient-to-b from-card/90 to-card/50 overflow-hidden">
1010+ <div className="flex items-start gap-3 px-4 py-3.5">
1011+ <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/8 text-primary mt-0.5">
1012+ <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
1013+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
1014+ <path d="M7 11V7a5 5 0 0110 0v4" />
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>
9541036 </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>
9661037 </div>
9671038 {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">
969− <p className="text-[12px] text-muted-foreground">
970− OAuth tokens are attached to this draft and stay hidden unless you need the raw refs.
1039+ <div className="flex items-center justify-between gap-3 border-t border-border/50 px-4 py-2.5">
1040+ <p className="text-[11px] text-muted-foreground/70">
1041+ Token refs attached to this draft
9711042 </p>
9721043 <button
9731044 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"
9751046 onClick={() =>
9761047 setExpandedOauthSecretRefTarget((current) =>
9771048 current === oauthSecretRefTarget ? null : oauthSecretRefTarget,
9781049 )}
9791050 >
980− {showOauthSecretRefs ? "Hide token refs" : "Show token refs"}
1051+ {showOauthSecretRefs ? "Hide refs" : "Show refs"}
9811052 </button>
9821053 </div>
9831054 )}
@@ -987,10 +1058,57 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 3 unmodified lines
9871058 <SelectInput
9881059 value={formState.authKind}
9891060 onChange={(value) => setField("authKind", value as Source["auth"]["kind"])}
1061+ disabled={formState.managedAuth !== null}
9901062 options={authOptions.map((value) => ({ value, label: value }))}
9911063 />
9921064 </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 && (
9941112 <>
9951113 <Field label="Header name">
9961114 <TextInput
@@ -1009,7 +1127,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 12 unmodified lines
10091127 </>
10101128 )}
10111129
1012− {formState.authKind === "bearer" && (
1130+ {formState.authKind === "bearer" && formState.managedAuth === null && (
10131131 <Field label="Token" className="sm:col-span-2">
10141132 <SecretPicker
10151133 instanceConfig={instanceConfig}
@@ -1025,6 +1143,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 9 unmodified lines
10251143 )}
10261144
10271145 {formState.authKind === "oauth2"
1146+ && formState.managedAuth === null
10281147 && (formState.kind !== "mcp"
10291148 || formState.oauthAccessHandle.trim().length === 0
10301149 || showOauthSecretRefs) && (
@@ -1066,7 +1185,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 35 unmodified lines
10661185 variant="outline"
10671186 className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
10681187 onClick={handleRemove}
1069− disabled={isDeleting}
1188+ disabled={isDeleting || isRevokingGrant}
10701189 >
10711190 <IconTrash className="size-3.5" />
10721191 {isDeleting ? "Removing\u2026" : "Remove source"}
@@ -1079,7 +1198,7 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 6 unmodified lines
10791198 <Link {...backLink} className="inline-flex">
10801199 <Button variant="ghost" type="button">Cancel</Button>
10811200 </Link>
1082− <Button onClick={handleSubmit} disabled={isSubmitting}>
1201+ <Button onClick={handleSubmit} disabled={isSubmitting || isRevokingGrant}>
10831202 {props.mode === "edit" ? <IconPencil className="size-3.5" /> : <IconPlus className="size-3.5" />}
10841203 {isSubmitting
10851204 ? props.mode === "edit"
@@ -1147,10 +1266,12 @@ function SelectInput(props: { · 61 unmodified lines
11471266 value: string;
11481267 onChange: (value: string) => void;
11491268 options: ReadonlyArray<{ value: string; label: string }>;
1269+ disabled?: boolean;
11501270 }) {
11511271 return (
11521272 <select
11531273 value={props.value}
1274+ disabled={props.disabled}
11541275 onChange={(event) => props.onChange(event.target.value)}
11551276 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"
11561277 >
apps/web/src/views/source-templates.ts+12−0
@@ -5,6 +5,9 @@ type SourceTemplateBase = { · 4 unmodified lines
55 name: string;
66 summary: string;
77 endpoint: string;
8+ groupId?: string;
9+ groupLabel?: string;
10+ batchable?: boolean;
811 };
912
1013 export type OpenApiSourceTemplate = SourceTemplateBase & {
@@ -46,6 +49,9 @@ const googleDiscoveryTemplate = (input: { · 35 unmodified lines
4649 summary: input.summary,
4750 kind: "google_discovery",
4851 endpoint: discoveryUrl,
52+ groupId: "google_workspace",
53+ groupLabel: "Google Workspace",
54+ batchable: true,
4955 service: input.service,
5056 version: input.version,
5157 discoveryUrl,
@@ -89,6 +95,9 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 37 unmodified lines
8995 kind: "openapi",
9096 endpoint: "https://api.github.com",
9197 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,
92101 },
93102 {
94103 id: "github-graphql",
@@ -96,6 +105,9 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 1 unmodified line
96105 summary: "Issues, pull requests, discussions, and repository objects via GraphQL.",
97106 kind: "graphql",
98107 endpoint: "https://api.github.com/graphql",
108+ groupId: "github",
109+ groupLabel: "GitHub",
110+ batchable: false,
99111 },
100112 {
101113 id: "gitlab-graphql",
bun.lock+6−0
@@ -162,6 +162,7 @@ · 161 unmodified lines
162162 "name": "@executor/control-plane",
163163 "dependencies": {
164164 "@effect/platform": "catalog:",
165+ "@electric-sql/pglite": "^0.3.15",
165166 "@executor/codemode-core": "workspace:*",
166167 "@executor/codemode-google-discovery": "workspace:*",
167168 "@executor/codemode-mcp": "workspace:*",
@@ -172,6 +173,7 @@ · 4 unmodified lines
172173 "effect": "catalog:",
173174 "graphql": "^16.12.0",
174175 "jsonc-parser": "3.3.1",
176+ "postgres": "^3.4.8",
175177 "prettier": "^3.8.1",
176178 "typescript": "^5.9.3",
177179 },
@@ -442,6 +444,8 @@ · 264 unmodified lines
442444
443445 "@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=="],
444446
447+ "@electric-sql/pglite": ["@electric-sql/pglite@0.3.16", "", {}, "sha512-mZkZfOd9OqTMHsK+1cje8OSzfAQcpD7JmILXTl5ahdempjUDdmg4euf1biDex5/LfQIDJ3gvCu6qDgdnDxfJmA=="],
448+
445449 "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
446450
447451 "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -1486,6 +1490,8 @@ · 1038 unmodified lines
14861490
14871491 "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
14881492
1493+ "postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
1494+
14891495 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
14901496
14911497 "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 @@
1+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
12 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
23 import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
34 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -11,6 +12,7 @@ export type CreateSdkMcpConnectorInput = { · 7 unmodified lines
1112 transport?: McpTransportPreference;
1213 queryParams?: Record<string, string>;
1314 headers?: Record<string, string>;
15+ authProvider?: OAuthClientProvider;
1416 clientName?: string;
1517 clientVersion?: string;
1618 };
@@ -76,12 +78,16 @@ export const createSdkMcpConnector = ( · 59 unmodified lines
7678 const client = createClient();
7779
7880 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+ }));
8085 return connectionFromClient(client);
8186 }
8287
8388 if (transport === "sse") {
8489 await client.connect(new SSEClientTransport(endpoint, {
90+ authProvider: input.authProvider,
8591 requestInit,
8692 eventSourceInit: requestInit
8793 ? {
@@ -94,10 +100,14 @@ export const createSdkMcpConnector = ( · 6 unmodified lines
94100 }
95101
96102 try {
97− await client.connect(new StreamableHTTPClientTransport(endpoint, { requestInit }));
103+ await client.connect(new StreamableHTTPClientTransport(endpoint, {
104+ requestInit,
105+ authProvider: input.authProvider,
106+ }));
98107 return connectionFromClient(client);
99108 } catch {
100109 await client.connect(new SSEClientTransport(endpoint, {
110+ authProvider: input.authProvider,
101111 requestInit,
102112 eventSourceInit: requestInit
103113 ? {
packages/control-plane/package.json+2−0
@@ -21,6 +21,7 @@ · 20 unmodified lines
2121 },
2222 "dependencies": {
2323 "@effect/platform": "catalog:",
24+ "@electric-sql/pglite": "^0.3.15",
2425 "@executor/codemode-core": "workspace:*",
2526 "@executor/codemode-google-discovery": "workspace:*",
2627 "@executor/codemode-mcp": "workspace:*",
@@ -31,6 +32,7 @@ · 4 unmodified lines
3132 "effect": "catalog:",
3233 "graphql": "^16.12.0",
3334 "jsonc-parser": "3.3.1",
35+ "postgres": "^3.4.8",
3436 "prettier": "^3.8.1",
3537 "typescript": "^5.9.3"
3638 },
packages/control-plane/src/api/index.ts+6−0
@@ -50,13 +50,19 @@ export { · 49 unmodified lines
5050 } from "./oauth/api";
5151
5252 export {
53+ ConnectSourceBatchPayloadSchema,
54+ ConnectSourceBatchResultSchema,
5355 ConnectSourcePayloadSchema,
5456 ConnectSourceResultSchema,
57+ CreateWorkspaceOauthClientPayloadSchema,
5558 CreateSourcePayloadSchema,
5659 DiscoverSourcePayloadSchema,
5760 UpdateSourcePayloadSchema,
61+ type ConnectSourceBatchPayload,
62+ type ConnectSourceBatchResult,
5863 type ConnectSourcePayload,
5964 type ConnectSourceResult,
65+ type CreateWorkspaceOauthClientPayload,
6066 type CreateSourcePayload,
6167 type DiscoverSourcePayload,
6268 type UpdateSourcePayload,
packages/control-plane/src/api/sources/api.ts+105−0
@@ -1,7 +1,9 @@
11 import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
22 import {
3+ SourceOauthClientInputSchema,
34 ExecutionInteractionIdSchema,
45 JsonObjectSchema,
6+ ProviderAuthGrantIdSchema,
57 SourceAuthSchema,
68 SourceAuthSessionIdSchema,
79 SourceDiscoveryResultSchema,
@@ -15,6 +17,8 @@ import { · 7 unmodified lines
1517 SourceProbeAuthSchema,
1618 SourceSchema,
1719 SourceStatusSchema,
20+ WorkspaceOauthClientIdSchema,
21+ WorkspaceOauthClientSchema,
1822 WorkspaceIdSchema,
1923 } from "#schema";
2024 import * as Schema from "effect/Schema";
@@ -59,6 +63,8 @@ export const CreateSourcePayloadSchema = Schema.extend( · 38 unmodified lines
5963 );
6064
6165 export type CreateSourcePayload = typeof CreateSourcePayloadSchema.Type;
66+export type CreateWorkspaceOauthClientPayload =
67+ typeof CreateWorkspaceOauthClientPayloadSchema.Type;
6268
6369 export const UpdateSourcePayloadSchema = Schema.Struct({
6470 name: OptionalTrimmedNonEmptyStringSchema,
@@ -96,6 +102,49 @@ const CredentialOauthCompleteUrlParamsSchema = Schema.Struct({ · 31 unmodified lines
96102 error_description: Schema.optional(Schema.String),
97103 });
98104
105+const WorkspaceOauthClientQuerySchema = Schema.Struct({
106+ providerKey: Schema.String,
107+});
108+
109+const CreateWorkspaceOauthClientPayloadSchema = Schema.Struct({
110+ providerKey: Schema.String,
111+ label: Schema.optional(Schema.NullOr(Schema.String)),
112+ oauthClient: SourceOauthClientInputSchema,
113+});
114+
115+const oauthClientIdParam = HttpApiSchema.param("oauthClientId", WorkspaceOauthClientIdSchema);
116+const grantIdParam = HttpApiSchema.param("grantId", ProviderAuthGrantIdSchema);
117+
118+const 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+
127+const ConnectSourceBatchPayloadSchema = Schema.Struct({
128+ workspaceOauthClientId: WorkspaceOauthClientIdSchema,
129+ sources: Schema.Array(ConnectGoogleDiscoveryBatchSourceSchema),
130+});
131+
132+const 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+
99148 const DiscoverSourcePayloadSchema = Schema.Struct({
100149 url: TrimmedNonEmptyStringSchema,
101150 probeAuth: Schema.optional(SourceProbeAuthSchema),
@@ -122,10 +171,15 @@ const ConnectSourceResultSchema = Schema.Union( · 20 unmodified lines
122171 );
123172
124173 export type ConnectSourceResult = typeof ConnectSourceResultSchema.Type;
174+export type ConnectSourceBatchPayload = typeof ConnectSourceBatchPayloadSchema.Type;
175+export type ConnectSourceBatchResult = typeof ConnectSourceBatchResultSchema.Type;
125176
126177 export {
127178 ConnectSourcePayloadSchema,
179+ ConnectSourceBatchPayloadSchema,
180+ ConnectSourceBatchResultSchema,
128181 ConnectSourceResultSchema,
182+ CreateWorkspaceOauthClientPayloadSchema,
129183 DiscoverSourcePayloadSchema,
130184 };
131185
@@ -153,6 +207,49 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 21 unmodified lines
153207 .addError(ControlPlaneForbiddenError)
154208 .addError(ControlPlaneStorageError),
155209 )
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+ )
156253 .add(
157254 HttpApiEndpoint.get("list")`/workspaces/${workspaceIdParam}/sources`
158255 .addSuccess(Schema.Array(SourceSchema))
@@ -222,6 +319,14 @@ export class SourcesApi extends HttpApiGroup.make("sources") · 63 unmodified lines
222319 .addError(ControlPlaneNotFoundError)
223320 .addError(ControlPlaneStorageError),
224321 )
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+ )
225330 .add(
226331 HttpApiEndpoint.get("inspection")`/workspaces/${workspaceIdParam}/sources/${sourceIdParam}/inspection`
227332 .addSuccess(SourceInspectionSchema)
packages/control-plane/src/api/sources/http.ts+186−6
@@ -93,6 +93,8 @@ const serializeJsonForScript = (value: unknown): string => · 92 unmodified lines
9393 .replaceAll(">", "\\u003e")
9494 .replaceAll("&", "\\u0026");
9595
96+const SOURCE_OAUTH_RESULT_STORAGE_KEY_PREFIX = "executor:oauth-result:";
97+
9698 const credentialPageDocument = (input: {
9799 title: string;
98100 eyebrow: string;
@@ -460,10 +462,11 @@ const sourceOAuthPopupResultDocument = (input: { · 361 unmodified lines
460462 title: string;
461463 message: string;
462464 state: "stored" | "cancelled";
465+ sessionId?: string | null;
463466 payload:
464467 | {
465− type: "executor:source-oauth-result";
466− ok: true;
468+ type: "executor:source-oauth-result";
469+ ok: true;
467470 sourceId: Source["id"];
468471 }
469472 | {
@@ -560,12 +563,22 @@ const sourceOAuthPopupResultDocument = (input: { · 90 unmodified lines
560563 <script>
561564 (() => {
562565 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 {}
563576 try {
564577 if (window.opener) {
565578 window.opener.postMessage(payload, window.location.origin);
566579 }
567580 } finally {
568− window.setTimeout(() => window.close(), 60);
581+ window.setTimeout(() => window.close(), 120);
569582 }
570583 })();
571584 </script>
@@ -696,6 +709,105 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 124 unmodified lines
696709 ),
697710 ),
698711 )
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+ )
699811 .handle("list", ({ path }) =>
700812 resolveRequestedLocalWorkspace("sources.list", path.workspaceId).pipe(
701813 Effect.flatMap((runtimeLocalWorkspace) =>
@@ -899,16 +1011,17 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 197 unmodified lines
8991011 error: urlParams.error,
9001012 errorDescription: urlParams.error_description,
9011013 }).pipe(
902− Effect.map((source) =>
1014+ Effect.map((completed) =>
9031015 htmlResponse(
9041016 sourceOAuthPopupResultDocument({
9051017 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.`,
9071019 state: "stored",
1020+ sessionId: completed.sessionId,
9081021 payload: {
9091022 type: "executor:source-oauth-result",
9101023 ok: true,
911− sourceId: source.id,
1024+ sourceId: completed.source.id,
9121025 },
9131026 }),
9141027 ),
@@ -931,5 +1044,72 @@ export const ControlPlaneSourcesLive = HttpApiBuilder.group( · 16 unmodified lines
9311044 ),
9321045 ),
9331046 ),
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+ ),
9341114 ),
9351115 );
packages/control-plane/src/runtime/auth-artifacts.ts+111−0
@@ -1,6 +1,13 @@
1+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
12 import {
23 type AccountId,
34 AuthGrantSetJsonSchema,
5+ type McpOAuthAuthArtifactConfig,
6+ McpOAuthAuthArtifactConfigJsonSchema,
7+ McpOAuthAuthArtifactKind,
8+ type ProviderGrantRefAuthArtifactConfig,
9+ ProviderGrantRefAuthArtifactConfigJsonSchema,
10+ ProviderGrantRefAuthArtifactKind,
411 RequestPlacementTemplatesJsonSchema,
512 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
613 RefreshableOAuth2AuthorizedUserAuthArtifactKind,
@@ -13,6 +20,8 @@ import { · 6 unmodified lines
1320 authArtifactSecretRefs,
1421 decodeAuthGrantSet,
1522 decodeBuiltInAuthArtifactConfig,
23+ decodeMcpOAuthAuthArtifactConfig,
24+ decodeProviderGrantRefAuthArtifactConfig,
1625 type AuthArtifact,
1726 type CredentialSlot,
1827 type RequestPlacement,
@@ -37,6 +46,7 @@ export type ResolvedSourceAuthMaterial = { · 18 unmodified lines
3746 bodyValues: Readonly<Record<string, string>>;
3847 expiresAt: number | null;
3948 refreshAfter: number | null;
49+ authProvider?: OAuthClientProvider | undefined;
4050 };
4151
4252 const emptyResolvedAuthMaterial: ResolvedSourceAuthMaterial = {
@@ -47,6 +57,7 @@ const emptyResolvedAuthMaterial: ResolvedSourceAuthMaterial = { · 4 unmodified lines
4757 bodyValues: {},
4858 expiresAt: null,
4959 refreshAfter: null,
60+ authProvider: undefined,
5061 };
5162
5263 const encodeStaticBearerAuthArtifactConfig = Schema.encodeSync(
@@ -63,6 +74,12 @@ const encodeStaticPlacementsAuthArtifactConfig = Schema.encodeSync( · 10 unmodified lines
6374 const encodeRefreshableOAuth2AuthorizedUserAuthArtifactConfig = Schema.encodeSync(
6475 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
6576 );
77+const encodeProviderGrantRefAuthArtifactConfig = Schema.encodeSync(
78+ ProviderGrantRefAuthArtifactConfigJsonSchema,
79+);
80+const encodeMcpOAuthAuthArtifactConfig = Schema.encodeSync(
81+ McpOAuthAuthArtifactConfigJsonSchema,
82+);
6683
6784 const decodeRequestPlacementTemplates = Schema.decodeUnknownEither(
6885 RequestPlacementTemplatesJsonSchema,
@@ -192,6 +209,54 @@ export const authArtifactFromSourceAuth = (input: { · 123 unmodified lines
192209 };
193210 }
194211
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+
195260 return {
196261 id,
197262 workspaceId: input.source.workspaceId,
@@ -216,6 +281,36 @@ export const sourceAuthFromAuthArtifact = (artifact: AuthArtifact | null): Sourc · 18 unmodified lines
216281 return { kind: "none" };
217282 }
218283
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+
219314 const decoded = decodeBuiltInAuthArtifactConfig(artifact);
220315 if (decoded === null) {
221316 return { kind: "none" };
@@ -360,6 +455,22 @@ export const resolveAuthArtifactMaterial = (input: { · 138 unmodified lines
360455 }
361456 }
362457
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+
363474 if (input.lease === null || input.lease === undefined) {
364475 return yield* Effect.fail(
365476 new Error(`Unsupported auth artifact kind: ${input.artifact.artifactKind}`),
packages/control-plane/src/runtime/auth-leases.ts+193−0
@@ -1,11 +1,14 @@
11 import type {
22 AuthArtifact,
33 AuthLease,
4+ ProviderAuthGrant,
45 RequestPlacementTemplate,
56 SecretRef,
67 } from "#schema";
78 import {
89 AuthLeaseIdSchema,
10+ decodeMcpOAuthAuthArtifactConfig,
11+ decodeProviderGrantRefAuthArtifactConfig,
912 RefreshableOAuth2AuthorizedUserAuthArtifactKind,
1013 authLeaseSecretRefs,
1114 decodeBuiltInAuthArtifactConfig,
@@ -23,6 +26,7 @@ import { · 11 unmodified lines
2326 } from "./secret-material-providers";
2427 import { refreshOAuth2AccessToken } from "./oauth2-pkce";
2528 import type { OAuth2TokenResponse } from "./oauth2-pkce";
29+import { createPersistedMcpAuthProvider } from "./mcp-auth-provider";
2630
2731 const LEASE_REFRESH_SKEW_MS = 60_000;
2832
@@ -168,6 +172,155 @@ const refreshRefreshableOauth2AuthorizedUserArtifact = (input: { · 139 unmodified lines
168172 return nextLease;
169173 });
170174
175+const 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+
186+const 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+
197+const 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+
171324 export const removeAuthLeaseAndSecrets = (rows: ControlPlaneStoreShape, input: {
172325 authArtifactId: AuthArtifact["id"];
173326 }): Effect.Effect<void, Error, never> =>
@@ -273,6 +426,8 @@ export const resolveAuthArtifactMaterialWithLeases = (input: { · 99 unmodified lines
273426 const existingLeaseOption = yield* input.rows.authLeases.getByAuthArtifactId(input.artifact.id);
274427 const existingLease = Option.isSome(existingLeaseOption) ? existingLeaseOption.value : null;
275428 const decoded = decodeBuiltInAuthArtifactConfig(input.artifact);
429+ const providerGrantConfig = decodeProviderGrantRefAuthArtifactConfig(input.artifact);
430+ const mcpOAuthConfig = decodeMcpOAuthAuthArtifactConfig(input.artifact);
276431
277432 if (
278433 decoded !== null
@@ -296,6 +451,44 @@ export const resolveAuthArtifactMaterialWithLeases = (input: { · 17 unmodified lines
296451 });
297452 }
298453
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+
299492 return yield* resolveAuthArtifactMaterial({
300493 artifact: input.artifact,
301494 lease: existingLease,
packages/control-plane/src/runtime/control-plane-runtime.test.ts+836−0
@@ -15,12 +15,16 @@ import { · 14 unmodified lines
1515 ExecutionIdSchema,
1616 ExecutionInteractionIdSchema,
1717 type ExecutionInteraction,
18+ ProviderAuthGrantIdSchema,
1819 SecretMaterialIdSchema,
20+ SourceAuthSessionIdSchema,
1921 SourceIdSchema,
22+ WorkspaceOauthClientIdSchema,
2023 } from "#schema";
2124 import type { ToolPath } from "@executor/codemode-core";
2225
2326 import {
27+ type ControlPlaneRuntime,
2428 createControlPlaneRuntime,
2529 LiveExecutionManagerService,
2630 provideControlPlaneRuntime,
@@ -54,6 +58,25 @@ type OpenApiSpecServer = { · 27 unmodified lines
5458 close: () => Promise<void>;
5559 };
5660
61+type 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+
5780 const makeOpenApiSpecServer = Effect.acquireRelease(
5881 Effect.promise<OpenApiSpecServer>(
5982 () =>
@@ -122,6 +145,232 @@ const makeOpenApiSpecServer = Effect.acquireRelease( · 62 unmodified lines
122145 (server) => Effect.promise(() => server.close()).pipe(Effect.orDie),
123146 );
124147
148+const 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+
308+const 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+
325+const 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+
125374 const expectLeft = <A, E>(effect: Effect.Effect<A, E, never>) =>
126375 Effect.either(effect).pipe(
127376 Effect.flatMap((result) =>
@@ -689,4 +938,591 @@ describe("control-plane-runtime", () => { · 561 unmodified lines
689938 }),
690939 );
691940
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+
6921528 });
packages/control-plane/src/runtime/index.ts+15−0
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
1717 createLocalControlPlanePersistence,
1818 type LocalControlPlanePersistence,
1919 } from "./local-control-plane-store";
20+import { migrateLegacyPostgresWorkspaceIfNeeded } from "./legacy-postgres-migration";
2021 import {
2122 resolveLocalWorkspaceContext,
2223 } from "./local-config";
@@ -68,6 +69,9 @@ export type RuntimeControlPlaneOptions = { · 45 unmodified lines
6869 executionResolver?: ResolveExecutionEnvironment;
6970 resolveSecretMaterial?: ResolveSecretMaterial;
7071 getLocalServerBaseUrl?: () => string | undefined;
72+ localDataDir?: string;
73+ legacyLocalDataDir?: string;
74+ legacyDatabaseUrl?: string;
7175 workspaceRoot?: string;
7276 homeConfigPath?: string;
7377 homeStateDirectory?: string;
@@ -212,6 +216,17 @@ export const createControlPlaneRuntime = ( · 138 unmodified lines
212216 ),
213217 );
214218
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+
215230 const installationStore = LocalInstallationStore;
216231 const workspaceConfigStore = LocalWorkspaceConfigStore;
217232
packages/control-plane/src/runtime/ir-execution.ts+3−0
@@ -97,6 +97,7 @@ type McpInvocationRequestPlan = { · 96 unmodified lines
9797 transport?: "auto" | "streamable-http" | "sse";
9898 queryParams: Record<string, string>;
9999 headers: Record<string, string>;
100+ authProvider?: ResolvedSourceAuthMaterial["authProvider"];
100101 toolName: string;
101102 payload: unknown;
102103 inputSchema: unknown;
@@ -600,6 +601,7 @@ const planMcpInvocation = (input: { · 497 unmodified lines
600601 },
601602 cookies: input.auth.cookies,
602603 }),
604+ authProvider: input.auth.authProvider,
603605 toolName: input.executable.toolName,
604606 payload,
605607 inputSchema: input.tool.descriptor.inputSchema,
@@ -624,6 +626,7 @@ const executeMcpPlan = (input: { · 18 unmodified lines
624626 transport: input.requestPlan.transport,
625627 queryParams: input.requestPlan.queryParams,
626628 headers: input.requestPlan.headers,
629+ authProvider: input.requestPlan.authProvider,
627630 });
628631 const tools = createMcpToolsFromManifest({
629632 manifest: {
packages/control-plane/src/runtime/legacy-postgres-migration.test.ts+639−0
@@ -0,0 +1,639 @@
1+import { existsSync, mkdtempSync } from "node:fs";
2+import { tmpdir } from "node:os";
3+import { join } from "node:path";
4+
5+import { PGlite } from "@electric-sql/pglite";
6+import { NodeFileSystem } from "@effect/platform-node";
7+import { describe, expect, it } from "@effect/vitest";
8+import * as Effect from "effect/Effect";
9+
10+import { createControlPlaneRuntime } from "./index";
11+import {
12+ loadLocalExecutorConfig,
13+ resolveLocalWorkspaceContext,
14+} from "./local-config";
15+import {
16+ loadLocalControlPlaneState,
17+ localControlPlaneStatePath,
18+} from "./local-control-plane-store";
19+import { readLocalSourceArtifact } from "./local-source-artifacts";
20+import {
21+ loadLocalWorkspaceState,
22+ localWorkspaceStatePath,
23+} from "./local-workspace-state";
24+
25+const LEGACY_INSTALLATION_ID = "local_default";
26+const LEGACY_WORKSPACE_ID = "ws_legacy_default";
27+const LEGACY_ACCOUNT_ID = "acc_legacy_default";
28+const LEGACY_SOURCE_ID = "src_legacy_mcp";
29+const LEGACY_SECRET_ID = "sec_legacy_token";
30+const LEGACY_POLICY_ID = "pol_legacy_allow";
31+const LEGACY_EXECUTION_ID = "exec_legacy_run";
32+const LEGACY_INTERACTION_ID = "int_legacy_prompt";
33+const LEGACY_SESSION_ID = "sas_legacy_oauth";
34+const SECOND_LEGACY_SOURCE_ID = "src_should_not_migrate";
35+
36+const CREATED_AT = 1_700_000_000_000;
37+const UPDATED_AT = 1_700_000_000_500;
38+
39+const makeTempDir = (prefix: string) =>
40+ mkdtempSync(join(tmpdir(), prefix));
41+
42+const 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+
185+const 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+
473+const 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+
525+describe("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 @@
1+import { existsSync, readFileSync } from "node:fs";
2+import { mkdir, unlink } from "node:fs/promises";
3+import { join, resolve } from "node:path";
4+
5+import { FileSystem } from "@effect/platform";
6+import { PGlite } from "@electric-sql/pglite";
7+import {
8+ buildOpenApiToolPresentation,
9+ compileOpenApiToolDefinitions,
10+ extractOpenApiManifest,
11+ type OpenApiRefHintTable,
12+ type OpenApiToolProviderData,
13+} from "@executor/codemode-openapi";
14+import {
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";
27+import * as Effect from "effect/Effect";
28+import * as Schema from "effect/Schema";
29+import postgres from "postgres";
30+
31+import {
32+ buildGraphqlToolPresentation,
33+ compileGraphqlToolDefinitions,
34+ extractGraphqlManifest,
35+ type GraphqlToolProviderData,
36+} from "./graphql-tools";
37+import {
38+ type LocalControlPlaneState,
39+ writeLocalControlPlaneState,
40+} from "./local-control-plane-store";
41+import {
42+ type ResolvedLocalWorkspaceContext,
43+ writeProjectLocalExecutorConfig,
44+} from "./local-config";
45+import { deriveLocalInstallation } from "./local-installation";
46+import { derivePolicyConfigKey } from "./local-workspace-sync";
47+import {
48+ type LocalWorkspaceState,
49+ writeLocalWorkspaceState,
50+} from "./local-workspace-state";
51+import {
52+ buildLocalSourceArtifact,
53+ writeLocalSourceArtifact,
54+ type LocalSourceArtifact,
55+} from "./local-source-artifacts";
56+import { namespaceFromSourceName } from "./source-names";
57+import {
58+ createCatalogImportMetadata,
59+ createGraphqlCatalogFragment,
60+ createMcpCatalogFragment,
61+ createOpenApiCatalogFragment,
62+ type GraphqlCatalogOperationInput,
63+ type McpCatalogOperationInput,
64+ type OpenApiCatalogOperationInput,
65+} from "./source-catalog-snapshot";
66+import { authArtifactFromSourceAuth } from "./auth-artifacts";
67+import { createSourceCatalogSyncResult, contentHash } from "./source-catalog-support";
68+import { getSourceAdapter } from "./source-adapters";
69+
70+const LOCAL_CONTROL_PLANE_STATE_BASENAME = "control-plane-state.json";
71+const WORKSPACE_STATE_BASENAME = "workspace-state.json";
72+const LEGACY_LOCAL_INSTALLATION_ID = "local_default";
73+const LEGACY_POSTGRES_SECRET_PROVIDER_ID = "postgres";
74+const LOCAL_SECRET_PROVIDER_ID = "local";
75+const MIGRATABLE_LEGACY_SOURCE_KINDS = ["mcp", "openapi", "graphql"] as const;
76+
77+type MigratableLegacySourceKind = (typeof MIGRATABLE_LEGACY_SOURCE_KINDS)[number];
78+type LocalConfigSourceEntry = NonNullable<LocalExecutorConfig["sources"]>[string];
79+type LocalWorkspaceSourceState = LocalWorkspaceState["sources"][string];
80+
81+type LegacyRow = Record<string, unknown>;
82+
83+type LegacyLocalInstallationRow = {
84+ id: string;
85+ account_id: string;
86+ workspace_id: string;
87+};
88+
89+type LegacyWorkspaceRow = {
90+ id: string;
91+ name: string | null;
92+};
93+
94+type 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+
115+type 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+
129+type LegacySourceCredentialBindingRow = {
130+ id: string;
131+ workspace_id: string;
132+ source_id: string;
133+ credential_id: string;
134+};
135+
136+type 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+
145+type 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+
163+type 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+
178+type 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+
190+type 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+
215+type 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+
232+type 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+
246+type LegacyQueryClient = {
247+ label: string;
248+ query: (sql: string) => Promise<LegacyRow[]>;
249+ close: () => Promise<void>;
250+};
251+
252+type MigratedPolicyEntry = {
253+ key: string;
254+ config: NonNullable<LocalExecutorConfig["policies"]>[string];
255+ state: LocalWorkspaceState["policies"][string];
256+};
257+
258+type 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+
270+const encodeMcpSourceAuthSessionData = Schema.encodeSync(
271+ McpSourceAuthSessionDataJsonSchema,
272+);
273+
274+const 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+
283+const toError = (cause: unknown): Error =>
284+ cause instanceof Error ? cause : new Error(String(cause));
285+
286+const asRecord = (value: unknown): Record<string, unknown> =>
287+ value !== null && typeof value === "object" && !Array.isArray(value)
288+ ? (value as Record<string, unknown>)
289+ : {};
290+
291+const asString = (value: unknown): string => {
292+ const trimmed = trimOrNull(value);
293+ return trimmed ?? "";
294+};
295+
296+const asNullableString = (value: unknown): string | null => trimOrNull(value);
297+
298+const 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+
312+const asNullableNumber = (value: unknown): number | null => {
313+ if (value === null || value === undefined) {
314+ return null;
315+ }
316+ return asNumber(value);
317+};
318+
319+const 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+
333+const 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+
353+const 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+
365+const stableSourceNamespace = (row: Pick<LegacySourceRow, "name" | "namespace">): string =>
366+ trimOrNull(row.namespace) ?? namespaceFromSourceName(row.name);
367+
368+const 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+
381+const cloneJson = <T>(value: T): T =>
382+ JSON.parse(JSON.stringify(value)) as T;
383+
384+const isMigratableLegacySourceKind = (
385+ value: string,
386+): value is MigratableLegacySourceKind =>
387+ (MIGRATABLE_LEGACY_SOURCE_KINDS as readonly string[]).includes(value);
388+
389+const 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+
395+const decodeLegacyWorkspaceRow = (row: LegacyRow): LegacyWorkspaceRow => ({
396+ id: asString(row.id),
397+ name: asNullableString(row.name),
398+});
399+
400+const 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+
421+const 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+
435+const 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+
444+const 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+
454+const 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+
472+const 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+
487+const 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+
501+const 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+
528+const 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+
545+const isPostgresUrl = (value: string | undefined): boolean => {
546+ const trimmed = trimOrNull(value);
547+ return trimmed !== null
548+ && (trimmed.startsWith("postgres://") || trimmed.startsWith("postgresql://"));
549+};
550+
551+const 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+
574+const 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+
597+const 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+
612+const queryLegacyTable = async (
613+ client: LegacyQueryClient,
614+ tableName: string,
615+): Promise<LegacyRow[]> =>
616+ client.query(`select * from ${tableName}`);
617+
618+const 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+
627+const 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+
729+const 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+
768+const targetControlPlaneStatePath = (context: ResolvedLocalWorkspaceContext): string =>
769+ join(
770+ context.homeStateDirectory,
771+ "workspaces",
772+ deriveLocalInstallation(context).workspaceId,
773+ LOCAL_CONTROL_PLANE_STATE_BASENAME,
774+ );
775+
776+const targetWorkspaceStatePath = (context: ResolvedLocalWorkspaceContext): string =>
777+ join(context.stateDirectory, WORKSPACE_STATE_BASENAME);
778+
779+const workspaceAlreadyInitialized = (
780+ context: ResolvedLocalWorkspaceContext,
781+): boolean =>
782+ existsSync(context.projectConfigPath)
783+ || existsSync(targetWorkspaceStatePath(context))
784+ || existsSync(targetControlPlaneStatePath(context));
785+
786+const 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+
797+const 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+
812+const 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+
832+const 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+
869+const 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+
920+const 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+
972+const 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+
1071+const 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+
1087+const 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+
1159+const 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+
1223+const 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+
1280+const 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+
1297+const 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+
1519+export 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
55 type ParseError as JsoncParseError,
66 parse as parseJsoncDocument,
77 printParseErrorCode,
8−} from "jsonc-parser";
8+} from "jsonc-parser/lib/esm/main.js";
99
1010 import {
1111 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
4141 authArtifacts: [],
4242 authLeases: [],
4343 sourceOauthClients: [],
44+ workspaceOauthClients: [],
45+ providerAuthGrants: [],
4446 sourceAuthSessions: [],
4547 secretMaterials: [],
4648 executions: [],
@@ -76,6 +78,8 @@ describe("local-control-plane-store", () => { · 29 unmodified lines
7678 authArtifacts: [],
7779 authLeases: [],
7880 sourceOauthClients: [],
81+ workspaceOauthClients: [],
82+ providerAuthGrants: [],
7983 sourceAuthSessions: [],
8084 secretMaterials: [
8185 {
@@ -108,4 +112,33 @@ describe("local-control-plane-store", () => { · 26 unmodified lines
108112 ]);
109113 }),
110114 );
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+ );
111144 });
packages/control-plane/src/runtime/local-control-plane-store.ts+198−1
@@ -14,10 +14,14 @@ import { · 13 unmodified lines
1414 ExecutionInteractionSchema,
1515 ExecutionSchema,
1616 ExecutionStepSchema,
17+ type ProviderAuthGrant,
18+ ProviderAuthGrantSchema,
1719 SecretMaterialSchema,
1820 type SecretMaterial,
1921 type SourceAuthSession,
2022 SourceAuthSessionSchema,
23+ type WorkspaceOauthClient,
24+ WorkspaceOauthClientSchema,
2125 type WorkspaceSourceOauthClient,
2226 WorkspaceSourceOauthClientSchema,
2327 } from "#schema";
@@ -40,6 +44,8 @@ const LocalControlPlaneStateSchema = Schema.Struct({ · 16 unmodified lines
4044 authArtifacts: Schema.Array(AuthArtifactSchema),
4145 authLeases: Schema.Array(AuthLeaseSchema),
4246 sourceOauthClients: Schema.Array(WorkspaceSourceOauthClientSchema),
47+ workspaceOauthClients: Schema.Array(WorkspaceOauthClientSchema),
48+ providerAuthGrants: Schema.Array(ProviderAuthGrantSchema),
4349 sourceAuthSessions: Schema.Array(SourceAuthSessionSchema),
4450 secretMaterials: Schema.Array(SecretMaterialSchema),
4551 executions: Schema.Array(ExecutionSchema),
@@ -63,6 +69,8 @@ const defaultLocalControlPlaneState = (): LocalControlPlaneState => ({ · 17 unmodified lines
6369 authArtifacts: [],
6470 authLeases: [],
6571 sourceOauthClients: [],
72+ workspaceOauthClients: [],
73+ providerAuthGrants: [],
6674 sourceAuthSessions: [],
6775 secretMaterials: [],
6876 executions: [],
@@ -70,6 +78,30 @@ const defaultLocalControlPlaneState = (): LocalControlPlaneState => ({ · 1 unmodified line
7078 executionSteps: [],
7179 });
7280
81+const 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+
73105 const cloneValue = <T>(value: T): T =>
74106 JSON.parse(JSON.stringify(value)) as T;
75107
@@ -119,7 +151,9 @@ const readStateFromDisk = async ( · 43 unmodified lines
119151 }
120152
121153 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+ );
123157 };
124158
125159 const writeStateToDisk = async (
@@ -253,6 +287,34 @@ const mergeSourceOauthClients = ( · 127 unmodified lines
253287 return [...merged.values()];
254288 };
255289
290+const 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+
313+const mergeProviderAuthGrants = (
314+ current: readonly ProviderAuthGrant[],
315+ imported: readonly ProviderAuthGrant[],
316+): ProviderAuthGrant[] => mergeById(current, imported);
317+
256318 export const mergeImportedLocalControlPlaneState = (input: {
257319 current: LocalControlPlaneState;
258320 imported: Partial<Omit<LocalControlPlaneState, "version">>;
@@ -270,6 +332,14 @@ export const mergeImportedLocalControlPlaneState = (input: { · 11 unmodified lines
270332 input.current.sourceOauthClients,
271333 input.imported.sourceOauthClients ?? [],
272334 ),
335+ workspaceOauthClients: mergeWorkspaceOauthClients(
336+ input.current.workspaceOauthClients,
337+ input.imported.workspaceOauthClients ?? [],
338+ ),
339+ providerAuthGrants: mergeProviderAuthGrants(
340+ input.current.providerAuthGrants,
341+ input.imported.providerAuthGrants ?? [],
342+ ),
273343 sourceAuthSessions: mergeById(
274344 input.current.sourceAuthSessions,
275345 input.imported.sourceAuthSessions ?? [],
@@ -581,6 +651,133 @@ export const createLocalControlPlaneStore = ( · 305 unmodified lines
581651 }),
582652 },
583653
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+
584781 sourceAuthSessions: {
585782 listAll: () =>
586783 stateManager.read((state) => sortByUpdatedAtAndIdAsc(state.sourceAuthSessions)),
packages/control-plane/src/runtime/mcp-auth-provider.ts+262−0
@@ -0,0 +1,262 @@
1+import type {
2+ OAuthClientInformationMixed,
3+ OAuthTokens,
4+} from "@modelcontextprotocol/sdk/shared/auth.js";
5+import type {
6+ OAuthClientMetadata,
7+} from "@modelcontextprotocol/sdk/shared/auth.js";
8+import type {
9+ OAuthClientProvider,
10+ OAuthDiscoveryState,
11+} from "@modelcontextprotocol/sdk/client/auth.js";
12+import type {
13+ AuthArtifact,
14+ McpOAuthAuthArtifactConfig,
15+ SecretRef,
16+} from "#schema";
17+import {
18+ McpOAuthAuthArtifactConfigJsonSchema,
19+} from "#schema";
20+import * as Effect from "effect/Effect";
21+import * as Schema from "effect/Schema";
22+
23+import type { ControlPlaneStoreShape } from "./store";
24+import {
25+ createDefaultSecretMaterialDeleter,
26+ createDefaultSecretMaterialStorer,
27+ type ResolveSecretMaterial,
28+ type SecretMaterialResolveContext,
29+} from "./secret-material-providers";
30+
31+const encodeMcpOAuthAuthArtifactConfig = Schema.encodeSync(
32+ McpOAuthAuthArtifactConfigJsonSchema,
33+);
34+
35+const 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+
50+const stringifyJsonObject = (value: unknown): string | null =>
51+ value === null || value === undefined ? null : JSON.stringify(value);
52+
53+const 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+
61+const secretRefEquals = (left: SecretRef | null, right: SecretRef | null): boolean =>
62+ (left?.providerId ?? null) === (right?.providerId ?? null)
63+ && (left?.handle ?? null) === (right?.handle ?? null);
64+
65+const 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+
86+export 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+
113+export 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 @@
1+import type {
2+ AuthArtifact,
3+ ProviderAuthGrant,
4+ WorkspaceId,
5+} from "#schema";
6+import {
7+ decodeProviderGrantRefAuthArtifactConfig,
8+} from "#schema";
9+import * as Effect from "effect/Effect";
10+import * as Option from "effect/Option";
11+
12+import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
13+import type { ControlPlaneStoreShape } from "./store";
14+
15+const providerGrantRefFromArtifact = (
16+ artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
17+) => decodeProviderGrantRefAuthArtifactConfig(artifact);
18+
19+export const providerGrantIdFromArtifact = (
20+ artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
21+): ProviderAuthGrant["id"] | null =>
22+ providerGrantRefFromArtifact(artifact)?.grantId ?? null;
23+
24+export 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+
37+export 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+
54+export 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+
82+export 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
1010 type GoogleDiscoveryToolProviderData,
1111 } from "@executor/codemode-google-discovery";
1212 import type { Source } from "#schema";
13−import { StringMapSchema } from "#schema";
13+import { StringMapSchema, WorkspaceOauthClientIdSchema } from "#schema";
1414 import * as Effect from "effect/Effect";
1515 import * as Schema from "effect/Schema";
1616
@@ -46,6 +46,9 @@ const GoogleDiscoveryConnectPayloadSchema = Schema.extend( · 29 unmodified lines
4646 scopes: Schema.optional(
4747 Schema.Array(Schema.Trim.pipe(Schema.nonEmptyString())),
4848 ),
49+ workspaceOauthClientId: Schema.optional(
50+ Schema.NullOr(WorkspaceOauthClientIdSchema),
51+ ),
4952 oauthClient: ConnectOauthClientSchema,
5053 name: OptionalNullableStringSchema,
5154 namespace: OptionalNullableStringSchema,
packages/control-plane/src/runtime/source-adapters/mcp.test.ts+227−0
@@ -1,6 +1,7 @@
11 import { randomUUID } from "node:crypto";
22
33 import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
4+import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
45 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
56 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
67 import { describe, expect, it } from "@effect/vitest";
@@ -215,6 +216,151 @@ const makeRealMcpServer = Effect.acquireRelease( · 208 unmodified lines
215216 }).pipe(Effect.orDie),
216217 );
217218
219+const 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+
337+const 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+
218364 describe("mcp source adapter", () => {
219365 it.scoped("syncs MCP annotations and introspection metadata into snapshot", () =>
220366 Effect.gen(function* () {
@@ -389,4 +535,85 @@ describe("mcp source adapter", () => { · 168 unmodified lines
389535 });
390536 }),
391537 );
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+ );
392619 });
packages/control-plane/src/runtime/source-adapters/mcp.ts+1−0
@@ -272,6 +272,7 @@ export const mcpSourceAdapter: SourceAdapter = { · 271 unmodified lines
272272 authHeaders: auth.headers,
273273 authCookies: auth.cookies,
274274 }),
275+ authProvider: auth.authProvider,
275276 }),
276277 catch: (cause) =>
277278 cause instanceof Error ? cause : new Error(String(cause)),
packages/control-plane/src/runtime/source-auth-material.test.ts+94−0
@@ -1,7 +1,9 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import * as Effect from "effect/Effect";
3+import * as Option from "effect/Option";
34
45 import {
6+ AuthArtifactIdSchema,
57 SourceIdSchema,
68 WorkspaceIdSchema,
79 type Source,
@@ -150,5 +152,97 @@ describe("source-auth-material", () => { · 142 unmodified lines
150152 });
151153 }));
152154 });
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+ });
153247 });
154248 });
packages/control-plane/src/runtime/source-auth-material.ts+5−1
@@ -109,7 +109,11 @@ export const resolveSourceAuthMaterialWithDeps = (input: { · 108 unmodified lines
109109 });
110110 }
111111
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+ ) {
113117 return yield* Effect.fail(
114118 new Error("Dynamic auth artifacts require persistence-backed lease resolution"),
115119 );
packages/control-plane/src/runtime/source-auth-service.ts+1312−77
@@ -11,6 +11,10 @@ import { · 10 unmodified lines
1111 type McpSourceAuthSessionData,
1212 OAuth2PkceSourceAuthSessionDataJsonSchema,
1313 type OAuth2PkceSourceAuthSessionData,
14+ type ProviderAuthGrant,
15+ ProviderAuthGrantIdSchema,
16+ ProviderOauthBatchSourceAuthSessionDataJsonSchema,
17+ type ProviderOauthBatchSourceAuthSessionData,
1418 type SecretMaterialPurpose,
1519 Source,
1620 type SourceImportAuthPolicy,
@@ -22,8 +26,10 @@ import { · 5 unmodified lines
2226 SourceSchema,
2327 type SecretRef,
2428 type StringMap,
29+ WorkspaceOauthClientIdSchema,
2530 WorkspaceSourceOauthClientIdSchema,
2631 WorkspaceSourceOauthClientMetadataJsonSchema,
32+ type WorkspaceOauthClient,
2733 type WorkspaceSourceOauthClientRedirectMode,
2834 type WorkspaceId,
2935 } from "#schema";
@@ -49,15 +55,18 @@ import { · 19 unmodified lines
4955 exchangeMcpOAuthAuthorizationCode,
5056 startMcpOAuthAuthorization,
5157 } from "./mcp-oauth";
58+import { createPersistedMcpOAuthSourceAuth } from "./mcp-auth-provider";
5259 import {
5360 createSourceFromPayload,
5461 updateSourceFromPayload,
5562 } from "./source-definitions";
5663 import {
64+ getSourceAdapter,
5765 getSourceAdapterForSource,
5866 hasSourceAdapterFamily,
5967 sourceBindingStateFromSource,
6068 } from "./source-adapters";
69+import type { SourceAdapterOauth2SetupConfig } from "./source-adapters/types";
6170 import { isSourceCredentialRequiredError } from "./source-adapters/shared";
6271 import {
6372 createDefaultSecretMaterialDeleter,
@@ -66,7 +75,14 @@ import { · 2 unmodified lines
6675 SecretMaterialStorerService,
6776 type StoreSecretMaterial,
6877 } from "./secret-material-providers";
69−import { upsertOauth2AuthorizedUserLeaseFromTokenResponse } from "./auth-leases";
78+import {
79+ removeAuthLeaseAndSecrets,
80+ upsertOauth2AuthorizedUserLeaseFromTokenResponse,
81+} from "./auth-leases";
82+import {
83+ listProviderGrantRefArtifacts,
84+ removeProviderAuthGrantSecret,
85+} from "./provider-grant-lifecycle";
7086 import {
7187 RuntimeSourceCatalogSyncService,
7288 type RuntimeSourceCatalogSyncShape,
@@ -129,6 +145,15 @@ const resolveSourceOAuthCallbackUrl = (input: { · 56 unmodified lines
129145 input.baseUrl,
130146 ).toString();
131147
148+const 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+
132157 const normalizeEndpoint = (endpoint: string): string => {
133158 const url = new URL(endpoint.trim());
134159 return url.toString();
@@ -301,6 +326,42 @@ const mergeOauth2PkceSourceAuthSessionData = (input: { · 166 unmodified lines
301326 });
302327 };
303328
329+const encodeProviderOauthBatchSourceAuthSessionData = (
330+ sessionData: ProviderOauthBatchSourceAuthSessionData,
331+): string => Schema.encodeSync(ProviderOauthBatchSourceAuthSessionDataJsonSchema)(sessionData);
332+
333+const decodeProviderOauthBatchSourceAuthSessionDataJson = Schema.decodeUnknownEither(
334+ ProviderOauthBatchSourceAuthSessionDataJsonSchema,
335+);
336+
337+const 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+
354+const 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+
304365
305366 const completeLiveInteraction = (input: {
306367 rows: ControlPlaneStoreShape;
@@ -479,6 +540,7 @@ export type ExecutorAddSourceInput = · 172 unmodified lines
479540 version: string;
480541 discoveryUrl?: string | null;
481542 scopes?: ReadonlyArray<string> | null;
543+ workspaceOauthClientId?: WorkspaceOauthClient["id"] | null;
482544 oauthClient?: SourceOauthClientInput | null;
483545 name?: string | null;
484546 namespace?: string | null;
@@ -501,6 +563,42 @@ export type ConnectMcpSourceInput = { · 16 unmodified lines
501563 baseUrl?: string | null;
502564 };
503565
566+export 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+
583+export 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+
595+export type CreateWorkspaceOauthClientInput = {
596+ workspaceId: WorkspaceId;
597+ providerKey: string;
598+ label?: string | null;
599+ oauthClient: SourceOauthClientInput;
600+};
601+
504602 export type McpSourceConnectResult = Extract<ExecutorSourceAddResult, {
505603 kind: "connected" | "oauth_required";
506604 }>;
@@ -531,6 +629,16 @@ export type CompleteSourceOAuthSessionResult = { · 24 unmodified lines
531629 auth: Extract<Source["auth"], { kind: "oauth2" }>;
532630 };
533631
632+export type CompleteSourceCredentialSetupResult = {
633+ sessionId: SourceAuthSession["id"];
634+ source: Source;
635+};
636+
637+export type CompleteProviderOauthCallbackResult = {
638+ sessionId: SourceAuthSession["id"];
639+ sources: ReadonlyArray<Source>;
640+};
641+
534642 const materializeSecretRefInput = (input: {
535643 rawValue?: string | null;
536644 ref?: SecretRef | null;
@@ -713,6 +821,11 @@ type ResolvedSourceOauthClient = { · 176 unmodified lines
713821 redirectMode: WorkspaceSourceOauthClientRedirectMode;
714822 };
715823
824+type ResolvedWorkspaceOauthClient = ResolvedSourceOauthClient & {
825+ id: WorkspaceOauthClient["id"];
826+ label: string | null;
827+};
828+
716829 const decodeWorkspaceSourceOauthClientMetadataOption = Schema.decodeUnknownOption(
717830 WorkspaceSourceOauthClientMetadataJsonSchema,
718831 );
@@ -864,6 +977,235 @@ const resolveExistingSourceOauthClient = (input: { · 145 unmodified lines
864977 };
865978 });
866979
980+const 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+
1031+const 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+
1062+const 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+
1099+const 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+
1107+const normalizeScopes = (scopes: ReadonlyArray<string>): ReadonlyArray<string> =>
1108+ [...new Set(scopes.map((scope) => scope.trim()).filter((scope) => scope.length > 0))];
1109+
1110+const 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+
1134+const 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+
8671209 const startOauth2PkceSourceCredentialSetup = (input: {
8681210 rows: ControlPlaneStoreShape;
8691211 sourceStore: RuntimeSourceStore;
@@ -982,82 +1324,311 @@ const startOauth2PkceSourceCredentialSetup = (input: { · 112 unmodified lines
9821324 );
9831325 });
9841326
985−const connectMcpSourceInternal = (input: {
1327+const startProviderOauthBatchCredentialSetup = (input: {
9861328 rows: ControlPlaneStoreShape;
9871329 sourceStore: RuntimeSourceStore;
988− sourceCatalogSync: RuntimeSourceCatalogSyncShape;
989− getLocalServerBaseUrl?: () => string | undefined;
990− baseUrl?: string | null;
9911330 workspaceId: WorkspaceId;
9921331 actorAccountId?: AccountId | null;
993− sourceId?: Source["id"] | null;
9941332 executionId?: SourceAuthSession["executionId"];
9951333 interactionId?: SourceAuthSession["interactionId"];
996− endpoint: string;
997− name?: string | null;
998− namespace?: string | null;
999− enabled?: boolean;
1000− transport?: SourceTransport;
1001− queryParams?: StringMap | null;
1002− headers?: StringMap | null;
1003− mcpDiscoveryElicitation?: McpDiscoveryElicitationContext;
1004− resolveSecretMaterial: ResolveSecretMaterial;
1005−}): Effect.Effect<McpSourceConnectResult, Error, WorkspaceStorageServices> =>
1334+ baseUrl: string;
1335+ redirectModeOverride?: WorkspaceSourceOauthClientRedirectMode;
1336+ workspaceOauthClient: ResolvedWorkspaceOauthClient;
1337+ setupConfig: SourceAdapterOauth2SetupConfig;
1338+ targetSources: ReadonlyArray<{
1339+ source: Source;
1340+ requiredScopes: ReadonlyArray<string>;
1341+ }>;
1342+}): Effect.Effect<{
1343+ sessionId: SourceAuthSession["id"];
1344+ authorizationUrl: string;
1345+ source: Source;
1346+}, Error, WorkspaceStorageServices> =>
10061347 Effect.gen(function* () {
1007− const normalizedEndpoint = normalizeEndpoint(input.endpoint);
1008− const existing = yield* (
1009− input.sourceId
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− );
1348+ if (input.targetSources.length === 0) {
1349+ return yield* Effect.fail(new Error("Provider OAuth setup requires at least one target source"));
1350+ }
10331351
1034− const chosenName =
1035− trimOrNull(input.name) ?? existing?.name ?? defaultSourceNameFromEndpoint(normalizedEndpoint);
1036− const chosenNamespace =
1037− trimOrNull(input.namespace)
1038− ?? existing?.namespace
1039− ?? defaultNamespaceFromName(chosenName);
1040− const chosenEnabled = input.enabled ?? existing?.enabled ?? true;
1041− const existingBinding = existing
1042− ? yield* sourceBindingStateFromSource(existing)
1352+ const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`);
1353+ const state = crypto.randomUUID();
1354+ const completionUrl = resolveWorkspaceProviderOauthCompleteUrl({
1355+ baseUrl: input.baseUrl,
1356+ workspaceId: input.workspaceId,
1357+ });
1358+ const redirectMode = input.redirectModeOverride ?? input.workspaceOauthClient.redirectMode;
1359+ const redirectServer = redirectMode === "loopback"
1360+ ? yield* startOauthLoopbackRedirectServer({
1361+ completionUrl,
1362+ })
10431363 : null;
1044− const chosenTransport = input.transport ?? existingBinding?.transport ?? "auto";
1045− const chosenQueryParams =
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();
1364+ const redirectUri = redirectServer?.redirectUri ?? completionUrl;
1365+ const codeVerifier = createPkceCodeVerifier();
10501366
1051− const draftSource = existing
1052− ? yield* updateSourceFromPayload({
1053− source: existing,
1054− payload: {
1055− name: chosenName,
1056− endpoint: normalizedEndpoint,
1057− namespace: chosenNamespace,
1058− status: "probing",
1059− enabled: chosenEnabled,
1060− binding: {
1367+ return yield* Effect.gen(function* () {
1368+ const authorizationUrl = buildOAuth2AuthorizationUrl({
1369+ authorizationEndpoint: input.setupConfig.authorizationEndpoint,
1370+ clientId: input.workspaceOauthClient.clientId,
1371+ redirectUri,
1372+ scopes: [...normalizeScopes(input.setupConfig.scopes)],
1373+ state,
1374+ codeVerifier,
1375+ extraParams: input.setupConfig.authorizationParams,
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+
1452+const 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+
1493+const 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+
1556+const 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: {
10611632 transport: chosenTransport,
10621633 queryParams: chosenQueryParams,
10631634 headers: chosenHeaders,
@@ -1545,6 +2116,121 @@ const addExecutorGoogleDiscoverySource = (input: { · 481 unmodified lines
15452116 const persistedDraft = yield* input.sourceStore.persistSource(draftSource, {
15462117 actorAccountId: input.sourceInput.actorAccountId,
15472118 });
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+ }
15482234
15492235 if (input.sourceInput.oauthClient) {
15502236 yield* upsertSourceOauthClient({
@@ -1641,6 +2327,237 @@ const addExecutorGoogleDiscoverySource = (input: { · 90 unmodified lines
16412327 });
16422328 });
16432329
2330+const 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+
16442561 type RuntimeSourceAuthServiceShape = {
16452562 getSourceById: (input: {
16462563 workspaceId: WorkspaceId;
@@ -1659,9 +2576,27 @@ type RuntimeSourceAuthServiceShape = { · 12 unmodified lines
16592576 baseUrl?: string | null;
16602577 },
16612578 ) => Effect.Effect<ExecutorSourceAddResult, Error, WorkspaceStorageServices>;
2579+ connectGoogleDiscoveryBatch: (
2580+ input: ConnectGoogleDiscoveryBatchInput,
2581+ ) => Effect.Effect<ConnectGoogleDiscoveryBatchResult, Error, WorkspaceStorageServices>;
16622582 connectMcpSource: (
16632583 input: ConnectMcpSourceInput,
16642584 ) => 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>;
16652600 startSourceOAuthSession: (
16662601 input: StartSourceOAuthSessionInput,
16672602 ) => Effect.Effect<StartSourceOAuthSessionResult, Error, never>;
@@ -1671,6 +2606,14 @@ type RuntimeSourceAuthServiceShape = { · 3 unmodified lines
16712606 error?: string | null;
16722607 errorDescription?: string | null;
16732608 }) => 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>;
16742617 completeSourceCredentialSetup: (input: {
16752618 workspaceId: WorkspaceId;
16762619 sourceId: Source["id"];
@@ -1679,7 +2622,7 @@ type RuntimeSourceAuthServiceShape = { · 2 unmodified lines
16792622 code?: string | null;
16802623 error?: string | null;
16812624 errorDescription?: string | null;
1682− }) => Effect.Effect<Source, Error, WorkspaceStorageServices>;
2625+ }) => Effect.Effect<CompleteSourceCredentialSetupResult, Error, WorkspaceStorageServices>;
16832626 };
16842627
16852628 type RuntimeSourceAuthDependencies = {
@@ -1699,12 +2642,22 @@ type ProvideLocalWorkspace = <A, E, R>( · 13 unmodified lines
16992642
17002643 type RuntimeSourceConnectionServiceShape = Pick<
17012644 RuntimeSourceAuthServiceShape,
1702− "getSourceById" | "addExecutorSource" | "connectMcpSource"
2645+ | "getSourceById"
2646+ | "addExecutorSource"
2647+ | "connectGoogleDiscoveryBatch"
2648+ | "connectMcpSource"
2649+ | "listWorkspaceOauthClients"
2650+ | "createWorkspaceOauthClient"
2651+ | "removeWorkspaceOauthClient"
2652+ | "removeProviderAuthGrant"
17032653 >;
17042654
17052655 type RuntimeSourceOAuthSessionServiceShape = Pick<
17062656 RuntimeSourceAuthServiceShape,
1707− "startSourceOAuthSession" | "completeSourceOAuthSession" | "completeSourceCredentialSetup"
2657+ | "startSourceOAuthSession"
2658+ | "completeSourceOAuthSession"
2659+ | "completeProviderOauthCallback"
2660+ | "completeSourceCredentialSetup"
17082661 >;
17092662
17102663 const createRuntimeSourceConnectionService = (
@@ -1777,6 +2730,17 @@ const createRuntimeSourceConnectionService = ( · 66 unmodified lines
17772730 ),
17782731 ),
17792732
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+
17802744 connectMcpSource: (sourceInput) =>
17812745 provideLocalWorkspace(
17822746 connectMcpSourceInternal({
@@ -1802,6 +2766,83 @@ const createRuntimeSourceConnectionService = ( · 19 unmodified lines
18022766 Effect.flatMap(mirrorLocalMcpSourceResult),
18032767 ),
18042768 ),
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+ ),
18052846 };
18062847 };
18072848
@@ -1981,6 +3022,184 @@ const createRuntimeSourceOAuthSessionService = ( · 173 unmodified lines
19813022 } satisfies CompleteSourceOAuthSessionResult;
19823023 })),
19833024
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+
19843203 completeSourceCredentialSetup: ({ workspaceId, sourceId, actorAccountId, state, code, error, errorDescription }) =>
19853204 provideLocalWorkspace(Effect.gen(function* () {
19863205 const sessionOption = yield* input.rows.sourceAuthSessions.getByState(state);
@@ -2012,7 +3231,10 @@ const createRuntimeSourceOAuthSessionService = ( · 25 unmodified lines
20123231 });
20133232
20143233 if (session.status === "completed") {
2015− return source;
3234+ return {
3235+ sessionId: session.id,
3236+ source,
3237+ } satisfies CompleteSourceCredentialSetupResult;
20163238 }
20173239
20183240 if (session.status !== "pending") {
@@ -2186,13 +3408,23 @@ const createRuntimeSourceOAuthSessionService = ( · 167 unmodified lines
21863408 actorAccountId: session.actorAccountId,
21873409 status: "connected",
21883410 lastError: null,
2189− auth: {
2190− kind: "oauth2",
2191− headerName: "Authorization",
2192− prefix: "Bearer ",
3411+ auth: createPersistedMcpOAuthSourceAuth({
3412+ redirectUri: mcpSessionData.redirectUri,
21933413 accessToken: accessTokenRef,
21943414 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+ }),
21963428 });
21973429
21983430 yield* input.rows.sourceAuthSessions.update(
@@ -2242,7 +3474,10 @@ const createRuntimeSourceOAuthSessionService = ( · 43 unmodified lines
22423474 },
22433475 });
22443476
2245− return connectedSource;
3477+ return {
3478+ sessionId: session.id,
3479+ source: connectedSource,
3480+ } satisfies CompleteSourceCredentialSetupResult;
22463481 })),
22473482 });
22483483
packages/control-plane/src/runtime/source-definitions.ts+77−4
@@ -22,6 +22,7 @@ import type { · 21 unmodified lines
2222 } from "#schema";
2323 import {
2424 AuthArtifactIdSchema,
25+ ProviderAuthGrantIdSchema,
2526 SourceCatalogIdSchema,
2627 SourceCatalogRevisionIdSchema,
2728 } from "#schema";
@@ -90,10 +91,9 @@ const normalizeAuth = ( · 62 unmodified lines
9091 return { kind: "none" } satisfies SourceAuth;
9192 }
9293
93− const headerName = trimOrNull(auth.headerName) ?? "Authorization";
94− const prefix = auth.prefix ?? "Bearer ";
95−
96− if (auth.kind === "bearer") {
94+ if (auth.kind === "bearer") {
95+ const headerName = trimOrNull(auth.headerName) ?? "Authorization";
96+ const prefix = auth.prefix ?? "Bearer ";
9797 const providerId = trimOrNull(auth.token.providerId);
9898 const handle = trimOrNull(auth.token.handle);
9999 if (providerId === null || handle === null) {
@@ -112,6 +112,8 @@ const normalizeAuth = ( · 12 unmodified lines
112112 }
113113
114114 if (auth.kind === "oauth2_authorized_user") {
115+ const headerName = trimOrNull(auth.headerName) ?? "Authorization";
116+ const prefix = auth.prefix ?? "Bearer ";
115117 const refreshProviderId = trimOrNull(auth.refreshToken.providerId);
116118 const refreshHandle = trimOrNull(auth.refreshToken.handle);
117119 if (refreshProviderId === null || refreshHandle === null) {
@@ -159,6 +161,77 @@ const normalizeAuth = ( · 41 unmodified lines
159161 } satisfies SourceAuth;
160162 }
161163
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 ";
162235 const accessProviderId = trimOrNull(auth.accessToken.providerId);
163236 const accessHandle = trimOrNull(auth.accessToken.handle);
164237 if (accessProviderId === null || accessHandle === null) {
packages/control-plane/src/runtime/source-store.ts+59−1
@@ -3,12 +3,16 @@ import type { · 2 unmodified lines
33 AuthArtifact,
44 CredentialSlot,
55 LocalConfigSecretInput,
6+ ProviderAuthGrant,
67 LocalConfigSource,
78 Source,
89 SourceId,
910 WorkspaceId,
1011 } from "#schema";
11−import { SourceIdSchema } from "#schema";
12+import {
13+ decodeProviderGrantRefAuthArtifactConfig,
14+ SourceIdSchema,
15+} from "#schema";
1216 import * as Context from "effect/Context";
1317 import * as Effect from "effect/Effect";
1418 import * as Layer from "effect/Layer";
@@ -60,6 +64,10 @@ import { · 45 unmodified lines
6064 import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
6165 import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
6266 import { removeAuthLeaseAndSecrets } from "./auth-leases";
67+import {
68+ clearProviderGrantOrphanedAt,
69+ markProviderGrantOrphanedIfUnused,
70+} from "./provider-grant-lifecycle";
6371 import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
6472 import { getSourceAdapter } from "./source-adapters";
6573 import { refreshWorkspaceSourceTypeDeclarationsInBackground } from "./source-type-declarations";
@@ -92,6 +100,15 @@ const cleanupAuthArtifactSecretRefs = (rows: ControlPlaneStoreShape, input: { · 26 unmodified lines
92100 );
93101 });
94102
103+const 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+
95112 const selectPreferredAuthArtifact = (input: {
96113 authArtifacts: ReadonlyArray<AuthArtifact>;
97114 actorAccountId?: AccountId | null;
@@ -804,6 +821,11 @@ const removeSourceByIdWithDeps = (deps: RuntimeSourceStoreDeps, input: { · 706 unmodified lines
804821 context: localWorkspace.context,
805822 sourceId: input.sourceId,
806823 });
824+ const existingAuthArtifacts = yield* deps.rows.authArtifacts.listByWorkspaceAndSourceId({
825+ workspaceId: input.workspaceId,
826+ sourceId: input.sourceId,
827+ });
828+ const removedGrantIds = providerGrantIdsFromArtifacts(existingAuthArtifacts);
807829
808830 yield* deps.rows.sourceAuthSessions.removeByWorkspaceAndSourceId(
809831 input.workspaceId,
@@ -814,6 +836,15 @@ const removeSourceByIdWithDeps = (deps: RuntimeSourceStoreDeps, input: { · 4 unmodified lines
814836 sourceId: input.sourceId,
815837 });
816838 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+ );
817848 yield* syncWorkspaceSourceTypeDeclarationsWithDeps(deps, input.workspaceId);
818849
819850 return true;
@@ -947,6 +978,33 @@ const persistSourceWithDeps = ( · 127 unmodified lines
947978 next: importAuthArtifact,
948979 });
949980
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+
9501008 const existingSourceState = localWorkspace.workspaceState.sources[nextSource.id];
9511009 const workspaceState: LocalWorkspaceState = {
9521010 ...localWorkspace.workspaceState,
packages/control-plane/src/runtime/store.ts+28−0
@@ -7,8 +7,10 @@ import type { · 6 unmodified lines
77 Execution,
88 ExecutionInteraction,
99 ExecutionStep,
10+ ProviderAuthGrant,
1011 SecretMaterial,
1112 SourceAuthSession,
13+ WorkspaceOauthClient,
1214 WorkspaceSourceOauthClient,
1315 } from "#schema";
1416
@@ -76,6 +78,32 @@ export type ControlPlaneStoreShape = { · 61 unmodified lines
7678 sourceId: WorkspaceSourceOauthClient["sourceId"];
7779 }) => Effect.Effect<number, Error, never>;
7880 };
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+ };
79107 sourceAuthSessions: {
80108 listAll: () => Effect.Effect<readonly SourceAuthSession[], Error, never>;
81109 listByWorkspaceId: (
packages/control-plane/src/schema/ids.ts+8−0
@@ -20,6 +20,12 @@ export const CredentialIdSchema = AuthArtifactIdSchema; · 19 unmodified lines
2020 export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(
2121 Schema.brand("WorkspaceSourceOauthClientId"),
2222 );
23+export const WorkspaceOauthClientIdSchema = Schema.String.pipe(
24+ Schema.brand("WorkspaceOauthClientId"),
25+);
26+export const ProviderAuthGrantIdSchema = Schema.String.pipe(
27+ Schema.brand("ProviderAuthGrantId"),
28+);
2329 export const SecretMaterialIdSchema = Schema.String.pipe(
2430 Schema.brand("SecretMaterialId"),
2531 );
@@ -42,6 +48,8 @@ export type AuthArtifactId = typeof AuthArtifactIdSchema.Type; · 16 unmodified lines
4248 export type AuthLeaseId = typeof AuthLeaseIdSchema.Type;
4349 export type CredentialId = typeof CredentialIdSchema.Type;
4450 export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
51+export type WorkspaceOauthClientId = typeof WorkspaceOauthClientIdSchema.Type;
52+export type ProviderAuthGrantId = typeof ProviderAuthGrantIdSchema.Type;
4553 export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
4654 export type PolicyId = typeof PolicyIdSchema.Type;
4755 export 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
88 export * from "./models/auth-artifact";
99 export * from "./models/auth-lease";
1010 export * from "./models/source-oauth-client";
11+export * from "./models/workspace-oauth-client";
12+export * from "./models/provider-auth-grant";
1113 export * from "./models/secret-material";
1214 export * from "./models/source-auth-session";
1315 export * 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
44 import {
55 AccountIdSchema,
66 AuthArtifactIdSchema,
7+ ProviderAuthGrantIdSchema,
78 SourceIdSchema,
89 WorkspaceIdSchema,
910 } from "../ids";
@@ -85,6 +86,8 @@ export const StaticOAuth2AuthArtifactKind = "static_oauth2" as const; · 75 unmodified lines
8586 export const StaticPlacementsAuthArtifactKind = "static_placements" as const;
8687 export const RefreshableOAuth2AuthorizedUserAuthArtifactKind =
8788 "oauth2_authorized_user" as const;
89+export const ProviderGrantRefAuthArtifactKind = "provider_grant_ref" as const;
90+export const McpOAuthAuthArtifactKind = "mcp_oauth" as const;
8891
8992 export const OAuth2ClientAuthenticationMethodSchema = Schema.Literal(
9093 "none",
@@ -125,6 +128,28 @@ export const RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema = Schema.St · 34 unmodified lines
125128 refreshToken: SecretRefSchema,
126129 });
127130
131+export 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+
139+export 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+
128153 export const StaticBearerAuthArtifactConfigJsonSchema = Schema.parseJson(
129154 StaticBearerAuthArtifactConfigSchema,
130155 );
@@ -141,6 +166,14 @@ export const RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema = Schem · 10 unmodified lines
141166 RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema,
142167 );
143168
169+export const ProviderGrantRefAuthArtifactConfigJsonSchema = Schema.parseJson(
170+ ProviderGrantRefAuthArtifactConfigSchema,
171+);
172+
173+export const McpOAuthAuthArtifactConfigJsonSchema = Schema.parseJson(
174+ McpOAuthAuthArtifactConfigSchema,
175+);
176+
144177 export const RequestPlacementsJsonSchema = Schema.parseJson(
145178 Schema.Array(RequestPlacementSchema),
146179 );
@@ -162,6 +195,12 @@ const decodeStaticPlacementsAuthArtifactConfigOption = Schema.decodeUnknownOptio · 15 unmodified lines
162195 const decodeRefreshableOAuth2AuthorizedUserAuthArtifactConfigOption = Schema.decodeUnknownOption(
163196 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
164197 );
198+const decodeProviderGrantRefAuthArtifactConfigOption = Schema.decodeUnknownOption(
199+ ProviderGrantRefAuthArtifactConfigJsonSchema,
200+);
201+const decodeMcpOAuthAuthArtifactConfigOption = Schema.decodeUnknownOption(
202+ McpOAuthAuthArtifactConfigJsonSchema,
203+);
165204
166205 const decodeAuthGrantSetOption = Schema.decodeUnknownOption(AuthGrantSetJsonSchema);
167206
@@ -194,6 +233,9 @@ export type StaticOAuth2AuthArtifactConfig = typeof StaticOAuth2AuthArtifactConf · 26 unmodified lines
194233 export type StaticPlacementsAuthArtifactConfig = typeof StaticPlacementsAuthArtifactConfigSchema.Type;
195234 export type RefreshableOAuth2AuthorizedUserAuthArtifactConfig =
196235 typeof RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema.Type;
236+export type ProviderGrantRefAuthArtifactConfig =
237+ typeof ProviderGrantRefAuthArtifactConfigSchema.Type;
238+export type McpOAuthAuthArtifactConfig = typeof McpOAuthAuthArtifactConfigSchema.Type;
197239 export type AuthArtifact = typeof AuthArtifactSchema.Type;
198240
199241 export type DecodedBuiltInAuthArtifactConfig =
@@ -214,6 +256,28 @@ export type DecodedBuiltInAuthArtifactConfig = · 14 unmodified lines
214256 config: RefreshableOAuth2AuthorizedUserAuthArtifactConfig;
215257 };
216258
259+export 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+
270+export 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+
217281 export const decodeBuiltInAuthArtifactConfig = (
218282 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
219283 ): DecodedBuiltInAuthArtifactConfig | null => {
@@ -273,28 +337,35 @@ export const authArtifactSecretRefs = ( · 53 unmodified lines
273337 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
274338 ): ReadonlyArray<SecretRef> => {
275339 const decoded = decodeBuiltInAuthArtifactConfig(artifact);
276− if (decoded === null) {
277− return [];
340+ if (decoded !== null) {
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+ }
278359 }
279360
280− switch (decoded.artifactKind) {
281− case StaticBearerAuthArtifactKind:
282− return [decoded.config.token];
283− case StaticOAuth2AuthArtifactKind:
284− return decoded.config.refreshToken
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];
361+ const mcpOAuthConfig = decodeMcpOAuthAuthArtifactConfig(artifact);
362+ if (mcpOAuthConfig !== null) {
363+ return mcpOAuthConfig.refreshToken
364+ ? [mcpOAuthConfig.accessToken, mcpOAuthConfig.refreshToken]
365+ : [mcpOAuthConfig.accessToken];
297366 }
367+
368+ return [];
298369 };
299370
300371 export const authArtifactSecretMaterialRefs = authArtifactSecretRefs;
packages/control-plane/src/schema/models/provider-auth-grant.ts+34−0
@@ -0,0 +1,34 @@
1+import { Schema } from "effect";
2+
3+import { TimestampMsSchema } from "../common";
4+import {
5+ AccountIdSchema,
6+ ProviderAuthGrantIdSchema,
7+ WorkspaceIdSchema,
8+ WorkspaceOauthClientIdSchema,
9+} from "../ids";
10+
11+import {
12+ OAuth2ClientAuthenticationMethodSchema,
13+ SecretRefSchema,
14+} from "./auth-artifact";
15+
16+export 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+
34+export 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
55 AccountIdSchema,
66 ExecutionIdSchema,
77 ExecutionInteractionIdSchema,
8− SourceAuthSessionIdSchema,
98 SourceIdSchema,
9+ SourceAuthSessionIdSchema,
10+ WorkspaceOauthClientIdSchema,
1011 WorkspaceIdSchema,
1112 } from "../ids";
1213 import {
@@ -97,6 +98,35 @@ export const OAuth2PkceSourceAuthSessionDataJsonSchema = Schema.parseJson( · 84 unmodified lines
9798 OAuth2PkceSourceAuthSessionDataSchema,
9899 );
99100
101+export const ProviderOauthTargetSourceSchema = Schema.Struct({
102+ sourceId: SourceIdSchema,
103+ requiredScopes: Schema.Array(Schema.String),
104+});
105+
106+export 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+
126+export const ProviderOauthBatchSourceAuthSessionDataJsonSchema = Schema.parseJson(
127+ ProviderOauthBatchSourceAuthSessionDataSchema,
128+);
129+
100130 export const SourceAuthSessionSchema = Schema.Struct({
101131 id: SourceAuthSessionIdSchema,
102132 workspaceId: WorkspaceIdSchema,
@@ -120,4 +150,7 @@ export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; · 17 unmodified lines
120150 export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type;
121151 export type OAuth2PkceSourceAuthSessionData =
122152 typeof OAuth2PkceSourceAuthSessionDataSchema.Type;
153+export type ProviderOauthTargetSource = typeof ProviderOauthTargetSourceSchema.Type;
154+export type ProviderOauthBatchSourceAuthSessionData =
155+ typeof ProviderOauthBatchSourceAuthSessionDataSchema.Type;
123156 export 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
22
33 import { TimestampMsSchema } from "../common";
44 import {
5+ ProviderAuthGrantIdSchema,
56 SourceIdSchema,
67 SourceCatalogIdSchema,
78 SourceCatalogRevisionIdSchema,
@@ -60,6 +61,28 @@ export const SourceAuthSchema = Schema.Union( · 52 unmodified lines
6061 refreshToken: SecretRefSchema,
6162 grantSet: Schema.NullOr(Schema.Array(Schema.String)),
6263 }),
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+ }),
6386 );
6487
6588 export const StringMapSchema = Schema.Record({
packages/control-plane/src/schema/models/workspace-oauth-client.ts+22−0
@@ -0,0 +1,22 @@
1+import { Schema } from "effect";
2+
3+import { TimestampMsSchema } from "../common";
4+import {
5+ WorkspaceIdSchema,
6+ WorkspaceOauthClientIdSchema,
7+} from "../ids";
8+
9+export 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+
22+export 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
44 import { createControlPlaneClient } from "@executor/control-plane/client";
55 import type {
66 CompleteSourceOAuthResult,
7+ ConnectSourceBatchPayload,
8+ ConnectSourceBatchResult,
79 ConnectSourcePayload,
810 ConnectSourceResult,
911 ControlPlaneClient,
1012 CreateSecretPayload,
1113 CreateSecretResult,
1214 CreateSourcePayload,
15+ CreateWorkspaceOauthClientPayload,
1316 DeleteSecretResult,
1417 DiscoverSourcePayload,
1518 InstanceConfig,
@@ -25,6 +28,7 @@ import type { · 9 unmodified lines
2528 UpdateSecretPayload,
2629 UpdateSecretResult,
2730 UpdateSourcePayload,
31+ WorkspaceOauthClient,
2832 } from "@executor/control-plane";
2933 import * as Cause from "effect/Cause";
3034 import * as Effect from "effect/Effect";
@@ -66,6 +70,12 @@ type SourceDiscoveryKeyParts = readonly [ · 35 unmodified lines
6670 string,
6771 number | null,
6872 ];
73+type WorkspaceOauthClientsKeyParts = readonly [
74+ boolean,
75+ Source["workspaceId"],
76+ string,
77+ string,
78+];
6979
7080 type InvalidationTarget = {
7181 workspaceId?: Source["workspaceId"];
@@ -76,6 +86,7 @@ type InvalidationTarget = { · 4 unmodified lines
7686 type ActiveQueryCollections = {
7787 sourceLists: Set<string>;
7888 sources: Set<string>;
89+ workspaceOauthClients: Set<string>;
7990 inspections: Set<string>;
8091 toolDetails: Set<string>;
8192 discoveries: Set<string>;
@@ -163,6 +174,16 @@ const encodeDiscoveryKey = ( · 81 unmodified lines
163174 ): string =>
164175 encodeAtomKey([enabled, workspaceId, accountId, sourceId, query, limit] satisfies SourceDiscoveryKeyParts);
165176
177+const 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+
166187 const causeMessage = (cause: Cause.Cause<unknown>): Error =>
167188 new Error(Cause.pretty(cause));
168189
@@ -377,6 +398,26 @@ const sourceDiscoveryAtom = Atom.family((key: string) => { · 208 unmodified lines
377398 ).pipe(Atom.keepAlive);
378399 });
379400
401+const 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+
380421 export type Loadable<T> =
381422 | { status: "loading" }
382423 | { status: "error"; error: Error }
@@ -506,6 +547,7 @@ const setCachedAtomValue = <A>( · 123 unmodified lines
506547 const createActiveQueryCollections = (): ActiveQueryCollections => ({
507548 sourceLists: new Set(),
508549 sources: new Set(),
550+ workspaceOauthClients: new Set(),
509551 inspections: new Set(),
510552 toolDetails: new Set(),
511553 discoveries: new Set(),
@@ -548,6 +590,13 @@ const invalidateTrackedQueries = ( · 36 unmodified lines
548590 }
549591 });
550592
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+
551600 activeQueries.inspections.forEach((key) => {
552601 const [enabled, workspaceId, accountId, sourceId] = decodeAtomKey<SourceKeyParts>(key);
553602 if (enabled && targetMatches(target, workspaceId, accountId, sourceId)) {
@@ -905,6 +954,37 @@ export const useSource = (sourceId: string): Loadable<Source> => { · 351 unmodified lines
905954 return workspace.enabled ? source : pendingLoadable(workspace.workspace);
906955 };
907956
957+export 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+
908988 export const useSourceInspection = (sourceId: string): Loadable<SourceInspection> => {
909989 const workspace = useWorkspaceRequestContext();
910990 const requestedSourceId = workspace.enabled
@@ -1209,13 +1289,134 @@ export const useConnectSource = () => · 298 unmodified lines
12091289 },
12101290 );
12111291
1292+export 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+
1332+export 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+
1357+export 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+
1382+export 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+
12121410 export type {
12131411 CompleteSourceOAuthResult,
1412+ ConnectSourceBatchPayload,
1413+ ConnectSourceBatchResult,
12141414 ConnectSourcePayload,
12151415 ConnectSourceResult,
12161416 CreateSecretPayload,
12171417 CreateSecretResult,
12181418 CreateSourcePayload,
1419+ CreateWorkspaceOauthClientPayload,
12191420 DeleteSecretResult,
12201421 DiscoverSourcePayload,
12211422 InstanceConfig,
@@ -1232,4 +1433,5 @@ export type { · 10 unmodified lines
12321433 UpdateSecretPayload,
12331434 UpdateSecretResult,
12341435 UpdateSourcePayload,
1436+ WorkspaceOauthClient,
12351437 };
packages/server/src/index.ts+1−0
@@ -115,6 +115,7 @@ const createRuntime = ( · 114 unmodified lines
115115 executionResolver: options.executionResolver,
116116 resolveSecretMaterial: options.resolveSecretMaterial,
117117 getLocalServerBaseUrl,
118+ localDataDir,
118119 }).pipe(
119120 Effect.mapError((cause) =>
120121 cause instanceof Error ? cause : new Error(String(cause)),