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