Unified · Split
| @@ -5,20 +5,36 @@ · 4 unmodified lines | |||
| 5 | * control-plane handler. Everything else (frontend assets, HMR) is | 5 | * control-plane handler. Everything else (frontend assets, HMR) is |
| 6 | * handled by Vite itself. | 6 | * handled by Vite itself. |
| 7 | */ | 7 | */ |
| 8 | import { join } from "node:path"; | ||
| 8 | import * as Cause from "effect/Cause"; | 9 | import * as Cause from "effect/Cause"; |
| 9 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 10 | import * as Exit from "effect/Exit"; | 11 | import * as Exit from "effect/Exit"; |
| 11 | import * as Runtime from "effect/Runtime"; | 12 | import * as Runtime from "effect/Runtime"; |
| 12 | import * as Scope from "effect/Scope"; | 13 | import * as Scope from "effect/Scope"; |
| 13 | import { createLocalExecutorRequestHandler } from "@executor/server"; | 14 | import { |
| 15 | createLocalExecutorRequestHandler, | ||
| 16 | DEFAULT_EXECUTOR_DATA_DIR, | ||
| 17 | } from "@executor/server"; | ||
| 14 | 18 | ||
| 15 | const MAX_LOGGED_ERROR_BODY_LENGTH = 4_000; | 19 | const MAX_LOGGED_ERROR_BODY_LENGTH = 4_000; |
| 20 | const EXECUTOR_WEB_DEV_LOCAL_DATA_DIR_ENV = "EXECUTOR_WEB_DEV_LOCAL_DATA_DIR"; | ||
| 21 | const DEFAULT_WEB_DEV_LOCAL_DATA_DIR = join( | ||
| 22 | DEFAULT_EXECUTOR_DATA_DIR, | ||
| 23 | "control-plane-web-dev", | ||
| 24 | ); | ||
| 16 | 25 | ||
| 17 | const truncateForLog = (value: string): string => | 26 | const truncateForLog = (value: string): string => |
| 18 | value.length > MAX_LOGGED_ERROR_BODY_LENGTH | 27 | value.length > MAX_LOGGED_ERROR_BODY_LENGTH |
| 19 | ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]` | 28 | ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]` |
| 20 | : value; | 29 | : value; |
| 21 | 30 | ||
| 31 | const resolveWebDevLocalDataDir = (): string => { | ||
| 32 | const configured = process.env[EXECUTOR_WEB_DEV_LOCAL_DATA_DIR_ENV]?.trim(); | ||
| 33 | return configured && configured.length > 0 | ||
| 34 | ? configured | ||
| 35 | : DEFAULT_WEB_DEV_LOCAL_DATA_DIR; | ||
| 36 | }; | ||
| 37 | |||
| 22 | /** | 38 | /** |
| 23 | * Extract a detailed, human-readable description from an error. | 39 | * Extract a detailed, human-readable description from an error. |
| 24 | * | 40 | * |
| @@ -45,7 +61,9 @@ const handlerPromise = (async () => { · 20 unmodified lines | |||
| 45 | const exit = await Effect.runPromiseExit( | 61 | const exit = await Effect.runPromiseExit( |
| 46 | Effect.gen(function* () { | 62 | Effect.gen(function* () { |
| 47 | const scope = yield* Scope.make(); | 63 | const scope = yield* Scope.make(); |
| 48 | const handler = yield* createLocalExecutorRequestHandler().pipe( | 64 | const handler = yield* createLocalExecutorRequestHandler({ |
| 65 | localDataDir: resolveWebDevLocalDataDir(), | ||
| 66 | }).pipe( | ||
| 49 | Effect.provideService(Scope.Scope, scope), | 67 | Effect.provideService(Scope.Scope, scope), |
| 50 | ); | 68 | ); |
| 51 | return handler; | 69 | return handler; |
| @@ -57,9 +57,12 @@ type ProbeAuthState = { · 56 unmodified lines | |||
| 57 | }; | 57 | }; |
| 58 | 58 | ||
| 59 | type ConnectFormState = { | 59 | type ConnectFormState = { |
| 60 | kind: "mcp" | "openapi" | "graphql"; | 60 | kind: "mcp" | "openapi" | "graphql" | "google_discovery"; |
| 61 | endpoint: string; | 61 | endpoint: string; |
| 62 | specUrl: string; | 62 | specUrl: string; |
| 63 | service: string; | ||
| 64 | version: string; | ||
| 65 | discoveryUrl: string; | ||
| 63 | name: string; | 66 | name: string; |
| 64 | namespace: string; | 67 | namespace: string; |
| 65 | transport: "" | "auto" | "streamable-http" | "sse"; | 68 | transport: "" | "auto" | "streamable-http" | "sse"; |
| @@ -83,7 +86,12 @@ type OAuthRequiredInfo = { · 17 unmodified lines | |||
| 83 | // Constants | 86 | // Constants |
| 84 | // --------------------------------------------------------------------------- | 87 | // --------------------------------------------------------------------------- |
| 85 | 88 | ||
| 86 | const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = ["mcp", "openapi", "graphql"]; | 89 | const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [ |
| 90 | "mcp", | ||
| 91 | "openapi", | ||
| 92 | "graphql", | ||
| 93 | "google_discovery", | ||
| 94 | ]; | ||
| 87 | 95 | ||
| 88 | const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [ | 96 | const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [ |
| 89 | "auto", | 97 | "auto", |
| @@ -119,6 +127,44 @@ const namespaceFromUrl = (url: string): string => { · 29 unmodified lines | |||
| 119 | } | 127 | } |
| 120 | }; | 128 | }; |
| 121 | 129 | ||
| 130 | const googleDiscoveryNamespace = (service: string): string => | ||
| 131 | `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`; | ||
| 132 | |||
| 133 | const googleDiscoveryDefaultsFromUrl = (value: string): { | ||
| 134 | service: string; | ||
| 135 | version: string; | ||
| 136 | discoveryUrl: string; | ||
| 137 | } | null => { | ||
| 138 | try { | ||
| 139 | const url = new URL(value); | ||
| 140 | const byDirectory = url.pathname.match(/^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/); | ||
| 141 | if (byDirectory) { | ||
| 142 | return { | ||
| 143 | service: decodeURIComponent(byDirectory[1] ?? ""), | ||
| 144 | version: decodeURIComponent(byDirectory[2] ?? ""), | ||
| 145 | discoveryUrl: url.toString(), | ||
| 146 | }; | ||
| 147 | } | ||
| 148 | |||
| 149 | const versionParam = url.searchParams.get("version"); | ||
| 150 | const version = versionParam ? trimToNull(versionParam) : null; | ||
| 151 | const isHostScopedDiscovery = url.pathname === "/$discovery/rest" | ||
| 152 | && url.hostname.endsWith(".googleapis.com") | ||
| 153 | && url.hostname !== "www.googleapis.com"; | ||
| 154 | if (version && isHostScopedDiscovery) { | ||
| 155 | return { | ||
| 156 | service: url.hostname.split(".")[0] ?? "", | ||
| 157 | version, | ||
| 158 | discoveryUrl: url.toString(), | ||
| 159 | }; | ||
| 160 | } | ||
| 161 | |||
| 162 | return null; | ||
| 163 | } catch { | ||
| 164 | return null; | ||
| 165 | } | ||
| 166 | }; | ||
| 167 | |||
| 122 | const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => { | 168 | const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => { |
| 123 | const trimmed = text.trim(); | 169 | const trimmed = text.trim(); |
| 124 | if (trimmed.length === 0) { | 170 | if (trimmed.length === 0) { |
| @@ -158,6 +204,9 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 33 unmodified lines | |||
| 158 | kind: "openapi", | 204 | kind: "openapi", |
| 159 | endpoint: discovery?.endpoint ?? "", | 205 | endpoint: discovery?.endpoint ?? "", |
| 160 | specUrl: discovery?.specUrl ?? "", | 206 | specUrl: discovery?.specUrl ?? "", |
| 207 | service: "", | ||
| 208 | version: "", | ||
| 209 | discoveryUrl: "", | ||
| 161 | name: discovery?.name ?? "", | 210 | name: discovery?.name ?? "", |
| 162 | namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""), | 211 | namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""), |
| 163 | transport: "", | 212 | transport: "", |
| @@ -197,10 +246,17 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 33 unmodified lines | |||
| 197 | } | 246 | } |
| 198 | } | 247 | } |
| 199 | 248 | ||
| 249 | const googleDiscoveryDefaults = kind === "google_discovery" | ||
| 250 | ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint) | ||
| 251 | : null; | ||
| 252 | |||
| 200 | return { | 253 | return { |
| 201 | kind, | 254 | kind, |
| 202 | endpoint: discovery.endpoint, | 255 | endpoint: discovery.endpoint, |
| 203 | specUrl: discovery.specUrl ?? "", | 256 | specUrl: discovery.specUrl ?? "", |
| 257 | service: googleDiscoveryDefaults?.service ?? "", | ||
| 258 | version: googleDiscoveryDefaults?.version ?? "", | ||
| 259 | discoveryUrl: googleDiscoveryDefaults?.discoveryUrl ?? "", | ||
| 204 | name: discovery.name ?? "", | 260 | name: discovery.name ?? "", |
| 205 | namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint), | 261 | namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint), |
| 206 | transport: kind === "mcp" ? (discovery.transport ?? "auto") : "", | 262 | transport: kind === "mcp" ? (discovery.transport ?? "auto") : "", |
| @@ -220,11 +276,25 @@ const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => · 13 unmodified lines | |||
| 220 | kind: template.kind as ConnectFormState["kind"], | 276 | kind: template.kind as ConnectFormState["kind"], |
| 221 | endpoint: template.endpoint, | 277 | endpoint: template.endpoint, |
| 222 | specUrl: "specUrl" in template ? template.specUrl : "", | 278 | specUrl: "specUrl" in template ? template.specUrl : "", |
| 279 | service: "service" in template ? template.service : "", | ||
| 280 | version: "version" in template ? template.version : "", | ||
| 281 | discoveryUrl: "discoveryUrl" in template ? template.discoveryUrl : "", | ||
| 223 | name: template.name, | 282 | name: template.name, |
| 224 | namespace: namespaceFromUrl(template.endpoint), | 283 | namespace: "service" in template |
| 284 | ? googleDiscoveryNamespace(template.service) | ||
| 285 | : namespaceFromUrl(template.endpoint), | ||
| 225 | transport: template.kind === "mcp" ? "auto" : "", | 286 | transport: template.kind === "mcp" ? "auto" : "", |
| 287 | authKind: template.kind === "google_discovery" ? "oauth2" : "none", | ||
| 226 | }); | 288 | }); |
| 227 | 289 | ||
| 290 | const authKindForSourceKind = ( | ||
| 291 | currentAuthKind: ConnectFormState["authKind"], | ||
| 292 | nextKind: ConnectFormState["kind"], | ||
| 293 | ): ConnectFormState["authKind"] => | ||
| 294 | nextKind === "google_discovery" && currentAuthKind === "none" | ||
| 295 | ? "oauth2" | ||
| 296 | : currentAuthKind; | ||
| 297 | |||
| 228 | const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => { | 298 | const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => { |
| 229 | if (state.kind === "none") return { kind: "none" }; | 299 | if (state.kind === "none") return { kind: "none" }; |
| 230 | if (state.kind === "bearer") { | 300 | if (state.kind === "bearer") { |
| @@ -251,10 +321,9 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 20 unmodified lines | |||
| 251 | }; | 321 | }; |
| 252 | 322 | ||
| 253 | const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { | 323 | const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { |
| 254 | const endpoint = form.endpoint.trim(); | ||
| 255 | if (!endpoint) throw new Error("Endpoint is required."); | ||
| 256 | |||
| 257 | if (form.kind === "mcp") { | 324 | if (form.kind === "mcp") { |
| 325 | const endpoint = form.endpoint.trim(); | ||
| 326 | if (!endpoint) throw new Error("Endpoint is required."); | ||
| 258 | return { | 327 | return { |
| 259 | kind: "mcp", | 328 | kind: "mcp", |
| 260 | endpoint, | 329 | endpoint, |
| @@ -270,6 +339,8 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 9 unmodified lines | |||
| 270 | const auth = buildHttpAuth(form); | 339 | const auth = buildHttpAuth(form); |
| 271 | 340 | ||
| 272 | if (form.kind === "openapi") { | 341 | if (form.kind === "openapi") { |
| 342 | const endpoint = form.endpoint.trim(); | ||
| 343 | if (!endpoint) throw new Error("Endpoint is required."); | ||
| 273 | const specUrl = form.specUrl.trim(); | 344 | const specUrl = form.specUrl.trim(); |
| 274 | if (!specUrl) throw new Error("OpenAPI sources require a spec URL."); | 345 | if (!specUrl) throw new Error("OpenAPI sources require a spec URL."); |
| 275 | return { | 346 | return { |
| @@ -282,6 +353,24 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 6 unmodified lines | |||
| 282 | }; | 353 | }; |
| 283 | } | 354 | } |
| 284 | 355 | ||
| 356 | if (form.kind === "google_discovery") { | ||
| 357 | const service = form.service.trim(); | ||
| 358 | const version = form.version.trim(); | ||
| 359 | if (!service) throw new Error("Google Discovery sources require a service name."); | ||
| 360 | if (!version) throw new Error("Google Discovery sources require a version."); | ||
| 361 | return { | ||
| 362 | kind: "google_discovery", | ||
| 363 | service, | ||
| 364 | version, | ||
| 365 | discoveryUrl: trimToNull(form.discoveryUrl), | ||
| 366 | name: trimToNull(form.name), | ||
| 367 | namespace: trimToNull(form.namespace), | ||
| 368 | auth, | ||
| 369 | }; | ||
| 370 | } | ||
| 371 | |||
| 372 | const endpoint = form.endpoint.trim(); | ||
| 373 | if (!endpoint) throw new Error("Endpoint is required."); | ||
| 285 | return { | 374 | return { |
| 286 | kind: "graphql", | 375 | kind: "graphql", |
| 287 | endpoint, | 376 | endpoint, |
| @@ -346,6 +435,16 @@ type SourceOAuthPopupMessage = · 58 unmodified lines | |||
| 346 | ok: false; | 435 | ok: false; |
| 347 | sessionId: string | null; | 436 | sessionId: string | null; |
| 348 | error: string; | 437 | error: string; |
| 438 | } | ||
| 439 | | { | ||
| 440 | type: "executor:source-oauth-result"; | ||
| 441 | ok: true; | ||
| 442 | sourceId: string; | ||
| 443 | } | ||
| 444 | | { | ||
| 445 | type: "executor:source-oauth-result"; | ||
| 446 | ok: false; | ||
| 447 | error: string; | ||
| 349 | }; | 448 | }; |
| 350 | 449 | ||
| 351 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { | 450 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { |
| @@ -369,7 +468,7 @@ const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { · 17 unmodified lines | |||
| 369 | const startSourceOAuthPopup = async (input: { | 468 | const startSourceOAuthPopup = async (input: { |
| 370 | authorizationUrl: string; | 469 | authorizationUrl: string; |
| 371 | sessionId: string; | 470 | sessionId: string; |
| 372 | }): Promise<CompleteSourceOAuthResult["auth"]> => { | 471 | }): Promise<void> => { |
| 373 | if (typeof window === "undefined") { | 472 | if (typeof window === "undefined") { |
| 374 | throw new Error("OAuth popup is only available in a browser context"); | 473 | throw new Error("OAuth popup is only available in a browser context"); |
| 375 | } | 474 | } |
| @@ -388,7 +487,7 @@ const startSourceOAuthPopup = async (input: { · 12 unmodified lines | |||
| 388 | 487 | ||
| 389 | popup.focus(); | 488 | popup.focus(); |
| 390 | 489 | ||
| 391 | return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => { | 490 | return await new Promise<void>((resolve, reject) => { |
| 392 | let settled = false; | 491 | let settled = false; |
| 393 | let closedPoll = 0; | 492 | let closedPoll = 0; |
| 394 | let resultTimeout = 0; | 493 | let resultTimeout = 0; |
| @@ -416,15 +515,19 @@ const startSourceOAuthPopup = async (input: { · 21 unmodified lines | |||
| 416 | if (settled) return; | 515 | if (settled) return; |
| 417 | settled = true; | 516 | settled = true; |
| 418 | cleanup(); | 517 | cleanup(); |
| 419 | resolve(data.auth); | 518 | resolve(); |
| 420 | }; | 519 | }; |
| 421 | 520 | ||
| 422 | const onMessage = (event: MessageEvent) => { | 521 | const onMessage = (event: MessageEvent) => { |
| 423 | if (event.origin !== window.location.origin) return; | 522 | if (event.origin !== window.location.origin) return; |
| 424 | const data = event.data as SourceOAuthPopupMessage | undefined; | 523 | const data = event.data as SourceOAuthPopupMessage | undefined; |
| 425 | if (!data || data.type !== "executor:oauth-result") return; | 524 | if (!data) return; |
| 426 | if (data.ok && data.sessionId !== input.sessionId) return; | 525 | if (data.type === "executor:oauth-result") { |
| 427 | if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) return; | 526 | if (data.ok && data.sessionId !== input.sessionId) return; |
| 527 | if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) return; | ||
| 528 | } else if (data.type !== "executor:source-oauth-result") { | ||
| 529 | return; | ||
| 530 | } | ||
| 428 | settleFromPayload(data); | 531 | settleFromPayload(data); |
| 429 | }; | 532 | }; |
| 430 | 533 | ||
| @@ -517,6 +620,14 @@ export function AddSourcePage() { · 86 unmodified lines | |||
| 517 | setConnectForm((current) => ({ ...current, [key]: value })); | 620 | setConnectForm((current) => ({ ...current, [key]: value })); |
| 518 | }; | 621 | }; |
| 519 | 622 | ||
| 623 | const setSourceKind = (kind: ConnectFormState["kind"]) => { | ||
| 624 | setConnectForm((current) => ({ | ||
| 625 | ...current, | ||
| 626 | kind, | ||
| 627 | authKind: authKindForSourceKind(current.authKind, kind), | ||
| 628 | })); | ||
| 629 | }; | ||
| 630 | |||
| 520 | const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => { | 631 | const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => { |
| 521 | setProbeAuth((current) => ({ ...current, [key]: value })); | 632 | setProbeAuth((current) => ({ ...current, [key]: value })); |
| 522 | }; | 633 | }; |
| @@ -879,10 +990,10 @@ export function AddSourcePage() { · 356 unmodified lines | |||
| 879 | <div className="space-y-3 border-t border-border pt-4"> | 990 | <div className="space-y-3 border-t border-border pt-4"> |
| 880 | <div> | 991 | <div> |
| 881 | <p className="text-[12px] font-medium text-foreground">Start from a known source</p> | 992 | <p className="text-[12px] font-medium text-foreground">Start from a known source</p> |
| 882 | <p className="text-[11px] text-muted-foreground"> | 993 | <p className="text-[11px] text-muted-foreground"> |
| 883 | Load a real MCP, OpenAPI, or GraphQL template without running discovery first. | 994 | Load a real MCP, OpenAPI, GraphQL, or Google Discovery template without running discovery first. |
| 884 | </p> | 995 | </p> |
| 885 | </div> | 996 | </div> |
| 886 | <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> | 997 | <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
| 887 | {sourceTemplates.map((template) => ( | 998 | {sourceTemplates.map((template) => ( |
| 888 | <button | 999 | <button |
| @@ -930,18 +1041,20 @@ export function AddSourcePage() { · 41 unmodified lines | |||
| 930 | <Field label="Kind"> | 1041 | <Field label="Kind"> |
| 931 | <SelectInput | 1042 | <SelectInput |
| 932 | value={connectForm.kind} | 1043 | value={connectForm.kind} |
| 933 | onChange={(v) => setFormField("kind", v as ConnectFormState["kind"])} | 1044 | onChange={(v) => setSourceKind(v as ConnectFormState["kind"])} |
| 934 | options={kindOptions.map((v) => ({ value: v, label: v }))} | 1045 | options={kindOptions.map((v) => ({ value: v, label: v }))} |
| 935 | /> | 1046 | /> |
| 936 | </Field> | 1047 | </Field> |
| 937 | <Field label="Endpoint" className="sm:col-span-2"> | 1048 | {connectForm.kind !== "google_discovery" && ( |
| 938 | <TextInput | 1049 | <Field label="Endpoint" className="sm:col-span-2"> |
| 939 | value={connectForm.endpoint} | 1050 | <TextInput |
| 940 | onChange={(v) => setFormField("endpoint", v)} | 1051 | value={connectForm.endpoint} |
| 941 | placeholder="https://api.example.com" | 1052 | onChange={(v) => setFormField("endpoint", v)} |
| 942 | mono | 1053 | placeholder="https://api.example.com" |
| 943 | /> | 1054 | mono |
| 944 | </Field> | 1055 | /> |
| 1056 | </Field> | ||
| 1057 | )} | ||
| 945 | <Field label="Namespace"> | 1058 | <Field label="Namespace"> |
| 946 | <TextInput | 1059 | <TextInput |
| 947 | value={connectForm.namespace} | 1060 | value={connectForm.namespace} |
| @@ -959,6 +1072,35 @@ export function AddSourcePage() { · 11 unmodified lines | |||
| 959 | /> | 1072 | /> |
| 960 | </Field> | 1073 | </Field> |
| 961 | )} | 1074 | )} |
| 1075 | {connectForm.kind === "google_discovery" && ( | ||
| 1076 | <> | ||
| 1077 | <Field label="Service"> | ||
| 1078 | <TextInput | ||
| 1079 | value={connectForm.service} | ||
| 1080 | onChange={(v) => setFormField("service", v)} | ||
| 1081 | placeholder="sheets" | ||
| 1082 | /> | ||
| 1083 | </Field> | ||
| 1084 | <Field label="Version"> | ||
| 1085 | <TextInput | ||
| 1086 | value={connectForm.version} | ||
| 1087 | onChange={(v) => setFormField("version", v)} | ||
| 1088 | placeholder="v4" | ||
| 1089 | /> | ||
| 1090 | </Field> | ||
| 1091 | <Field label="Discovery URL (optional)" className="sm:col-span-2"> | ||
| 1092 | <TextInput | ||
| 1093 | value={connectForm.discoveryUrl} | ||
| 1094 | onChange={(v) => setFormField("discoveryUrl", v)} | ||
| 1095 | placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" | ||
| 1096 | mono | ||
| 1097 | /> | ||
| 1098 | </Field> | ||
| 1099 | <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground"> | ||
| 1100 | Common Google API versions: Gmail `v1`, Sheets `v4`, Drive `v3`, Calendar `v3`, Docs `v1`. | ||
| 1101 | </div> | ||
| 1102 | </> | ||
| 1103 | )} | ||
| 962 | </div> | 1104 | </div> |
| 963 | </Section> | 1105 | </Section> |
| 964 | 1106 | ||
| @@ -1004,6 +1146,13 @@ export function AddSourcePage() { · 39 unmodified lines | |||
| 1004 | options={authOptions.map((v) => ({ value: v, label: v }))} | 1146 | options={authOptions.map((v) => ({ value: v, label: v }))} |
| 1005 | /> | 1147 | /> |
| 1006 | </Field> | 1148 | </Field> |
| 1149 | {connectForm.kind === "google_discovery" && ( | ||
| 1150 | <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground"> | ||
| 1151 | {connectForm.authKind === "oauth2" | ||
| 1152 | ? "OAuth starts after you click Connect. Executor will open a Google sign-in popup and store the resulting refreshable credentials for this source." | ||
| 1153 | : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."} | ||
| 1154 | </div> | ||
| 1155 | )} | ||
| 1007 | {connectForm.authKind !== "none" && ( | 1156 | {connectForm.authKind !== "none" && ( |
| 1008 | <> | 1157 | <> |
| 1009 | <Field label="Header name"> | 1158 | <Field label="Header name"> |
| @@ -74,6 +74,8 @@ type SourceFormState = { · 73 unmodified lines | |||
| 74 | queryParamsText: string; | 74 | queryParamsText: string; |
| 75 | headersText: string; | 75 | headersText: string; |
| 76 | specUrl: string; | 76 | specUrl: string; |
| 77 | service: string; | ||
| 78 | version: string; | ||
| 77 | defaultHeadersText: string; | 79 | defaultHeadersText: string; |
| 78 | authKind: Source["auth"]["kind"]; | 80 | authKind: Source["auth"]["kind"]; |
| 79 | authHeaderName: string; | 81 | authHeaderName: string; |
| @@ -86,7 +88,13 @@ type SourceFormState = { · 6 unmodified lines | |||
| 86 | oauthRefreshHandle: string; | 88 | oauthRefreshHandle: string; |
| 87 | }; | 89 | }; |
| 88 | 90 | ||
| 89 | const kindOptions: ReadonlyArray<Source["kind"]> = ["mcp", "openapi", "graphql", "internal"]; | 91 | const kindOptions: ReadonlyArray<Source["kind"]> = [ |
| 92 | "mcp", | ||
| 93 | "openapi", | ||
| 94 | "graphql", | ||
| 95 | "google_discovery", | ||
| 96 | "internal", | ||
| 97 | ]; | ||
| 90 | 98 | ||
| 91 | const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [ | 99 | const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [ |
| 92 | "auto", | 100 | "auto", |
| @@ -112,6 +120,9 @@ const namespaceFromUrl = (url: string): string => { · 19 unmodified lines | |||
| 112 | } | 120 | } |
| 113 | }; | 121 | }; |
| 114 | 122 | ||
| 123 | const googleDiscoveryNamespace = (service: string): string => | ||
| 124 | `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`; | ||
| 125 | |||
| 115 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { | 126 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { |
| 116 | if (typeof window === "undefined") { | 127 | if (typeof window === "undefined") { |
| 117 | return null; | 128 | return null; |
| @@ -288,14 +299,20 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 170 unmodified lines | |||
| 288 | name: template?.name ?? "", | 299 | name: template?.name ?? "", |
| 289 | kind: template?.kind ?? "openapi", | 300 | kind: template?.kind ?? "openapi", |
| 290 | endpoint: template?.endpoint ?? "", | 301 | endpoint: template?.endpoint ?? "", |
| 291 | namespace: template ? namespaceFromUrl(template.endpoint) : "", | 302 | namespace: template |
| 303 | ? "service" in template | ||
| 304 | ? googleDiscoveryNamespace(template.service) | ||
| 305 | : namespaceFromUrl(template.endpoint) | ||
| 306 | : "", | ||
| 292 | enabled: true, | 307 | enabled: true, |
| 293 | transport: template?.kind === "mcp" ? "auto" : "", | 308 | transport: template?.kind === "mcp" ? "auto" : "", |
| 294 | queryParamsText: "", | 309 | queryParamsText: "", |
| 295 | headersText: "", | 310 | headersText: "", |
| 296 | specUrl: template && "specUrl" in template ? template.specUrl : "", | 311 | specUrl: template && "specUrl" in template ? template.specUrl : "", |
| 312 | service: template && "service" in template ? template.service : "", | ||
| 313 | version: template && "version" in template ? template.version : "", | ||
| 297 | defaultHeadersText: "", | 314 | defaultHeadersText: "", |
| 298 | authKind: "none", | 315 | authKind: template?.kind === "google_discovery" ? "oauth2" : "none", |
| 299 | authHeaderName: "Authorization", | 316 | authHeaderName: "Authorization", |
| 300 | authPrefix: "Bearer ", | 317 | authPrefix: "Bearer ", |
| 301 | bearerProviderId: "", | 318 | bearerProviderId: "", |
| @@ -316,6 +333,8 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 14 unmodified lines | |||
| 316 | queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")), | 333 | queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")), |
| 317 | headersText: stringMapToEditor(readBindingStringMap(source, "headers")), | 334 | headersText: stringMapToEditor(readBindingStringMap(source, "headers")), |
| 318 | specUrl: readBindingString(source, "specUrl"), | 335 | specUrl: readBindingString(source, "specUrl"), |
| 336 | service: readBindingString(source, "service"), | ||
| 337 | version: readBindingString(source, "version"), | ||
| 319 | defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")), | 338 | defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")), |
| 320 | authKind: source.auth.kind, | 339 | authKind: source.auth.kind, |
| 321 | authHeaderName: source.auth.kind === "none" ? "Authorization" : source.auth.headerName, | 340 | authHeaderName: source.auth.kind === "none" ? "Authorization" : source.auth.headerName, |
| @@ -484,6 +503,27 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 162 unmodified lines | |||
| 484 | }; | 503 | }; |
| 485 | } | 504 | } |
| 486 | 505 | ||
| 506 | if (state.kind === "google_discovery") { | ||
| 507 | const service = state.service.trim(); | ||
| 508 | const version = state.version.trim(); | ||
| 509 | if (!service) { | ||
| 510 | throw new Error("Google Discovery sources require a service."); | ||
| 511 | } | ||
| 512 | if (!version) { | ||
| 513 | throw new Error("Google Discovery sources require a version."); | ||
| 514 | } | ||
| 515 | |||
| 516 | return { | ||
| 517 | ...shared, | ||
| 518 | binding: { | ||
| 519 | service, | ||
| 520 | version, | ||
| 521 | discoveryUrl: endpoint, | ||
| 522 | defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText), | ||
| 523 | }, | ||
| 524 | }; | ||
| 525 | } | ||
| 526 | |||
| 487 | return { | 527 | return { |
| 488 | ...shared, | 528 | ...shared, |
| 489 | binding: {}, | 529 | binding: {}, |
| @@ -784,6 +824,8 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 294 unmodified lines | |||
| 784 | ? "https://api.github.com" | 824 | ? "https://api.github.com" |
| 785 | : formState.kind === "graphql" | 825 | : formState.kind === "graphql" |
| 786 | ? "https://api.linear.app/graphql" | 826 | ? "https://api.linear.app/graphql" |
| 827 | : formState.kind === "google_discovery" | ||
| 828 | ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" | ||
| 787 | : "https://mcp.deepwiki.com/mcp" | 829 | : "https://mcp.deepwiki.com/mcp" |
| 788 | } | 830 | } |
| 789 | mono | 831 | mono |
| @@ -871,6 +913,34 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 81 unmodified lines | |||
| 871 | </Section> | 913 | </Section> |
| 872 | )} | 914 | )} |
| 873 | 915 | ||
| 916 | {formState.kind === "google_discovery" && ( | ||
| 917 | <Section title="Google Discovery"> | ||
| 918 | <div className="grid gap-4 sm:grid-cols-2"> | ||
| 919 | <Field label="Service"> | ||
| 920 | <TextInput | ||
| 921 | value={formState.service} | ||
| 922 | onChange={(value) => setField("service", value)} | ||
| 923 | placeholder="sheets" | ||
| 924 | /> | ||
| 925 | </Field> | ||
| 926 | <Field label="Version"> | ||
| 927 | <TextInput | ||
| 928 | value={formState.version} | ||
| 929 | onChange={(value) => setField("version", value)} | ||
| 930 | placeholder="v4" | ||
| 931 | /> | ||
| 932 | </Field> | ||
| 933 | <Field label="Default headers (JSON)" className="sm:col-span-2"> | ||
| 934 | <CodeEditor | ||
| 935 | value={formState.defaultHeadersText} | ||
| 936 | onChange={(value) => setField("defaultHeadersText", value)} | ||
| 937 | placeholder={'{\n "x-goog-user-project": "my-project"\n}'} | ||
| 938 | /> | ||
| 939 | </Field> | ||
| 940 | </div> | ||
| 941 | </Section> | ||
| 942 | )} | ||
| 943 | |||
| 874 | <Section title="Authentication"> | 944 | <Section title="Authentication"> |
| 875 | <div className="grid gap-4 sm:grid-cols-2"> | 945 | <div className="grid gap-4 sm:grid-cols-2"> |
| 876 | {formState.kind === "mcp" && ( | 946 | {formState.kind === "mcp" && ( |
| @@ -12,11 +12,45 @@ export type OpenApiSourceTemplate = SourceTemplateBase & { · 11 unmodified lines | |||
| 12 | specUrl: string; | 12 | specUrl: string; |
| 13 | }; | 13 | }; |
| 14 | 14 | ||
| 15 | export type GoogleDiscoverySourceTemplate = SourceTemplateBase & { | ||
| 16 | kind: "google_discovery"; | ||
| 17 | service: string; | ||
| 18 | version: string; | ||
| 19 | discoveryUrl: string; | ||
| 20 | }; | ||
| 21 | |||
| 15 | export type NonOpenApiSourceTemplate = SourceTemplateBase & { | 22 | export type NonOpenApiSourceTemplate = SourceTemplateBase & { |
| 16 | kind: Exclude<Source["kind"], "openapi" | "internal">; | 23 | kind: Exclude<Source["kind"], "openapi" | "google_discovery" | "internal">; |
| 17 | }; | 24 | }; |
| 18 | 25 | ||
| 19 | export type SourceTemplate = OpenApiSourceTemplate | NonOpenApiSourceTemplate; | 26 | export type SourceTemplate = |
| 27 | | OpenApiSourceTemplate | ||
| 28 | | GoogleDiscoverySourceTemplate | ||
| 29 | | NonOpenApiSourceTemplate; | ||
| 30 | |||
| 31 | const googleDiscoveryUrl = (service: string, version: string): string => | ||
| 32 | `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`; | ||
| 33 | |||
| 34 | const googleDiscoveryTemplate = (input: { | ||
| 35 | id: string; | ||
| 36 | name: string; | ||
| 37 | summary: string; | ||
| 38 | service: string; | ||
| 39 | version: string; | ||
| 40 | discoveryUrl?: string; | ||
| 41 | }): GoogleDiscoverySourceTemplate => { | ||
| 42 | const discoveryUrl = input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version); | ||
| 43 | return { | ||
| 44 | id: input.id, | ||
| 45 | name: input.name, | ||
| 46 | summary: input.summary, | ||
| 47 | kind: "google_discovery", | ||
| 48 | endpoint: discoveryUrl, | ||
| 49 | service: input.service, | ||
| 50 | version: input.version, | ||
| 51 | discoveryUrl, | ||
| 52 | }; | ||
| 53 | }; | ||
| 20 | 54 | ||
| 21 | export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ | 55 | export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ |
| 22 | { | 56 | { |
| @@ -115,4 +149,147 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 92 unmodified lines | |||
| 115 | kind: "graphql", | 149 | kind: "graphql", |
| 116 | endpoint: "https://graphql.anilist.co", | 150 | endpoint: "https://graphql.anilist.co", |
| 117 | }, | 151 | }, |
| 152 | googleDiscoveryTemplate({ | ||
| 153 | id: "google-calendar", | ||
| 154 | name: "Google Calendar", | ||
| 155 | summary: "Calendars, events, ACLs, and scheduling workflows.", | ||
| 156 | service: "calendar", | ||
| 157 | version: "v3", | ||
| 158 | discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3", | ||
| 159 | }), | ||
| 160 | googleDiscoveryTemplate({ | ||
| 161 | id: "google-drive", | ||
| 162 | name: "Google Drive", | ||
| 163 | summary: "Files, folders, permissions, comments, and shared drives.", | ||
| 164 | service: "drive", | ||
| 165 | version: "v3", | ||
| 166 | }), | ||
| 167 | googleDiscoveryTemplate({ | ||
| 168 | id: "google-gmail", | ||
| 169 | name: "Gmail", | ||
| 170 | summary: "Messages, threads, labels, drafts, and mailbox automation.", | ||
| 171 | service: "gmail", | ||
| 172 | version: "v1", | ||
| 173 | discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1", | ||
| 174 | }), | ||
| 175 | googleDiscoveryTemplate({ | ||
| 176 | id: "google-docs", | ||
| 177 | name: "Google Docs", | ||
| 178 | summary: "Documents, structural edits, text ranges, and formatting.", | ||
| 179 | service: "docs", | ||
| 180 | version: "v1", | ||
| 181 | discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1", | ||
| 182 | }), | ||
| 183 | googleDiscoveryTemplate({ | ||
| 184 | id: "google-sheets", | ||
| 185 | name: "Google Sheets", | ||
| 186 | summary: "Spreadsheets, values, ranges, formatting, and batch updates.", | ||
| 187 | service: "sheets", | ||
| 188 | version: "v4", | ||
| 189 | discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4", | ||
| 190 | }), | ||
| 191 | googleDiscoveryTemplate({ | ||
| 192 | id: "google-slides", | ||
| 193 | name: "Google Slides", | ||
| 194 | summary: "Presentations, slides, page elements, and deck updates.", | ||
| 195 | service: "slides", | ||
| 196 | version: "v1", | ||
| 197 | discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1", | ||
| 198 | }), | ||
| 199 | googleDiscoveryTemplate({ | ||
| 200 | id: "google-forms", | ||
| 201 | name: "Google Forms", | ||
| 202 | summary: "Forms, questions, responses, quizzes, and form metadata.", | ||
| 203 | service: "forms", | ||
| 204 | version: "v1", | ||
| 205 | discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1", | ||
| 206 | }), | ||
| 207 | googleDiscoveryTemplate({ | ||
| 208 | id: "google-people", | ||
| 209 | name: "Google People", | ||
| 210 | summary: "Contacts, profiles, directory people, and contact groups.", | ||
| 211 | service: "people", | ||
| 212 | version: "v1", | ||
| 213 | discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1", | ||
| 214 | }), | ||
| 215 | googleDiscoveryTemplate({ | ||
| 216 | id: "google-tasks", | ||
| 217 | name: "Google Tasks", | ||
| 218 | summary: "Task lists, task items, notes, and due dates.", | ||
| 219 | service: "tasks", | ||
| 220 | version: "v1", | ||
| 221 | discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1", | ||
| 222 | }), | ||
| 223 | googleDiscoveryTemplate({ | ||
| 224 | id: "google-chat", | ||
| 225 | name: "Google Chat", | ||
| 226 | summary: "Spaces, messages, members, reactions, and chat workflows.", | ||
| 227 | service: "chat", | ||
| 228 | version: "v1", | ||
| 229 | discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1", | ||
| 230 | }), | ||
| 231 | googleDiscoveryTemplate({ | ||
| 232 | id: "google-keep", | ||
| 233 | name: "Google Keep", | ||
| 234 | summary: "Notes, lists, attachments, and collaborative annotations.", | ||
| 235 | service: "keep", | ||
| 236 | version: "v1", | ||
| 237 | discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1", | ||
| 238 | }), | ||
| 239 | googleDiscoveryTemplate({ | ||
| 240 | id: "google-classroom", | ||
| 241 | name: "Google Classroom", | ||
| 242 | summary: "Courses, rosters, coursework, submissions, and grading data.", | ||
| 243 | service: "classroom", | ||
| 244 | version: "v1", | ||
| 245 | discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1", | ||
| 246 | }), | ||
| 247 | googleDiscoveryTemplate({ | ||
| 248 | id: "google-admin-directory", | ||
| 249 | name: "Google Admin Directory", | ||
| 250 | summary: "Users, groups, org units, roles, and domain directory resources.", | ||
| 251 | service: "admin", | ||
| 252 | version: "directory_v1", | ||
| 253 | discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1", | ||
| 254 | }), | ||
| 255 | googleDiscoveryTemplate({ | ||
| 256 | id: "google-admin-reports", | ||
| 257 | name: "Google Admin Reports", | ||
| 258 | summary: "Audit events, usage reports, and admin activity logs.", | ||
| 259 | service: "admin", | ||
| 260 | version: "reports_v1", | ||
| 261 | discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1", | ||
| 262 | }), | ||
| 263 | googleDiscoveryTemplate({ | ||
| 264 | id: "google-apps-script", | ||
| 265 | name: "Google Apps Script", | ||
| 266 | summary: "Projects, deployments, script execution, and Apps Script metadata.", | ||
| 267 | service: "script", | ||
| 268 | version: "v1", | ||
| 269 | discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1", | ||
| 270 | }), | ||
| 271 | googleDiscoveryTemplate({ | ||
| 272 | id: "google-bigquery", | ||
| 273 | name: "Google BigQuery", | ||
| 274 | summary: "Datasets, tables, jobs, routines, and analytical query workflows.", | ||
| 275 | service: "bigquery", | ||
| 276 | version: "v2", | ||
| 277 | discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2", | ||
| 278 | }), | ||
| 279 | googleDiscoveryTemplate({ | ||
| 280 | id: "google-cloud-resource-manager", | ||
| 281 | name: "Google Cloud Resource Manager", | ||
| 282 | summary: "Projects, folders, organizations, and IAM-oriented resource hierarchy.", | ||
| 283 | service: "cloudresourcemanager", | ||
| 284 | version: "v3", | ||
| 285 | discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3", | ||
| 286 | }), | ||
| 287 | googleDiscoveryTemplate({ | ||
| 288 | id: "google-youtube-data", | ||
| 289 | name: "YouTube Data", | ||
| 290 | summary: "Channels, playlists, videos, comments, captions, and uploads.", | ||
| 291 | service: "youtube", | ||
| 292 | version: "v3", | ||
| 293 | discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3", | ||
| 294 | }), | ||
| 118 | ]; | 295 | ]; |
| @@ -110,6 +110,21 @@ · 109 unmodified lines | |||
| 110 | "vitest": "catalog:", | 110 | "vitest": "catalog:", |
| 111 | }, | 111 | }, |
| 112 | }, | 112 | }, |
| 113 | "packages/codemode-google-discovery": { | ||
| 114 | "name": "@executor/codemode-google-discovery", | ||
| 115 | "dependencies": { | ||
| 116 | "@effect/platform": "catalog:", | ||
| 117 | "@executor/codemode-core": "workspace:*", | ||
| 118 | "effect": "catalog:", | ||
| 119 | }, | ||
| 120 | "devDependencies": { | ||
| 121 | "@effect/platform-node": "^0.104.1", | ||
| 122 | "@effect/vitest": "catalog:", | ||
| 123 | "@types/node": "catalog:", | ||
| 124 | "bun-types": "catalog:", | ||
| 125 | "vitest": "catalog:", | ||
| 126 | }, | ||
| 127 | }, | ||
| 113 | "packages/codemode-mcp": { | 128 | "packages/codemode-mcp": { |
| 114 | "name": "@executor/codemode-mcp", | 129 | "name": "@executor/codemode-mcp", |
| 115 | "dependencies": { | 130 | "dependencies": { |
| @@ -148,6 +163,7 @@ · 32 unmodified lines | |||
| 148 | "@effect/platform": "catalog:", | 163 | "@effect/platform": "catalog:", |
| 149 | "@electric-sql/pglite": "^0.3.12", | 164 | "@electric-sql/pglite": "^0.3.12", |
| 150 | "@executor/codemode-core": "workspace:*", | 165 | "@executor/codemode-core": "workspace:*", |
| 166 | "@executor/codemode-google-discovery": "workspace:*", | ||
| 151 | "@executor/codemode-mcp": "workspace:*", | 167 | "@executor/codemode-mcp": "workspace:*", |
| 152 | "@executor/codemode-openapi": "workspace:*", | 168 | "@executor/codemode-openapi": "workspace:*", |
| 153 | "@executor/runtime-deno-subprocess": "workspace:*", | 169 | "@executor/runtime-deno-subprocess": "workspace:*", |
| @@ -472,6 +488,8 @@ · 318 unmodified lines | |||
| 472 | 488 | ||
| 473 | "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/codemode-core"], | 489 | "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/codemode-core"], |
| 474 | 490 | ||
| 491 | "@executor/codemode-google-discovery": ["@executor/codemode-google-discovery@workspace:packages/codemode-google-discovery"], | ||
| 492 | |||
| 475 | "@executor/codemode-mcp": ["@executor/codemode-mcp@workspace:packages/codemode-mcp"], | 493 | "@executor/codemode-mcp": ["@executor/codemode-mcp@workspace:packages/codemode-mcp"], |
| 476 | 494 | ||
| 477 | "@executor/codemode-openapi": ["@executor/codemode-openapi@workspace:packages/codemode-openapi"], | 495 | "@executor/codemode-openapi": ["@executor/codemode-openapi@workspace:packages/codemode-openapi"], |
| @@ -0,0 +1,164 @@ | |||
| 1 | export type HttpRequestPlacements = { | ||
| 2 | headers?: Readonly<Record<string, string>>; | ||
| 3 | queryParams?: Readonly<Record<string, string>>; | ||
| 4 | cookies?: Readonly<Record<string, string>>; | ||
| 5 | bodyValues?: Readonly<Record<string, string>>; | ||
| 6 | }; | ||
| 7 | |||
| 8 | const isRecord = (value: unknown): value is Record<string, unknown> => | ||
| 9 | typeof value === "object" && value !== null && !Array.isArray(value); | ||
| 10 | |||
| 11 | const decodeJsonPointerSegment = (value: string): string => | ||
| 12 | value.replaceAll("~1", "/").replaceAll("~0", "~"); | ||
| 13 | |||
| 14 | const pathSegmentsFromPlacementPath = (value: string): ReadonlyArray<string> => { | ||
| 15 | const trimmed = value.trim(); | ||
| 16 | if (trimmed.length === 0) { | ||
| 17 | return []; | ||
| 18 | } | ||
| 19 | |||
| 20 | if (trimmed.startsWith("/")) { | ||
| 21 | return trimmed | ||
| 22 | .split("/") | ||
| 23 | .slice(1) | ||
| 24 | .map(decodeJsonPointerSegment) | ||
| 25 | .filter((segment) => segment.length > 0); | ||
| 26 | } | ||
| 27 | |||
| 28 | return trimmed.split(".").filter((segment) => segment.length > 0); | ||
| 29 | }; | ||
| 30 | |||
| 31 | const findHeaderKey = ( | ||
| 32 | headers: Readonly<Record<string, string>>, | ||
| 33 | name: string, | ||
| 34 | ): string | null => { | ||
| 35 | const lowered = name.toLowerCase(); | ||
| 36 | for (const key of Object.keys(headers)) { | ||
| 37 | if (key.toLowerCase() === lowered) { | ||
| 38 | return key; | ||
| 39 | } | ||
| 40 | } | ||
| 41 | |||
| 42 | return null; | ||
| 43 | }; | ||
| 44 | |||
| 45 | const parseCookieHeader = (value: string): Record<string, string> => { | ||
| 46 | const parsed: Record<string, string> = {}; | ||
| 47 | |||
| 48 | for (const part of value.split(";")) { | ||
| 49 | const trimmed = part.trim(); | ||
| 50 | if (trimmed.length === 0) { | ||
| 51 | continue; | ||
| 52 | } | ||
| 53 | |||
| 54 | const separatorIndex = trimmed.indexOf("="); | ||
| 55 | if (separatorIndex === -1) { | ||
| 56 | parsed[trimmed] = ""; | ||
| 57 | continue; | ||
| 58 | } | ||
| 59 | |||
| 60 | const name = trimmed.slice(0, separatorIndex).trim(); | ||
| 61 | const cookieValue = trimmed.slice(separatorIndex + 1).trim(); | ||
| 62 | if (name.length > 0) { | ||
| 63 | parsed[name] = cookieValue; | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | return parsed; | ||
| 68 | }; | ||
| 69 | |||
| 70 | const setNestedRecordValue = ( | ||
| 71 | target: Record<string, unknown>, | ||
| 72 | path: ReadonlyArray<string>, | ||
| 73 | value: string, | ||
| 74 | ) => { | ||
| 75 | let current = target; | ||
| 76 | |||
| 77 | for (let index = 0; index < path.length - 1; index += 1) { | ||
| 78 | const segment = path[index]!; | ||
| 79 | const next = current[segment]; | ||
| 80 | if (isRecord(next)) { | ||
| 81 | current = next; | ||
| 82 | continue; | ||
| 83 | } | ||
| 84 | |||
| 85 | const replacement: Record<string, unknown> = {}; | ||
| 86 | current[segment] = replacement; | ||
| 87 | current = replacement; | ||
| 88 | } | ||
| 89 | |||
| 90 | current[path[path.length - 1]!] = value; | ||
| 91 | }; | ||
| 92 | |||
| 93 | export const applyHttpQueryPlacementsToUrl = (input: { | ||
| 94 | url: string | URL; | ||
| 95 | queryParams?: Readonly<Record<string, string>>; | ||
| 96 | }): URL => { | ||
| 97 | const url = input.url instanceof URL ? new URL(input.url.toString()) : new URL(input.url); | ||
| 98 | |||
| 99 | for (const [key, value] of Object.entries(input.queryParams ?? {})) { | ||
| 100 | url.searchParams.set(key, value); | ||
| 101 | } | ||
| 102 | |||
| 103 | return url; | ||
| 104 | }; | ||
| 105 | |||
| 106 | export const applyCookiePlacementsToHeaders = (input: { | ||
| 107 | headers: Readonly<Record<string, string>>; | ||
| 108 | cookies?: Readonly<Record<string, string>>; | ||
| 109 | }): Record<string, string> => { | ||
| 110 | const cookies = input.cookies ?? {}; | ||
| 111 | if (Object.keys(cookies).length === 0) { | ||
| 112 | return { ...input.headers }; | ||
| 113 | } | ||
| 114 | |||
| 115 | const cookieHeaderKey = findHeaderKey(input.headers, "cookie"); | ||
| 116 | const cookieHeaderValue = cookieHeaderKey ? input.headers[cookieHeaderKey] : null; | ||
| 117 | const mergedCookies = { | ||
| 118 | ...(cookieHeaderValue ? parseCookieHeader(cookieHeaderValue) : {}), | ||
| 119 | ...cookies, | ||
| 120 | }; | ||
| 121 | const headers = { ...input.headers }; | ||
| 122 | |||
| 123 | headers[cookieHeaderKey ?? "cookie"] = Object.entries(mergedCookies) | ||
| 124 | .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) | ||
| 125 | .join("; "); | ||
| 126 | |||
| 127 | return headers; | ||
| 128 | }; | ||
| 129 | |||
| 130 | export const applyJsonBodyPlacements = (input: { | ||
| 131 | body: unknown; | ||
| 132 | bodyValues?: Readonly<Record<string, string>>; | ||
| 133 | label?: string; | ||
| 134 | }): unknown => { | ||
| 135 | const bodyValues = input.bodyValues ?? {}; | ||
| 136 | if (Object.keys(bodyValues).length === 0) { | ||
| 137 | return input.body; | ||
| 138 | } | ||
| 139 | |||
| 140 | const root = | ||
| 141 | input.body == null | ||
| 142 | ? {} | ||
| 143 | : isRecord(input.body) | ||
| 144 | ? structuredClone(input.body) | ||
| 145 | : null; | ||
| 146 | |||
| 147 | if (root === null) { | ||
| 148 | throw new Error( | ||
| 149 | `${input.label ?? "HTTP request"} auth body placements require an object JSON body`, | ||
| 150 | ); | ||
| 151 | } | ||
| 152 | |||
| 153 | for (const [rawPath, value] of Object.entries(bodyValues)) { | ||
| 154 | const path = pathSegmentsFromPlacementPath(rawPath); | ||
| 155 | if (path.length === 0) { | ||
| 156 | throw new Error( | ||
| 157 | `${input.label ?? "HTTP request"} auth body placement path cannot be empty`, | ||
| 158 | ); | ||
| 159 | } | ||
| 160 | setNestedRecordValue(root, path, value); | ||
| 161 | } | ||
| 162 | |||
| 163 | return root; | ||
| 164 | }; | ||
| @@ -4,3 +4,5 @@ export * from "./schema-types"; · 3 unmodified lines | |||
| 4 | export * from "./tool-map"; | 4 | export * from "./tool-map"; |
| 5 | export * from "./discovery"; | 5 | export * from "./discovery"; |
| 6 | export * from "./system-tools"; | 6 | export * from "./system-tools"; |
| 7 | export * from "./http-request-placements"; | ||
| 8 | export * from "./isomorphic-hash"; | ||
| @@ -0,0 +1,132 @@ | |||
| 1 | /** | ||
| 2 | * Isomorphic SHA-256 implementation that works in both Node.js and the browser. | ||
| 3 | * | ||
| 4 | * Avoids importing `node:crypto`, which Vite externalizes for browser builds, | ||
| 5 | * causing runtime errors when this module is pulled into client bundles. | ||
| 6 | * | ||
| 7 | * Uses the same SHA-256 algorithm as `node:crypto`, producing identical hex output. | ||
| 8 | */ | ||
| 9 | |||
| 10 | // SHA-256 constants: first 32 bits of the fractional parts of the cube roots of the first 64 primes | ||
| 11 | const K = new Uint32Array([ | ||
| 12 | 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, | ||
| 13 | 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, | ||
| 14 | 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, | ||
| 15 | 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, | ||
| 16 | 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, | ||
| 17 | 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, | ||
| 18 | 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, | ||
| 19 | 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, | ||
| 20 | ]); | ||
| 21 | |||
| 22 | const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n)); | ||
| 23 | |||
| 24 | const sha256Compress = (state: Uint32Array, block: Uint32Array): void => { | ||
| 25 | const w = new Uint32Array(64); | ||
| 26 | for (let i = 0; i < 16; i++) w[i] = block[i]; | ||
| 27 | for (let i = 16; i < 64; i++) { | ||
| 28 | const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3); | ||
| 29 | const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10); | ||
| 30 | w[i] = (w[i - 16] + s0 + w[i - 7] + s1) | 0; | ||
| 31 | } | ||
| 32 | |||
| 33 | let a = state[0], b = state[1], c = state[2], d = state[3]; | ||
| 34 | let e = state[4], f = state[5], g = state[6], h = state[7]; | ||
| 35 | |||
| 36 | for (let i = 0; i < 64; i++) { | ||
| 37 | const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); | ||
| 38 | const ch = (e & f) ^ (~e & g); | ||
| 39 | const temp1 = (h + S1 + ch + K[i] + w[i]) | 0; | ||
| 40 | const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); | ||
| 41 | const maj = (a & b) ^ (a & c) ^ (b & c); | ||
| 42 | const temp2 = (S0 + maj) | 0; | ||
| 43 | |||
| 44 | h = g; g = f; f = e; e = (d + temp1) | 0; | ||
| 45 | d = c; c = b; b = a; a = (temp1 + temp2) | 0; | ||
| 46 | } | ||
| 47 | |||
| 48 | state[0] = (state[0] + a) | 0; | ||
| 49 | state[1] = (state[1] + b) | 0; | ||
| 50 | state[2] = (state[2] + c) | 0; | ||
| 51 | state[3] = (state[3] + d) | 0; | ||
| 52 | state[4] = (state[4] + e) | 0; | ||
| 53 | state[5] = (state[5] + f) | 0; | ||
| 54 | state[6] = (state[6] + g) | 0; | ||
| 55 | state[7] = (state[7] + h) | 0; | ||
| 56 | }; | ||
| 57 | |||
| 58 | const utf8Encode = (str: string): Uint8Array => { | ||
| 59 | if (typeof TextEncoder !== "undefined") { | ||
| 60 | return new TextEncoder().encode(str); | ||
| 61 | } | ||
| 62 | |||
| 63 | // Fallback for environments without TextEncoder | ||
| 64 | const bytes: number[] = []; | ||
| 65 | for (let i = 0; i < str.length; i++) { | ||
| 66 | let c = str.charCodeAt(i); | ||
| 67 | if (c < 0x80) { | ||
| 68 | bytes.push(c); | ||
| 69 | } else if (c < 0x800) { | ||
| 70 | bytes.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f)); | ||
| 71 | } else if (c >= 0xd800 && c < 0xdc00 && i + 1 < str.length) { | ||
| 72 | const next = str.charCodeAt(++i); | ||
| 73 | c = 0x10000 + ((c - 0xd800) << 10) + (next - 0xdc00); | ||
| 74 | bytes.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); | ||
| 75 | } else { | ||
| 76 | bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f)); | ||
| 77 | } | ||
| 78 | } | ||
| 79 | return new Uint8Array(bytes); | ||
| 80 | }; | ||
| 81 | |||
| 82 | const sha256 = (data: Uint8Array): Uint8Array => { | ||
| 83 | const state = new Uint32Array([ | ||
| 84 | 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, | ||
| 85 | 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, | ||
| 86 | ]); | ||
| 87 | |||
| 88 | const bitLen = data.length * 8; | ||
| 89 | |||
| 90 | // Padding: append 1 bit, then zeros, then 64-bit big-endian length | ||
| 91 | const padded = new Uint8Array(Math.ceil((data.length + 9) / 64) * 64); | ||
| 92 | padded.set(data); | ||
| 93 | padded[data.length] = 0x80; | ||
| 94 | // Write the 64-bit big-endian bit length at the end | ||
| 95 | const view = new DataView(padded.buffer); | ||
| 96 | // For strings up to ~256 MB, the high 32 bits of the bit length are zero | ||
| 97 | view.setUint32(padded.length - 4, bitLen, false); | ||
| 98 | if (bitLen > 0xffffffff) { | ||
| 99 | view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false); | ||
| 100 | } | ||
| 101 | |||
| 102 | // Process each 64-byte block | ||
| 103 | const block = new Uint32Array(16); | ||
| 104 | for (let offset = 0; offset < padded.length; offset += 64) { | ||
| 105 | for (let i = 0; i < 16; i++) { | ||
| 106 | block[i] = view.getUint32(offset + i * 4, false); | ||
| 107 | } | ||
| 108 | sha256Compress(state, block); | ||
| 109 | } | ||
| 110 | |||
| 111 | const result = new Uint8Array(32); | ||
| 112 | const resultView = new DataView(result.buffer); | ||
| 113 | for (let i = 0; i < 8; i++) { | ||
| 114 | resultView.setUint32(i * 4, state[i], false); | ||
| 115 | } | ||
| 116 | return result; | ||
| 117 | }; | ||
| 118 | |||
| 119 | const hexFromBytes = (bytes: Uint8Array): string => { | ||
| 120 | let hex = ""; | ||
| 121 | for (let i = 0; i < bytes.length; i++) { | ||
| 122 | hex += bytes[i].toString(16).padStart(2, "0"); | ||
| 123 | } | ||
| 124 | return hex; | ||
| 125 | }; | ||
| 126 | |||
| 127 | /** | ||
| 128 | * Compute a SHA-256 hex digest of a UTF-8 string. | ||
| 129 | * Drop-in replacement for `createHash("sha256").update(value).digest("hex")`. | ||
| 130 | */ | ||
| 131 | export const sha256Hex = (value: string): string => | ||
| 132 | hexFromBytes(sha256(utf8Encode(value))); | ||
| @@ -0,0 +1,28 @@ | |||
| 1 | { | ||
| 2 | "name": "@executor/codemode-google-discovery", | ||
| 3 | "private": true, | ||
| 4 | "type": "module", | ||
| 5 | "exports": { | ||
| 6 | ".": "./src/index.ts" | ||
| 7 | }, | ||
| 8 | "files": [ | ||
| 9 | "src" | ||
| 10 | ], | ||
| 11 | "scripts": { | ||
| 12 | "typecheck": "bunx tsc --noEmit -p tsconfig.json", | ||
| 13 | "test": "vitest run", | ||
| 14 | "test:watch": "vitest" | ||
| 15 | }, | ||
| 16 | "dependencies": { | ||
| 17 | "@effect/platform": "catalog:", | ||
| 18 | "@executor/codemode-core": "workspace:*", | ||
| 19 | "effect": "catalog:" | ||
| 20 | }, | ||
| 21 | "devDependencies": { | ||
| 22 | "@effect/platform-node": "^0.104.1", | ||
| 23 | "@effect/vitest": "catalog:", | ||
| 24 | "@types/node": "catalog:", | ||
| 25 | "bun-types": "catalog:", | ||
| 26 | "vitest": "catalog:" | ||
| 27 | } | ||
| 28 | } | ||
| @@ -0,0 +1,525 @@ | |||
| 1 | import { sha256Hex } from "@executor/codemode-core"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | |||
| 4 | import type { | ||
| 5 | GoogleDiscoveryHttpMethod, | ||
| 6 | GoogleDiscoveryManifestMethod, | ||
| 7 | GoogleDiscoveryMethodParameter, | ||
| 8 | GoogleDiscoverySchemaRefTable, | ||
| 9 | GoogleDiscoveryToolManifest, | ||
| 10 | } from "./google-discovery-types"; | ||
| 11 | |||
| 12 | type JsonObject = Record<string, unknown>; | ||
| 13 | |||
| 14 | type DiscoveryParameter = { | ||
| 15 | location?: unknown; | ||
| 16 | type?: unknown; | ||
| 17 | required?: unknown; | ||
| 18 | repeated?: unknown; | ||
| 19 | description?: unknown; | ||
| 20 | enum?: unknown; | ||
| 21 | default?: unknown; | ||
| 22 | $ref?: unknown; | ||
| 23 | items?: unknown; | ||
| 24 | }; | ||
| 25 | |||
| 26 | type DiscoverySchema = { | ||
| 27 | id?: unknown; | ||
| 28 | type?: unknown; | ||
| 29 | description?: unknown; | ||
| 30 | properties?: unknown; | ||
| 31 | items?: unknown; | ||
| 32 | additionalProperties?: unknown; | ||
| 33 | enum?: unknown; | ||
| 34 | format?: unknown; | ||
| 35 | readOnly?: unknown; | ||
| 36 | default?: unknown; | ||
| 37 | $ref?: unknown; | ||
| 38 | }; | ||
| 39 | |||
| 40 | type DiscoveryMethod = { | ||
| 41 | id?: unknown; | ||
| 42 | description?: unknown; | ||
| 43 | httpMethod?: unknown; | ||
| 44 | path?: unknown; | ||
| 45 | flatPath?: unknown; | ||
| 46 | parameters?: unknown; | ||
| 47 | request?: unknown; | ||
| 48 | response?: unknown; | ||
| 49 | scopes?: unknown; | ||
| 50 | mediaUpload?: unknown; | ||
| 51 | supportsMediaDownload?: unknown; | ||
| 52 | }; | ||
| 53 | |||
| 54 | type DiscoveryResource = { | ||
| 55 | methods?: unknown; | ||
| 56 | resources?: unknown; | ||
| 57 | }; | ||
| 58 | |||
| 59 | type DiscoveryDocument = { | ||
| 60 | name?: unknown; | ||
| 61 | version?: unknown; | ||
| 62 | title?: unknown; | ||
| 63 | description?: unknown; | ||
| 64 | rootUrl?: unknown; | ||
| 65 | servicePath?: unknown; | ||
| 66 | batchPath?: unknown; | ||
| 67 | documentationLink?: unknown; | ||
| 68 | resources?: unknown; | ||
| 69 | methods?: unknown; | ||
| 70 | schemas?: unknown; | ||
| 71 | auth?: unknown; | ||
| 72 | }; | ||
| 73 | |||
| 74 | const asRecord = (value: unknown): JsonObject => | ||
| 75 | typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 76 | ? value as JsonObject | ||
| 77 | : {}; | ||
| 78 | |||
| 79 | const asString = (value: unknown): string | null => | ||
| 80 | typeof value === "string" && value.trim().length > 0 ? value.trim() : null; | ||
| 81 | |||
| 82 | const asBoolean = (value: unknown): boolean | null => | ||
| 83 | typeof value === "boolean" ? value : null; | ||
| 84 | |||
| 85 | const asStringArray = (value: unknown): ReadonlyArray<string> => | ||
| 86 | Array.isArray(value) | ||
| 87 | ? value.flatMap((entry) => { | ||
| 88 | const stringValue = asString(entry); | ||
| 89 | return stringValue ? [stringValue] : []; | ||
| 90 | }) | ||
| 91 | : []; | ||
| 92 | |||
| 93 | const contentHash = (value: string): string => | ||
| 94 | sha256Hex(value); | ||
| 95 | |||
| 96 | const escapeJsonPointerSegment = (value: string): string => | ||
| 97 | value.replaceAll("~", "~0").replaceAll("/", "~1"); | ||
| 98 | |||
| 99 | const schemaRefKey = (schemaId: string): string => | ||
| 100 | `#/$defs/google/${escapeJsonPointerSegment(schemaId)}`; | ||
| 101 | |||
| 102 | const normalizeHttpMethod = (value: unknown): GoogleDiscoveryHttpMethod => { | ||
| 103 | const method = asString(value)?.toLowerCase(); | ||
| 104 | switch (method) { | ||
| 105 | case "get": | ||
| 106 | case "put": | ||
| 107 | case "post": | ||
| 108 | case "delete": | ||
| 109 | case "patch": | ||
| 110 | case "head": | ||
| 111 | case "options": | ||
| 112 | return method; | ||
| 113 | default: | ||
| 114 | throw new Error(`Unsupported Google Discovery HTTP method: ${String(value)}`); | ||
| 115 | } | ||
| 116 | }; | ||
| 117 | |||
| 118 | const googleSchemaToJsonSchema = (input: { | ||
| 119 | schema: DiscoverySchema | DiscoveryParameter | unknown; | ||
| 120 | topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | ||
| 121 | }): unknown => { | ||
| 122 | const schema = asRecord(input.schema) as DiscoverySchema; | ||
| 123 | const ref = asString(schema.$ref); | ||
| 124 | if (ref) { | ||
| 125 | return { $ref: schemaRefKey(ref) }; | ||
| 126 | } | ||
| 127 | |||
| 128 | const description = asString(schema.description); | ||
| 129 | const format = asString(schema.format); | ||
| 130 | const type = asString(schema.type); | ||
| 131 | const enumValues = asStringArray(schema.enum); | ||
| 132 | const defaultValue = | ||
| 133 | typeof schema.default === "string" | ||
| 134 | || typeof schema.default === "number" | ||
| 135 | || typeof schema.default === "boolean" | ||
| 136 | ? schema.default | ||
| 137 | : undefined; | ||
| 138 | const readOnly = asBoolean(schema.readOnly); | ||
| 139 | |||
| 140 | const base: Record<string, unknown> = { | ||
| 141 | ...(description ? { description } : {}), | ||
| 142 | ...(format ? { format } : {}), | ||
| 143 | ...(enumValues.length > 0 ? { enum: [...enumValues] } : {}), | ||
| 144 | ...(defaultValue !== undefined ? { default: defaultValue } : {}), | ||
| 145 | ...(readOnly === true ? { readOnly: true } : {}), | ||
| 146 | }; | ||
| 147 | |||
| 148 | if (type === "any") { | ||
| 149 | return base; | ||
| 150 | } | ||
| 151 | |||
| 152 | if (type === "array") { | ||
| 153 | const items = googleSchemaToJsonSchema({ | ||
| 154 | schema: schema.items, | ||
| 155 | topLevelSchemas: input.topLevelSchemas, | ||
| 156 | }); | ||
| 157 | return { | ||
| 158 | ...base, | ||
| 159 | type: "array", | ||
| 160 | items: items ?? {}, | ||
| 161 | }; | ||
| 162 | } | ||
| 163 | |||
| 164 | const properties = asRecord(schema.properties); | ||
| 165 | const additionalProperties = schema.additionalProperties; | ||
| 166 | if ( | ||
| 167 | type === "object" | ||
| 168 | || Object.keys(properties).length > 0 | ||
| 169 | || additionalProperties !== undefined | ||
| 170 | ) { | ||
| 171 | const convertedProperties = Object.fromEntries( | ||
| 172 | Object.entries(properties).map(([key, propertySchema]) => [ | ||
| 173 | key, | ||
| 174 | googleSchemaToJsonSchema({ | ||
| 175 | schema: propertySchema, | ||
| 176 | topLevelSchemas: input.topLevelSchemas, | ||
| 177 | }), | ||
| 178 | ]), | ||
| 179 | ); | ||
| 180 | |||
| 181 | const convertedAdditionalProperties = | ||
| 182 | additionalProperties === undefined | ||
| 183 | ? undefined | ||
| 184 | : additionalProperties === true | ||
| 185 | ? true | ||
| 186 | : googleSchemaToJsonSchema({ | ||
| 187 | schema: additionalProperties, | ||
| 188 | topLevelSchemas: input.topLevelSchemas, | ||
| 189 | }); | ||
| 190 | |||
| 191 | return { | ||
| 192 | ...base, | ||
| 193 | type: "object", | ||
| 194 | ...(Object.keys(convertedProperties).length > 0 | ||
| 195 | ? { properties: convertedProperties } | ||
| 196 | : {}), | ||
| 197 | ...(convertedAdditionalProperties !== undefined | ||
| 198 | ? { additionalProperties: convertedAdditionalProperties } | ||
| 199 | : {}), | ||
| 200 | }; | ||
| 201 | } | ||
| 202 | |||
| 203 | if (type === "boolean" || type === "number" || type === "integer" || type === "string") { | ||
| 204 | return { | ||
| 205 | ...base, | ||
| 206 | type, | ||
| 207 | }; | ||
| 208 | } | ||
| 209 | |||
| 210 | return Object.keys(base).length > 0 ? base : {}; | ||
| 211 | }; | ||
| 212 | |||
| 213 | const parameterToJsonSchema = (input: { | ||
| 214 | parameter: DiscoveryParameter; | ||
| 215 | topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | ||
| 216 | }): unknown => { | ||
| 217 | const schema = googleSchemaToJsonSchema({ | ||
| 218 | schema: input.parameter, | ||
| 219 | topLevelSchemas: input.topLevelSchemas, | ||
| 220 | }); | ||
| 221 | const repeated = input.parameter.repeated === true; | ||
| 222 | if (!repeated) { | ||
| 223 | return schema; | ||
| 224 | } | ||
| 225 | |||
| 226 | return { | ||
| 227 | type: "array", | ||
| 228 | items: schema, | ||
| 229 | }; | ||
| 230 | }; | ||
| 231 | |||
| 232 | const methodRequestSchemaJson = (input: { | ||
| 233 | method: DiscoveryMethod; | ||
| 234 | topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | ||
| 235 | }): string | undefined => { | ||
| 236 | const methodRecord = asRecord(input.method); | ||
| 237 | const parameters = asRecord(methodRecord.parameters); | ||
| 238 | const properties: Record<string, unknown> = {}; | ||
| 239 | const required: string[] = []; | ||
| 240 | |||
| 241 | for (const [name, rawParameter] of Object.entries(parameters)) { | ||
| 242 | const parameter = asRecord(rawParameter) as DiscoveryParameter; | ||
| 243 | properties[name] = parameterToJsonSchema({ | ||
| 244 | parameter, | ||
| 245 | topLevelSchemas: input.topLevelSchemas, | ||
| 246 | }); | ||
| 247 | if (parameter.required === true) { | ||
| 248 | required.push(name); | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | const requestRef = asString(asRecord(methodRecord.request).$ref); | ||
| 253 | if (requestRef) { | ||
| 254 | const requestSchema = input.topLevelSchemas[requestRef]; | ||
| 255 | if (requestSchema) { | ||
| 256 | properties.body = googleSchemaToJsonSchema({ | ||
| 257 | schema: requestSchema, | ||
| 258 | topLevelSchemas: input.topLevelSchemas, | ||
| 259 | }); | ||
| 260 | } else { | ||
| 261 | properties.body = { $ref: schemaRefKey(requestRef) }; | ||
| 262 | } | ||
| 263 | } | ||
| 264 | |||
| 265 | if (Object.keys(properties).length === 0) { | ||
| 266 | return undefined; | ||
| 267 | } | ||
| 268 | |||
| 269 | return JSON.stringify({ | ||
| 270 | type: "object", | ||
| 271 | properties, | ||
| 272 | ...(required.length > 0 ? { required } : {}), | ||
| 273 | additionalProperties: false, | ||
| 274 | }); | ||
| 275 | }; | ||
| 276 | |||
| 277 | const methodResponseSchemaJson = (input: { | ||
| 278 | method: DiscoveryMethod; | ||
| 279 | topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | ||
| 280 | }): string | undefined => { | ||
| 281 | const responseRef = asString(asRecord(asRecord(input.method).response).$ref); | ||
| 282 | if (!responseRef) { | ||
| 283 | return undefined; | ||
| 284 | } | ||
| 285 | |||
| 286 | const responseSchema = input.topLevelSchemas[responseRef]; | ||
| 287 | const schemaJson = responseSchema | ||
| 288 | ? googleSchemaToJsonSchema({ | ||
| 289 | schema: responseSchema, | ||
| 290 | topLevelSchemas: input.topLevelSchemas, | ||
| 291 | }) | ||
| 292 | : { $ref: schemaRefKey(responseRef) }; | ||
| 293 | |||
| 294 | return JSON.stringify(schemaJson); | ||
| 295 | }; | ||
| 296 | |||
| 297 | const parameterRecordFromMethod = ( | ||
| 298 | method: DiscoveryMethod, | ||
| 299 | ): ReadonlyArray<GoogleDiscoveryMethodParameter> => | ||
| 300 | Object.entries(asRecord(asRecord(method).parameters)) | ||
| 301 | .flatMap(([name, rawParameter]) => { | ||
| 302 | const parameter = asRecord(rawParameter) as DiscoveryParameter; | ||
| 303 | const location = asString(parameter.location); | ||
| 304 | if (location !== "path" && location !== "query" && location !== "header") { | ||
| 305 | return []; | ||
| 306 | } | ||
| 307 | |||
| 308 | return [{ | ||
| 309 | name, | ||
| 310 | location, | ||
| 311 | required: parameter.required === true, | ||
| 312 | repeated: parameter.repeated === true, | ||
| 313 | description: asString(parameter.description), | ||
| 314 | type: asString(parameter.type) ?? asString(parameter.$ref), | ||
| 315 | ...(asStringArray(parameter.enum).length > 0 | ||
| 316 | ? { enum: [...asStringArray(parameter.enum)] } | ||
| 317 | : {}), | ||
| 318 | ...(asString(parameter.default) ? { default: asString(parameter.default)! } : {}), | ||
| 319 | } satisfies GoogleDiscoveryMethodParameter]; | ||
| 320 | }); | ||
| 321 | |||
| 322 | const scopesRecordFromDocument = ( | ||
| 323 | document: DiscoveryDocument, | ||
| 324 | ): Record<string, string> | undefined => { | ||
| 325 | const scopes = asRecord( | ||
| 326 | asRecord( | ||
| 327 | asRecord(document.auth).oauth2, | ||
| 328 | ).scopes, | ||
| 329 | ); | ||
| 330 | |||
| 331 | const normalized = Object.fromEntries( | ||
| 332 | Object.entries(scopes).flatMap(([scope, rawValue]) => { | ||
| 333 | const description = asString(asRecord(rawValue).description) ?? ""; | ||
| 334 | return [[scope, description]]; | ||
| 335 | }), | ||
| 336 | ); | ||
| 337 | |||
| 338 | return Object.keys(normalized).length > 0 ? normalized : undefined; | ||
| 339 | }; | ||
| 340 | |||
| 341 | const manifestMethodFromDiscoveryMethod = (input: { | ||
| 342 | service: string; | ||
| 343 | version: string; | ||
| 344 | rootUrl: string; | ||
| 345 | servicePath: string; | ||
| 346 | topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | ||
| 347 | method: DiscoveryMethod; | ||
| 348 | }): GoogleDiscoveryManifestMethod | null => { | ||
| 349 | const methodId = asString(input.method.id); | ||
| 350 | const path = asString(input.method.path); | ||
| 351 | if (!methodId || !path) { | ||
| 352 | return null; | ||
| 353 | } | ||
| 354 | |||
| 355 | const method = normalizeHttpMethod(input.method.httpMethod); | ||
| 356 | const rawToolId = methodId; | ||
| 357 | const toolId = rawToolId.startsWith(`${input.service}.`) | ||
| 358 | ? rawToolId.slice(input.service.length + 1) | ||
| 359 | : rawToolId; | ||
| 360 | const segments = toolId.split(".").filter((segment) => segment.length > 0); | ||
| 361 | const leaf = segments.at(-1) ?? toolId; | ||
| 362 | const group = segments.length > 1 ? segments.slice(0, -1).join(".") : null; | ||
| 363 | const responseRef = asString(asRecord(asRecord(input.method).response).$ref); | ||
| 364 | const requestRef = asString(asRecord(asRecord(input.method).request).$ref); | ||
| 365 | const mediaUpload = asRecord(input.method.mediaUpload); | ||
| 366 | |||
| 367 | return { | ||
| 368 | toolId, | ||
| 369 | rawToolId, | ||
| 370 | methodId, | ||
| 371 | name: toolId, | ||
| 372 | description: asString(input.method.description), | ||
| 373 | group, | ||
| 374 | leaf, | ||
| 375 | method, | ||
| 376 | path, | ||
| 377 | flatPath: asString(input.method.flatPath), | ||
| 378 | parameters: parameterRecordFromMethod(input.method), | ||
| 379 | requestSchemaId: requestRef, | ||
| 380 | responseSchemaId: responseRef, | ||
| 381 | scopes: [...asStringArray(input.method.scopes)], | ||
| 382 | supportsMediaUpload: Object.keys(mediaUpload).length > 0, | ||
| 383 | supportsMediaDownload: asBoolean(input.method.supportsMediaDownload) === true, | ||
| 384 | ...(methodRequestSchemaJson({ | ||
| 385 | method: input.method, | ||
| 386 | topLevelSchemas: input.topLevelSchemas, | ||
| 387 | }) ? { inputSchemaJson: methodRequestSchemaJson({ | ||
| 388 | method: input.method, | ||
| 389 | topLevelSchemas: input.topLevelSchemas, | ||
| 390 | }) } : {}), | ||
| 391 | ...(methodResponseSchemaJson({ | ||
| 392 | method: input.method, | ||
| 393 | topLevelSchemas: input.topLevelSchemas, | ||
| 394 | }) ? { outputSchemaJson: methodResponseSchemaJson({ | ||
| 395 | method: input.method, | ||
| 396 | topLevelSchemas: input.topLevelSchemas, | ||
| 397 | }) } : {}), | ||
| 398 | }; | ||
| 399 | }; | ||
| 400 | |||
| 401 | const collectDiscoveryMethods = (input: { | ||
| 402 | service: string; | ||
| 403 | version: string; | ||
| 404 | rootUrl: string; | ||
| 405 | servicePath: string; | ||
| 406 | topLevelSchemas: Readonly<Record<string, DiscoverySchema>>; | ||
| 407 | resource: DiscoveryResource | JsonObject | unknown; | ||
| 408 | }): ReadonlyArray<GoogleDiscoveryManifestMethod> => { | ||
| 409 | const resource = asRecord(input.resource) as DiscoveryResource; | ||
| 410 | const methods = Object.values(asRecord(resource.methods)).flatMap((method) => { | ||
| 411 | try { | ||
| 412 | const manifestMethod = manifestMethodFromDiscoveryMethod({ | ||
| 413 | service: input.service, | ||
| 414 | version: input.version, | ||
| 415 | rootUrl: input.rootUrl, | ||
| 416 | servicePath: input.servicePath, | ||
| 417 | topLevelSchemas: input.topLevelSchemas, | ||
| 418 | method: asRecord(method) as DiscoveryMethod, | ||
| 419 | }); | ||
| 420 | return manifestMethod ? [manifestMethod] : []; | ||
| 421 | } catch { | ||
| 422 | return []; | ||
| 423 | } | ||
| 424 | }); | ||
| 425 | const nested = Object.values(asRecord(resource.resources)).flatMap((nestedResource) => | ||
| 426 | collectDiscoveryMethods({ | ||
| 427 | ...input, | ||
| 428 | resource: nestedResource, | ||
| 429 | })); | ||
| 430 | |||
| 431 | return [...methods, ...nested]; | ||
| 432 | }; | ||
| 433 | |||
| 434 | export const extractGoogleDiscoveryManifest = ( | ||
| 435 | sourceName: string, | ||
| 436 | discoveryInput: string | JsonObject, | ||
| 437 | ): Effect.Effect<GoogleDiscoveryToolManifest, Error, never> => | ||
| 438 | Effect.try({ | ||
| 439 | try: () => { | ||
| 440 | const document = typeof discoveryInput === "string" | ||
| 441 | ? JSON.parse(discoveryInput) as DiscoveryDocument | ||
| 442 | : discoveryInput as DiscoveryDocument; | ||
| 443 | |||
| 444 | const service = asString(document.name); | ||
| 445 | const versionName = asString(document.version); | ||
| 446 | const rootUrl = asString(document.rootUrl); | ||
| 447 | const servicePath = typeof document.servicePath === "string" ? document.servicePath : ""; | ||
| 448 | |||
| 449 | if (!service || !versionName || !rootUrl) { | ||
| 450 | throw new Error(`Invalid Google Discovery document for ${sourceName}`); | ||
| 451 | } | ||
| 452 | |||
| 453 | const schemas = Object.fromEntries( | ||
| 454 | Object.entries(asRecord(document.schemas)).map(([schemaId, rawSchema]) => [ | ||
| 455 | schemaId, | ||
| 456 | asRecord(rawSchema) as DiscoverySchema, | ||
| 457 | ]), | ||
| 458 | ); | ||
| 459 | const schemaRefTable = Object.fromEntries( | ||
| 460 | Object.entries(schemas).map(([schemaId, schema]) => [ | ||
| 461 | schemaRefKey(schemaId), | ||
| 462 | JSON.stringify( | ||
| 463 | googleSchemaToJsonSchema({ | ||
| 464 | schema, | ||
| 465 | topLevelSchemas: schemas, | ||
| 466 | }), | ||
| 467 | ), | ||
| 468 | ]), | ||
| 469 | ) satisfies GoogleDiscoverySchemaRefTable; | ||
| 470 | const methods = [ | ||
| 471 | ...Object.values(asRecord(document.methods)).flatMap((method) => { | ||
| 472 | const manifestMethod = manifestMethodFromDiscoveryMethod({ | ||
| 473 | service, | ||
| 474 | version: versionName, | ||
| 475 | rootUrl, | ||
| 476 | servicePath, | ||
| 477 | topLevelSchemas: schemas, | ||
| 478 | method: asRecord(method) as DiscoveryMethod, | ||
| 479 | }); | ||
| 480 | return manifestMethod ? [manifestMethod] : []; | ||
| 481 | }), | ||
| 482 | ...Object.values(asRecord(document.resources)).flatMap((resource) => | ||
| 483 | collectDiscoveryMethods({ | ||
| 484 | service, | ||
| 485 | version: versionName, | ||
| 486 | rootUrl, | ||
| 487 | servicePath, | ||
| 488 | topLevelSchemas: schemas, | ||
| 489 | resource, | ||
| 490 | })), | ||
| 491 | ].sort((left, right) => left.toolId.localeCompare(right.toolId)); | ||
| 492 | |||
| 493 | const sourceHash = contentHash( | ||
| 494 | typeof discoveryInput === "string" | ||
| 495 | ? discoveryInput | ||
| 496 | : JSON.stringify(discoveryInput), | ||
| 497 | ); | ||
| 498 | |||
| 499 | return { | ||
| 500 | version: 1, | ||
| 501 | sourceHash, | ||
| 502 | service, | ||
| 503 | versionName, | ||
| 504 | title: asString(document.title), | ||
| 505 | description: asString(document.description), | ||
| 506 | rootUrl, | ||
| 507 | servicePath, | ||
| 508 | batchPath: asString(document.batchPath), | ||
| 509 | documentationLink: asString(document.documentationLink), | ||
| 510 | ...(Object.keys(schemaRefTable).length > 0 ? { schemaRefTable } : {}), | ||
| 511 | ...(scopesRecordFromDocument(document) | ||
| 512 | ? { oauthScopes: scopesRecordFromDocument(document)! } | ||
| 513 | : {}), | ||
| 514 | methods, | ||
| 515 | }; | ||
| 516 | }, | ||
| 517 | catch: (cause) => | ||
| 518 | cause instanceof Error | ||
| 519 | ? cause | ||
| 520 | : new Error(`Failed to extract Google Discovery manifest: ${String(cause)}`), | ||
| 521 | }); | ||
| 522 | |||
| 523 | export const compileGoogleDiscoveryToolDefinitions = ( | ||
| 524 | manifest: GoogleDiscoveryToolManifest, | ||
| 525 | ): Array<GoogleDiscoveryManifestMethod> => [...manifest.methods]; | ||
| @@ -0,0 +1,193 @@ | |||
| 1 | import { createServer } from "node:http"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import { makeToolInvokerFromTools } from "@executor/codemode-core"; | ||
| 5 | |||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | |||
| 8 | import { | ||
| 9 | compileGoogleDiscoveryToolDefinitions, | ||
| 10 | createGoogleDiscoveryToolFromDefinition, | ||
| 11 | extractGoogleDiscoveryManifest, | ||
| 12 | } from "./index"; | ||
| 13 | |||
| 14 | const fetchLiveDiscoveryDocument = async (url: string): Promise<string> => { | ||
| 15 | const response = await fetch(url, { | ||
| 16 | signal: AbortSignal.timeout(20_000), | ||
| 17 | }); | ||
| 18 | if (!response.ok) { | ||
| 19 | throw new Error(`Failed to fetch ${url}: ${response.status}`); | ||
| 20 | } | ||
| 21 | |||
| 22 | return response.text(); | ||
| 23 | }; | ||
| 24 | |||
| 25 | const withJsonServer = async <T>(handler: (input: { | ||
| 26 | baseUrl: string; | ||
| 27 | requests: Array<{ | ||
| 28 | method: string; | ||
| 29 | url: string; | ||
| 30 | headers: Record<string, string | string[] | undefined>; | ||
| 31 | body: string; | ||
| 32 | }>; | ||
| 33 | }) => Promise<T>): Promise<T> => { | ||
| 34 | const requests: Array<{ | ||
| 35 | method: string; | ||
| 36 | url: string; | ||
| 37 | headers: Record<string, string | string[] | undefined>; | ||
| 38 | body: string; | ||
| 39 | }> = []; | ||
| 40 | const server = createServer(async (request, response) => { | ||
| 41 | const chunks: Buffer[] = []; | ||
| 42 | for await (const chunk of request) { | ||
| 43 | chunks.push(Buffer.from(chunk)); | ||
| 44 | } | ||
| 45 | requests.push({ | ||
| 46 | method: request.method ?? "GET", | ||
| 47 | url: request.url ?? "/", | ||
| 48 | headers: request.headers, | ||
| 49 | body: Buffer.concat(chunks).toString("utf8"), | ||
| 50 | }); | ||
| 51 | |||
| 52 | response.statusCode = 200; | ||
| 53 | response.setHeader("content-type", "application/json"); | ||
| 54 | response.end( | ||
| 55 | JSON.stringify({ | ||
| 56 | range: "Sheet1!A1:B2", | ||
| 57 | values: [["ok"]], | ||
| 58 | }), | ||
| 59 | ); | ||
| 60 | }); | ||
| 61 | |||
| 62 | await new Promise<void>((resolve, reject) => { | ||
| 63 | server.listen(0, "127.0.0.1", (error?: Error) => { | ||
| 64 | if (error) { | ||
| 65 | reject(error); | ||
| 66 | return; | ||
| 67 | } | ||
| 68 | resolve(); | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | try { | ||
| 73 | const address = server.address(); | ||
| 74 | if (!address || typeof address === "string") { | ||
| 75 | throw new Error("Failed to resolve local test server address"); | ||
| 76 | } | ||
| 77 | return await handler({ | ||
| 78 | baseUrl: `http://127.0.0.1:${address.port}`, | ||
| 79 | requests, | ||
| 80 | }); | ||
| 81 | } finally { | ||
| 82 | await new Promise<void>((resolve, reject) => { | ||
| 83 | server.close((error) => { | ||
| 84 | if (error) { | ||
| 85 | reject(error); | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | resolve(); | ||
| 89 | }); | ||
| 90 | }); | ||
| 91 | } | ||
| 92 | }; | ||
| 93 | |||
| 94 | describe("google discovery tools", () => { | ||
| 95 | it("extracts a real Google Sheets discovery document into compact tools", () => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const discoveryDocument = yield* Effect.tryPromise({ | ||
| 98 | try: () => | ||
| 99 | fetchLiveDiscoveryDocument( | ||
| 100 | "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | ||
| 101 | ), | ||
| 102 | catch: (cause) => | ||
| 103 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 104 | }); | ||
| 105 | |||
| 106 | const manifest = yield* extractGoogleDiscoveryManifest( | ||
| 107 | "Google Sheets", | ||
| 108 | discoveryDocument, | ||
| 109 | ); | ||
| 110 | const definitions = compileGoogleDiscoveryToolDefinitions(manifest); | ||
| 111 | const valuesGet = definitions.find( | ||
| 112 | (definition) => definition.toolId === "spreadsheets.values.get", | ||
| 113 | ); | ||
| 114 | |||
| 115 | expect(manifest.service).toBe("sheets"); | ||
| 116 | expect(manifest.versionName).toBe("v4"); | ||
| 117 | expect(definitions.length).toBeGreaterThan(10); | ||
| 118 | expect(valuesGet).toBeDefined(); | ||
| 119 | expect(valuesGet?.method).toBe("get"); | ||
| 120 | expect(valuesGet?.inputSchemaJson).toContain("\"spreadsheetId\""); | ||
| 121 | expect(valuesGet?.outputSchemaJson).toContain("\"values\""); | ||
| 122 | expect(Object.keys(manifest.schemaRefTable ?? {})).toContain( | ||
| 123 | "#/$defs/google/ValueRange", | ||
| 124 | ); | ||
| 125 | }).pipe(Effect.runPromise)); | ||
| 126 | |||
| 127 | it("invokes a tool compiled from a real Google discovery document", () => | ||
| 128 | Effect.tryPromise({ | ||
| 129 | try: async () => { | ||
| 130 | const discoveryDocument = await fetchLiveDiscoveryDocument( | ||
| 131 | "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | ||
| 132 | ); | ||
| 133 | const manifest = await Effect.runPromise( | ||
| 134 | extractGoogleDiscoveryManifest("Google Sheets", discoveryDocument), | ||
| 135 | ); | ||
| 136 | const definitions = compileGoogleDiscoveryToolDefinitions(manifest); | ||
| 137 | const valuesGet = definitions.find( | ||
| 138 | (definition) => definition.toolId === "spreadsheets.values.get", | ||
| 139 | ); | ||
| 140 | if (!valuesGet) { | ||
| 141 | throw new Error("Expected sheets.spreadsheets.values.get to exist"); | ||
| 142 | } | ||
| 143 | |||
| 144 | await withJsonServer(async ({ baseUrl, requests }) => { | ||
| 145 | const tool = createGoogleDiscoveryToolFromDefinition({ | ||
| 146 | definition: valuesGet, | ||
| 147 | service: manifest.service, | ||
| 148 | version: manifest.versionName, | ||
| 149 | rootUrl: manifest.rootUrl, | ||
| 150 | servicePath: manifest.servicePath, | ||
| 151 | path: "google.sheets.spreadsheets.values.get", | ||
| 152 | sourceKey: "src_google_sheets", | ||
| 153 | schemaRefTable: manifest.schemaRefTable, | ||
| 154 | baseUrl, | ||
| 155 | credentialPlacements: { | ||
| 156 | headers: { | ||
| 157 | authorization: "Bearer live-test-token", | ||
| 158 | }, | ||
| 159 | }, | ||
| 160 | }); | ||
| 161 | |||
| 162 | const result = await Effect.runPromise( | ||
| 163 | makeToolInvokerFromTools({ | ||
| 164 | tools: { | ||
| 165 | "google.sheets.spreadsheets.values.get": tool, | ||
| 166 | }, | ||
| 167 | }).invoke({ | ||
| 168 | path: "google.sheets.spreadsheets.values.get", | ||
| 169 | args: { | ||
| 170 | spreadsheetId: "sheet123", | ||
| 171 | range: "Sheet1!A1:B2", | ||
| 172 | majorDimension: "ROWS", | ||
| 173 | }, | ||
| 174 | }), | ||
| 175 | ); | ||
| 176 | |||
| 177 | expect(result).toEqual({ | ||
| 178 | range: "Sheet1!A1:B2", | ||
| 179 | values: [["ok"]], | ||
| 180 | }); | ||
| 181 | expect(requests).toHaveLength(1); | ||
| 182 | expect(requests[0]?.method).toBe("GET"); | ||
| 183 | expect(requests[0]?.url).toContain( | ||
| 184 | "/v4/spreadsheets/sheet123/values/Sheet1!A1%3AB2", | ||
| 185 | ); | ||
| 186 | expect(requests[0]?.url).toContain("majorDimension=ROWS"); | ||
| 187 | expect(requests[0]?.headers.authorization).toBe("Bearer live-test-token"); | ||
| 188 | }); | ||
| 189 | }, | ||
| 190 | catch: (cause) => | ||
| 191 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 192 | })); | ||
| 193 | }); | ||
| @@ -0,0 +1,480 @@ | |||
| 1 | import { | ||
| 2 | FetchHttpClient, | ||
| 3 | HttpClient, | ||
| 4 | type HttpClientResponse, | ||
| 5 | HttpClientRequest, | ||
| 6 | } from "@effect/platform"; | ||
| 7 | import { | ||
| 8 | applyCookiePlacementsToHeaders, | ||
| 9 | applyHttpQueryPlacementsToUrl, | ||
| 10 | applyJsonBodyPlacements, | ||
| 11 | standardSchemaFromJsonSchema, | ||
| 12 | toTool, | ||
| 13 | type HttpRequestPlacements, | ||
| 14 | type ToolMetadata, | ||
| 15 | } from "@executor/codemode-core"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Schema from "effect/Schema"; | ||
| 19 | |||
| 20 | import type { | ||
| 21 | GoogleDiscoveryManifestMethod, | ||
| 22 | GoogleDiscoverySchemaRefTable, | ||
| 23 | GoogleDiscoveryToolDefinition, | ||
| 24 | } from "./google-discovery-types"; | ||
| 25 | import { | ||
| 26 | GoogleDiscoveryToolProviderDataSchema, | ||
| 27 | } from "./google-discovery-types"; | ||
| 28 | |||
| 29 | const decodeGoogleDiscoveryToolProviderDataJson = Schema.decodeUnknownEither( | ||
| 30 | Schema.parseJson(GoogleDiscoveryToolProviderDataSchema), | ||
| 31 | ); | ||
| 32 | |||
| 33 | export const googleDiscoveryProviderDataJsonFromDefinition = ( | ||
| 34 | input: { | ||
| 35 | service: string; | ||
| 36 | version: string; | ||
| 37 | rootUrl: string; | ||
| 38 | servicePath: string; | ||
| 39 | definition: GoogleDiscoveryManifestMethod; | ||
| 40 | }, | ||
| 41 | ): string => | ||
| 42 | JSON.stringify({ | ||
| 43 | kind: "google_discovery", | ||
| 44 | service: input.service, | ||
| 45 | version: input.version, | ||
| 46 | toolId: input.definition.toolId, | ||
| 47 | rawToolId: input.definition.rawToolId, | ||
| 48 | methodId: input.definition.methodId, | ||
| 49 | group: input.definition.group, | ||
| 50 | leaf: input.definition.leaf, | ||
| 51 | invocation: { | ||
| 52 | method: input.definition.method, | ||
| 53 | path: input.definition.path, | ||
| 54 | flatPath: input.definition.flatPath, | ||
| 55 | rootUrl: input.rootUrl, | ||
| 56 | servicePath: input.servicePath, | ||
| 57 | parameters: input.definition.parameters, | ||
| 58 | requestSchemaId: input.definition.requestSchemaId, | ||
| 59 | responseSchemaId: input.definition.responseSchemaId, | ||
| 60 | scopes: input.definition.scopes, | ||
| 61 | supportsMediaUpload: input.definition.supportsMediaUpload, | ||
| 62 | supportsMediaDownload: input.definition.supportsMediaDownload, | ||
| 63 | }, | ||
| 64 | }); | ||
| 65 | |||
| 66 | const decodeGoogleDiscoverySchemaRefTableJson = Schema.decodeUnknownEither( | ||
| 67 | Schema.parseJson( | ||
| 68 | Schema.Record({ | ||
| 69 | key: Schema.String, | ||
| 70 | value: Schema.Unknown, | ||
| 71 | }), | ||
| 72 | ), | ||
| 73 | ); | ||
| 74 | |||
| 75 | const asRecord = (value: unknown): Record<string, unknown> => | ||
| 76 | typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 77 | ? value as Record<string, unknown> | ||
| 78 | : {}; | ||
| 79 | |||
| 80 | const normalizeCredentialPlacements = (input: { | ||
| 81 | credentialHeaders?: Readonly<Record<string, string>>; | ||
| 82 | credentialPlacements?: HttpRequestPlacements; | ||
| 83 | }): HttpRequestPlacements => ({ | ||
| 84 | headers: { | ||
| 85 | ...(input.credentialHeaders ?? {}), | ||
| 86 | ...(input.credentialPlacements?.headers ?? {}), | ||
| 87 | }, | ||
| 88 | queryParams: input.credentialPlacements?.queryParams, | ||
| 89 | cookies: input.credentialPlacements?.cookies, | ||
| 90 | bodyValues: input.credentialPlacements?.bodyValues, | ||
| 91 | }); | ||
| 92 | |||
| 93 | const setNestedSchemaProperty = ( | ||
| 94 | target: Record<string, unknown>, | ||
| 95 | path: readonly string[], | ||
| 96 | value: unknown, | ||
| 97 | ): void => { | ||
| 98 | if (path.length === 0) { | ||
| 99 | return; | ||
| 100 | } | ||
| 101 | |||
| 102 | const [head, ...rest] = path; | ||
| 103 | if (!head) { | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | |||
| 107 | if (rest.length === 0) { | ||
| 108 | target[head] = value; | ||
| 109 | return; | ||
| 110 | } | ||
| 111 | |||
| 112 | const next = asRecord(target[head]); | ||
| 113 | target[head] = next; | ||
| 114 | setNestedSchemaProperty(next, rest, value); | ||
| 115 | }; | ||
| 116 | |||
| 117 | const materializeSchemaWithRefDefinitions = (input: { | ||
| 118 | schemaJson: string | undefined; | ||
| 119 | refTable?: Readonly<Record<string, unknown>>; | ||
| 120 | }): Record<string, unknown> => { | ||
| 121 | if (!input.schemaJson) { | ||
| 122 | return {}; | ||
| 123 | } | ||
| 124 | |||
| 125 | let rootSchema: Record<string, unknown>; | ||
| 126 | try { | ||
| 127 | rootSchema = asRecord(JSON.parse(input.schemaJson) as unknown); | ||
| 128 | } catch { | ||
| 129 | return {}; | ||
| 130 | } | ||
| 131 | |||
| 132 | if (!input.refTable || Object.keys(input.refTable).length === 0) { | ||
| 133 | return rootSchema; | ||
| 134 | } | ||
| 135 | |||
| 136 | const defsRoot = asRecord(rootSchema.$defs); | ||
| 137 | for (const [ref, value] of Object.entries(input.refTable)) { | ||
| 138 | if (!ref.startsWith("#/$defs/")) { | ||
| 139 | continue; | ||
| 140 | } | ||
| 141 | |||
| 142 | const materializedValue = | ||
| 143 | typeof value === "string" | ||
| 144 | ? (() => { | ||
| 145 | try { | ||
| 146 | return JSON.parse(value) as unknown; | ||
| 147 | } catch { | ||
| 148 | return value; | ||
| 149 | } | ||
| 150 | })() | ||
| 151 | : value; | ||
| 152 | |||
| 153 | const path = ref | ||
| 154 | .slice("#/$defs/".length) | ||
| 155 | .split("/") | ||
| 156 | .filter((segment) => segment.length > 0); | ||
| 157 | |||
| 158 | setNestedSchemaProperty(defsRoot, path, materializedValue); | ||
| 159 | } | ||
| 160 | |||
| 161 | return Object.keys(defsRoot).length > 0 | ||
| 162 | ? { ...rootSchema, $defs: defsRoot } | ||
| 163 | : rootSchema; | ||
| 164 | }; | ||
| 165 | |||
| 166 | const decodeResponseBody = (response: HttpClientResponse.HttpClientResponse) => | ||
| 167 | Effect.gen(function* () { | ||
| 168 | const contentType = response.headers["content-type"]?.toLowerCase() ?? ""; | ||
| 169 | const text = yield* response.text.pipe( | ||
| 170 | Effect.mapError((cause) => | ||
| 171 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 172 | ), | ||
| 173 | ); | ||
| 174 | |||
| 175 | if (text.trim().length === 0) { | ||
| 176 | return null; | ||
| 177 | } | ||
| 178 | |||
| 179 | if (contentType.includes("application/json") || contentType.includes("+json")) { | ||
| 180 | try { | ||
| 181 | return JSON.parse(text) as unknown; | ||
| 182 | } catch { | ||
| 183 | return text; | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | return text; | ||
| 188 | }); | ||
| 189 | |||
| 190 | const stringValuesFromParameter = ( | ||
| 191 | value: unknown, | ||
| 192 | repeated: boolean, | ||
| 193 | ): string[] => { | ||
| 194 | if (value === undefined || value === null) { | ||
| 195 | return []; | ||
| 196 | } | ||
| 197 | |||
| 198 | if (Array.isArray(value)) { | ||
| 199 | const normalized = value | ||
| 200 | .flatMap((entry) => | ||
| 201 | entry === undefined || entry === null ? [] : [String(entry)], | ||
| 202 | ); | ||
| 203 | return repeated ? normalized : [normalized.join(",")]; | ||
| 204 | } | ||
| 205 | |||
| 206 | if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| 207 | return [String(value)]; | ||
| 208 | } | ||
| 209 | |||
| 210 | return [JSON.stringify(value)]; | ||
| 211 | }; | ||
| 212 | |||
| 213 | const replacePathParameters = (input: { | ||
| 214 | pathTemplate: string; | ||
| 215 | args: Record<string, unknown>; | ||
| 216 | parameters: ReadonlyArray<GoogleDiscoveryManifestMethod["parameters"][number]>; | ||
| 217 | }): string => | ||
| 218 | input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => { | ||
| 219 | const parameter = input.parameters.find( | ||
| 220 | (entry) => entry.location === "path" && entry.name === name, | ||
| 221 | ); | ||
| 222 | const rawValue = input.args[name]; | ||
| 223 | if ((rawValue === undefined || rawValue === null) && parameter?.required) { | ||
| 224 | throw new Error(`Missing required path parameter: ${name}`); | ||
| 225 | } | ||
| 226 | |||
| 227 | const values = stringValuesFromParameter(rawValue, false); | ||
| 228 | if (values.length === 0) { | ||
| 229 | return ""; | ||
| 230 | } | ||
| 231 | |||
| 232 | return encodeURIComponent(values[0]!); | ||
| 233 | }); | ||
| 234 | |||
| 235 | const resolveBaseUrl = (input: { | ||
| 236 | rootUrl: string; | ||
| 237 | servicePath: string; | ||
| 238 | baseUrl?: string | undefined; | ||
| 239 | }): string => { | ||
| 240 | if (input.baseUrl) { | ||
| 241 | return new URL(input.baseUrl).toString(); | ||
| 242 | } | ||
| 243 | |||
| 244 | return new URL(input.servicePath || "", input.rootUrl).toString(); | ||
| 245 | }; | ||
| 246 | |||
| 247 | const buildGoogleDiscoveryRequest = (input: { | ||
| 248 | definition: GoogleDiscoveryToolDefinition; | ||
| 249 | args: Record<string, unknown>; | ||
| 250 | defaultHeaders: Readonly<Record<string, string>>; | ||
| 251 | credentialPlacements: HttpRequestPlacements; | ||
| 252 | baseUrl?: string | undefined; | ||
| 253 | providerDataJson: string; | ||
| 254 | }): { | ||
| 255 | method: string; | ||
| 256 | url: URL; | ||
| 257 | headers: Record<string, string>; | ||
| 258 | body?: string; | ||
| 259 | } => { | ||
| 260 | const providerData = decodeGoogleDiscoveryToolProviderDataJson(input.providerDataJson); | ||
| 261 | if (providerData._tag === "Left") { | ||
| 262 | throw new Error("Invalid Google Discovery provider data"); | ||
| 263 | } | ||
| 264 | |||
| 265 | const invocation = providerData.right.invocation; | ||
| 266 | const resolvedPath = replacePathParameters({ | ||
| 267 | pathTemplate: invocation.path, | ||
| 268 | args: input.args, | ||
| 269 | parameters: input.definition.parameters, | ||
| 270 | }); | ||
| 271 | const url = new URL( | ||
| 272 | resolvedPath.replace(/^\//, ""), | ||
| 273 | resolveBaseUrl({ | ||
| 274 | rootUrl: invocation.rootUrl, | ||
| 275 | servicePath: invocation.servicePath, | ||
| 276 | baseUrl: input.baseUrl, | ||
| 277 | }), | ||
| 278 | ); | ||
| 279 | const headers: Record<string, string> = { | ||
| 280 | ...input.defaultHeaders, | ||
| 281 | }; | ||
| 282 | |||
| 283 | for (const parameter of input.definition.parameters) { | ||
| 284 | if (parameter.location === "path") { | ||
| 285 | continue; | ||
| 286 | } | ||
| 287 | |||
| 288 | const rawValue = input.args[parameter.name]; | ||
| 289 | if ((rawValue === undefined || rawValue === null) && parameter.required) { | ||
| 290 | throw new Error(`Missing required ${parameter.location} parameter: ${parameter.name}`); | ||
| 291 | } | ||
| 292 | |||
| 293 | const values = stringValuesFromParameter(rawValue, parameter.repeated); | ||
| 294 | if (values.length === 0) { | ||
| 295 | continue; | ||
| 296 | } | ||
| 297 | |||
| 298 | switch (parameter.location) { | ||
| 299 | case "query": | ||
| 300 | for (const value of values) { | ||
| 301 | url.searchParams.append(parameter.name, value); | ||
| 302 | } | ||
| 303 | break; | ||
| 304 | case "header": | ||
| 305 | headers[parameter.name] = parameter.repeated ? values.join(",") : values[0]!; | ||
| 306 | break; | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | const urlWithAuth = applyHttpQueryPlacementsToUrl({ | ||
| 311 | url, | ||
| 312 | queryParams: input.credentialPlacements.queryParams, | ||
| 313 | }); | ||
| 314 | const headersWithCookies = applyCookiePlacementsToHeaders({ | ||
| 315 | headers, | ||
| 316 | cookies: input.credentialPlacements.cookies, | ||
| 317 | }); | ||
| 318 | for (const [key, value] of Object.entries(input.credentialPlacements.headers ?? {})) { | ||
| 319 | headersWithCookies[key] = value; | ||
| 320 | } | ||
| 321 | |||
| 322 | let body: string | undefined; | ||
| 323 | if (input.definition.requestSchemaId !== null) { | ||
| 324 | const rawBody = input.args.body; | ||
| 325 | const bodyValues = input.credentialPlacements.bodyValues ?? {}; | ||
| 326 | const hasBodyValues = Object.keys(bodyValues).length > 0; | ||
| 327 | if (rawBody !== undefined || hasBodyValues) { | ||
| 328 | body = JSON.stringify( | ||
| 329 | applyJsonBodyPlacements({ | ||
| 330 | body: rawBody !== undefined ? rawBody : {}, | ||
| 331 | bodyValues, | ||
| 332 | label: `${input.definition.method.toUpperCase()} ${input.definition.path}`, | ||
| 333 | }), | ||
| 334 | ); | ||
| 335 | if (!("content-type" in headersWithCookies)) { | ||
| 336 | headersWithCookies["content-type"] = "application/json"; | ||
| 337 | } | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | return { | ||
| 342 | method: input.definition.method.toUpperCase(), | ||
| 343 | url: urlWithAuth, | ||
| 344 | headers: headersWithCookies, | ||
| 345 | ...(body !== undefined ? { body } : {}), | ||
| 346 | }; | ||
| 347 | }; | ||
| 348 | |||
| 349 | export type CreateGoogleDiscoveryToolFromDefinitionInput = { | ||
| 350 | definition: GoogleDiscoveryToolDefinition; | ||
| 351 | service: string; | ||
| 352 | version: string; | ||
| 353 | rootUrl: string; | ||
| 354 | servicePath: string; | ||
| 355 | path: string; | ||
| 356 | sourceKey: string; | ||
| 357 | defaultHeaders?: Readonly<Record<string, string>>; | ||
| 358 | credentialHeaders?: Readonly<Record<string, string>>; | ||
| 359 | credentialPlacements?: HttpRequestPlacements; | ||
| 360 | schemaRefTable?: Readonly<GoogleDiscoverySchemaRefTable>; | ||
| 361 | baseUrl?: string; | ||
| 362 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | ||
| 363 | }; | ||
| 364 | |||
| 365 | export const createGoogleDiscoveryToolFromDefinition = ( | ||
| 366 | input: CreateGoogleDiscoveryToolFromDefinitionInput, | ||
| 367 | ) => { | ||
| 368 | const providerDataJson = JSON.stringify({ | ||
| 369 | kind: "google_discovery", | ||
| 370 | service: input.service, | ||
| 371 | version: input.version, | ||
| 372 | toolId: input.definition.toolId, | ||
| 373 | rawToolId: input.definition.rawToolId, | ||
| 374 | methodId: input.definition.methodId, | ||
| 375 | group: input.definition.group, | ||
| 376 | leaf: input.definition.leaf, | ||
| 377 | invocation: { | ||
| 378 | method: input.definition.method, | ||
| 379 | path: input.definition.path, | ||
| 380 | flatPath: input.definition.flatPath, | ||
| 381 | rootUrl: input.rootUrl, | ||
| 382 | servicePath: input.servicePath, | ||
| 383 | parameters: input.definition.parameters, | ||
| 384 | requestSchemaId: input.definition.requestSchemaId, | ||
| 385 | responseSchemaId: input.definition.responseSchemaId, | ||
| 386 | scopes: input.definition.scopes, | ||
| 387 | supportsMediaUpload: input.definition.supportsMediaUpload, | ||
| 388 | supportsMediaDownload: input.definition.supportsMediaDownload, | ||
| 389 | }, | ||
| 390 | }); | ||
| 391 | const credentialPlacements = normalizeCredentialPlacements({ | ||
| 392 | credentialHeaders: input.credentialHeaders, | ||
| 393 | credentialPlacements: input.credentialPlacements, | ||
| 394 | }); | ||
| 395 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | ||
| 396 | const refTable = Object.fromEntries( | ||
| 397 | Object.entries(input.schemaRefTable ?? {}).map(([ref, value]) => { | ||
| 398 | try { | ||
| 399 | return [ref, JSON.parse(value) as unknown]; | ||
| 400 | } catch { | ||
| 401 | return [ref, value]; | ||
| 402 | } | ||
| 403 | }), | ||
| 404 | ); | ||
| 405 | const inputSchema = materializeSchemaWithRefDefinitions({ | ||
| 406 | schemaJson: input.definition.inputSchemaJson, | ||
| 407 | refTable, | ||
| 408 | }); | ||
| 409 | |||
| 410 | const metadata: ToolMetadata = { | ||
| 411 | interaction: input.definition.method === "get" || input.definition.method === "head" | ||
| 412 | ? "auto" | ||
| 413 | : "required", | ||
| 414 | ...(input.definition.inputSchemaJson | ||
| 415 | ? { inputSchemaJson: input.definition.inputSchemaJson } | ||
| 416 | : {}), | ||
| 417 | ...(input.definition.outputSchemaJson | ||
| 418 | ? { outputSchemaJson: input.definition.outputSchemaJson } | ||
| 419 | : {}), | ||
| 420 | sourceKey: input.sourceKey, | ||
| 421 | providerKind: "google_discovery", | ||
| 422 | providerDataJson, | ||
| 423 | }; | ||
| 424 | |||
| 425 | return toTool({ | ||
| 426 | tool: { | ||
| 427 | description: input.definition.description ?? undefined, | ||
| 428 | inputSchema: standardSchemaFromJsonSchema(inputSchema), | ||
| 429 | execute: (args: unknown) => | ||
| 430 | Effect.runPromise( | ||
| 431 | Effect.gen(function* () { | ||
| 432 | const request = buildGoogleDiscoveryRequest({ | ||
| 433 | definition: input.definition, | ||
| 434 | args: asRecord(args), | ||
| 435 | defaultHeaders: input.defaultHeaders ?? {}, | ||
| 436 | credentialPlacements, | ||
| 437 | baseUrl: input.baseUrl, | ||
| 438 | providerDataJson, | ||
| 439 | }); | ||
| 440 | let clientRequest = HttpClientRequest.make( | ||
| 441 | request.method as Parameters<typeof HttpClientRequest.make>[0], | ||
| 442 | )(request.url, { | ||
| 443 | headers: request.headers, | ||
| 444 | }); | ||
| 445 | |||
| 446 | if (request.body !== undefined) { | ||
| 447 | clientRequest = HttpClientRequest.bodyText( | ||
| 448 | clientRequest, | ||
| 449 | request.body, | ||
| 450 | request.headers["content-type"], | ||
| 451 | ); | ||
| 452 | } | ||
| 453 | |||
| 454 | const client = yield* HttpClient.HttpClient; | ||
| 455 | const response = yield* client.execute(clientRequest).pipe( | ||
| 456 | Effect.mapError((cause) => | ||
| 457 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 458 | ), | ||
| 459 | ); | ||
| 460 | const body = yield* decodeResponseBody(response); | ||
| 461 | |||
| 462 | if (response.status < 200 || response.status >= 300) { | ||
| 463 | return yield* Effect.fail( | ||
| 464 | new Error( | ||
| 465 | `Google Discovery request failed with HTTP ${response.status}: ${typeof body === "string" ? body : JSON.stringify(body)}`, | ||
| 466 | ), | ||
| 467 | ); | ||
| 468 | } | ||
| 469 | |||
| 470 | return body; | ||
| 471 | }).pipe(Effect.provide(httpClientLayer)), | ||
| 472 | ), | ||
| 473 | }, | ||
| 474 | metadata, | ||
| 475 | }); | ||
| 476 | }; | ||
| 477 | |||
| 478 | export { | ||
| 479 | decodeGoogleDiscoverySchemaRefTableJson, | ||
| 480 | }; | ||
| @@ -0,0 +1,123 @@ | |||
| 1 | import { Schema } from "effect"; | ||
| 2 | |||
| 3 | export const GOOGLE_DISCOVERY_HTTP_METHODS = [ | ||
| 4 | "get", | ||
| 5 | "put", | ||
| 6 | "post", | ||
| 7 | "delete", | ||
| 8 | "patch", | ||
| 9 | "head", | ||
| 10 | "options", | ||
| 11 | ] as const; | ||
| 12 | |||
| 13 | export const GoogleDiscoveryHttpMethodSchema = Schema.Literal( | ||
| 14 | ...GOOGLE_DISCOVERY_HTTP_METHODS, | ||
| 15 | ); | ||
| 16 | |||
| 17 | export const GoogleDiscoveryParameterLocationSchema = Schema.Literal( | ||
| 18 | "path", | ||
| 19 | "query", | ||
| 20 | "header", | ||
| 21 | ); | ||
| 22 | |||
| 23 | export const GoogleDiscoveryMethodParameterSchema = Schema.Struct({ | ||
| 24 | name: Schema.String, | ||
| 25 | location: GoogleDiscoveryParameterLocationSchema, | ||
| 26 | required: Schema.Boolean, | ||
| 27 | repeated: Schema.Boolean, | ||
| 28 | description: Schema.NullOr(Schema.String), | ||
| 29 | type: Schema.NullOr(Schema.String), | ||
| 30 | enum: Schema.optional(Schema.Array(Schema.String)), | ||
| 31 | default: Schema.optional(Schema.String), | ||
| 32 | }); | ||
| 33 | |||
| 34 | export const GoogleDiscoveryInvocationPayloadSchema = Schema.Struct({ | ||
| 35 | method: GoogleDiscoveryHttpMethodSchema, | ||
| 36 | path: Schema.String, | ||
| 37 | flatPath: Schema.NullOr(Schema.String), | ||
| 38 | rootUrl: Schema.String, | ||
| 39 | servicePath: Schema.String, | ||
| 40 | parameters: Schema.Array(GoogleDiscoveryMethodParameterSchema), | ||
| 41 | requestSchemaId: Schema.NullOr(Schema.String), | ||
| 42 | responseSchemaId: Schema.NullOr(Schema.String), | ||
| 43 | scopes: Schema.Array(Schema.String), | ||
| 44 | supportsMediaUpload: Schema.Boolean, | ||
| 45 | supportsMediaDownload: Schema.Boolean, | ||
| 46 | }); | ||
| 47 | |||
| 48 | export const GoogleDiscoveryToolProviderDataSchema = Schema.Struct({ | ||
| 49 | kind: Schema.Literal("google_discovery"), | ||
| 50 | service: Schema.String, | ||
| 51 | version: Schema.String, | ||
| 52 | toolId: Schema.String, | ||
| 53 | rawToolId: Schema.String, | ||
| 54 | methodId: Schema.String, | ||
| 55 | group: Schema.NullOr(Schema.String), | ||
| 56 | leaf: Schema.String, | ||
| 57 | invocation: GoogleDiscoveryInvocationPayloadSchema, | ||
| 58 | }); | ||
| 59 | |||
| 60 | export const GoogleDiscoveryManifestMethodSchema = Schema.Struct({ | ||
| 61 | toolId: Schema.String, | ||
| 62 | rawToolId: Schema.String, | ||
| 63 | methodId: Schema.String, | ||
| 64 | name: Schema.String, | ||
| 65 | description: Schema.NullOr(Schema.String), | ||
| 66 | group: Schema.NullOr(Schema.String), | ||
| 67 | leaf: Schema.String, | ||
| 68 | method: GoogleDiscoveryHttpMethodSchema, | ||
| 69 | path: Schema.String, | ||
| 70 | flatPath: Schema.NullOr(Schema.String), | ||
| 71 | parameters: Schema.Array(GoogleDiscoveryMethodParameterSchema), | ||
| 72 | requestSchemaId: Schema.NullOr(Schema.String), | ||
| 73 | responseSchemaId: Schema.NullOr(Schema.String), | ||
| 74 | scopes: Schema.Array(Schema.String), | ||
| 75 | supportsMediaUpload: Schema.Boolean, | ||
| 76 | supportsMediaDownload: Schema.Boolean, | ||
| 77 | inputSchemaJson: Schema.optional(Schema.String), | ||
| 78 | outputSchemaJson: Schema.optional(Schema.String), | ||
| 79 | }); | ||
| 80 | |||
| 81 | export const GoogleDiscoverySchemaRefTableSchema = Schema.Record({ | ||
| 82 | key: Schema.String, | ||
| 83 | value: Schema.String, | ||
| 84 | }); | ||
| 85 | |||
| 86 | export const GoogleDiscoveryToolManifestSchema = Schema.Struct({ | ||
| 87 | version: Schema.Literal(1), | ||
| 88 | sourceHash: Schema.String, | ||
| 89 | service: Schema.String, | ||
| 90 | versionName: Schema.String, | ||
| 91 | title: Schema.NullOr(Schema.String), | ||
| 92 | description: Schema.NullOr(Schema.String), | ||
| 93 | rootUrl: Schema.String, | ||
| 94 | servicePath: Schema.String, | ||
| 95 | batchPath: Schema.NullOr(Schema.String), | ||
| 96 | documentationLink: Schema.NullOr(Schema.String), | ||
| 97 | oauthScopes: Schema.optional( | ||
| 98 | Schema.Record({ | ||
| 99 | key: Schema.String, | ||
| 100 | value: Schema.String, | ||
| 101 | }), | ||
| 102 | ), | ||
| 103 | schemaRefTable: Schema.optional(GoogleDiscoverySchemaRefTableSchema), | ||
| 104 | methods: Schema.Array(GoogleDiscoveryManifestMethodSchema), | ||
| 105 | }); | ||
| 106 | |||
| 107 | export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethodSchema.Type; | ||
| 108 | export type GoogleDiscoveryParameterLocation = | ||
| 109 | typeof GoogleDiscoveryParameterLocationSchema.Type; | ||
| 110 | export type GoogleDiscoveryMethodParameter = | ||
| 111 | typeof GoogleDiscoveryMethodParameterSchema.Type; | ||
| 112 | export type GoogleDiscoveryInvocationPayload = | ||
| 113 | typeof GoogleDiscoveryInvocationPayloadSchema.Type; | ||
| 114 | export type GoogleDiscoveryToolProviderData = | ||
| 115 | typeof GoogleDiscoveryToolProviderDataSchema.Type; | ||
| 116 | export type GoogleDiscoveryManifestMethod = | ||
| 117 | typeof GoogleDiscoveryManifestMethodSchema.Type; | ||
| 118 | export type GoogleDiscoverySchemaRefTable = | ||
| 119 | typeof GoogleDiscoverySchemaRefTableSchema.Type; | ||
| 120 | export type GoogleDiscoveryToolManifest = | ||
| 121 | typeof GoogleDiscoveryToolManifestSchema.Type; | ||
| 122 | |||
| 123 | export type GoogleDiscoveryToolDefinition = GoogleDiscoveryManifestMethod; | ||
| @@ -0,0 +1,29 @@ | |||
| 1 | export { | ||
| 2 | extractGoogleDiscoveryManifest, | ||
| 3 | compileGoogleDiscoveryToolDefinitions, | ||
| 4 | } from "./google-discovery-document"; | ||
| 5 | export { | ||
| 6 | createGoogleDiscoveryToolFromDefinition, | ||
| 7 | decodeGoogleDiscoverySchemaRefTableJson, | ||
| 8 | googleDiscoveryProviderDataJsonFromDefinition, | ||
| 9 | type CreateGoogleDiscoveryToolFromDefinitionInput, | ||
| 10 | } from "./google-discovery-tools"; | ||
| 11 | export { | ||
| 12 | GoogleDiscoveryHttpMethodSchema, | ||
| 13 | GoogleDiscoveryParameterLocationSchema, | ||
| 14 | GoogleDiscoveryMethodParameterSchema, | ||
| 15 | GoogleDiscoveryInvocationPayloadSchema, | ||
| 16 | GoogleDiscoveryToolProviderDataSchema, | ||
| 17 | GoogleDiscoveryManifestMethodSchema, | ||
| 18 | GoogleDiscoverySchemaRefTableSchema, | ||
| 19 | GoogleDiscoveryToolManifestSchema, | ||
| 20 | type GoogleDiscoveryHttpMethod, | ||
| 21 | type GoogleDiscoveryParameterLocation, | ||
| 22 | type GoogleDiscoveryMethodParameter, | ||
| 23 | type GoogleDiscoveryInvocationPayload, | ||
| 24 | type GoogleDiscoveryToolProviderData, | ||
| 25 | type GoogleDiscoveryManifestMethod, | ||
| 26 | type GoogleDiscoveryToolDefinition, | ||
| 27 | type GoogleDiscoverySchemaRefTable, | ||
| 28 | type GoogleDiscoveryToolManifest, | ||
| 29 | } from "./google-discovery-types"; | ||
| @@ -0,0 +1,15 @@ | |||
| 1 | { | ||
| 2 | "compilerOptions": { | ||
| 3 | "target": "ES2022", | ||
| 4 | "module": "ESNext", | ||
| 5 | "moduleResolution": "Bundler", | ||
| 6 | "strict": true, | ||
| 7 | "skipLibCheck": true, | ||
| 8 | "noEmit": true, | ||
| 9 | "lib": ["ES2022"], | ||
| 10 | "types": ["bun-types", "node"] | ||
| 11 | }, | ||
| 12 | "include": [ | ||
| 13 | "src/**/*.ts" | ||
| 14 | ] | ||
| 15 | } | ||
| @@ -1,5 +1,3 @@ | |||
| 1 | import { URL } from "node:url"; | ||
| 2 | |||
| 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | 2 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| 5 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 3 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| @@ -30,6 +30,7 @@ type TestServer = { · 29 unmodified lines | |||
| 30 | path: string; | 30 | path: string; |
| 31 | query: string; | 31 | query: string; |
| 32 | body: string; | 32 | body: string; |
| 33 | headers: Record<string, string>; | ||
| 33 | }>; | 34 | }>; |
| 34 | close: () => Promise<void>; | 35 | close: () => Promise<void>; |
| 35 | }; | 36 | }; |
| @@ -53,6 +54,7 @@ const makeEffectServerHandler = ( · 17 unmodified lines | |||
| 53 | path: `/repos/${path.owner}/${path.repo}`, | 54 | path: `/repos/${path.owner}/${path.repo}`, |
| 54 | query: include ? `?include=${encodeURIComponent(include)}` : "", | 55 | query: include ? `?include=${encodeURIComponent(include)}` : "", |
| 55 | body: "", | 56 | body: "", |
| 57 | headers: {}, | ||
| 56 | }); | 58 | }); |
| 57 | 59 | ||
| 58 | return { | 60 | return { |
| @@ -69,6 +71,7 @@ const makeEffectServerHandler = ( · 10 unmodified lines | |||
| 69 | path: `/repos/${path.owner}/${path.repo}/issues`, | 71 | path: `/repos/${path.owner}/${path.repo}/issues`, |
| 70 | query: "", | 72 | query: "", |
| 71 | body: bodyText, | 73 | body: bodyText, |
| 74 | headers: {}, | ||
| 72 | }); | 75 | }); |
| 73 | 76 | ||
| 74 | return { | 77 | return { |
| @@ -207,11 +210,21 @@ const makeTestServer = Effect.acquireRelease( · 132 unmodified lines | |||
| 207 | catch: (error: unknown) => | 210 | catch: (error: unknown) => |
| 208 | error instanceof Error ? error : new Error(String(error)), | 211 | error instanceof Error ? error : new Error(String(error)), |
| 209 | }).pipe(Effect.orDie), | 212 | }).pipe(Effect.orDie), |
| 210 | ); | 213 | ); |
| 211 | 214 | ||
| 212 | const ownerParam = HttpApiSchema.param("owner", Schema.String); | 215 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 213 | const repoParam = HttpApiSchema.param("repo", Schema.String); | 216 | const repoParam = HttpApiSchema.param("repo", Schema.String); |
| 214 | 217 | ||
| 218 | const requestHeadersFromNode = ( | ||
| 219 | request: IncomingMessage, | ||
| 220 | ): Record<string, string> => | ||
| 221 | Object.fromEntries( | ||
| 222 | Object.entries(request.headers).flatMap(([key, value]) => | ||
| 223 | value === undefined | ||
| 224 | ? [] | ||
| 225 | : [[key, Array.isArray(value) ? value.join(",") : value]]), | ||
| 226 | ); | ||
| 227 | |||
| 215 | class GeneratedReposApi extends HttpApiGroup.make("repos") | 228 | class GeneratedReposApi extends HttpApiGroup.make("repos") |
| 216 | .add( | 229 | .add( |
| 217 | HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}` | 230 | HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}` |
| @@ -475,6 +488,117 @@ describe("openapi-tools", () => { · 257 unmodified lines | |||
| 475 | }), | 488 | }), |
| 476 | ); | 489 | ); |
| 477 | 490 | ||
| 491 | it.scoped("applies credential placements for query params, cookies, and body values", () => | ||
| 492 | Effect.gen(function* () { | ||
| 493 | const requests: TestServer["requests"] = []; | ||
| 494 | const server = yield* Effect.acquireRelease( | ||
| 495 | Effect.promise<TestServer>( | ||
| 496 | () => | ||
| 497 | new Promise<TestServer>((resolve, reject) => { | ||
| 498 | const httpServer = createServer((request, response) => { | ||
| 499 | const url = new URL( | ||
| 500 | request.url ?? "/", | ||
| 501 | `http://${request.headers.host ?? "127.0.0.1"}`, | ||
| 502 | ); | ||
| 503 | let body = ""; | ||
| 504 | |||
| 505 | request.setEncoding("utf8"); | ||
| 506 | request.on("data", (chunk) => { | ||
| 507 | body += chunk; | ||
| 508 | }); | ||
| 509 | request.on("end", () => { | ||
| 510 | requests.push({ | ||
| 511 | method: request.method ?? "GET", | ||
| 512 | path: url.pathname, | ||
| 513 | query: url.search, | ||
| 514 | body, | ||
| 515 | headers: requestHeadersFromNode(request), | ||
| 516 | }); | ||
| 517 | response.statusCode = 200; | ||
| 518 | response.setHeader("content-type", "application/json"); | ||
| 519 | response.end(JSON.stringify({ ok: true })); | ||
| 520 | }); | ||
| 521 | }); | ||
| 522 | |||
| 523 | httpServer.once("error", reject); | ||
| 524 | httpServer.listen(0, "127.0.0.1", () => { | ||
| 525 | const address = httpServer.address(); | ||
| 526 | if (!address || typeof address === "string") { | ||
| 527 | reject(new Error("failed to resolve test server address")); | ||
| 528 | return; | ||
| 529 | } | ||
| 530 | |||
| 531 | resolve({ | ||
| 532 | baseUrl: `http://127.0.0.1:${address.port}`, | ||
| 533 | requests, | ||
| 534 | close: async () => { | ||
| 535 | await new Promise<void>((closeResolve, closeReject) => { | ||
| 536 | httpServer.close((error: Error | undefined) => { | ||
| 537 | if (error) { | ||
| 538 | closeReject(error); | ||
| 539 | return; | ||
| 540 | } | ||
| 541 | closeResolve(); | ||
| 542 | }); | ||
| 543 | }); | ||
| 544 | }, | ||
| 545 | }); | ||
| 546 | }); | ||
| 547 | }), | ||
| 548 | ), | ||
| 549 | (resource) => | ||
| 550 | Effect.tryPromise({ | ||
| 551 | try: () => resource.close(), | ||
| 552 | catch: (error: unknown) => | ||
| 553 | error instanceof Error ? error : new Error(String(error)), | ||
| 554 | }).pipe(Effect.orDie), | ||
| 555 | ); | ||
| 556 | const extracted = yield* createOpenApiToolsFromSpec({ | ||
| 557 | sourceName: "demo", | ||
| 558 | openApiSpec: generatedOpenApiSpec, | ||
| 559 | baseUrl: server.baseUrl, | ||
| 560 | namespace: "source.demo", | ||
| 561 | credentialPlacements: { | ||
| 562 | headers: { | ||
| 563 | authorization: "Bearer top-secret", | ||
| 564 | }, | ||
| 565 | queryParams: { | ||
| 566 | api_key: "secret-key", | ||
| 567 | }, | ||
| 568 | cookies: { | ||
| 569 | session: "abc123", | ||
| 570 | }, | ||
| 571 | bodyValues: { | ||
| 572 | "auth.token": "body-secret", | ||
| 573 | }, | ||
| 574 | }, | ||
| 575 | }); | ||
| 576 | |||
| 577 | const getRepo = resolveToolExecutor(extracted.tools, "source.demo.repos.getRepo"); | ||
| 578 | const createIssue = resolveToolExecutor(extracted.tools, "source.demo.repos.createIssue"); | ||
| 579 | |||
| 580 | yield* Effect.promise(() => getRepo({ owner: "octocat", repo: "hello-world" })); | ||
| 581 | yield* Effect.promise(() => | ||
| 582 | createIssue({ | ||
| 583 | owner: "octocat", | ||
| 584 | repo: "hello-world", | ||
| 585 | body: { title: "Bug report" }, | ||
| 586 | }), | ||
| 587 | ); | ||
| 588 | |||
| 589 | expect(server.requests).toHaveLength(2); | ||
| 590 | expect(server.requests[0]?.query).toContain("api_key=secret-key"); | ||
| 591 | expect(server.requests[0]?.headers.authorization).toBe("Bearer top-secret"); | ||
| 592 | expect(server.requests[0]?.headers.cookie).toContain("session=abc123"); | ||
| 593 | expect(JSON.parse(server.requests[1]?.body ?? "{}")).toEqual({ | ||
| 594 | title: "Bug report", | ||
| 595 | auth: { | ||
| 596 | token: "body-secret", | ||
| 597 | }, | ||
| 598 | }); | ||
| 599 | }), | ||
| 600 | ); | ||
| 601 | |||
| 478 | it.scoped("falls back to path-template args when extracted path parameters are missing", () => | 602 | it.scoped("falls back to path-template args when extracted path parameters are missing", () => |
| 479 | Effect.gen(function* () { | 603 | Effect.gen(function* () { |
| 480 | const server = yield* makeTestServer; | 604 | const server = yield* makeTestServer; |
| @@ -8,8 +8,12 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | |||
| 8 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | 9 | ||
| 10 | import { | 10 | import { |
| 11 | applyCookiePlacementsToHeaders, | ||
| 12 | applyHttpQueryPlacementsToUrl, | ||
| 13 | applyJsonBodyPlacements, | ||
| 11 | standardSchemaFromJsonSchema, | 14 | standardSchemaFromJsonSchema, |
| 12 | toTool, | 15 | toTool, |
| 16 | type HttpRequestPlacements, | ||
| 13 | type ToolMap, | 17 | type ToolMap, |
| 14 | unknownInputSchema, | 18 | unknownInputSchema, |
| 15 | } from "@executor/codemode-core"; | 19 | } from "@executor/codemode-core"; |
| @@ -288,6 +292,7 @@ export type CreateOpenApiToolsFromManifestInput = { · 272 unmodified lines | |||
| 288 | sourceKey?: string; | 292 | sourceKey?: string; |
| 289 | defaultHeaders?: Readonly<Record<string, string>>; | 293 | defaultHeaders?: Readonly<Record<string, string>>; |
| 290 | credentialHeaders?: Readonly<Record<string, string>>; | 294 | credentialHeaders?: Readonly<Record<string, string>>; |
| 295 | credentialPlacements?: HttpRequestPlacements; | ||
| 291 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | 296 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 292 | }; | 297 | }; |
| 293 | 298 | ||
| @@ -317,7 +322,7 @@ const buildFetchRequest = (input: { · 23 unmodified lines | |||
| 317 | args: OpenApiToolArgs; | 322 | args: OpenApiToolArgs; |
| 318 | baseUrl: string; | 323 | baseUrl: string; |
| 319 | defaultHeaders: Readonly<Record<string, string>>; | 324 | defaultHeaders: Readonly<Record<string, string>>; |
| 320 | credentialHeaders: Readonly<Record<string, string>>; | 325 | credentialPlacements: HttpRequestPlacements; |
| 321 | }): { | 326 | }): { |
| 322 | url: URL; | 327 | url: URL; |
| 323 | method: string; | 328 | method: string; |
| @@ -369,9 +374,11 @@ const buildFetchRequest = (input: { · 45 unmodified lines | |||
| 369 | } | 374 | } |
| 370 | 375 | ||
| 371 | let body: string | undefined; | 376 | let body: string | undefined; |
| 377 | const bodyValues = input.credentialPlacements.bodyValues ?? {}; | ||
| 378 | const hasCredentialBodyValues = Object.keys(bodyValues).length > 0; | ||
| 372 | 379 | ||
| 373 | if (input.payload.requestBody !== null) { | 380 | if (input.payload.requestBody !== null) { |
| 374 | if (!hasRequestBody(input.args)) { | 381 | if (!hasRequestBody(input.args) && !hasCredentialBodyValues) { |
| 375 | if (input.payload.requestBody.required) { | 382 | if (input.payload.requestBody.required) { |
| 376 | throw new OpenApiToolInvocationError({ | 383 | throw new OpenApiToolInvocationError({ |
| 377 | operation: "validate_args", | 384 | operation: "validate_args", |
| @@ -380,7 +387,13 @@ const buildFetchRequest = (input: { · 2 unmodified lines | |||
| 380 | }); | 387 | }); |
| 381 | } | 388 | } |
| 382 | } else { | 389 | } else { |
| 383 | body = JSON.stringify(input.args.body); | 390 | body = JSON.stringify( |
| 391 | applyJsonBodyPlacements({ | ||
| 392 | body: hasRequestBody(input.args) ? input.args.body : {}, | ||
| 393 | bodyValues, | ||
| 394 | label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`, | ||
| 395 | }), | ||
| 396 | ); | ||
| 384 | 397 | ||
| 385 | const preferredContentType = input.payload.requestBody.contentTypes[0]; | 398 | const preferredContentType = input.payload.requestBody.contentTypes[0]; |
| 386 | if (preferredContentType) { | 399 | if (preferredContentType) { |
| @@ -391,14 +404,23 @@ const buildFetchRequest = (input: { · 4 unmodified lines | |||
| 391 | } | 404 | } |
| 392 | } | 405 | } |
| 393 | 406 | ||
| 394 | for (const [key, value] of Object.entries(input.credentialHeaders)) { | 407 | const urlWithAuth = applyHttpQueryPlacementsToUrl({ |
| 395 | headers[key] = value; | 408 | url, |
| 409 | queryParams: input.credentialPlacements.queryParams, | ||
| 410 | }); | ||
| 411 | const headersWithAuthCookies = applyCookiePlacementsToHeaders({ | ||
| 412 | headers, | ||
| 413 | cookies: input.credentialPlacements.cookies, | ||
| 414 | }); | ||
| 415 | |||
| 416 | for (const [key, value] of Object.entries(input.credentialPlacements.headers ?? {})) { | ||
| 417 | headersWithAuthCookies[key] = value; | ||
| 396 | } | 418 | } |
| 397 | 419 | ||
| 398 | return { | 420 | return { |
| 399 | url, | 421 | url: urlWithAuth, |
| 400 | method: input.payload.method.toUpperCase(), | 422 | method: input.payload.method.toUpperCase(), |
| 401 | headers, | 423 | headers: headersWithAuthCookies, |
| 402 | body, | 424 | body, |
| 403 | }; | 425 | }; |
| 404 | }; | 426 | }; |
| @@ -413,15 +435,32 @@ export type CreateOpenApiToolFromDefinitionInput = { · 8 unmodified lines | |||
| 413 | baseUrl: string; | 435 | baseUrl: string; |
| 414 | defaultHeaders?: Readonly<Record<string, string>>; | 436 | defaultHeaders?: Readonly<Record<string, string>>; |
| 415 | credentialHeaders?: Readonly<Record<string, string>>; | 437 | credentialHeaders?: Readonly<Record<string, string>>; |
| 438 | credentialPlacements?: HttpRequestPlacements; | ||
| 416 | refHintTable?: Readonly<OpenApiRefHintTable>; | 439 | refHintTable?: Readonly<OpenApiRefHintTable>; |
| 417 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | 440 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 418 | }; | 441 | }; |
| 419 | 442 | ||
| 443 | const normalizeCredentialPlacements = (input: { | ||
| 444 | credentialHeaders?: Readonly<Record<string, string>>; | ||
| 445 | credentialPlacements?: HttpRequestPlacements; | ||
| 446 | }): HttpRequestPlacements => ({ | ||
| 447 | headers: { | ||
| 448 | ...(input.credentialHeaders ?? {}), | ||
| 449 | ...(input.credentialPlacements?.headers ?? {}), | ||
| 450 | }, | ||
| 451 | queryParams: input.credentialPlacements?.queryParams, | ||
| 452 | cookies: input.credentialPlacements?.cookies, | ||
| 453 | bodyValues: input.credentialPlacements?.bodyValues, | ||
| 454 | }); | ||
| 455 | |||
| 420 | export const createOpenApiToolFromDefinition = ( | 456 | export const createOpenApiToolFromDefinition = ( |
| 421 | input: CreateOpenApiToolFromDefinitionInput, | 457 | input: CreateOpenApiToolFromDefinitionInput, |
| 422 | ) => { | 458 | ) => { |
| 423 | const defaultHeaders = input.defaultHeaders ?? {}; | 459 | const defaultHeaders = input.defaultHeaders ?? {}; |
| 424 | const credentialHeaders = input.credentialHeaders ?? {}; | 460 | const credentialPlacements = normalizeCredentialPlacements({ |
| 461 | credentialHeaders: input.credentialHeaders, | ||
| 462 | credentialPlacements: input.credentialPlacements, | ||
| 463 | }); | ||
| 425 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | 464 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 426 | const presentation = buildOpenApiToolPresentation({ | 465 | const presentation = buildOpenApiToolPresentation({ |
| 427 | definition: input.definition, | 466 | definition: input.definition, |
| @@ -441,7 +480,7 @@ export const createOpenApiToolFromDefinition = ( · 13 unmodified lines | |||
| 441 | args: decodedArgs, | 480 | args: decodedArgs, |
| 442 | baseUrl: input.baseUrl, | 481 | baseUrl: input.baseUrl, |
| 443 | defaultHeaders, | 482 | defaultHeaders, |
| 444 | credentialHeaders, | 483 | credentialPlacements, |
| 445 | }); | 484 | }); |
| 446 | 485 | ||
| 447 | return Effect.runPromise( | 486 | return Effect.runPromise( |
| @@ -503,7 +542,6 @@ export const createOpenApiToolsFromManifest = ( · 55 unmodified lines | |||
| 503 | const baseUrl = normalizeHttpUrl(input.baseUrl); | 542 | const baseUrl = normalizeHttpUrl(input.baseUrl); |
| 504 | const sourceKey = input.sourceKey ?? "openapi.generated"; | 543 | const sourceKey = input.sourceKey ?? "openapi.generated"; |
| 505 | const defaultHeaders = input.defaultHeaders ?? {}; | 544 | const defaultHeaders = input.defaultHeaders ?? {}; |
| 506 | const credentialHeaders = input.credentialHeaders ?? {}; | ||
| 507 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; | 545 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 508 | 546 | ||
| 509 | const definitions = compileOpenApiToolDefinitions(input.manifest); | 547 | const definitions = compileOpenApiToolDefinitions(input.manifest); |
| @@ -517,7 +555,8 @@ export const createOpenApiToolsFromManifest = ( · 7 unmodified lines | |||
| 517 | sourceKey, | 555 | sourceKey, |
| 518 | baseUrl, | 556 | baseUrl, |
| 519 | defaultHeaders, | 557 | defaultHeaders, |
| 520 | credentialHeaders, | 558 | credentialHeaders: input.credentialHeaders, |
| 559 | credentialPlacements: input.credentialPlacements, | ||
| 521 | refHintTable: input.manifest.refHintTable, | 560 | refHintTable: input.manifest.refHintTable, |
| 522 | httpClientLayer, | 561 | httpClientLayer, |
| 523 | }); | 562 | }); |
| @@ -534,6 +573,7 @@ export const createOpenApiToolsFromSpec = (input: { · 10 unmodified lines | |||
| 534 | sourceKey?: string; | 573 | sourceKey?: string; |
| 535 | defaultHeaders?: Readonly<Record<string, string>>; | 574 | defaultHeaders?: Readonly<Record<string, string>>; |
| 536 | credentialHeaders?: Readonly<Record<string, string>>; | 575 | credentialHeaders?: Readonly<Record<string, string>>; |
| 576 | credentialPlacements?: HttpRequestPlacements; | ||
| 537 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; | 577 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 538 | }): Effect.Effect< | 578 | }): Effect.Effect< |
| 539 | { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap }, | 579 | { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap }, |
| @@ -551,6 +591,7 @@ export const createOpenApiToolsFromSpec = (input: { · 11 unmodified lines | |||
| 551 | sourceKey: input.sourceKey, | 591 | sourceKey: input.sourceKey, |
| 552 | defaultHeaders: input.defaultHeaders, | 592 | defaultHeaders: input.defaultHeaders, |
| 553 | credentialHeaders: input.credentialHeaders, | 593 | credentialHeaders: input.credentialHeaders, |
| 594 | credentialPlacements: input.credentialPlacements, | ||
| 554 | httpClientLayer: input.httpClientLayer, | 595 | httpClientLayer: input.httpClientLayer, |
| 555 | }), | 596 | }), |
| 556 | }), | 597 | }), |
| @@ -0,0 +1,132 @@ | |||
| 1 | ALTER TABLE "workspace_source_credentials" | ||
| 2 | RENAME TO "workspace_source_auth_artifacts";--> statement-breakpoint | ||
| 3 | |||
| 4 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 5 | ADD COLUMN "artifact_kind" text;--> statement-breakpoint | ||
| 6 | |||
| 7 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 8 | ADD COLUMN "config_json" text;--> statement-breakpoint | ||
| 9 | |||
| 10 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 11 | ADD COLUMN "grant_set_json" text;--> statement-breakpoint | ||
| 12 | |||
| 13 | UPDATE "workspace_source_auth_artifacts" | ||
| 14 | SET | ||
| 15 | "artifact_kind" = CASE | ||
| 16 | WHEN "auth_kind" = 'bearer' THEN 'static_bearer' | ||
| 17 | WHEN "auth_kind" = 'oauth2' THEN 'static_oauth2' | ||
| 18 | ELSE 'static_placements' | ||
| 19 | END, | ||
| 20 | "config_json" = CASE | ||
| 21 | WHEN "auth_kind" = 'bearer' THEN jsonb_build_object( | ||
| 22 | 'headerName', "auth_header_name", | ||
| 23 | 'prefix', "auth_prefix", | ||
| 24 | 'token', jsonb_build_object( | ||
| 25 | 'providerId', "token_provider_id", | ||
| 26 | 'handle', "token_handle" | ||
| 27 | ) | ||
| 28 | )::text | ||
| 29 | WHEN "auth_kind" = 'oauth2' THEN jsonb_build_object( | ||
| 30 | 'headerName', "auth_header_name", | ||
| 31 | 'prefix', "auth_prefix", | ||
| 32 | 'accessToken', jsonb_build_object( | ||
| 33 | 'providerId', "token_provider_id", | ||
| 34 | 'handle', "token_handle" | ||
| 35 | ), | ||
| 36 | 'refreshToken', | ||
| 37 | CASE | ||
| 38 | WHEN "refresh_token_provider_id" IS NOT NULL AND "refresh_token_handle" IS NOT NULL | ||
| 39 | THEN jsonb_build_object( | ||
| 40 | 'providerId', "refresh_token_provider_id", | ||
| 41 | 'handle', "refresh_token_handle" | ||
| 42 | ) | ||
| 43 | ELSE 'null'::jsonb | ||
| 44 | END | ||
| 45 | )::text | ||
| 46 | ELSE jsonb_build_object('placements', '[]'::jsonb)::text | ||
| 47 | END | ||
| 48 | WHERE "artifact_kind" IS NULL OR "config_json" IS NULL;--> statement-breakpoint | ||
| 49 | |||
| 50 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 51 | ALTER COLUMN "artifact_kind" SET NOT NULL;--> statement-breakpoint | ||
| 52 | |||
| 53 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 54 | ALTER COLUMN "config_json" SET NOT NULL;--> statement-breakpoint | ||
| 55 | |||
| 56 | DROP INDEX IF EXISTS "credentials_workspace_idx";--> statement-breakpoint | ||
| 57 | DROP INDEX IF EXISTS "credentials_workspace_source_actor_idx";--> statement-breakpoint | ||
| 58 | DROP INDEX IF EXISTS "credentials_workspace_source_idx";--> statement-breakpoint | ||
| 59 | |||
| 60 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 61 | DROP CONSTRAINT IF EXISTS "credentials_auth_kind_check";--> statement-breakpoint | ||
| 62 | |||
| 63 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 64 | DROP CONSTRAINT IF EXISTS "credentials_slot_check";--> statement-breakpoint | ||
| 65 | |||
| 66 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 67 | DROP COLUMN "auth_kind";--> statement-breakpoint | ||
| 68 | |||
| 69 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 70 | DROP COLUMN "auth_header_name";--> statement-breakpoint | ||
| 71 | |||
| 72 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 73 | DROP COLUMN "auth_prefix";--> statement-breakpoint | ||
| 74 | |||
| 75 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 76 | DROP COLUMN "token_provider_id";--> statement-breakpoint | ||
| 77 | |||
| 78 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 79 | DROP COLUMN "token_handle";--> statement-breakpoint | ||
| 80 | |||
| 81 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 82 | DROP COLUMN "refresh_token_provider_id";--> statement-breakpoint | ||
| 83 | |||
| 84 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 85 | DROP COLUMN "refresh_token_handle";--> statement-breakpoint | ||
| 86 | |||
| 87 | ALTER TABLE "workspace_source_auth_artifacts" | ||
| 88 | ADD CONSTRAINT "auth_artifacts_slot_check" | ||
| 89 | CHECK ("slot" in ('runtime', 'import'));--> statement-breakpoint | ||
| 90 | |||
| 91 | CREATE INDEX "auth_artifacts_workspace_idx" | ||
| 92 | ON "workspace_source_auth_artifacts" ("workspace_id","updated_at","id");--> statement-breakpoint | ||
| 93 | |||
| 94 | CREATE UNIQUE INDEX "auth_artifacts_workspace_source_actor_idx" | ||
| 95 | ON "workspace_source_auth_artifacts" ("workspace_id","source_id","actor_account_id","slot");--> statement-breakpoint | ||
| 96 | |||
| 97 | CREATE INDEX "auth_artifacts_workspace_source_idx" | ||
| 98 | ON "workspace_source_auth_artifacts" ("workspace_id","source_id","updated_at","id");--> statement-breakpoint | ||
| 99 | |||
| 100 | CREATE TABLE "workspace_source_auth_leases" ( | ||
| 101 | "id" text PRIMARY KEY NOT NULL, | ||
| 102 | "auth_artifact_id" text NOT NULL, | ||
| 103 | "workspace_id" text NOT NULL, | ||
| 104 | "source_id" text NOT NULL, | ||
| 105 | "actor_account_id" text, | ||
| 106 | "slot" text NOT NULL, | ||
| 107 | "placements_json" text NOT NULL, | ||
| 108 | "expires_at" bigint, | ||
| 109 | "refresh_after" bigint, | ||
| 110 | "created_at" bigint NOT NULL, | ||
| 111 | "updated_at" bigint NOT NULL | ||
| 112 | );--> statement-breakpoint | ||
| 113 | |||
| 114 | ALTER TABLE "workspace_source_auth_leases" | ||
| 115 | ADD CONSTRAINT "auth_leases_slot_check" | ||
| 116 | CHECK ("slot" in ('runtime', 'import'));--> statement-breakpoint | ||
| 117 | |||
| 118 | CREATE UNIQUE INDEX "auth_leases_auth_artifact_idx" | ||
| 119 | ON "workspace_source_auth_leases" ("auth_artifact_id");--> statement-breakpoint | ||
| 120 | |||
| 121 | CREATE INDEX "auth_leases_workspace_source_idx" | ||
| 122 | ON "workspace_source_auth_leases" ( | ||
| 123 | "workspace_id", | ||
| 124 | "source_id", | ||
| 125 | "actor_account_id", | ||
| 126 | "slot", | ||
| 127 | "updated_at", | ||
| 128 | "id" | ||
| 129 | );--> statement-breakpoint | ||
| 130 | |||
| 131 | ALTER TABLE "source_auth_sessions" | ||
| 132 | DROP CONSTRAINT IF EXISTS "source_auth_sessions_provider_kind_check";--> statement-breakpoint | ||
| @@ -0,0 +1,40 @@ | |||
| 1 | ALTER TABLE "workspace_source_auth_leases" | ||
| 2 | ADD COLUMN "placements_template_json" text;--> statement-breakpoint | ||
| 3 | |||
| 4 | UPDATE "workspace_source_auth_leases" | ||
| 5 | SET "placements_template_json" = COALESCE( | ||
| 6 | ( | ||
| 7 | SELECT jsonb_agg( | ||
| 8 | CASE | ||
| 9 | WHEN placement ->> 'location' = 'body' THEN jsonb_build_object( | ||
| 10 | 'location', 'body', | ||
| 11 | 'path', placement ->> 'path', | ||
| 12 | 'parts', jsonb_build_array( | ||
| 13 | jsonb_build_object( | ||
| 14 | 'kind', 'literal', | ||
| 15 | 'value', COALESCE(placement ->> 'value', '') | ||
| 16 | ) | ||
| 17 | ) | ||
| 18 | ) | ||
| 19 | ELSE jsonb_build_object( | ||
| 20 | 'location', placement ->> 'location', | ||
| 21 | 'name', placement ->> 'name', | ||
| 22 | 'parts', jsonb_build_array( | ||
| 23 | jsonb_build_object( | ||
| 24 | 'kind', 'literal', | ||
| 25 | 'value', COALESCE(placement ->> 'value', '') | ||
| 26 | ) | ||
| 27 | ) | ||
| 28 | ) | ||
| 29 | END | ||
| 30 | )::text | ||
| 31 | FROM jsonb_array_elements(COALESCE("placements_json"::jsonb, '[]'::jsonb)) AS placement | ||
| 32 | ), | ||
| 33 | '[]' | ||
| 34 | );--> statement-breakpoint | ||
| 35 | |||
| 36 | ALTER TABLE "workspace_source_auth_leases" | ||
| 37 | ALTER COLUMN "placements_template_json" SET NOT NULL;--> statement-breakpoint | ||
| 38 | |||
| 39 | ALTER TABLE "workspace_source_auth_leases" | ||
| 40 | DROP COLUMN "placements_json";--> statement-breakpoint | ||
| @@ -26,6 +26,7 @@ · 25 unmodified lines | |||
| 26 | "@effect/platform": "catalog:", | 26 | "@effect/platform": "catalog:", |
| 27 | "@electric-sql/pglite": "^0.3.12", | 27 | "@electric-sql/pglite": "^0.3.12", |
| 28 | "@executor/codemode-core": "workspace:*", | 28 | "@executor/codemode-core": "workspace:*", |
| 29 | "@executor/codemode-google-discovery": "workspace:*", | ||
| 29 | "@executor/codemode-mcp": "workspace:*", | 30 | "@executor/codemode-mcp": "workspace:*", |
| 30 | "@executor/runtime-ses": "workspace:*", | 31 | "@executor/runtime-ses": "workspace:*", |
| 31 | "@executor/codemode-openapi": "workspace:*", | 32 | "@executor/codemode-openapi": "workspace:*", |
| @@ -64,9 +64,23 @@ describe("control-plane payload schemas", () => { · 63 unmodified lines | |||
| 64 | specUrl: " https://example.com/openapi.json ", | 64 | specUrl: " https://example.com/openapi.json ", |
| 65 | }), | 65 | }), |
| 66 | ).toEqual({ | 66 | ).toEqual({ |
| 67 | kind: "openapi", | 67 | kind: "openapi", |
| 68 | endpoint: "https://api.github.com", | 68 | endpoint: "https://api.github.com", |
| 69 | specUrl: "https://example.com/openapi.json", | 69 | specUrl: "https://example.com/openapi.json", |
| 70 | }); | ||
| 71 | |||
| 72 | expect( | ||
| 73 | Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({ | ||
| 74 | kind: "google_discovery", | ||
| 75 | service: " sheets ", | ||
| 76 | version: " v4 ", | ||
| 77 | discoveryUrl: " https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest ", | ||
| 78 | }), | ||
| 79 | ).toEqual({ | ||
| 80 | kind: "google_discovery", | ||
| 81 | service: "sheets", | ||
| 82 | version: "v4", | ||
| 83 | discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", | ||
| 70 | }); | 84 | }); |
| 71 | 85 | ||
| 72 | expect( | 86 | expect( |
| @@ -8,6 +8,9 @@ import { · 7 unmodified lines | |||
| 8 | OrganizationMemberIdSchema, | 8 | OrganizationMemberIdSchema, |
| 9 | PolicyIdSchema, | 9 | PolicyIdSchema, |
| 10 | SecretMaterialIdSchema, | 10 | SecretMaterialIdSchema, |
| 11 | StaticBearerAuthArtifactConfigJsonSchema, | ||
| 12 | StaticOAuth2AuthArtifactConfigJsonSchema, | ||
| 13 | decodeBuiltInAuthArtifactConfig, | ||
| 11 | SourceAuthSessionIdSchema, | 14 | SourceAuthSessionIdSchema, |
| 12 | SourceIdSchema, | 15 | SourceIdSchema, |
| 13 | SourceRecipeIdSchema, | 16 | SourceRecipeIdSchema, |
| @@ -39,6 +42,12 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 25 unmodified lines | |||
| 39 | ); | 42 | ); |
| 40 | 43 | ||
| 41 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); | 44 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 45 | const encodeStaticBearerArtifactConfig = Schema.encodeSync( | ||
| 46 | StaticBearerAuthArtifactConfigJsonSchema, | ||
| 47 | ); | ||
| 48 | const encodeStaticOAuth2ArtifactConfig = Schema.encodeSync( | ||
| 49 | StaticOAuth2AuthArtifactConfigJsonSchema, | ||
| 50 | ); | ||
| 42 | 51 | ||
| 43 | const openApiBindingConfigJson = (specUrl: string): string => | 52 | const openApiBindingConfigJson = (specUrl: string): string => |
| 44 | JSON.stringify({ | 53 | JSON.stringify({ |
| @@ -148,7 +157,7 @@ const seedWorkspaceSourceState = (input: { · 103 unmodified lines | |||
| 148 | }); | 157 | }); |
| 149 | }); | 158 | }); |
| 150 | 159 | ||
| 151 | const seedWorkspaceCredentialState = (input: { | 160 | const seedWorkspaceAuthArtifactState = (input: { |
| 152 | persistence: SqlControlPlanePersistence; | 161 | persistence: SqlControlPlanePersistence; |
| 153 | accountId: ReturnType<typeof AccountIdSchema.make>; | 162 | accountId: ReturnType<typeof AccountIdSchema.make>; |
| 154 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; | 163 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; |
| @@ -160,7 +169,7 @@ const seedWorkspaceCredentialState = (input: { · 5 unmodified lines | |||
| 160 | }, unknown, never> => | 169 | }, unknown, never> => |
| 161 | Effect.gen(function* () { | 170 | Effect.gen(function* () { |
| 162 | const now = Date.now(); | 171 | const now = Date.now(); |
| 163 | const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`); | 172 | const authArtifactId = CredentialIdSchema.make(`cred_${input.workspaceId}`); |
| 164 | const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`); | 173 | const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`); |
| 165 | const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`); | 174 | const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`); |
| 166 | 175 | ||
| @@ -181,19 +190,26 @@ const seedWorkspaceCredentialState = (input: { · 14 unmodified lines | |||
| 181 | createdAt: now, | 190 | createdAt: now, |
| 182 | updatedAt: now, | 191 | updatedAt: now, |
| 183 | }); | 192 | }); |
| 184 | yield* input.persistence.rows.credentials.upsert({ | 193 | yield* input.persistence.rows.authArtifacts.upsert({ |
| 185 | id: credentialId, | 194 | id: authArtifactId, |
| 186 | workspaceId: input.workspaceId, | 195 | workspaceId: input.workspaceId, |
| 187 | sourceId: input.sourceId, | 196 | sourceId: input.sourceId, |
| 188 | actorAccountId: input.accountId, | 197 | actorAccountId: input.accountId, |
| 189 | slot: "runtime", | 198 | slot: "runtime", |
| 190 | authKind: "oauth2", | 199 | artifactKind: "static_oauth2", |
| 191 | authHeaderName: "Authorization", | 200 | configJson: encodeStaticOAuth2ArtifactConfig({ |
| 192 | authPrefix: "Bearer ", | 201 | headerName: "Authorization", |
| 193 | tokenProviderId: "postgres", | 202 | prefix: "Bearer ", |
| 194 | tokenHandle: tokenId, | 203 | accessToken: { |
| 195 | refreshTokenProviderId: "postgres", | 204 | providerId: "postgres", |
| 196 | refreshTokenHandle: refreshId, | 205 | handle: tokenId, |
| 206 | }, | ||
| 207 | refreshToken: { | ||
| 208 | providerId: "postgres", | ||
| 209 | handle: refreshId, | ||
| 210 | }, | ||
| 211 | }), | ||
| 212 | grantSetJson: null, | ||
| 197 | createdAt: now, | 213 | createdAt: now, |
| 198 | updatedAt: now, | 214 | updatedAt: now, |
| 199 | }); | 215 | }); |
| @@ -463,20 +479,23 @@ describe("control-plane-persistence-drizzle", () => { · 263 unmodified lines | |||
| 463 | }); | 479 | }); |
| 464 | 480 | ||
| 465 | yield* Effect.tryPromise(async () => { | 481 | yield* Effect.tryPromise(async () => { |
| 466 | await persistence.db.insert(drizzleSchema.credentialsTable).values([ | 482 | await persistence.db.insert(drizzleSchema.authArtifactsTable).values([ |
| 467 | { | 483 | { |
| 468 | id: nullCredentialA, | 484 | id: nullCredentialA, |
| 469 | workspaceId, | 485 | workspaceId, |
| 470 | sourceId, | 486 | sourceId, |
| 471 | actorAccountId: null, | 487 | actorAccountId: null, |
| 472 | slot: "runtime", | 488 | slot: "runtime", |
| 473 | authKind: "bearer", | 489 | artifactKind: "static_bearer", |
| 474 | authHeaderName: "Authorization", | 490 | configJson: encodeStaticBearerArtifactConfig({ |
| 475 | authPrefix: "Bearer ", | 491 | headerName: "Authorization", |
| 476 | tokenProviderId: "postgres", | 492 | prefix: "Bearer ", |
| 477 | tokenHandle: "sec_null_a", | 493 | token: { |
| 478 | refreshTokenProviderId: null, | 494 | providerId: "postgres", |
| 479 | refreshTokenHandle: null, | 495 | handle: "sec_null_a", |
| 496 | }, | ||
| 497 | }), | ||
| 498 | grantSetJson: null, | ||
| 480 | createdAt: now, | 499 | createdAt: now, |
| 481 | updatedAt: now, | 500 | updatedAt: now, |
| 482 | }, | 501 | }, |
| @@ -486,79 +505,95 @@ describe("control-plane-persistence-drizzle", () => { · 3 unmodified lines | |||
| 486 | sourceId, | 505 | sourceId, |
| 487 | actorAccountId: null, | 506 | actorAccountId: null, |
| 488 | slot: "runtime", | 507 | slot: "runtime", |
| 489 | authKind: "bearer", | 508 | artifactKind: "static_bearer", |
| 490 | authHeaderName: "Authorization", | 509 | configJson: encodeStaticBearerArtifactConfig({ |
| 491 | authPrefix: "Bearer ", | 510 | headerName: "Authorization", |
| 492 | tokenProviderId: "postgres", | 511 | prefix: "Bearer ", |
| 493 | tokenHandle: "sec_null_b", | 512 | token: { |
| 494 | refreshTokenProviderId: null, | 513 | providerId: "postgres", |
| 495 | refreshTokenHandle: null, | 514 | handle: "sec_null_b", |
| 515 | }, | ||
| 516 | }), | ||
| 517 | grantSetJson: null, | ||
| 496 | createdAt: now + 1, | 518 | createdAt: now + 1, |
| 497 | updatedAt: now + 1, | 519 | updatedAt: now + 1, |
| 498 | }, | 520 | }, |
| 499 | ]); | 521 | ]); |
| 500 | }).pipe(Effect.orDie); | 522 | }).pipe(Effect.orDie); |
| 501 | 523 | ||
| 502 | yield* persistence.rows.credentials.upsert({ | 524 | yield* persistence.rows.authArtifacts.upsert({ |
| 503 | id: CredentialIdSchema.make("cred_null_credentials_replacement"), | 525 | id: CredentialIdSchema.make("cred_null_credentials_replacement"), |
| 504 | workspaceId, | 526 | workspaceId, |
| 505 | sourceId, | 527 | sourceId, |
| 506 | actorAccountId: null, | 528 | actorAccountId: null, |
| 507 | slot: "runtime", | 529 | slot: "runtime", |
| 508 | authKind: "oauth2", | 530 | artifactKind: "static_oauth2", |
| 509 | authHeaderName: "X-Auth", | 531 | configJson: encodeStaticOAuth2ArtifactConfig({ |
| 510 | authPrefix: "Token ", | 532 | headerName: "X-Auth", |
| 511 | tokenProviderId: "postgres", | 533 | prefix: "Token ", |
| 512 | tokenHandle: "sec_null_replacement", | 534 | accessToken: { |
| 513 | refreshTokenProviderId: "postgres", | 535 | providerId: "postgres", |
| 514 | refreshTokenHandle: "sec_null_refresh", | 536 | handle: "sec_null_replacement", |
| 537 | }, | ||
| 538 | refreshToken: { | ||
| 539 | providerId: "postgres", | ||
| 540 | handle: "sec_null_refresh", | ||
| 541 | }, | ||
| 542 | }), | ||
| 543 | grantSetJson: null, | ||
| 515 | createdAt: now + 2, | 544 | createdAt: now + 2, |
| 516 | updatedAt: now + 2, | 545 | updatedAt: now + 2, |
| 517 | }); | 546 | }); |
| 518 | 547 | ||
| 519 | yield* persistence.rows.credentials.upsert({ | 548 | yield* persistence.rows.authArtifacts.upsert({ |
| 520 | id: actorCredentialId, | 549 | id: actorCredentialId, |
| 521 | workspaceId, | 550 | workspaceId, |
| 522 | sourceId, | 551 | sourceId, |
| 523 | actorAccountId: accountId, | 552 | actorAccountId: accountId, |
| 524 | slot: "runtime", | 553 | slot: "runtime", |
| 525 | authKind: "bearer", | 554 | artifactKind: "static_bearer", |
| 526 | authHeaderName: "Authorization", | 555 | configJson: encodeStaticBearerArtifactConfig({ |
| 527 | authPrefix: "Bearer ", | 556 | headerName: "Authorization", |
| 528 | tokenProviderId: "postgres", | 557 | prefix: "Bearer ", |
| 529 | tokenHandle: "sec_actor", | 558 | token: { |
| 530 | refreshTokenProviderId: null, | 559 | providerId: "postgres", |
| 531 | refreshTokenHandle: null, | 560 | handle: "sec_actor", |
| 561 | }, | ||
| 562 | }), | ||
| 563 | grantSetJson: null, | ||
| 532 | createdAt: now + 3, | 564 | createdAt: now + 3, |
| 533 | updatedAt: now + 3, | 565 | updatedAt: now + 3, |
| 534 | }); | 566 | }); |
| 535 | 567 | ||
| 536 | const allCredentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({ | 568 | const allAuthArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 537 | workspaceId, | 569 | workspaceId, |
| 538 | sourceId, | 570 | sourceId, |
| 539 | }); | 571 | }); |
| 540 | expect(allCredentials).toHaveLength(2); | 572 | expect(allAuthArtifacts).toHaveLength(2); |
| 541 | const nullActorCredentials = allCredentials.filter((credential) => credential.actorAccountId === null); | 573 | const nullActorArtifacts = allAuthArtifacts.filter((artifact) => artifact.actorAccountId === null); |
| 542 | expect(nullActorCredentials).toHaveLength(1); | 574 | expect(nullActorArtifacts).toHaveLength(1); |
| 543 | expect(nullActorCredentials[0]?.id).toBe(nullCredentialA); | 575 | expect(nullActorArtifacts[0]?.id).toBe(nullCredentialA); |
| 544 | expect(nullActorCredentials[0]?.authKind).toBe("oauth2"); | 576 | const nullActorConfig = decodeBuiltInAuthArtifactConfig(nullActorArtifacts[0]!); |
| 545 | expect(nullActorCredentials[0]?.authHeaderName).toBe("X-Auth"); | 577 | expect(nullActorConfig?.artifactKind).toBe("static_oauth2"); |
| 546 | expect(nullActorCredentials[0]?.tokenHandle).toBe("sec_null_replacement"); | 578 | if (nullActorConfig !== null && nullActorConfig.artifactKind === "static_oauth2") { |
| 547 | expect(allCredentials.map((credential) => credential.id).sort()).toEqual( | 579 | expect(nullActorConfig.config.headerName).toBe("X-Auth"); |
| 580 | expect(nullActorConfig.config.accessToken.handle).toBe("sec_null_replacement"); | ||
| 581 | } | ||
| 582 | expect(allAuthArtifacts.map((artifact) => artifact.id).sort()).toEqual( | ||
| 548 | [actorCredentialId, nullCredentialA].sort(), | 583 | [actorCredentialId, nullCredentialA].sort(), |
| 549 | ); | 584 | ); |
| 550 | 585 | ||
| 551 | const forActor = yield* persistence.rows.credentials.listByWorkspaceSourceAndActor({ | 586 | const forActor = yield* persistence.rows.authArtifacts.listByWorkspaceSourceAndActor({ |
| 552 | workspaceId, | 587 | workspaceId, |
| 553 | sourceId, | 588 | sourceId, |
| 554 | actorAccountId: accountId, | 589 | actorAccountId: accountId, |
| 555 | }); | 590 | }); |
| 556 | expect(forActor).toHaveLength(2); | 591 | expect(forActor).toHaveLength(2); |
| 557 | expect(new Set(forActor.map((credential) => credential.id))).toEqual( | 592 | expect(new Set(forActor.map((artifact) => artifact.id))).toEqual( |
| 558 | new Set([actorCredentialId, nullCredentialA]), | 593 | new Set([actorCredentialId, nullCredentialA]), |
| 559 | ); | 594 | ); |
| 560 | 595 | ||
| 561 | const nullActorOnly = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({ | 596 | const nullActorOnly = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({ |
| 562 | workspaceId, | 597 | workspaceId, |
| 563 | sourceId, | 598 | sourceId, |
| 564 | actorAccountId: null, | 599 | actorAccountId: null, |
| @@ -569,7 +604,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines | |||
| 569 | expect(nullActorOnly.value.id).toBe(nullCredentialA); | 604 | expect(nullActorOnly.value.id).toBe(nullCredentialA); |
| 570 | } | 605 | } |
| 571 | 606 | ||
| 572 | const missingActor = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({ | 607 | const missingActor = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({ |
| 573 | workspaceId, | 608 | workspaceId, |
| 574 | sourceId, | 609 | sourceId, |
| 575 | actorAccountId: AccountIdSchema.make("acc_missing_credentials"), | 610 | actorAccountId: AccountIdSchema.make("acc_missing_credentials"), |
| @@ -588,7 +623,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines | |||
| 588 | const sourceId = SourceIdSchema.make("src_ws_cleanup"); | 623 | const sourceId = SourceIdSchema.make("src_ws_cleanup"); |
| 589 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`); | 624 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`); |
| 590 | 625 | ||
| 591 | const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({ | 626 | const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({ |
| 592 | persistence, | 627 | persistence, |
| 593 | accountId, | 628 | accountId, |
| 594 | organizationId, | 629 | organizationId, |
| @@ -670,7 +705,7 @@ describe("control-plane-persistence-drizzle", () => { · 75 unmodified lines | |||
| 670 | const removed = yield* persistence.rows.workspaces.removeById(workspaceId); | 705 | const removed = yield* persistence.rows.workspaces.removeById(workspaceId); |
| 671 | expect(removed).toBe(true); | 706 | expect(removed).toBe(true); |
| 672 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); | 707 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 673 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); | 708 | expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 674 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 709 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 675 | expect( | 710 | expect( |
| 676 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId), | 711 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId), |
| @@ -892,7 +927,7 @@ describe("control-plane-persistence-drizzle", () => { · 215 unmodified lines | |||
| 892 | const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup"); | 927 | const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup"); |
| 893 | const sourceId = SourceIdSchema.make("src_org_cleanup"); | 928 | const sourceId = SourceIdSchema.make("src_org_cleanup"); |
| 894 | 929 | ||
| 895 | const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({ | 930 | const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({ |
| 896 | persistence, | 931 | persistence, |
| 897 | accountId, | 932 | accountId, |
| 898 | organizationId, | 933 | organizationId, |
| @@ -903,7 +938,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines | |||
| 903 | const removed = yield* persistence.rows.organizations.removeTreeById(organizationId); | 938 | const removed = yield* persistence.rows.organizations.removeTreeById(organizationId); |
| 904 | expect(removed).toBe(true); | 939 | expect(removed).toBe(true); |
| 905 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); | 940 | expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true); |
| 906 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); | 941 | expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 907 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 942 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 908 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); | 943 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 909 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); | 944 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true); |
| @@ -6,7 +6,8 @@ import * as Layer from "effect/Layer"; · 5 unmodified lines | |||
| 6 | import { createDrizzleClient, type DrizzleClient } from "./client"; | 6 | import { createDrizzleClient, type DrizzleClient } from "./client"; |
| 7 | import { | 7 | import { |
| 8 | createAccountsRepo, | 8 | createAccountsRepo, |
| 9 | createCredentialsRepo, | 9 | createAuthArtifactsRepo, |
| 10 | createAuthLeasesRepo, | ||
| 10 | createCodeMigrationsRepo, | 11 | createCodeMigrationsRepo, |
| 11 | createExecutionInteractionsRepo, | 12 | createExecutionInteractionsRepo, |
| 12 | createExecutionsRepo, | 13 | createExecutionsRepo, |
| @@ -64,7 +65,8 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 51 unmodified lines | |||
| 64 | sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables), | 65 | sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables), |
| 65 | sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables), | 66 | sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables), |
| 66 | codeMigrations: createCodeMigrationsRepo(client, tables), | 67 | codeMigrations: createCodeMigrationsRepo(client, tables), |
| 67 | credentials: createCredentialsRepo(client, tables), | 68 | authArtifacts: createAuthArtifactsRepo(client, tables), |
| 69 | authLeases: createAuthLeasesRepo(client, tables), | ||
| 68 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), | 70 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), |
| 69 | secretMaterials: createSecretMaterialsRepo(client, tables), | 71 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 70 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), | 72 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | McpSourceAuthSessionDataJsonSchema, | 9 | McpSourceAuthSessionDataJsonSchema, |
| 10 | OrganizationIdSchema, | 10 | OrganizationIdSchema, |
| 11 | SourceAuthSessionIdSchema, | 11 | SourceAuthSessionIdSchema, |
| 12 | decodeAuthLeasePlacementTemplates, | ||
| 12 | SourceIdSchema, | 13 | SourceIdSchema, |
| 13 | SourceRecipeIdSchema, | 14 | SourceRecipeIdSchema, |
| 14 | SourceRecipeRevisionIdSchema, | 15 | SourceRecipeRevisionIdSchema, |
| @@ -753,4 +754,184 @@ describe("code-migrations", () => { · 738 unmodified lines | |||
| 753 | rmSync(legacyMigrationsDir, { recursive: true, force: true }); | 754 | rmSync(legacyMigrationsDir, { recursive: true, force: true }); |
| 754 | } | 755 | } |
| 755 | }); | 756 | }); |
| 757 | |||
| 758 | it("upgrades auth leases into template-based placement storage", async () => { | ||
| 759 | const localDataDir = mkdtempSync(path.join(tmpdir(), "executor-cp-db-")); | ||
| 760 | const legacyMigrationsDir = copyMigrationsBefore( | ||
| 761 | "20260312143000_auth_lease_template_placements", | ||
| 762 | ); | ||
| 763 | const workspaceId = WorkspaceIdSchema.make("ws_legacy_auth_lease"); | ||
| 764 | const accountId = AccountIdSchema.make("acc_legacy_auth_lease"); | ||
| 765 | const organizationId = OrganizationIdSchema.make("org_legacy_auth_lease"); | ||
| 766 | const sourceId = SourceIdSchema.make("src_legacy_auth_lease"); | ||
| 767 | const recipeId = SourceRecipeIdSchema.make("src_recipe_legacy_auth_lease"); | ||
| 768 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make( | ||
| 769 | "src_recipe_rev_legacy_auth_lease", | ||
| 770 | ); | ||
| 771 | const authArtifactId = "auth_art_legacy_auth_lease"; | ||
| 772 | const now = Date.now(); | ||
| 773 | let legacyPersistence: SqlControlPlanePersistence | null = null; | ||
| 774 | let upgradedPersistence: SqlControlPlanePersistence | null = null; | ||
| 775 | |||
| 776 | try { | ||
| 777 | legacyPersistence = await Effect.runPromise( | ||
| 778 | createSqlControlPlanePersistence({ | ||
| 779 | localDataDir, | ||
| 780 | migrationsFolder: legacyMigrationsDir, | ||
| 781 | runCodeMigrations: false, | ||
| 782 | }), | ||
| 783 | ); | ||
| 784 | |||
| 785 | await Effect.runPromise(legacyPersistence.rows.organizations.insert({ | ||
| 786 | id: organizationId, | ||
| 787 | slug: "org-legacy-auth-lease", | ||
| 788 | name: "Legacy Org", | ||
| 789 | status: "active", | ||
| 790 | createdByAccountId: accountId, | ||
| 791 | createdAt: now, | ||
| 792 | updatedAt: now, | ||
| 793 | })); | ||
| 794 | await Effect.runPromise(legacyPersistence.rows.workspaces.insert({ | ||
| 795 | id: workspaceId, | ||
| 796 | organizationId, | ||
| 797 | name: "Legacy Workspace", | ||
| 798 | createdByAccountId: accountId, | ||
| 799 | createdAt: now, | ||
| 800 | updatedAt: now, | ||
| 801 | })); | ||
| 802 | await Effect.runPromise(legacyPersistence.rows.sourceRecipes.upsert({ | ||
| 803 | id: recipeId, | ||
| 804 | kind: "http_api", | ||
| 805 | adapterKey: "openapi", | ||
| 806 | providerKey: "generic_http", | ||
| 807 | name: "Legacy OpenAPI", | ||
| 808 | summary: null, | ||
| 809 | visibility: "workspace", | ||
| 810 | latestRevisionId: recipeRevisionId, | ||
| 811 | createdAt: now, | ||
| 812 | updatedAt: now, | ||
| 813 | })); | ||
| 814 | await Effect.runPromise(legacyPersistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({ | ||
| 815 | id: recipeRevisionId, | ||
| 816 | recipeId, | ||
| 817 | revisionNumber: 1, | ||
| 818 | sourceConfigJson: JSON.stringify({ | ||
| 819 | kind: "openapi", | ||
| 820 | endpoint: "https://api.example.com", | ||
| 821 | specUrl: "https://api.example.com/openapi.json", | ||
| 822 | }), | ||
| 823 | createdAt: now, | ||
| 824 | updatedAt: now, | ||
| 825 | }))); | ||
| 826 | await Effect.runPromise(legacyPersistence.rows.sources.insert({ | ||
| 827 | id: sourceId, | ||
| 828 | workspaceId, | ||
| 829 | recipeId, | ||
| 830 | recipeRevisionId, | ||
| 831 | name: "Legacy OpenAPI", | ||
| 832 | kind: "openapi", | ||
| 833 | endpoint: "https://api.example.com", | ||
| 834 | status: "connected", | ||
| 835 | enabled: true, | ||
| 836 | namespace: "legacy.openapi", | ||
| 837 | importAuthPolicy: "reuse_runtime", | ||
| 838 | bindingConfigJson: openApiBindingConfigJson("https://api.example.com/openapi.json"), | ||
| 839 | sourceHash: null, | ||
| 840 | lastError: null, | ||
| 841 | createdAt: now, | ||
| 842 | updatedAt: now, | ||
| 843 | })); | ||
| 844 | await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({ | ||
| 845 | id: authArtifactId as any, | ||
| 846 | workspaceId, | ||
| 847 | sourceId, | ||
| 848 | actorAccountId: accountId, | ||
| 849 | slot: "runtime", | ||
| 850 | artifactKind: "static_bearer", | ||
| 851 | configJson: JSON.stringify({ | ||
| 852 | headerName: "Authorization", | ||
| 853 | prefix: "Bearer ", | ||
| 854 | token: { | ||
| 855 | providerId: "postgres", | ||
| 856 | handle: "sec_legacy_auth_lease_token", | ||
| 857 | }, | ||
| 858 | }), | ||
| 859 | grantSetJson: null, | ||
| 860 | createdAt: now, | ||
| 861 | updatedAt: now, | ||
| 862 | })); | ||
| 863 | |||
| 864 | await legacyPersistence.db.execute(sql` | ||
| 865 | INSERT INTO "workspace_source_auth_leases" ( | ||
| 866 | "id", | ||
| 867 | "auth_artifact_id", | ||
| 868 | "workspace_id", | ||
| 869 | "source_id", | ||
| 870 | "actor_account_id", | ||
| 871 | "slot", | ||
| 872 | "placements_json", | ||
| 873 | "expires_at", | ||
| 874 | "refresh_after", | ||
| 875 | "created_at", | ||
| 876 | "updated_at" | ||
| 877 | ) VALUES ( | ||
| 878 | 'auth_lease_legacy_auth_lease', | ||
| 879 | ${authArtifactId}, | ||
| 880 | ${workspaceId}, | ||
| 881 | ${sourceId}, | ||
| 882 | ${accountId}, | ||
| 883 | 'runtime', | ||
| 884 | '[{"location":"header","name":"Authorization","value":"Bearer abc"},{"location":"query","name":"api_key","value":"q123"},{"location":"cookie","name":"sid","value":"cookie123"},{"location":"body","path":"auth.token","value":"body123"}]', | ||
| 885 | NULL, | ||
| 886 | NULL, | ||
| 887 | ${now}, | ||
| 888 | ${now} | ||
| 889 | ); | ||
| 890 | `); | ||
| 891 | |||
| 892 | await legacyPersistence.close(); | ||
| 893 | legacyPersistence = null; | ||
| 894 | |||
| 895 | upgradedPersistence = await Effect.runPromise( | ||
| 896 | createSqlControlPlanePersistence({ | ||
| 897 | localDataDir, | ||
| 898 | }), | ||
| 899 | ); | ||
| 900 | |||
| 901 | const leaseOption = await Effect.runPromise( | ||
| 902 | upgradedPersistence.rows.authLeases.getByAuthArtifactId(authArtifactId as any), | ||
| 903 | ); | ||
| 904 | expect(Option.isSome(leaseOption)).toBe(true); | ||
| 905 | const lease = Option.getOrNull(leaseOption); | ||
| 906 | expect(lease).not.toBeNull(); | ||
| 907 | expect(lease?.placementsTemplateJson).not.toBeNull(); | ||
| 908 | expect(decodeAuthLeasePlacementTemplates(lease!)).toEqual([ | ||
| 909 | { | ||
| 910 | location: "header", | ||
| 911 | name: "Authorization", | ||
| 912 | parts: [{ kind: "literal", value: "Bearer abc" }], | ||
| 913 | }, | ||
| 914 | { | ||
| 915 | location: "query", | ||
| 916 | name: "api_key", | ||
| 917 | parts: [{ kind: "literal", value: "q123" }], | ||
| 918 | }, | ||
| 919 | { | ||
| 920 | location: "cookie", | ||
| 921 | name: "sid", | ||
| 922 | parts: [{ kind: "literal", value: "cookie123" }], | ||
| 923 | }, | ||
| 924 | { | ||
| 925 | location: "body", | ||
| 926 | path: "auth.token", | ||
| 927 | parts: [{ kind: "literal", value: "body123" }], | ||
| 928 | }, | ||
| 929 | ]); | ||
| 930 | } finally { | ||
| 931 | await legacyPersistence?.close().catch(() => undefined); | ||
| 932 | await upgradedPersistence?.close().catch(() => undefined); | ||
| 933 | rmSync(localDataDir, { recursive: true, force: true }); | ||
| 934 | rmSync(legacyMigrationsDir, { recursive: true, force: true }); | ||
| 935 | } | ||
| 936 | }); | ||
| 756 | }); | 937 | }); |
| @@ -0,0 +1,226 @@ | |||
| 1 | import { | ||
| 2 | AuthArtifactSchema, | ||
| 3 | type AuthArtifact, | ||
| 4 | } from "#schema"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { Schema } from "effect"; | ||
| 7 | import { and, asc, eq, inArray, isNull, or } from "drizzle-orm"; | ||
| 8 | |||
| 9 | import type { DrizzleClient } from "../client"; | ||
| 10 | import type { DrizzleTables } from "../schema"; | ||
| 11 | import { firstOption } from "./shared"; | ||
| 12 | |||
| 13 | const decodeAuthArtifact = Schema.decodeUnknownSync(AuthArtifactSchema); | ||
| 14 | |||
| 15 | const authArtifactUpdateSet = (artifact: AuthArtifact) => { | ||
| 16 | const { | ||
| 17 | id: _id, | ||
| 18 | workspaceId: _workspaceId, | ||
| 19 | sourceId: _sourceId, | ||
| 20 | actorAccountId: _actorAccountId, | ||
| 21 | createdAt: _createdAt, | ||
| 22 | ...patch | ||
| 23 | } = artifact; | ||
| 24 | return patch; | ||
| 25 | }; | ||
| 26 | |||
| 27 | export const createAuthArtifactsRepo = ( | ||
| 28 | client: DrizzleClient, | ||
| 29 | tables: DrizzleTables, | ||
| 30 | ) => ({ | ||
| 31 | listByWorkspaceId: (workspaceId: AuthArtifact["workspaceId"]) => | ||
| 32 | client.use("rows.auth_artifacts.list_by_workspace", async (db) => { | ||
| 33 | const rows = await db | ||
| 34 | .select() | ||
| 35 | .from(tables.authArtifactsTable) | ||
| 36 | .where(eq(tables.authArtifactsTable.workspaceId, workspaceId)) | ||
| 37 | .orderBy( | ||
| 38 | asc(tables.authArtifactsTable.updatedAt), | ||
| 39 | asc(tables.authArtifactsTable.id), | ||
| 40 | ); | ||
| 41 | |||
| 42 | return rows.map((row) => decodeAuthArtifact(row)); | ||
| 43 | }), | ||
| 44 | |||
| 45 | listByWorkspaceAndSourceId: (input: { | ||
| 46 | workspaceId: AuthArtifact["workspaceId"]; | ||
| 47 | sourceId: AuthArtifact["sourceId"]; | ||
| 48 | }) => | ||
| 49 | client.use("rows.auth_artifacts.list_by_workspace_source", async (db) => { | ||
| 50 | const rows = await db | ||
| 51 | .select() | ||
| 52 | .from(tables.authArtifactsTable) | ||
| 53 | .where( | ||
| 54 | and( | ||
| 55 | eq(tables.authArtifactsTable.workspaceId, input.workspaceId), | ||
| 56 | eq(tables.authArtifactsTable.sourceId, input.sourceId), | ||
| 57 | ), | ||
| 58 | ) | ||
| 59 | .orderBy( | ||
| 60 | asc(tables.authArtifactsTable.updatedAt), | ||
| 61 | asc(tables.authArtifactsTable.id), | ||
| 62 | ); | ||
| 63 | |||
| 64 | return rows.map((row) => decodeAuthArtifact(row)); | ||
| 65 | }), | ||
| 66 | |||
| 67 | listByWorkspaceSourceAndActor: (input: { | ||
| 68 | workspaceId: AuthArtifact["workspaceId"]; | ||
| 69 | sourceId: AuthArtifact["sourceId"]; | ||
| 70 | actorAccountId: AuthArtifact["actorAccountId"]; | ||
| 71 | }) => | ||
| 72 | client.use("rows.auth_artifacts.list_by_workspace_source_actor", async (db) => { | ||
| 73 | const rows = await db | ||
| 74 | .select() | ||
| 75 | .from(tables.authArtifactsTable) | ||
| 76 | .where( | ||
| 77 | and( | ||
| 78 | eq(tables.authArtifactsTable.workspaceId, input.workspaceId), | ||
| 79 | eq(tables.authArtifactsTable.sourceId, input.sourceId), | ||
| 80 | or( | ||
| 81 | input.actorAccountId === null | ||
| 82 | ? isNull(tables.authArtifactsTable.actorAccountId) | ||
| 83 | : eq(tables.authArtifactsTable.actorAccountId, input.actorAccountId), | ||
| 84 | input.actorAccountId === null | ||
| 85 | ? undefined | ||
| 86 | : isNull(tables.authArtifactsTable.actorAccountId), | ||
| 87 | ), | ||
| 88 | ), | ||
| 89 | ) | ||
| 90 | .orderBy( | ||
| 91 | asc(tables.authArtifactsTable.slot), | ||
| 92 | asc(tables.authArtifactsTable.actorAccountId), | ||
| 93 | asc(tables.authArtifactsTable.updatedAt), | ||
| 94 | asc(tables.authArtifactsTable.id), | ||
| 95 | ); | ||
| 96 | |||
| 97 | return rows.map((row) => decodeAuthArtifact(row)); | ||
| 98 | }), | ||
| 99 | |||
| 100 | getByWorkspaceSourceAndActor: (input: { | ||
| 101 | workspaceId: AuthArtifact["workspaceId"]; | ||
| 102 | sourceId: AuthArtifact["sourceId"]; | ||
| 103 | actorAccountId: AuthArtifact["actorAccountId"]; | ||
| 104 | slot: AuthArtifact["slot"]; | ||
| 105 | }) => | ||
| 106 | client.use("rows.auth_artifacts.get_by_workspace_source_actor", async (db) => { | ||
| 107 | const rows = await db | ||
| 108 | .select() | ||
| 109 | .from(tables.authArtifactsTable) | ||
| 110 | .where( | ||
| 111 | and( | ||
| 112 | eq(tables.authArtifactsTable.workspaceId, input.workspaceId), | ||
| 113 | eq(tables.authArtifactsTable.sourceId, input.sourceId), | ||
| 114 | eq(tables.authArtifactsTable.slot, input.slot), | ||
| 115 | input.actorAccountId === null | ||
| 116 | ? isNull(tables.authArtifactsTable.actorAccountId) | ||
| 117 | : eq(tables.authArtifactsTable.actorAccountId, input.actorAccountId), | ||
| 118 | ), | ||
| 119 | ) | ||
| 120 | .limit(1); | ||
| 121 | |||
| 122 | const row = firstOption(rows); | ||
| 123 | return Option.isSome(row) | ||
| 124 | ? Option.some(decodeAuthArtifact(row.value)) | ||
| 125 | : Option.none<AuthArtifact>(); | ||
| 126 | }), | ||
| 127 | |||
| 128 | upsert: (artifact: AuthArtifact) => | ||
| 129 | client.use("rows.auth_artifacts.upsert", async (db) => { | ||
| 130 | if (artifact.actorAccountId === null) { | ||
| 131 | const existingRows = await db | ||
| 132 | .select({ | ||
| 133 | id: tables.authArtifactsTable.id, | ||
| 134 | }) | ||
| 135 | .from(tables.authArtifactsTable) | ||
| 136 | .where( | ||
| 137 | and( | ||
| 138 | eq(tables.authArtifactsTable.workspaceId, artifact.workspaceId), | ||
| 139 | eq(tables.authArtifactsTable.sourceId, artifact.sourceId), | ||
| 140 | eq(tables.authArtifactsTable.slot, artifact.slot), | ||
| 141 | isNull(tables.authArtifactsTable.actorAccountId), | ||
| 142 | ), | ||
| 143 | ) | ||
| 144 | .orderBy( | ||
| 145 | asc(tables.authArtifactsTable.updatedAt), | ||
| 146 | asc(tables.authArtifactsTable.id), | ||
| 147 | ); | ||
| 148 | |||
| 149 | const existing = firstOption(existingRows); | ||
| 150 | if (Option.isSome(existing)) { | ||
| 151 | await db | ||
| 152 | .update(tables.authArtifactsTable) | ||
| 153 | .set(authArtifactUpdateSet(artifact)) | ||
| 154 | .where(eq(tables.authArtifactsTable.id, existing.value.id)); | ||
| 155 | |||
| 156 | const duplicateIds = existingRows.slice(1).map((row) => row.id); | ||
| 157 | if (duplicateIds.length > 0) { | ||
| 158 | await db | ||
| 159 | .delete(tables.authArtifactsTable) | ||
| 160 | .where(inArray(tables.authArtifactsTable.id, duplicateIds)); | ||
| 161 | } | ||
| 162 | |||
| 163 | return; | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | await db | ||
| 168 | .insert(tables.authArtifactsTable) | ||
| 169 | .values(artifact) | ||
| 170 | .onConflictDoUpdate({ | ||
| 171 | target: [ | ||
| 172 | tables.authArtifactsTable.workspaceId, | ||
| 173 | tables.authArtifactsTable.sourceId, | ||
| 174 | tables.authArtifactsTable.actorAccountId, | ||
| 175 | tables.authArtifactsTable.slot, | ||
| 176 | ], | ||
| 177 | set: { | ||
| 178 | ...authArtifactUpdateSet(artifact), | ||
| 179 | }, | ||
| 180 | }); | ||
| 181 | }), | ||
| 182 | |||
| 183 | removeByWorkspaceSourceAndActor: (input: { | ||
| 184 | workspaceId: AuthArtifact["workspaceId"]; | ||
| 185 | sourceId: AuthArtifact["sourceId"]; | ||
| 186 | actorAccountId: AuthArtifact["actorAccountId"]; | ||
| 187 | slot?: AuthArtifact["slot"]; | ||
| 188 | }) => | ||
| 189 | client.use("rows.auth_artifacts.remove_by_workspace_source_actor", async (db) => { | ||
| 190 | const deleted = await db | ||
| 191 | .delete(tables.authArtifactsTable) | ||
| 192 | .where( | ||
| 193 | and( | ||
| 194 | eq(tables.authArtifactsTable.workspaceId, input.workspaceId), | ||
| 195 | eq(tables.authArtifactsTable.sourceId, input.sourceId), | ||
| 196 | input.slot === undefined | ||
| 197 | ? undefined | ||
| 198 | : eq(tables.authArtifactsTable.slot, input.slot), | ||
| 199 | input.actorAccountId === null | ||
| 200 | ? isNull(tables.authArtifactsTable.actorAccountId) | ||
| 201 | : eq(tables.authArtifactsTable.actorAccountId, input.actorAccountId), | ||
| 202 | ), | ||
| 203 | ) | ||
| 204 | .returning(); | ||
| 205 | |||
| 206 | return deleted.length > 0; | ||
| 207 | }), | ||
| 208 | |||
| 209 | removeByWorkspaceAndSourceId: (input: { | ||
| 210 | workspaceId: AuthArtifact["workspaceId"]; | ||
| 211 | sourceId: AuthArtifact["sourceId"]; | ||
| 212 | }) => | ||
| 213 | client.use("rows.auth_artifacts.remove_by_workspace_source", async (db) => { | ||
| 214 | const deleted = await db | ||
| 215 | .delete(tables.authArtifactsTable) | ||
| 216 | .where( | ||
| 217 | and( | ||
| 218 | eq(tables.authArtifactsTable.workspaceId, input.workspaceId), | ||
| 219 | eq(tables.authArtifactsTable.sourceId, input.sourceId), | ||
| 220 | ), | ||
| 221 | ) | ||
| 222 | .returning(); | ||
| 223 | |||
| 224 | return deleted.length; | ||
| 225 | }), | ||
| 226 | }); | ||
| @@ -0,0 +1,145 @@ | |||
| 1 | import { | ||
| 2 | AuthLeaseSchema, | ||
| 3 | type AuthLease, | ||
| 4 | } from "#schema"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { Schema } from "effect"; | ||
| 7 | import { and, asc, eq, isNull } from "drizzle-orm"; | ||
| 8 | |||
| 9 | import type { DrizzleClient } from "../client"; | ||
| 10 | import type { DrizzleTables } from "../schema"; | ||
| 11 | import { firstOption, withoutCreatedAt } from "./shared"; | ||
| 12 | |||
| 13 | const decodeAuthLease = Schema.decodeUnknownSync(AuthLeaseSchema); | ||
| 14 | |||
| 15 | export const createAuthLeasesRepo = ( | ||
| 16 | client: DrizzleClient, | ||
| 17 | tables: DrizzleTables, | ||
| 18 | ) => ({ | ||
| 19 | getByAuthArtifactId: (authArtifactId: AuthLease["authArtifactId"]) => | ||
| 20 | client.use("rows.auth_leases.get_by_auth_artifact_id", async (db) => { | ||
| 21 | const rows = await db | ||
| 22 | .select() | ||
| 23 | .from(tables.authLeasesTable) | ||
| 24 | .where(eq(tables.authLeasesTable.authArtifactId, authArtifactId)) | ||
| 25 | .limit(1); | ||
| 26 | |||
| 27 | const row = firstOption(rows); | ||
| 28 | return Option.isSome(row) | ||
| 29 | ? Option.some(decodeAuthLease(row.value)) | ||
| 30 | : Option.none<AuthLease>(); | ||
| 31 | }), | ||
| 32 | |||
| 33 | listByWorkspaceId: (workspaceId: AuthLease["workspaceId"]) => | ||
| 34 | client.use("rows.auth_leases.list_by_workspace", async (db) => { | ||
| 35 | const rows = await db | ||
| 36 | .select() | ||
| 37 | .from(tables.authLeasesTable) | ||
| 38 | .where(eq(tables.authLeasesTable.workspaceId, workspaceId)) | ||
| 39 | .orderBy( | ||
| 40 | asc(tables.authLeasesTable.updatedAt), | ||
| 41 | asc(tables.authLeasesTable.id), | ||
| 42 | ); | ||
| 43 | |||
| 44 | return rows.map((row) => decodeAuthLease(row)); | ||
| 45 | }), | ||
| 46 | |||
| 47 | getByWorkspaceSourceAndActor: (input: { | ||
| 48 | workspaceId: AuthLease["workspaceId"]; | ||
| 49 | sourceId: AuthLease["sourceId"]; | ||
| 50 | actorAccountId: AuthLease["actorAccountId"]; | ||
| 51 | slot: AuthLease["slot"]; | ||
| 52 | }) => | ||
| 53 | client.use("rows.auth_leases.get_by_workspace_source_actor", async (db) => { | ||
| 54 | const rows = await db | ||
| 55 | .select() | ||
| 56 | .from(tables.authLeasesTable) | ||
| 57 | .where( | ||
| 58 | and( | ||
| 59 | eq(tables.authLeasesTable.workspaceId, input.workspaceId), | ||
| 60 | eq(tables.authLeasesTable.sourceId, input.sourceId), | ||
| 61 | eq(tables.authLeasesTable.slot, input.slot), | ||
| 62 | input.actorAccountId === null | ||
| 63 | ? isNull(tables.authLeasesTable.actorAccountId) | ||
| 64 | : eq(tables.authLeasesTable.actorAccountId, input.actorAccountId), | ||
| 65 | ), | ||
| 66 | ) | ||
| 67 | .orderBy( | ||
| 68 | asc(tables.authLeasesTable.updatedAt), | ||
| 69 | asc(tables.authLeasesTable.id), | ||
| 70 | ) | ||
| 71 | .limit(1); | ||
| 72 | |||
| 73 | const row = firstOption(rows); | ||
| 74 | return Option.isSome(row) | ||
| 75 | ? Option.some(decodeAuthLease(row.value)) | ||
| 76 | : Option.none<AuthLease>(); | ||
| 77 | }), | ||
| 78 | |||
| 79 | upsert: (lease: AuthLease) => | ||
| 80 | client.use("rows.auth_leases.upsert", async (db) => { | ||
| 81 | await db | ||
| 82 | .insert(tables.authLeasesTable) | ||
| 83 | .values(lease) | ||
| 84 | .onConflictDoUpdate({ | ||
| 85 | target: [tables.authLeasesTable.authArtifactId], | ||
| 86 | set: { | ||
| 87 | ...withoutCreatedAt(lease), | ||
| 88 | }, | ||
| 89 | }); | ||
| 90 | }), | ||
| 91 | |||
| 92 | removeByAuthArtifactId: (authArtifactId: AuthLease["authArtifactId"]) => | ||
| 93 | client.use("rows.auth_leases.remove_by_auth_artifact_id", async (db) => { | ||
| 94 | const deleted = await db | ||
| 95 | .delete(tables.authLeasesTable) | ||
| 96 | .where(eq(tables.authLeasesTable.authArtifactId, authArtifactId)) | ||
| 97 | .returning(); | ||
| 98 | |||
| 99 | return deleted.length > 0; | ||
| 100 | }), | ||
| 101 | |||
| 102 | removeByWorkspaceSourceAndActor: (input: { | ||
| 103 | workspaceId: AuthLease["workspaceId"]; | ||
| 104 | sourceId: AuthLease["sourceId"]; | ||
| 105 | actorAccountId: AuthLease["actorAccountId"]; | ||
| 106 | slot?: AuthLease["slot"]; | ||
| 107 | }) => | ||
| 108 | client.use("rows.auth_leases.remove_by_workspace_source_actor", async (db) => { | ||
| 109 | const deleted = await db | ||
| 110 | .delete(tables.authLeasesTable) | ||
| 111 | .where( | ||
| 112 | and( | ||
| 113 | eq(tables.authLeasesTable.workspaceId, input.workspaceId), | ||
| 114 | eq(tables.authLeasesTable.sourceId, input.sourceId), | ||
| 115 | input.slot === undefined | ||
| 116 | ? undefined | ||
| 117 | : eq(tables.authLeasesTable.slot, input.slot), | ||
| 118 | input.actorAccountId === null | ||
| 119 | ? isNull(tables.authLeasesTable.actorAccountId) | ||
| 120 | : eq(tables.authLeasesTable.actorAccountId, input.actorAccountId), | ||
| 121 | ), | ||
| 122 | ) | ||
| 123 | .returning(); | ||
| 124 | |||
| 125 | return deleted.length > 0; | ||
| 126 | }), | ||
| 127 | |||
| 128 | removeByWorkspaceAndSourceId: (input: { | ||
| 129 | workspaceId: AuthLease["workspaceId"]; | ||
| 130 | sourceId: AuthLease["sourceId"]; | ||
| 131 | }) => | ||
| 132 | client.use("rows.auth_leases.remove_by_workspace_source", async (db) => { | ||
| 133 | const deleted = await db | ||
| 134 | .delete(tables.authLeasesTable) | ||
| 135 | .where( | ||
| 136 | and( | ||
| 137 | eq(tables.authLeasesTable.workspaceId, input.workspaceId), | ||
| 138 | eq(tables.authLeasesTable.sourceId, input.sourceId), | ||
| 139 | ), | ||
| 140 | ) | ||
| 141 | .returning(); | ||
| 142 | |||
| 143 | return deleted.length; | ||
| 144 | }), | ||
| 145 | }); | ||
| @@ -1,228 +0,0 @@ | |||
| 1 | import { | ||
| 2 | CredentialSchema, | ||
| 3 | type Credential, | ||
| 4 | } from "#schema"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { Schema } from "effect"; | ||
| 7 | import { and, asc, eq, inArray, isNull, or } from "drizzle-orm"; | ||
| 8 | |||
| 9 | import type { DrizzleClient } from "../client"; | ||
| 10 | import type { DrizzleTables } from "../schema"; | ||
| 11 | import { firstOption } from "./shared"; | ||
| 12 | |||
| 13 | const decodeCredential = Schema.decodeUnknownSync(CredentialSchema); | ||
| 14 | |||
| 15 | const credentialUpdateSet = (credential: Credential) => { | ||
| 16 | const { | ||
| 17 | id: _id, | ||
| 18 | workspaceId: _workspaceId, | ||
| 19 | sourceId: _sourceId, | ||
| 20 | actorAccountId: _actorAccountId, | ||
| 21 | createdAt: _createdAt, | ||
| 22 | ...patch | ||
| 23 | } = credential; | ||
| 24 | return patch; | ||
| 25 | }; | ||
| 26 | |||
| 27 | export const createCredentialsRepo = ( | ||
| 28 | client: DrizzleClient, | ||
| 29 | tables: DrizzleTables, | ||
| 30 | ) => ({ | ||
| 31 | listByWorkspaceId: (workspaceId: Credential["workspaceId"]) => | ||
| 32 | client.use("rows.credentials.list_by_workspace", async (db) => { | ||
| 33 | const rows = await db | ||
| 34 | .select() | ||
| 35 | .from(tables.credentialsTable) | ||
| 36 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)) | ||
| 37 | .orderBy( | ||
| 38 | asc(tables.credentialsTable.updatedAt), | ||
| 39 | asc(tables.credentialsTable.id), | ||
| 40 | ); | ||
| 41 | |||
| 42 | return rows.map((row) => decodeCredential(row)); | ||
| 43 | }), | ||
| 44 | |||
| 45 | listByWorkspaceAndSourceId: (input: { | ||
| 46 | workspaceId: Credential["workspaceId"]; | ||
| 47 | sourceId: Credential["sourceId"]; | ||
| 48 | }) => | ||
| 49 | client.use("rows.credentials.list_by_workspace_source", async (db) => { | ||
| 50 | const rows = await db | ||
| 51 | .select() | ||
| 52 | .from(tables.credentialsTable) | ||
| 53 | .where( | ||
| 54 | and( | ||
| 55 | eq(tables.credentialsTable.workspaceId, input.workspaceId), | ||
| 56 | eq(tables.credentialsTable.sourceId, input.sourceId), | ||
| 57 | ), | ||
| 58 | ) | ||
| 59 | .orderBy( | ||
| 60 | asc(tables.credentialsTable.updatedAt), | ||
| 61 | asc(tables.credentialsTable.id), | ||
| 62 | ); | ||
| 63 | |||
| 64 | return rows.map((row) => decodeCredential(row)); | ||
| 65 | }), | ||
| 66 | |||
| 67 | listByWorkspaceSourceAndActor: (input: { | ||
| 68 | workspaceId: Credential["workspaceId"]; | ||
| 69 | sourceId: Credential["sourceId"]; | ||
| 70 | actorAccountId: Credential["actorAccountId"]; | ||
| 71 | }) => | ||
| 72 | client.use("rows.credentials.list_by_workspace_source_actor", async (db) => { | ||
| 73 | const rows = await db | ||
| 74 | .select() | ||
| 75 | .from(tables.credentialsTable) | ||
| 76 | .where( | ||
| 77 | and( | ||
| 78 | eq(tables.credentialsTable.workspaceId, input.workspaceId), | ||
| 79 | eq(tables.credentialsTable.sourceId, input.sourceId), | ||
| 80 | or( | ||
| 81 | input.actorAccountId === null | ||
| 82 | ? isNull(tables.credentialsTable.actorAccountId) | ||
| 83 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | ||
| 84 | input.actorAccountId === null | ||
| 85 | ? undefined | ||
| 86 | : isNull(tables.credentialsTable.actorAccountId), | ||
| 87 | ), | ||
| 88 | ), | ||
| 89 | ) | ||
| 90 | .orderBy( | ||
| 91 | asc(tables.credentialsTable.slot), | ||
| 92 | asc(tables.credentialsTable.actorAccountId), | ||
| 93 | asc(tables.credentialsTable.updatedAt), | ||
| 94 | asc(tables.credentialsTable.id), | ||
| 95 | ); | ||
| 96 | |||
| 97 | return rows.map((row) => decodeCredential(row)); | ||
| 98 | }), | ||
| 99 | |||
| 100 | getByWorkspaceSourceAndActor: (input: { | ||
| 101 | workspaceId: Credential["workspaceId"]; | ||
| 102 | sourceId: Credential["sourceId"]; | ||
| 103 | actorAccountId: Credential["actorAccountId"]; | ||
| 104 | slot: Credential["slot"]; | ||
| 105 | }) => | ||
| 106 | client.use("rows.credentials.get_by_workspace_source_actor", async (db) => { | ||
| 107 | const rows = await db | ||
| 108 | .select() | ||
| 109 | .from(tables.credentialsTable) | ||
| 110 | .where( | ||
| 111 | and( | ||
| 112 | eq(tables.credentialsTable.workspaceId, input.workspaceId), | ||
| 113 | eq(tables.credentialsTable.sourceId, input.sourceId), | ||
| 114 | eq(tables.credentialsTable.slot, input.slot), | ||
| 115 | input.actorAccountId === null | ||
| 116 | ? isNull(tables.credentialsTable.actorAccountId) | ||
| 117 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | ||
| 118 | ), | ||
| 119 | ) | ||
| 120 | .limit(1); | ||
| 121 | |||
| 122 | const row = firstOption(rows); | ||
| 123 | return Option.isSome(row) | ||
| 124 | ? Option.some(decodeCredential(row.value)) | ||
| 125 | : Option.none<Credential>(); | ||
| 126 | }), | ||
| 127 | |||
| 128 | upsert: (credential: Credential) => | ||
| 129 | client.use("rows.credentials.upsert", async (db) => { | ||
| 130 | if (credential.actorAccountId === null) { | ||
| 131 | const existingRows = await db | ||
| 132 | .select({ | ||
| 133 | id: tables.credentialsTable.id, | ||
| 134 | }) | ||
| 135 | .from(tables.credentialsTable) | ||
| 136 | .where( | ||
| 137 | and( | ||
| 138 | eq(tables.credentialsTable.workspaceId, credential.workspaceId), | ||
| 139 | eq(tables.credentialsTable.sourceId, credential.sourceId), | ||
| 140 | eq(tables.credentialsTable.slot, credential.slot), | ||
| 141 | isNull(tables.credentialsTable.actorAccountId), | ||
| 142 | ), | ||
| 143 | ) | ||
| 144 | .orderBy( | ||
| 145 | asc(tables.credentialsTable.updatedAt), | ||
| 146 | asc(tables.credentialsTable.id), | ||
| 147 | ); | ||
| 148 | |||
| 149 | const existing = firstOption(existingRows); | ||
| 150 | if (Option.isSome(existing)) { | ||
| 151 | await db | ||
| 152 | .update(tables.credentialsTable) | ||
| 153 | .set(credentialUpdateSet(credential)) | ||
| 154 | .where(eq(tables.credentialsTable.id, existing.value.id)); | ||
| 155 | |||
| 156 | const duplicateIds = existingRows | ||
| 157 | .slice(1) | ||
| 158 | .map((row) => row.id); | ||
| 159 | if (duplicateIds.length > 0) { | ||
| 160 | await db | ||
| 161 | .delete(tables.credentialsTable) | ||
| 162 | .where(inArray(tables.credentialsTable.id, duplicateIds)); | ||
| 163 | } | ||
| 164 | |||
| 165 | return; | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 169 | await db | ||
| 170 | .insert(tables.credentialsTable) | ||
| 171 | .values(credential) | ||
| 172 | .onConflictDoUpdate({ | ||
| 173 | target: [ | ||
| 174 | tables.credentialsTable.workspaceId, | ||
| 175 | tables.credentialsTable.sourceId, | ||
| 176 | tables.credentialsTable.actorAccountId, | ||
| 177 | tables.credentialsTable.slot, | ||
| 178 | ], | ||
| 179 | set: { | ||
| 180 | ...credentialUpdateSet(credential), | ||
| 181 | }, | ||
| 182 | }); | ||
| 183 | }), | ||
| 184 | |||
| 185 | removeByWorkspaceSourceAndActor: (input: { | ||
| 186 | workspaceId: Credential["workspaceId"]; | ||
| 187 | sourceId: Credential["sourceId"]; | ||
| 188 | actorAccountId: Credential["actorAccountId"]; | ||
| 189 | slot?: Credential["slot"]; | ||
| 190 | }) => | ||
| 191 | client.use("rows.credentials.remove_by_workspace_source_actor", async (db) => { | ||
| 192 | const deleted = await db | ||
| 193 | .delete(tables.credentialsTable) | ||
| 194 | .where( | ||
| 195 | and( | ||
| 196 | eq(tables.credentialsTable.workspaceId, input.workspaceId), | ||
| 197 | eq(tables.credentialsTable.sourceId, input.sourceId), | ||
| 198 | input.slot === undefined | ||
| 199 | ? undefined | ||
| 200 | : eq(tables.credentialsTable.slot, input.slot), | ||
| 201 | input.actorAccountId === null | ||
| 202 | ? isNull(tables.credentialsTable.actorAccountId) | ||
| 203 | : eq(tables.credentialsTable.actorAccountId, input.actorAccountId), | ||
| 204 | ), | ||
| 205 | ) | ||
| 206 | .returning(); | ||
| 207 | |||
| 208 | return deleted.length > 0; | ||
| 209 | }), | ||
| 210 | |||
| 211 | removeByWorkspaceAndSourceId: (input: { | ||
| 212 | workspaceId: Credential["workspaceId"]; | ||
| 213 | sourceId: Credential["sourceId"]; | ||
| 214 | }) => | ||
| 215 | client.use("rows.credentials.remove_by_workspace_source", async (db) => { | ||
| 216 | const deleted = await db | ||
| 217 | .delete(tables.credentialsTable) | ||
| 218 | .where( | ||
| 219 | and( | ||
| 220 | eq(tables.credentialsTable.workspaceId, input.workspaceId), | ||
| 221 | eq(tables.credentialsTable.sourceId, input.sourceId), | ||
| 222 | ), | ||
| 223 | ) | ||
| 224 | .returning(); | ||
| 225 | |||
| 226 | return deleted.length; | ||
| 227 | }), | ||
| 228 | }); | ||
| @@ -9,7 +9,8 @@ export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo" · 8 unmodified lines | |||
| 9 | export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo"; | 9 | export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo"; |
| 10 | export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo"; | 10 | export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo"; |
| 11 | export { createCodeMigrationsRepo } from "./code-migrations-repo"; | 11 | export { createCodeMigrationsRepo } from "./code-migrations-repo"; |
| 12 | export { createCredentialsRepo } from "./credentials-repo"; | 12 | export { createAuthArtifactsRepo } from "./auth-artifacts-repo"; |
| 13 | export { createAuthLeasesRepo } from "./auth-leases-repo"; | ||
| 13 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; | 14 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; |
| 14 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; | 15 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 15 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; | 16 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| @@ -13,7 +13,7 @@ import type { DrizzleTables } from "../schema"; · 12 unmodified lines | |||
| 13 | import { | 13 | import { |
| 14 | cleanupOrphanedSourceRecipes, | 14 | cleanupOrphanedSourceRecipes, |
| 15 | firstOption, | 15 | firstOption, |
| 16 | postgresSecretHandlesFromCredentials, | 16 | postgresSecretHandlesFromAuthArtifacts, |
| 17 | withoutCreatedAt, | 17 | withoutCreatedAt, |
| 18 | } from "./shared"; | 18 | } from "./shared"; |
| 19 | 19 | ||
| @@ -144,20 +144,18 @@ export const createOrganizationsRepo = ( · 124 unmodified lines | |||
| 144 | }) | 144 | }) |
| 145 | .from(tables.sourcesTable) | 145 | .from(tables.sourcesTable) |
| 146 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | 146 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| 147 | const credentials = await tx | 147 | const authArtifacts = await tx |
| 148 | .select({ | 148 | .select({ |
| 149 | tokenProviderId: tables.credentialsTable.tokenProviderId, | 149 | artifactKind: tables.authArtifactsTable.artifactKind, |
| 150 | tokenHandle: tables.credentialsTable.tokenHandle, | 150 | configJson: tables.authArtifactsTable.configJson, |
| 151 | refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | ||
| 152 | refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | ||
| 153 | }) | 151 | }) |
| 154 | .from(tables.credentialsTable) | 152 | .from(tables.authArtifactsTable) |
| 155 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | 153 | .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds)); |
| 156 | 154 | ||
| 157 | const executionIds = executionRows.map((execution) => execution.id); | 155 | const executionIds = executionRows.map((execution) => execution.id); |
| 158 | const recipeIds = sourceRows.map((source) => source.recipeId); | 156 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 159 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); | 157 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); |
| 160 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | 158 | const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts); |
| 161 | 159 | ||
| 162 | if (executionIds.length > 0) { | 160 | if (executionIds.length > 0) { |
| 163 | await tx | 161 | await tx |
| @@ -171,6 +169,10 @@ export const createOrganizationsRepo = ( · 7 unmodified lines | |||
| 171 | .delete(tables.executionsTable) | 169 | .delete(tables.executionsTable) |
| 172 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); | 170 | .where(inArray(tables.executionsTable.workspaceId, workspaceIds)); |
| 173 | 171 | ||
| 172 | await tx | ||
| 173 | .delete(tables.authLeasesTable) | ||
| 174 | .where(inArray(tables.authLeasesTable.workspaceId, workspaceIds)); | ||
| 175 | |||
| 174 | await tx | 176 | await tx |
| 175 | .delete(tables.sourceAuthSessionsTable) | 177 | .delete(tables.sourceAuthSessionsTable) |
| 176 | .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds)); | 178 | .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds)); |
| @@ -180,8 +182,8 @@ export const createOrganizationsRepo = ( · 3 unmodified lines | |||
| 180 | .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds)); | 182 | .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds)); |
| 181 | 183 | ||
| 182 | await tx | 184 | await tx |
| 183 | .delete(tables.credentialsTable) | 185 | .delete(tables.authArtifactsTable) |
| 184 | .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | 186 | .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds)); |
| 185 | 187 | ||
| 186 | await tx | 188 | await tx |
| 187 | .delete(tables.sourcesTable) | 189 | .delete(tables.sourcesTable) |
| @@ -1,10 +1,11 @@ | |||
| 1 | import { | 1 | import { |
| 2 | authArtifactSecretRefs, | ||
| 2 | type SecretMaterial, | 3 | type SecretMaterial, |
| 3 | SecretMaterialSchema, | 4 | SecretMaterialSchema, |
| 4 | } from "#schema"; | 5 | } from "#schema"; |
| 5 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 6 | import { Schema } from "effect"; | 7 | import { Schema } from "effect"; |
| 7 | import { eq, desc, or, and } from "drizzle-orm"; | 8 | import { eq, desc, and } from "drizzle-orm"; |
| 8 | 9 | ||
| 9 | import type { DrizzleClient } from "../client"; | 10 | import type { DrizzleClient } from "../client"; |
| 10 | import type { DrizzleTables } from "../schema"; | 11 | import type { DrizzleTables } from "../schema"; |
| @@ -92,34 +93,24 @@ export const createSecretMaterialsRepo = ( · 81 unmodified lines | |||
| 92 | }), | 93 | }), |
| 93 | 94 | ||
| 94 | /** | 95 | /** |
| 95 | * For each secret, find the sources that reference it via credentials. | 96 | * For each secret, find the sources that reference it via auth artifacts. |
| 96 | * Returns a map of secretId -> Array<{ sourceId, sourceName }>. | 97 | * Returns a map of secretId -> Array<{ sourceId, sourceName }>. |
| 97 | */ | 98 | */ |
| 98 | listLinkedSources: () => | 99 | listLinkedSources: () => |
| 99 | client.use("rows.secret_materials.list_linked_sources", async (db) => { | 100 | client.use("rows.secret_materials.list_linked_sources", async (db) => { |
| 100 | // Find all credentials that reference any postgres-stored secret | ||
| 101 | // and join through bindings to sources. | ||
| 102 | const rows = await db | 101 | const rows = await db |
| 103 | .select({ | 102 | .select({ |
| 104 | tokenHandle: tables.credentialsTable.tokenHandle, | 103 | artifactKind: tables.authArtifactsTable.artifactKind, |
| 105 | tokenProviderId: tables.credentialsTable.tokenProviderId, | 104 | configJson: tables.authArtifactsTable.configJson, |
| 106 | refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | 105 | sourceId: tables.authArtifactsTable.sourceId, |
| 107 | refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | ||
| 108 | sourceId: tables.credentialsTable.sourceId, | ||
| 109 | sourceName: tables.sourcesTable.name, | 106 | sourceName: tables.sourcesTable.name, |
| 110 | }) | 107 | }) |
| 111 | .from(tables.credentialsTable) | 108 | .from(tables.authArtifactsTable) |
| 112 | .innerJoin( | 109 | .innerJoin( |
| 113 | tables.sourcesTable, | 110 | tables.sourcesTable, |
| 114 | and( | 111 | and( |
| 115 | eq(tables.credentialsTable.workspaceId, tables.sourcesTable.workspaceId), | 112 | eq(tables.authArtifactsTable.workspaceId, tables.sourcesTable.workspaceId), |
| 116 | eq(tables.credentialsTable.sourceId, tables.sourcesTable.sourceId), | 113 | eq(tables.authArtifactsTable.sourceId, tables.sourcesTable.sourceId), |
| 117 | ), | ||
| 118 | ) | ||
| 119 | .where( | ||
| 120 | or( | ||
| 121 | eq(tables.credentialsTable.tokenProviderId, "postgres"), | ||
| 122 | eq(tables.credentialsTable.refreshTokenProviderId, "postgres"), | ||
| 123 | ), | 114 | ), |
| 124 | ); | 115 | ); |
| 125 | 116 | ||
| @@ -138,11 +129,13 @@ export const createSecretMaterialsRepo = ( · 12 unmodified lines | |||
| 138 | } | 129 | } |
| 139 | }; | 130 | }; |
| 140 | 131 | ||
| 141 | if (row.tokenProviderId === "postgres") { | 132 | for (const ref of authArtifactSecretRefs({ |
| 142 | addLink(row.tokenHandle); | 133 | artifactKind: row.artifactKind, |
| 143 | } | 134 | configJson: row.configJson, |
| 144 | if (row.refreshTokenProviderId === "postgres" && row.refreshTokenHandle) { | 135 | })) { |
| 145 | addLink(row.refreshTokenHandle); | 136 | if (ref.providerId === "postgres") { |
| 137 | addLink(ref.handle); | ||
| 138 | } | ||
| 146 | } | 139 | } |
| 147 | } | 140 | } |
| 148 | 141 | ||
| @@ -1,3 +1,4 @@ | |||
| 1 | import { authArtifactSecretRefs, type AuthArtifact } from "#schema"; | ||
| 1 | import { inArray } from "drizzle-orm"; | 2 | import { inArray } from "drizzle-orm"; |
| 2 | import * as Option from "effect/Option"; | 3 | import * as Option from "effect/Option"; |
| 3 | 4 | ||
| @@ -16,26 +17,16 @@ export const withoutCreatedAt = <A extends { createdAt: unknown }>( · 12 unmodified lines | |||
| 16 | 17 | ||
| 17 | const POSTGRES_SECRET_PROVIDER_ID = "postgres"; | 18 | const POSTGRES_SECRET_PROVIDER_ID = "postgres"; |
| 18 | 19 | ||
| 19 | export const postgresSecretHandlesFromCredentials = ( | 20 | export const postgresSecretHandlesFromAuthArtifacts = ( |
| 20 | credentials: ReadonlyArray<{ | 21 | artifacts: ReadonlyArray<Pick<AuthArtifact, "artifactKind" | "configJson">>, |
| 21 | tokenProviderId: string; | ||
| 22 | tokenHandle: string; | ||
| 23 | refreshTokenProviderId: string | null; | ||
| 24 | refreshTokenHandle: string | null; | ||
| 25 | }>, | ||
| 26 | ): ReadonlyArray<string> => { | 22 | ): ReadonlyArray<string> => { |
| 27 | const handles = new Set<string>(); | 23 | const handles = new Set<string>(); |
| 28 | 24 | ||
| 29 | for (const credential of credentials) { | 25 | for (const artifact of artifacts) { |
| 30 | if (credential.tokenProviderId === POSTGRES_SECRET_PROVIDER_ID) { | 26 | for (const ref of authArtifactSecretRefs(artifact)) { |
| 31 | handles.add(credential.tokenHandle); | 27 | if (ref.providerId === POSTGRES_SECRET_PROVIDER_ID) { |
| 32 | } | 28 | handles.add(ref.handle); |
| 33 | 29 | } | |
| 34 | if ( | ||
| 35 | credential.refreshTokenProviderId === POSTGRES_SECRET_PROVIDER_ID | ||
| 36 | && credential.refreshTokenHandle !== null | ||
| 37 | ) { | ||
| 38 | handles.add(credential.refreshTokenHandle); | ||
| 39 | } | 30 | } |
| 40 | } | 31 | } |
| 41 | 32 | ||
| @@ -8,7 +8,7 @@ import type { DrizzleTables } from "../schema"; · 7 unmodified lines | |||
| 8 | import { | 8 | import { |
| 9 | cleanupOrphanedSourceRecipes, | 9 | cleanupOrphanedSourceRecipes, |
| 10 | firstOption, | 10 | firstOption, |
| 11 | postgresSecretHandlesFromCredentials, | 11 | postgresSecretHandlesFromAuthArtifacts, |
| 12 | } from "./shared"; | 12 | } from "./shared"; |
| 13 | 13 | ||
| 14 | const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); | 14 | const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); |
| @@ -83,16 +83,14 @@ export const createWorkspacesRepo = ( · 68 unmodified lines | |||
| 83 | const executionIds = executionRows.map((execution) => execution.id); | 83 | const executionIds = executionRows.map((execution) => execution.id); |
| 84 | const recipeIds = sourceRows.map((source) => source.recipeId); | 84 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 85 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); | 85 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); |
| 86 | const credentials = await tx | 86 | const authArtifacts = await tx |
| 87 | .select({ | 87 | .select({ |
| 88 | tokenProviderId: tables.credentialsTable.tokenProviderId, | 88 | artifactKind: tables.authArtifactsTable.artifactKind, |
| 89 | tokenHandle: tables.credentialsTable.tokenHandle, | 89 | configJson: tables.authArtifactsTable.configJson, |
| 90 | refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | ||
| 91 | refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | ||
| 92 | }) | 90 | }) |
| 93 | .from(tables.credentialsTable) | 91 | .from(tables.authArtifactsTable) |
| 94 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | 92 | .where(eq(tables.authArtifactsTable.workspaceId, workspaceId)); |
| 95 | const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | 93 | const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts); |
| 96 | 94 | ||
| 97 | if (executionIds.length > 0) { | 95 | if (executionIds.length > 0) { |
| 98 | await tx | 96 | await tx |
| @@ -104,6 +102,10 @@ export const createWorkspacesRepo = ( · 5 unmodified lines | |||
| 104 | .delete(tables.executionsTable) | 102 | .delete(tables.executionsTable) |
| 105 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); | 103 | .where(eq(tables.executionsTable.workspaceId, workspaceId)); |
| 106 | 104 | ||
| 105 | await tx | ||
| 106 | .delete(tables.authLeasesTable) | ||
| 107 | .where(eq(tables.authLeasesTable.workspaceId, workspaceId)); | ||
| 108 | |||
| 107 | await tx | 109 | await tx |
| 108 | .delete(tables.sourceAuthSessionsTable) | 110 | .delete(tables.sourceAuthSessionsTable) |
| 109 | .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId)); | 111 | .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId)); |
| @@ -113,8 +115,8 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | |||
| 113 | .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId)); | 115 | .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId)); |
| 114 | 116 | ||
| 115 | await tx | 117 | await tx |
| 116 | .delete(tables.credentialsTable) | 118 | .delete(tables.authArtifactsTable) |
| 117 | .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | 119 | .where(eq(tables.authArtifactsTable.workspaceId, workspaceId)); |
| 118 | 120 | ||
| 119 | await tx | 121 | await tx |
| 120 | .delete(tables.sourcesTable) | 122 | .delete(tables.sourcesTable) |
| @@ -22,7 +22,8 @@ export const tableNames = { · 21 unmodified lines | |||
| 22 | sourceRecipeSchemaBundles: "source_recipe_schema_bundles", | 22 | sourceRecipeSchemaBundles: "source_recipe_schema_bundles", |
| 23 | sourceRecipeOperations: "source_recipe_operations", | 23 | sourceRecipeOperations: "source_recipe_operations", |
| 24 | codeMigrations: "control_plane_code_migrations", | 24 | codeMigrations: "control_plane_code_migrations", |
| 25 | credentials: "workspace_source_credentials", | 25 | authArtifacts: "workspace_source_auth_artifacts", |
| 26 | authLeases: "workspace_source_auth_leases", | ||
| 26 | workspaceSourceOauthClients: "workspace_source_oauth_clients", | 27 | workspaceSourceOauthClients: "workspace_source_oauth_clients", |
| 27 | secretMaterials: "secret_materials", | 28 | secretMaterials: "secret_materials", |
| 28 | sourceAuthSessions: "source_auth_sessions", | 29 | sourceAuthSessions: "source_auth_sessions", |
| @@ -351,48 +352,72 @@ export const codeMigrationsTable = pgTable( · 322 unmodified lines | |||
| 351 | }, | 352 | }, |
| 352 | ); | 353 | ); |
| 353 | 354 | ||
| 354 | export const credentialsTable = pgTable( | 355 | export const authArtifactsTable = pgTable( |
| 355 | tableNames.credentials, | 356 | tableNames.authArtifacts, |
| 356 | { | 357 | { |
| 357 | id: text("id").notNull().primaryKey(), | 358 | id: text("id").notNull().primaryKey(), |
| 358 | workspaceId: text("workspace_id").notNull(), | 359 | workspaceId: text("workspace_id").notNull(), |
| 359 | sourceId: text("source_id").notNull(), | 360 | sourceId: text("source_id").notNull(), |
| 360 | actorAccountId: text("actor_account_id"), | 361 | actorAccountId: text("actor_account_id"), |
| 361 | slot: text("slot").notNull(), | 362 | slot: text("slot").notNull(), |
| 362 | authKind: text("auth_kind").notNull(), | 363 | artifactKind: text("artifact_kind").notNull(), |
| 363 | authHeaderName: text("auth_header_name").notNull(), | 364 | configJson: text("config_json").notNull(), |
| 364 | authPrefix: text("auth_prefix").notNull(), | 365 | grantSetJson: text("grant_set_json"), |
| 365 | tokenProviderId: text("token_provider_id").notNull(), | ||
| 366 | tokenHandle: text("token_handle").notNull(), | ||
| 367 | refreshTokenProviderId: text("refresh_token_provider_id"), | ||
| 368 | refreshTokenHandle: text("refresh_token_handle"), | ||
| 369 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | 366 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 370 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | 367 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 371 | }, | 368 | }, |
| 372 | (table) => [ | 369 | (table) => [ |
| 373 | index("credentials_workspace_idx").on( | 370 | index("auth_artifacts_workspace_idx").on( |
| 374 | table.workspaceId, | 371 | table.workspaceId, |
| 375 | table.updatedAt, | 372 | table.updatedAt, |
| 376 | table.id, | 373 | table.id, |
| 377 | ), | 374 | ), |
| 378 | uniqueIndex("credentials_workspace_source_actor_idx").on( | 375 | uniqueIndex("auth_artifacts_workspace_source_actor_idx").on( |
| 379 | table.workspaceId, | 376 | table.workspaceId, |
| 380 | table.sourceId, | 377 | table.sourceId, |
| 381 | table.actorAccountId, | 378 | table.actorAccountId, |
| 382 | table.slot, | 379 | table.slot, |
| 383 | ), | 380 | ), |
| 384 | index("credentials_workspace_source_idx").on( | 381 | index("auth_artifacts_workspace_source_idx").on( |
| 385 | table.workspaceId, | 382 | table.workspaceId, |
| 386 | table.sourceId, | 383 | table.sourceId, |
| 387 | table.updatedAt, | 384 | table.updatedAt, |
| 388 | table.id, | 385 | table.id, |
| 389 | ), | 386 | ), |
| 390 | check( | 387 | check( |
| 391 | "credentials_auth_kind_check", | 388 | "auth_artifacts_slot_check", |
| 392 | sql`${table.authKind} in ('bearer', 'oauth2')`, | 389 | sql`${table.slot} in ('runtime', 'import')`, |
| 390 | ), | ||
| 391 | ], | ||
| 392 | ); | ||
| 393 | |||
| 394 | export const authLeasesTable = pgTable( | ||
| 395 | tableNames.authLeases, | ||
| 396 | { | ||
| 397 | id: text("id").notNull().primaryKey(), | ||
| 398 | authArtifactId: text("auth_artifact_id").notNull(), | ||
| 399 | workspaceId: text("workspace_id").notNull(), | ||
| 400 | sourceId: text("source_id").notNull(), | ||
| 401 | actorAccountId: text("actor_account_id"), | ||
| 402 | slot: text("slot").notNull(), | ||
| 403 | placementsTemplateJson: text("placements_template_json").notNull(), | ||
| 404 | expiresAt: bigint("expires_at", { mode: "number" }), | ||
| 405 | refreshAfter: bigint("refresh_after", { mode: "number" }), | ||
| 406 | createdAt: bigint("created_at", { mode: "number" }).notNull(), | ||
| 407 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), | ||
| 408 | }, | ||
| 409 | (table) => [ | ||
| 410 | uniqueIndex("auth_leases_auth_artifact_idx").on(table.authArtifactId), | ||
| 411 | index("auth_leases_workspace_source_idx").on( | ||
| 412 | table.workspaceId, | ||
| 413 | table.sourceId, | ||
| 414 | table.actorAccountId, | ||
| 415 | table.slot, | ||
| 416 | table.updatedAt, | ||
| 417 | table.id, | ||
| 393 | ), | 418 | ), |
| 394 | check( | 419 | check( |
| 395 | "credentials_slot_check", | 420 | "auth_leases_slot_check", |
| 396 | sql`${table.slot} in ('runtime', 'import')`, | 421 | sql`${table.slot} in ('runtime', 'import')`, |
| 397 | ), | 422 | ), |
| 398 | ], | 423 | ], |
| @@ -638,7 +663,8 @@ export const drizzleSchema = { · 239 unmodified lines | |||
| 638 | sourceRecipeSchemaBundlesTable, | 663 | sourceRecipeSchemaBundlesTable, |
| 639 | sourceRecipeOperationsTable, | 664 | sourceRecipeOperationsTable, |
| 640 | codeMigrationsTable, | 665 | codeMigrationsTable, |
| 641 | credentialsTable, | 666 | authArtifactsTable, |
| 667 | authLeasesTable, | ||
| 642 | workspaceSourceOauthClientsTable, | 668 | workspaceSourceOauthClientsTable, |
| 643 | secretMaterialsTable, | 669 | secretMaterialsTable, |
| 644 | sourceAuthSessionsTable, | 670 | sourceAuthSessionsTable, |
| @@ -0,0 +1,403 @@ | |||
| 1 | import { | ||
| 2 | type AccountId, | ||
| 3 | AuthGrantSetJsonSchema, | ||
| 4 | RequestPlacementTemplatesJsonSchema, | ||
| 5 | RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema, | ||
| 6 | RefreshableOAuth2AuthorizedUserAuthArtifactKind, | ||
| 7 | StaticBearerAuthArtifactConfigJsonSchema, | ||
| 8 | StaticBearerAuthArtifactKind, | ||
| 9 | StaticOAuth2AuthArtifactConfigJsonSchema, | ||
| 10 | StaticOAuth2AuthArtifactKind, | ||
| 11 | StaticPlacementsAuthArtifactConfigJsonSchema, | ||
| 12 | StaticPlacementsAuthArtifactKind, | ||
| 13 | authArtifactSecretRefs, | ||
| 14 | decodeAuthGrantSet, | ||
| 15 | decodeBuiltInAuthArtifactConfig, | ||
| 16 | type AuthArtifact, | ||
| 17 | type CredentialSlot, | ||
| 18 | type RequestPlacement, | ||
| 19 | type RequestPlacementPart, | ||
| 20 | type SecretRef, | ||
| 21 | type Source, | ||
| 22 | type SourceAuth, | ||
| 23 | } from "#schema"; | ||
| 24 | import * as Effect from "effect/Effect"; | ||
| 25 | import * as Schema from "effect/Schema"; | ||
| 26 | |||
| 27 | import type { | ||
| 28 | ResolveSecretMaterial, | ||
| 29 | SecretMaterialResolveContext, | ||
| 30 | } from "./secret-material-providers"; | ||
| 31 | |||
| 32 | export type ResolvedSourceAuthMaterial = { | ||
| 33 | placements: ReadonlyArray<RequestPlacement>; | ||
| 34 | headers: Readonly<Record<string, string>>; | ||
| 35 | queryParams: Readonly<Record<string, string>>; | ||
| 36 | cookies: Readonly<Record<string, string>>; | ||
| 37 | bodyValues: Readonly<Record<string, string>>; | ||
| 38 | expiresAt: number | null; | ||
| 39 | refreshAfter: number | null; | ||
| 40 | }; | ||
| 41 | |||
| 42 | const emptyResolvedAuthMaterial: ResolvedSourceAuthMaterial = { | ||
| 43 | placements: [], | ||
| 44 | headers: {}, | ||
| 45 | queryParams: {}, | ||
| 46 | cookies: {}, | ||
| 47 | bodyValues: {}, | ||
| 48 | expiresAt: null, | ||
| 49 | refreshAfter: null, | ||
| 50 | }; | ||
| 51 | |||
| 52 | const encodeStaticBearerAuthArtifactConfig = Schema.encodeSync( | ||
| 53 | StaticBearerAuthArtifactConfigJsonSchema, | ||
| 54 | ); | ||
| 55 | |||
| 56 | const encodeStaticOAuth2AuthArtifactConfig = Schema.encodeSync( | ||
| 57 | StaticOAuth2AuthArtifactConfigJsonSchema, | ||
| 58 | ); | ||
| 59 | |||
| 60 | const encodeStaticPlacementsAuthArtifactConfig = Schema.encodeSync( | ||
| 61 | StaticPlacementsAuthArtifactConfigJsonSchema, | ||
| 62 | ); | ||
| 63 | const encodeRefreshableOAuth2AuthorizedUserAuthArtifactConfig = Schema.encodeSync( | ||
| 64 | RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema, | ||
| 65 | ); | ||
| 66 | |||
| 67 | const decodeRequestPlacementTemplates = Schema.decodeUnknownEither( | ||
| 68 | RequestPlacementTemplatesJsonSchema, | ||
| 69 | ); | ||
| 70 | const encodeAuthGrantSet = Schema.encodeSync(AuthGrantSetJsonSchema); | ||
| 71 | |||
| 72 | const mergePlacement = (acc: { | ||
| 73 | headers: Record<string, string>; | ||
| 74 | queryParams: Record<string, string>; | ||
| 75 | cookies: Record<string, string>; | ||
| 76 | bodyValues: Record<string, string>; | ||
| 77 | }, placement: RequestPlacement) => { | ||
| 78 | switch (placement.location) { | ||
| 79 | case "header": | ||
| 80 | acc.headers[placement.name] = placement.value; | ||
| 81 | break; | ||
| 82 | case "query": | ||
| 83 | acc.queryParams[placement.name] = placement.value; | ||
| 84 | break; | ||
| 85 | case "cookie": | ||
| 86 | acc.cookies[placement.name] = placement.value; | ||
| 87 | break; | ||
| 88 | case "body": | ||
| 89 | acc.bodyValues[placement.path] = placement.value; | ||
| 90 | break; | ||
| 91 | } | ||
| 92 | }; | ||
| 93 | |||
| 94 | const summarizeResolvedPlacements = (placements: ReadonlyArray<RequestPlacement>, input: { | ||
| 95 | expiresAt?: number | null; | ||
| 96 | refreshAfter?: number | null; | ||
| 97 | } = {}): ResolvedSourceAuthMaterial => { | ||
| 98 | const acc = { | ||
| 99 | headers: {} as Record<string, string>, | ||
| 100 | queryParams: {} as Record<string, string>, | ||
| 101 | cookies: {} as Record<string, string>, | ||
| 102 | bodyValues: {} as Record<string, string>, | ||
| 103 | }; | ||
| 104 | |||
| 105 | for (const placement of placements) { | ||
| 106 | mergePlacement(acc, placement); | ||
| 107 | } | ||
| 108 | |||
| 109 | return { | ||
| 110 | placements, | ||
| 111 | headers: acc.headers, | ||
| 112 | queryParams: acc.queryParams, | ||
| 113 | cookies: acc.cookies, | ||
| 114 | bodyValues: acc.bodyValues, | ||
| 115 | expiresAt: input.expiresAt ?? null, | ||
| 116 | refreshAfter: input.refreshAfter ?? null, | ||
| 117 | }; | ||
| 118 | }; | ||
| 119 | |||
| 120 | const resolvePlacementParts = (input: { | ||
| 121 | parts: ReadonlyArray<RequestPlacementPart>; | ||
| 122 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 123 | context?: SecretMaterialResolveContext; | ||
| 124 | }): Effect.Effect<string, Error, never> => | ||
| 125 | Effect.map( | ||
| 126 | Effect.forEach(input.parts, (part) => | ||
| 127 | part.kind === "literal" | ||
| 128 | ? Effect.succeed(part.value) | ||
| 129 | : input.resolveSecretMaterial({ | ||
| 130 | ref: part.ref, | ||
| 131 | context: input.context, | ||
| 132 | }), { discard: false }), | ||
| 133 | (values) => values.join(""), | ||
| 134 | ); | ||
| 135 | |||
| 136 | export const authArtifactFromSourceAuth = (input: { | ||
| 137 | source: Source; | ||
| 138 | auth: SourceAuth; | ||
| 139 | slot: CredentialSlot; | ||
| 140 | actorAccountId?: AccountId | null; | ||
| 141 | existingAuthArtifactId?: AuthArtifact["id"] | null; | ||
| 142 | }): AuthArtifact | null => { | ||
| 143 | if (input.auth.kind === "none") { | ||
| 144 | return null; | ||
| 145 | } | ||
| 146 | |||
| 147 | const id = input.existingAuthArtifactId ?? `auth_art_${crypto.randomUUID()}` as AuthArtifact["id"]; | ||
| 148 | const grantSetJson = | ||
| 149 | input.auth.kind === "oauth2_authorized_user" | ||
| 150 | ? encodeAuthGrantSetJson(input.auth.grantSet) | ||
| 151 | : null; | ||
| 152 | |||
| 153 | if (input.auth.kind === "bearer") { | ||
| 154 | return { | ||
| 155 | id, | ||
| 156 | workspaceId: input.source.workspaceId, | ||
| 157 | sourceId: input.source.id, | ||
| 158 | actorAccountId: input.actorAccountId ?? null, | ||
| 159 | slot: input.slot, | ||
| 160 | artifactKind: StaticBearerAuthArtifactKind, | ||
| 161 | configJson: encodeStaticBearerAuthArtifactConfig({ | ||
| 162 | headerName: input.auth.headerName, | ||
| 163 | prefix: input.auth.prefix, | ||
| 164 | token: input.auth.token, | ||
| 165 | }), | ||
| 166 | grantSetJson, | ||
| 167 | createdAt: input.source.createdAt, | ||
| 168 | updatedAt: input.source.updatedAt, | ||
| 169 | }; | ||
| 170 | } | ||
| 171 | |||
| 172 | if (input.auth.kind === "oauth2_authorized_user") { | ||
| 173 | return { | ||
| 174 | id, | ||
| 175 | workspaceId: input.source.workspaceId, | ||
| 176 | sourceId: input.source.id, | ||
| 177 | actorAccountId: input.actorAccountId ?? null, | ||
| 178 | slot: input.slot, | ||
| 179 | artifactKind: RefreshableOAuth2AuthorizedUserAuthArtifactKind, | ||
| 180 | configJson: encodeRefreshableOAuth2AuthorizedUserAuthArtifactConfig({ | ||
| 181 | headerName: input.auth.headerName, | ||
| 182 | prefix: input.auth.prefix, | ||
| 183 | tokenEndpoint: input.auth.tokenEndpoint, | ||
| 184 | clientId: input.auth.clientId, | ||
| 185 | clientAuthentication: input.auth.clientAuthentication, | ||
| 186 | clientSecret: input.auth.clientSecret, | ||
| 187 | refreshToken: input.auth.refreshToken, | ||
| 188 | }), | ||
| 189 | grantSetJson, | ||
| 190 | createdAt: input.source.createdAt, | ||
| 191 | updatedAt: input.source.updatedAt, | ||
| 192 | }; | ||
| 193 | } | ||
| 194 | |||
| 195 | return { | ||
| 196 | id, | ||
| 197 | workspaceId: input.source.workspaceId, | ||
| 198 | sourceId: input.source.id, | ||
| 199 | actorAccountId: input.actorAccountId ?? null, | ||
| 200 | slot: input.slot, | ||
| 201 | artifactKind: StaticOAuth2AuthArtifactKind, | ||
| 202 | configJson: encodeStaticOAuth2AuthArtifactConfig({ | ||
| 203 | headerName: input.auth.headerName, | ||
| 204 | prefix: input.auth.prefix, | ||
| 205 | accessToken: input.auth.accessToken, | ||
| 206 | refreshToken: input.auth.refreshToken, | ||
| 207 | }), | ||
| 208 | grantSetJson, | ||
| 209 | createdAt: input.source.createdAt, | ||
| 210 | updatedAt: input.source.updatedAt, | ||
| 211 | }; | ||
| 212 | }; | ||
| 213 | |||
| 214 | export const sourceAuthFromAuthArtifact = (artifact: AuthArtifact | null): SourceAuth => { | ||
| 215 | if (artifact === null) { | ||
| 216 | return { kind: "none" }; | ||
| 217 | } | ||
| 218 | |||
| 219 | const decoded = decodeBuiltInAuthArtifactConfig(artifact); | ||
| 220 | if (decoded === null) { | ||
| 221 | return { kind: "none" }; | ||
| 222 | } | ||
| 223 | |||
| 224 | switch (decoded.artifactKind) { | ||
| 225 | case StaticBearerAuthArtifactKind: | ||
| 226 | return { | ||
| 227 | kind: "bearer", | ||
| 228 | headerName: decoded.config.headerName, | ||
| 229 | prefix: decoded.config.prefix, | ||
| 230 | token: decoded.config.token, | ||
| 231 | }; | ||
| 232 | case StaticOAuth2AuthArtifactKind: | ||
| 233 | return { | ||
| 234 | kind: "oauth2", | ||
| 235 | headerName: decoded.config.headerName, | ||
| 236 | prefix: decoded.config.prefix, | ||
| 237 | accessToken: decoded.config.accessToken, | ||
| 238 | refreshToken: decoded.config.refreshToken, | ||
| 239 | }; | ||
| 240 | case StaticPlacementsAuthArtifactKind: | ||
| 241 | return { kind: "none" }; | ||
| 242 | case RefreshableOAuth2AuthorizedUserAuthArtifactKind: | ||
| 243 | return { | ||
| 244 | kind: "oauth2_authorized_user", | ||
| 245 | headerName: decoded.config.headerName, | ||
| 246 | prefix: decoded.config.prefix, | ||
| 247 | tokenEndpoint: decoded.config.tokenEndpoint, | ||
| 248 | clientId: decoded.config.clientId, | ||
| 249 | clientAuthentication: decoded.config.clientAuthentication, | ||
| 250 | clientSecret: decoded.config.clientSecret, | ||
| 251 | refreshToken: decoded.config.refreshToken, | ||
| 252 | grantSet: decodeAuthGrantSet(artifact.grantSetJson), | ||
| 253 | }; | ||
| 254 | } | ||
| 255 | }; | ||
| 256 | |||
| 257 | export const authArtifactGrantSet = (artifact: Pick<AuthArtifact, "grantSetJson">): ReadonlyArray<string> | null => | ||
| 258 | decodeAuthGrantSet(artifact.grantSetJson); | ||
| 259 | |||
| 260 | export const authArtifactSecretMaterialRefs = ( | ||
| 261 | artifact: Pick<AuthArtifact, "artifactKind" | "configJson">, | ||
| 262 | ): ReadonlyArray<SecretRef> => authArtifactSecretRefs(artifact); | ||
| 263 | |||
| 264 | export const encodeStaticPlacementsArtifactConfig = (placements: ReadonlyArray<{ | ||
| 265 | location: "header" | "query" | "cookie" | "body"; | ||
| 266 | name?: string; | ||
| 267 | path?: string; | ||
| 268 | parts: ReadonlyArray<RequestPlacementPart>; | ||
| 269 | }>): string => | ||
| 270 | encodeStaticPlacementsAuthArtifactConfig({ | ||
| 271 | placements: placements.map((placement) => | ||
| 272 | placement.location === "body" | ||
| 273 | ? { | ||
| 274 | location: "body" as const, | ||
| 275 | path: placement.path ?? "", | ||
| 276 | parts: [...placement.parts], | ||
| 277 | } | ||
| 278 | : { | ||
| 279 | location: placement.location, | ||
| 280 | name: placement.name ?? "", | ||
| 281 | parts: [...placement.parts], | ||
| 282 | }), | ||
| 283 | }); | ||
| 284 | |||
| 285 | export const encodeAuthGrantSetJson = (grantSet: ReadonlyArray<string> | null): string | null => | ||
| 286 | grantSet === null ? null : encodeAuthGrantSet([...grantSet]); | ||
| 287 | |||
| 288 | export const resolveAuthArtifactMaterial = (input: { | ||
| 289 | artifact: AuthArtifact | null; | ||
| 290 | lease?: { | ||
| 291 | placementsTemplateJson: string; | ||
| 292 | expiresAt: number | null; | ||
| 293 | refreshAfter: number | null; | ||
| 294 | } | null; | ||
| 295 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 296 | context?: SecretMaterialResolveContext; | ||
| 297 | }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> => | ||
| 298 | Effect.gen(function* () { | ||
| 299 | if (input.artifact === null) { | ||
| 300 | return emptyResolvedAuthMaterial; | ||
| 301 | } | ||
| 302 | |||
| 303 | const decoded = decodeBuiltInAuthArtifactConfig(input.artifact); | ||
| 304 | if (decoded !== null) { | ||
| 305 | switch (decoded.artifactKind) { | ||
| 306 | case StaticBearerAuthArtifactKind: { | ||
| 307 | const token = yield* input.resolveSecretMaterial({ | ||
| 308 | ref: decoded.config.token, | ||
| 309 | context: input.context, | ||
| 310 | }); | ||
| 311 | |||
| 312 | return summarizeResolvedPlacements([ | ||
| 313 | { | ||
| 314 | location: "header", | ||
| 315 | name: decoded.config.headerName, | ||
| 316 | value: `${decoded.config.prefix}${token}`, | ||
| 317 | }, | ||
| 318 | ]); | ||
| 319 | } | ||
| 320 | case StaticOAuth2AuthArtifactKind: { | ||
| 321 | const accessToken = yield* input.resolveSecretMaterial({ | ||
| 322 | ref: decoded.config.accessToken, | ||
| 323 | context: input.context, | ||
| 324 | }); | ||
| 325 | |||
| 326 | return summarizeResolvedPlacements([ | ||
| 327 | { | ||
| 328 | location: "header", | ||
| 329 | name: decoded.config.headerName, | ||
| 330 | value: `${decoded.config.prefix}${accessToken}`, | ||
| 331 | }, | ||
| 332 | ]); | ||
| 333 | } | ||
| 334 | case StaticPlacementsAuthArtifactKind: { | ||
| 335 | const placements = yield* Effect.forEach(decoded.config.placements, (placement) => | ||
| 336 | Effect.map( | ||
| 337 | resolvePlacementParts({ | ||
| 338 | parts: placement.parts, | ||
| 339 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 340 | context: input.context, | ||
| 341 | }), | ||
| 342 | (value): RequestPlacement => | ||
| 343 | placement.location === "body" | ||
| 344 | ? { | ||
| 345 | location: "body", | ||
| 346 | path: placement.path, | ||
| 347 | value, | ||
| 348 | } | ||
| 349 | : { | ||
| 350 | location: placement.location, | ||
| 351 | name: placement.name, | ||
| 352 | value, | ||
| 353 | }, | ||
| 354 | ), { discard: false }); | ||
| 355 | |||
| 356 | return summarizeResolvedPlacements(placements); | ||
| 357 | } | ||
| 358 | case RefreshableOAuth2AuthorizedUserAuthArtifactKind: | ||
| 359 | break; | ||
| 360 | } | ||
| 361 | } | ||
| 362 | |||
| 363 | if (input.lease === null || input.lease === undefined) { | ||
| 364 | return yield* Effect.fail( | ||
| 365 | new Error(`Unsupported auth artifact kind: ${input.artifact.artifactKind}`), | ||
| 366 | ); | ||
| 367 | } | ||
| 368 | |||
| 369 | const templatesEither = decodeRequestPlacementTemplates( | ||
| 370 | input.lease.placementsTemplateJson, | ||
| 371 | ); | ||
| 372 | if (templatesEither._tag === "Left") { | ||
| 373 | return yield* Effect.fail(new Error( | ||
| 374 | `Invalid auth lease placements for artifact ${input.artifact.id}`, | ||
| 375 | )); | ||
| 376 | } | ||
| 377 | |||
| 378 | const placements = yield* Effect.forEach(templatesEither.right, (placement) => | ||
| 379 | Effect.map( | ||
| 380 | resolvePlacementParts({ | ||
| 381 | parts: placement.parts, | ||
| 382 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 383 | context: input.context, | ||
| 384 | }), | ||
| 385 | (value): RequestPlacement => | ||
| 386 | placement.location === "body" | ||
| 387 | ? { | ||
| 388 | location: "body", | ||
| 389 | path: placement.path, | ||
| 390 | value, | ||
| 391 | } | ||
| 392 | : { | ||
| 393 | location: placement.location, | ||
| 394 | name: placement.name, | ||
| 395 | value, | ||
| 396 | }, | ||
| 397 | ), { discard: false }); | ||
| 398 | |||
| 399 | return summarizeResolvedPlacements(placements, { | ||
| 400 | expiresAt: input.lease.expiresAt, | ||
| 401 | refreshAfter: input.lease.refreshAfter, | ||
| 402 | }); | ||
| 403 | }); | ||
| @@ -0,0 +1,305 @@ | |||
| 1 | import type { SqlControlPlaneRows } from "#persistence"; | ||
| 2 | import type { | ||
| 3 | AuthArtifact, | ||
| 4 | AuthLease, | ||
| 5 | RequestPlacementTemplate, | ||
| 6 | SecretRef, | ||
| 7 | } from "#schema"; | ||
| 8 | import { | ||
| 9 | AuthLeaseIdSchema, | ||
| 10 | RefreshableOAuth2AuthorizedUserAuthArtifactKind, | ||
| 11 | authLeaseSecretRefs, | ||
| 12 | decodeBuiltInAuthArtifactConfig, | ||
| 13 | } from "#schema"; | ||
| 14 | import * as Effect from "effect/Effect"; | ||
| 15 | import * as Option from "effect/Option"; | ||
| 16 | |||
| 17 | import { resolveAuthArtifactMaterial, type ResolvedSourceAuthMaterial } from "./auth-artifacts"; | ||
| 18 | import { | ||
| 19 | createDefaultSecretMaterialDeleter, | ||
| 20 | createDefaultSecretMaterialStorer, | ||
| 21 | type ResolveSecretMaterial, | ||
| 22 | type SecretMaterialResolveContext, | ||
| 23 | } from "./secret-material-providers"; | ||
| 24 | import { refreshOAuth2AccessToken } from "./oauth2-pkce"; | ||
| 25 | import type { OAuth2TokenResponse } from "./oauth2-pkce"; | ||
| 26 | |||
| 27 | const LEASE_REFRESH_SKEW_MS = 60_000; | ||
| 28 | |||
| 29 | const encodeLeasePlacementTemplatesJson = (templates: ReadonlyArray<RequestPlacementTemplate>): string => | ||
| 30 | JSON.stringify(templates); | ||
| 31 | |||
| 32 | const secretRefKey = (ref: SecretRef): string => | ||
| 33 | `${ref.providerId}:${ref.handle}`; | ||
| 34 | |||
| 35 | const cleanupAuthLeaseSecretRefs = ( | ||
| 36 | rows: SqlControlPlaneRows, | ||
| 37 | input: { | ||
| 38 | previous: Pick<AuthLease, "placementsTemplateJson"> | null; | ||
| 39 | next: Pick<AuthLease, "placementsTemplateJson"> | null; | ||
| 40 | }, | ||
| 41 | ) => | ||
| 42 | Effect.gen(function* () { | ||
| 43 | if (input.previous === null) { | ||
| 44 | return; | ||
| 45 | } | ||
| 46 | |||
| 47 | const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows }); | ||
| 48 | const nextRefKeys = new Set( | ||
| 49 | (input.next === null ? [] : authLeaseSecretRefs(input.next)).map(secretRefKey), | ||
| 50 | ); | ||
| 51 | const refsToDelete = authLeaseSecretRefs(input.previous).filter( | ||
| 52 | (ref) => !nextRefKeys.has(secretRefKey(ref)), | ||
| 53 | ); | ||
| 54 | |||
| 55 | yield* Effect.forEach( | ||
| 56 | refsToDelete, | ||
| 57 | (ref) => Effect.either(deleteSecretMaterial(ref)), | ||
| 58 | { discard: true }, | ||
| 59 | ); | ||
| 60 | }); | ||
| 61 | |||
| 62 | const leaseIsFresh = (lease: AuthLease | null, now: number): boolean => { | ||
| 63 | if (lease === null) { | ||
| 64 | return false; | ||
| 65 | } | ||
| 66 | |||
| 67 | if (lease.refreshAfter !== null && now >= lease.refreshAfter) { | ||
| 68 | return false; | ||
| 69 | } | ||
| 70 | |||
| 71 | if (lease.expiresAt !== null && now >= lease.expiresAt - LEASE_REFRESH_SKEW_MS) { | ||
| 72 | return false; | ||
| 73 | } | ||
| 74 | |||
| 75 | return true; | ||
| 76 | }; | ||
| 77 | |||
| 78 | const refreshRefreshableOauth2AuthorizedUserArtifact = (input: { | ||
| 79 | rows: SqlControlPlaneRows; | ||
| 80 | artifact: AuthArtifact; | ||
| 81 | lease: AuthLease | null; | ||
| 82 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 83 | context?: SecretMaterialResolveContext; | ||
| 84 | }): Effect.Effect<AuthLease, Error, never> => | ||
| 85 | Effect.gen(function* () { | ||
| 86 | const decoded = decodeBuiltInAuthArtifactConfig(input.artifact); | ||
| 87 | if ( | ||
| 88 | decoded === null | ||
| 89 | || decoded.artifactKind !== RefreshableOAuth2AuthorizedUserAuthArtifactKind | ||
| 90 | ) { | ||
| 91 | return yield* Effect.fail( | ||
| 92 | new Error(`Unsupported auth artifact kind: ${input.artifact.artifactKind}`), | ||
| 93 | ); | ||
| 94 | } | ||
| 95 | |||
| 96 | const refreshToken = yield* input.resolveSecretMaterial({ | ||
| 97 | ref: decoded.config.refreshToken, | ||
| 98 | context: input.context, | ||
| 99 | }); | ||
| 100 | let clientSecret: string | null = null; | ||
| 101 | if (decoded.config.clientSecret) { | ||
| 102 | clientSecret = yield* input.resolveSecretMaterial({ | ||
| 103 | ref: decoded.config.clientSecret, | ||
| 104 | context: input.context, | ||
| 105 | }); | ||
| 106 | } | ||
| 107 | |||
| 108 | const tokenResponse = yield* refreshOAuth2AccessToken({ | ||
| 109 | tokenEndpoint: decoded.config.tokenEndpoint, | ||
| 110 | clientId: decoded.config.clientId, | ||
| 111 | clientAuthentication: decoded.config.clientAuthentication, | ||
| 112 | clientSecret, | ||
| 113 | refreshToken, | ||
| 114 | }); | ||
| 115 | |||
| 116 | const storeSecretMaterial = createDefaultSecretMaterialStorer({ | ||
| 117 | rows: input.rows, | ||
| 118 | }); | ||
| 119 | const accessTokenRef = yield* storeSecretMaterial({ | ||
| 120 | purpose: "oauth_access_token", | ||
| 121 | value: tokenResponse.access_token, | ||
| 122 | name: `${decoded.config.clientId} Access Token`, | ||
| 123 | }); | ||
| 124 | const now = Date.now(); | ||
| 125 | const expiresInMs = | ||
| 126 | typeof tokenResponse.expires_in === "number" && Number.isFinite(tokenResponse.expires_in) | ||
| 127 | ? Math.max(0, tokenResponse.expires_in * 1000) | ||
| 128 | : null; | ||
| 129 | const expiresAt = expiresInMs === null ? null : now + expiresInMs; | ||
| 130 | const refreshAfter = | ||
| 131 | expiresAt === null ? null : Math.max(now, expiresAt - LEASE_REFRESH_SKEW_MS); | ||
| 132 | |||
| 133 | const nextLease: AuthLease = { | ||
| 134 | id: input.lease?.id ?? AuthLeaseIdSchema.make(`auth_lease_${crypto.randomUUID()}`), | ||
| 135 | authArtifactId: input.artifact.id, | ||
| 136 | workspaceId: input.artifact.workspaceId, | ||
| 137 | sourceId: input.artifact.sourceId, | ||
| 138 | actorAccountId: input.artifact.actorAccountId, | ||
| 139 | slot: input.artifact.slot, | ||
| 140 | placementsTemplateJson: encodeLeasePlacementTemplatesJson([ | ||
| 141 | { | ||
| 142 | location: "header", | ||
| 143 | name: decoded.config.headerName, | ||
| 144 | parts: [ | ||
| 145 | { | ||
| 146 | kind: "literal", | ||
| 147 | value: decoded.config.prefix, | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | kind: "secret_ref", | ||
| 151 | ref: accessTokenRef, | ||
| 152 | }, | ||
| 153 | ], | ||
| 154 | }, | ||
| 155 | ]), | ||
| 156 | expiresAt, | ||
| 157 | refreshAfter, | ||
| 158 | createdAt: input.lease?.createdAt ?? now, | ||
| 159 | updatedAt: now, | ||
| 160 | }; | ||
| 161 | |||
| 162 | yield* input.rows.authLeases.upsert(nextLease); | ||
| 163 | yield* cleanupAuthLeaseSecretRefs(input.rows, { | ||
| 164 | previous: input.lease, | ||
| 165 | next: nextLease, | ||
| 166 | }); | ||
| 167 | |||
| 168 | return nextLease; | ||
| 169 | }); | ||
| 170 | |||
| 171 | export const removeAuthLeaseAndSecrets = (rows: SqlControlPlaneRows, input: { | ||
| 172 | authArtifactId: AuthArtifact["id"]; | ||
| 173 | }): Effect.Effect<void, Error, never> => | ||
| 174 | Effect.gen(function* () { | ||
| 175 | const existingLease = yield* rows.authLeases.getByAuthArtifactId(input.authArtifactId); | ||
| 176 | if (Option.isNone(existingLease)) { | ||
| 177 | return; | ||
| 178 | } | ||
| 179 | |||
| 180 | yield* rows.authLeases.removeByAuthArtifactId(input.authArtifactId); | ||
| 181 | yield* cleanupAuthLeaseSecretRefs(rows, { | ||
| 182 | previous: existingLease.value, | ||
| 183 | next: null, | ||
| 184 | }); | ||
| 185 | }); | ||
| 186 | |||
| 187 | export const upsertOauth2AuthorizedUserLeaseFromTokenResponse = (input: { | ||
| 188 | rows: SqlControlPlaneRows; | ||
| 189 | artifact: AuthArtifact; | ||
| 190 | tokenResponse: OAuth2TokenResponse; | ||
| 191 | }): Effect.Effect<void, Error, never> => | ||
| 192 | Effect.gen(function* () { | ||
| 193 | const decoded = decodeBuiltInAuthArtifactConfig(input.artifact); | ||
| 194 | if ( | ||
| 195 | decoded === null | ||
| 196 | || decoded.artifactKind !== RefreshableOAuth2AuthorizedUserAuthArtifactKind | ||
| 197 | ) { | ||
| 198 | return yield* Effect.fail( | ||
| 199 | new Error(`Unsupported auth artifact kind: ${input.artifact.artifactKind}`), | ||
| 200 | ); | ||
| 201 | } | ||
| 202 | |||
| 203 | const existingLeaseOption = yield* input.rows.authLeases.getByAuthArtifactId(input.artifact.id); | ||
| 204 | const existingLease = Option.isSome(existingLeaseOption) ? existingLeaseOption.value : null; | ||
| 205 | const storeSecretMaterial = createDefaultSecretMaterialStorer({ | ||
| 206 | rows: input.rows, | ||
| 207 | }); | ||
| 208 | const accessTokenRef = yield* storeSecretMaterial({ | ||
| 209 | purpose: "oauth_access_token", | ||
| 210 | value: input.tokenResponse.access_token, | ||
| 211 | name: `${decoded.config.clientId} Access Token`, | ||
| 212 | }); | ||
| 213 | const now = Date.now(); | ||
| 214 | const expiresInMs = | ||
| 215 | typeof input.tokenResponse.expires_in === "number" && Number.isFinite(input.tokenResponse.expires_in) | ||
| 216 | ? Math.max(0, input.tokenResponse.expires_in * 1000) | ||
| 217 | : null; | ||
| 218 | const expiresAt = expiresInMs === null ? null : now + expiresInMs; | ||
| 219 | const refreshAfter = | ||
| 220 | expiresAt === null ? null : Math.max(now, expiresAt - LEASE_REFRESH_SKEW_MS); | ||
| 221 | |||
| 222 | const nextLease: AuthLease = { | ||
| 223 | id: existingLease?.id ?? AuthLeaseIdSchema.make(`auth_lease_${crypto.randomUUID()}`), | ||
| 224 | authArtifactId: input.artifact.id, | ||
| 225 | workspaceId: input.artifact.workspaceId, | ||
| 226 | sourceId: input.artifact.sourceId, | ||
| 227 | actorAccountId: input.artifact.actorAccountId, | ||
| 228 | slot: input.artifact.slot, | ||
| 229 | placementsTemplateJson: encodeLeasePlacementTemplatesJson([ | ||
| 230 | { | ||
| 231 | location: "header", | ||
| 232 | name: decoded.config.headerName, | ||
| 233 | parts: [ | ||
| 234 | { | ||
| 235 | kind: "literal", | ||
| 236 | value: decoded.config.prefix, | ||
| 237 | }, | ||
| 238 | { | ||
| 239 | kind: "secret_ref", | ||
| 240 | ref: accessTokenRef, | ||
| 241 | }, | ||
| 242 | ], | ||
| 243 | }, | ||
| 244 | ]), | ||
| 245 | expiresAt, | ||
| 246 | refreshAfter, | ||
| 247 | createdAt: existingLease?.createdAt ?? now, | ||
| 248 | updatedAt: now, | ||
| 249 | }; | ||
| 250 | |||
| 251 | yield* input.rows.authLeases.upsert(nextLease); | ||
| 252 | yield* cleanupAuthLeaseSecretRefs(input.rows, { | ||
| 253 | previous: existingLease, | ||
| 254 | next: nextLease, | ||
| 255 | }); | ||
| 256 | }); | ||
| 257 | |||
| 258 | export const resolveAuthArtifactMaterialWithLeases = (input: { | ||
| 259 | rows: SqlControlPlaneRows; | ||
| 260 | artifact: AuthArtifact | null; | ||
| 261 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 262 | context?: SecretMaterialResolveContext; | ||
| 263 | }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> => | ||
| 264 | Effect.gen(function* () { | ||
| 265 | if (input.artifact === null) { | ||
| 266 | return yield* resolveAuthArtifactMaterial({ | ||
| 267 | artifact: null, | ||
| 268 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 269 | context: input.context, | ||
| 270 | }); | ||
| 271 | } | ||
| 272 | |||
| 273 | const existingLeaseOption = yield* input.rows.authLeases.getByAuthArtifactId(input.artifact.id); | ||
| 274 | const existingLease = Option.isSome(existingLeaseOption) ? existingLeaseOption.value : null; | ||
| 275 | const decoded = decodeBuiltInAuthArtifactConfig(input.artifact); | ||
| 276 | |||
| 277 | if ( | ||
| 278 | decoded !== null | ||
| 279 | && decoded.artifactKind === RefreshableOAuth2AuthorizedUserAuthArtifactKind | ||
| 280 | ) { | ||
| 281 | const lease = leaseIsFresh(existingLease, Date.now()) | ||
| 282 | ? existingLease | ||
| 283 | : yield* refreshRefreshableOauth2AuthorizedUserArtifact({ | ||
| 284 | rows: input.rows, | ||
| 285 | artifact: input.artifact, | ||
| 286 | lease: existingLease, | ||
| 287 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 288 | context: input.context, | ||
| 289 | }); | ||
| 290 | |||
| 291 | return yield* resolveAuthArtifactMaterial({ | ||
| 292 | artifact: input.artifact, | ||
| 293 | lease, | ||
| 294 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 295 | context: input.context, | ||
| 296 | }); | ||
| 297 | } | ||
| 298 | |||
| 299 | return yield* resolveAuthArtifactMaterial({ | ||
| 300 | artifact: input.artifact, | ||
| 301 | lease: existingLease, | ||
| 302 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 303 | context: input.context, | ||
| 304 | }); | ||
| 305 | }); | ||
| @@ -106,11 +106,6 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 105 unmodified lines | |||
| 106 | return trimmed.length > 0 ? trimmed : null; | 106 | return trimmed.length > 0 ? trimmed : null; |
| 107 | }; | 107 | }; |
| 108 | 108 | ||
| 109 | const resolveOpenApiSourceLabel = (input: { | ||
| 110 | name?: string | null; | ||
| 111 | endpoint: string; | ||
| 112 | }): string => trimOrNull(input.name) ?? input.endpoint; | ||
| 113 | |||
| 114 | const resolveLocalCredentialUrl = (input: { | 109 | const resolveLocalCredentialUrl = (input: { |
| 115 | baseUrl: string; | 110 | baseUrl: string; |
| 116 | workspaceId: WorkspaceId; | 111 | workspaceId: WorkspaceId; |
| @@ -127,9 +122,12 @@ const promptForSourceCredentialSelection = (input: { · 10 unmodified lines | |||
| 127 | args: { | 122 | args: { |
| 128 | workspaceId: WorkspaceId; | 123 | workspaceId: WorkspaceId; |
| 129 | sourceId: Source["id"]; | 124 | sourceId: Source["id"]; |
| 130 | kind: "openapi" | "graphql"; | 125 | kind: "openapi" | "graphql" | "google_discovery"; |
| 131 | endpoint: string; | 126 | endpoint?: string; |
| 132 | specUrl?: string; | 127 | specUrl?: string; |
| 128 | service?: string; | ||
| 129 | version?: string; | ||
| 130 | discoveryUrl?: string | null; | ||
| 133 | name?: string | null; | 131 | name?: string | null; |
| 134 | namespace?: string | null; | 132 | namespace?: string | null; |
| 135 | }; | 133 | }; |
| @@ -233,6 +231,14 @@ export const createExecutorToolMap = (input: { · 97 unmodified lines | |||
| 233 | endpoint: string; | 231 | endpoint: string; |
| 234 | name?: string | null; | 232 | name?: string | null; |
| 235 | namespace?: string | null; | 233 | namespace?: string | null; |
| 234 | } | ||
| 235 | | { | ||
| 236 | kind: "google_discovery"; | ||
| 237 | service: string; | ||
| 238 | version: string; | ||
| 239 | discoveryUrl?: string | null; | ||
| 240 | name?: string | null; | ||
| 241 | namespace?: string | null; | ||
| 236 | }, | 242 | }, |
| 237 | context, | 243 | context, |
| 238 | ): Promise<Source> => { | 244 | ): Promise<Source> => { |
| @@ -241,27 +247,19 @@ export const createExecutorToolMap = (input: { · 2 unmodified lines | |||
| 241 | `executor.sources.add:${crypto.randomUUID()}`, | 247 | `executor.sources.add:${crypto.randomUUID()}`, |
| 242 | ); | 248 | ); |
| 243 | const preparedArgs: ExecutorAddSourceInput = | 249 | const preparedArgs: ExecutorAddSourceInput = |
| 244 | args.kind === "openapi" | 250 | args.kind === undefined || args.kind === "mcp" |
| 245 | ? { | 251 | ? { |
| 246 | ...args, | 252 | kind: args.kind, |
| 253 | endpoint: args.endpoint, | ||
| 254 | name: args.name ?? null, | ||
| 255 | namespace: args.namespace ?? null, | ||
| 247 | workspaceId: input.workspaceId, | 256 | workspaceId: input.workspaceId, |
| 248 | actorAccountId: input.accountId, | 257 | actorAccountId: input.accountId, |
| 249 | executionId, | 258 | executionId, |
| 250 | interactionId, | 259 | interactionId, |
| 251 | } | 260 | } |
| 252 | : args.kind === "graphql" | ||
| 253 | ? { | ||
| 254 | ...args, | ||
| 255 | workspaceId: input.workspaceId, | ||
| 256 | actorAccountId: input.accountId, | ||
| 257 | executionId, | ||
| 258 | interactionId, | ||
| 259 | } | ||
| 260 | : { | 261 | : { |
| 261 | kind: args.kind, | 262 | ...args, |
| 262 | endpoint: args.endpoint, | ||
| 263 | name: args.name ?? null, | ||
| 264 | namespace: args.namespace ?? null, | ||
| 265 | workspaceId: input.workspaceId, | 263 | workspaceId: input.workspaceId, |
| 266 | actorAccountId: input.accountId, | 264 | actorAccountId: input.accountId, |
| 267 | executionId, | 265 | executionId, |
| @@ -293,7 +291,7 @@ export const createExecutorToolMap = (input: { · 25 unmodified lines | |||
| 293 | let pendingResult = result; | 291 | let pendingResult = result; |
| 294 | let pendingArgs = preparedArgs as Extract< | 292 | let pendingArgs = preparedArgs as Extract< |
| 295 | ExecutorAddSourceInput, | 293 | ExecutorAddSourceInput, |
| 296 | { kind: "openapi" | "graphql" } | 294 | { kind: "openapi" | "graphql" | "google_discovery" } |
| 297 | >; | 295 | >; |
| 298 | 296 | ||
| 299 | while (pendingResult.kind === "credential_required") { | 297 | while (pendingResult.kind === "credential_required") { |
| @@ -0,0 +1,154 @@ | |||
| 1 | import { createServer, type IncomingMessage } from "node:http"; | ||
| 2 | |||
| 3 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 4 | |||
| 5 | import { createGraphqlToolFromPersistedOperation } from "./graphql-tools"; | ||
| 6 | |||
| 7 | type CapturedRequest = { | ||
| 8 | path: string; | ||
| 9 | query: string; | ||
| 10 | headers: Record<string, string>; | ||
| 11 | body: string; | ||
| 12 | }; | ||
| 13 | |||
| 14 | const requestHeadersFromNode = ( | ||
| 15 | request: IncomingMessage, | ||
| 16 | ): Record<string, string> => | ||
| 17 | Object.fromEntries( | ||
| 18 | Object.entries(request.headers).flatMap(([key, value]) => | ||
| 19 | value === undefined | ||
| 20 | ? [] | ||
| 21 | : [[key, Array.isArray(value) ? value.join(",") : value]]), | ||
| 22 | ); | ||
| 23 | |||
| 24 | const makeServer = async () => { | ||
| 25 | const requests: CapturedRequest[] = []; | ||
| 26 | const server = createServer((request, response) => { | ||
| 27 | const url = new URL( | ||
| 28 | request.url ?? "/", | ||
| 29 | `http://${request.headers.host ?? "127.0.0.1"}`, | ||
| 30 | ); | ||
| 31 | let body = ""; | ||
| 32 | |||
| 33 | request.setEncoding("utf8"); | ||
| 34 | request.on("data", (chunk) => { | ||
| 35 | body += chunk; | ||
| 36 | }); | ||
| 37 | request.on("end", () => { | ||
| 38 | requests.push({ | ||
| 39 | path: url.pathname, | ||
| 40 | query: url.search, | ||
| 41 | headers: requestHeadersFromNode(request), | ||
| 42 | body, | ||
| 43 | }); | ||
| 44 | response.statusCode = 200; | ||
| 45 | response.setHeader("content-type", "application/json"); | ||
| 46 | response.end(JSON.stringify({ data: { viewer: { id: "usr_123" } } })); | ||
| 47 | }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | await new Promise<void>((resolve, reject) => { | ||
| 51 | server.once("error", reject); | ||
| 52 | server.listen(0, "127.0.0.1", () => resolve()); | ||
| 53 | }); | ||
| 54 | |||
| 55 | const address = server.address(); | ||
| 56 | if (!address || typeof address === "string") { | ||
| 57 | throw new Error("failed to resolve server address"); | ||
| 58 | } | ||
| 59 | |||
| 60 | return { | ||
| 61 | baseUrl: `http://127.0.0.1:${address.port}/graphql`, | ||
| 62 | requests, | ||
| 63 | close: async () => { | ||
| 64 | await new Promise<void>((resolve, reject) => { | ||
| 65 | server.close((error) => { | ||
| 66 | if (error) { | ||
| 67 | reject(error); | ||
| 68 | return; | ||
| 69 | } | ||
| 70 | resolve(); | ||
| 71 | }); | ||
| 72 | }); | ||
| 73 | }, | ||
| 74 | }; | ||
| 75 | }; | ||
| 76 | |||
| 77 | describe("graphql-tools", () => { | ||
| 78 | const resources: Array<Awaited<ReturnType<typeof makeServer>>> = []; | ||
| 79 | |||
| 80 | afterEach(async () => { | ||
| 81 | while (resources.length > 0) { | ||
| 82 | await resources.pop()?.close(); | ||
| 83 | } | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("applies credential placements for query params, cookies, and body values", async () => { | ||
| 87 | const server = await makeServer(); | ||
| 88 | resources.push(server); | ||
| 89 | |||
| 90 | const toolInput = createGraphqlToolFromPersistedOperation({ | ||
| 91 | path: "source.graphql.raw", | ||
| 92 | sourceKey: "src_graphql", | ||
| 93 | endpoint: server.baseUrl, | ||
| 94 | providerDataJson: JSON.stringify({ | ||
| 95 | kind: "graphql", | ||
| 96 | toolKind: "request", | ||
| 97 | toolId: "raw", | ||
| 98 | rawToolId: null, | ||
| 99 | group: null, | ||
| 100 | leaf: null, | ||
| 101 | fieldName: null, | ||
| 102 | operationType: "query", | ||
| 103 | operationName: null, | ||
| 104 | operationDocument: null, | ||
| 105 | queryTypeName: "Query", | ||
| 106 | mutationTypeName: "Mutation", | ||
| 107 | subscriptionTypeName: null, | ||
| 108 | }), | ||
| 109 | credentialPlacements: { | ||
| 110 | headers: { | ||
| 111 | authorization: "Token top-secret", | ||
| 112 | }, | ||
| 113 | queryParams: { | ||
| 114 | tenant: "acme", | ||
| 115 | }, | ||
| 116 | cookies: { | ||
| 117 | session: "abc123", | ||
| 118 | }, | ||
| 119 | bodyValues: { | ||
| 120 | "extensions.clientVersion": "v1.0.0", | ||
| 121 | }, | ||
| 122 | }, | ||
| 123 | }); | ||
| 124 | const execute = | ||
| 125 | typeof toolInput === "object" && toolInput !== null && "tool" in toolInput | ||
| 126 | ? toolInput.tool.execute | ||
| 127 | : (() => { | ||
| 128 | throw new Error("Expected GraphQL tool definition"); | ||
| 129 | }); | ||
| 130 | |||
| 131 | const result = await Promise.resolve( | ||
| 132 | execute({ | ||
| 133 | query: "query Viewer { viewer { id } }", | ||
| 134 | }), | ||
| 135 | ); | ||
| 136 | |||
| 137 | expect(result).toEqual({ | ||
| 138 | status: 200, | ||
| 139 | headers: expect.any(Object), | ||
| 140 | body: { data: { viewer: { id: "usr_123" } } }, | ||
| 141 | isError: false, | ||
| 142 | }); | ||
| 143 | expect(server.requests).toHaveLength(1); | ||
| 144 | expect(server.requests[0]?.query).toContain("tenant=acme"); | ||
| 145 | expect(server.requests[0]?.headers.authorization).toBe("Token top-secret"); | ||
| 146 | expect(server.requests[0]?.headers.cookie).toContain("session=abc123"); | ||
| 147 | expect(JSON.parse(server.requests[0]?.body ?? "{}")).toEqual({ | ||
| 148 | query: "query Viewer { viewer { id } }", | ||
| 149 | extensions: { | ||
| 150 | clientVersion: "v1.0.0", | ||
| 151 | }, | ||
| 152 | }); | ||
| 153 | }); | ||
| 154 | }); | ||
| @@ -1,8 +1,11 @@ | |||
| 1 | import { createHash } from "node:crypto"; | ||
| 2 | |||
| 3 | import { | 1 | import { |
| 2 | applyCookiePlacementsToHeaders, | ||
| 3 | applyHttpQueryPlacementsToUrl, | ||
| 4 | applyJsonBodyPlacements, | ||
| 5 | sha256Hex, | ||
| 4 | standardSchemaFromJsonSchema, | 6 | standardSchemaFromJsonSchema, |
| 5 | toTool, | 7 | toTool, |
| 8 | type HttpRequestPlacements, | ||
| 6 | type ToolDescriptor, | 9 | type ToolDescriptor, |
| 7 | type ToolMap, | 10 | type ToolMap, |
| 8 | type ToolMetadata, | 11 | type ToolMetadata, |
| @@ -137,6 +140,7 @@ type GraphqlHttpInvocation = { · 128 unmodified lines | |||
| 137 | path: string; | 140 | path: string; |
| 138 | defaultHeaders?: Readonly<Record<string, string>>; | 141 | defaultHeaders?: Readonly<Record<string, string>>; |
| 139 | credentialHeaders?: Readonly<Record<string, string>>; | 142 | credentialHeaders?: Readonly<Record<string, string>>; |
| 143 | credentialPlacements?: HttpRequestPlacements; | ||
| 140 | query: string; | 144 | query: string; |
| 141 | variables?: Record<string, unknown>; | 145 | variables?: Record<string, unknown>; |
| 142 | operationName?: string; | 146 | operationName?: string; |
| @@ -998,7 +1002,7 @@ const resolveGraphqlFieldToolIds = ( · 855 unmodified lines | |||
| 998 | ); | 1002 | ); |
| 999 | applyDuplicates( | 1003 | applyDuplicates( |
| 1000 | (draft) => | 1004 | (draft) => |
| 1001 | `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`, | 1005 | `${draft.leaf}${toPascalCase(draft.operationType)}${sha256Hex(`${draft.group}:${draft.fieldName}`).slice(0, 6)}`, |
| 1002 | ); | 1006 | ); |
| 1003 | 1007 | ||
| 1004 | return staged | 1008 | return staged |
| @@ -1071,7 +1075,7 @@ const fieldToolDraftsFromRootType = (input: { · 66 unmodified lines | |||
| 1071 | }; | 1075 | }; |
| 1072 | 1076 | ||
| 1073 | export const createGraphqlSourceHash = (documentText: string): string => | 1077 | export const createGraphqlSourceHash = (documentText: string): string => |
| 1074 | createHash("sha256").update(documentText).digest("hex"); | 1078 | sha256Hex(documentText); |
| 1075 | 1079 | ||
| 1076 | export const extractGraphqlManifest = ( | 1080 | export const extractGraphqlManifest = ( |
| 1077 | sourceName: string, | 1081 | sourceName: string, |
| @@ -1272,6 +1276,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 194 unmodified lines | |||
| 1272 | schemaRefTable?: Readonly<Record<string, unknown>>; | 1276 | schemaRefTable?: Readonly<Record<string, unknown>>; |
| 1273 | defaultHeaders?: Readonly<Record<string, string>>; | 1277 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1274 | credentialHeaders?: Readonly<Record<string, string>>; | 1278 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1279 | credentialPlacements?: HttpRequestPlacements; | ||
| 1275 | }) => { | 1280 | }) => { |
| 1276 | const decodedProviderData = decodeGraphqlToolProviderDataJson( | 1281 | const decodedProviderData = decodeGraphqlToolProviderDataJson( |
| 1277 | input.providerDataJson, | 1282 | input.providerDataJson, |
| @@ -1340,6 +1345,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 62 unmodified lines | |||
| 1340 | path: input.path, | 1345 | path: input.path, |
| 1341 | defaultHeaders: input.defaultHeaders, | 1346 | defaultHeaders: input.defaultHeaders, |
| 1342 | credentialHeaders: input.credentialHeaders, | 1347 | credentialHeaders: input.credentialHeaders, |
| 1348 | credentialPlacements: input.credentialPlacements, | ||
| 1343 | args, | 1349 | args, |
| 1344 | }), | 1350 | }), |
| 1345 | ); | 1351 | ); |
| @@ -1351,6 +1357,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 5 unmodified lines | |||
| 1351 | path: input.path, | 1357 | path: input.path, |
| 1352 | defaultHeaders: input.defaultHeaders, | 1358 | defaultHeaders: input.defaultHeaders, |
| 1353 | credentialHeaders: input.credentialHeaders, | 1359 | credentialHeaders: input.credentialHeaders, |
| 1360 | credentialPlacements: input.credentialPlacements, | ||
| 1354 | args, | 1361 | args, |
| 1355 | }), | 1362 | }), |
| 1356 | ); | 1363 | ); |
| @@ -1396,25 +1403,40 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 39 unmodified lines | |||
| 1396 | 1403 | ||
| 1397 | const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => | 1404 | const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => |
| 1398 | Effect.gen(function* () { | 1405 | Effect.gen(function* () { |
| 1406 | const endpoint = applyHttpQueryPlacementsToUrl({ | ||
| 1407 | url: input.endpoint, | ||
| 1408 | queryParams: input.credentialPlacements?.queryParams, | ||
| 1409 | }).toString(); | ||
| 1410 | const requestBody = applyJsonBodyPlacements({ | ||
| 1411 | body: { | ||
| 1412 | query: input.query, | ||
| 1413 | ...(input.variables !== undefined | ||
| 1414 | ? { variables: input.variables } | ||
| 1415 | : {}), | ||
| 1416 | ...(input.operationName | ||
| 1417 | ? { operationName: input.operationName } | ||
| 1418 | : {}), | ||
| 1419 | }, | ||
| 1420 | bodyValues: input.credentialPlacements?.bodyValues, | ||
| 1421 | label: `GraphQL ${input.path}`, | ||
| 1422 | }); | ||
| 1423 | const headers = applyCookiePlacementsToHeaders({ | ||
| 1424 | headers: { | ||
| 1425 | "content-type": "application/json", | ||
| 1426 | ...(input.defaultHeaders ?? {}), | ||
| 1427 | ...(input.requestHeaders ?? {}), | ||
| 1428 | ...(input.credentialHeaders ?? {}), | ||
| 1429 | ...(input.credentialPlacements?.headers ?? {}), | ||
| 1430 | }, | ||
| 1431 | cookies: input.credentialPlacements?.cookies, | ||
| 1432 | }); | ||
| 1433 | |||
| 1399 | const response = yield* Effect.tryPromise({ | 1434 | const response = yield* Effect.tryPromise({ |
| 1400 | try: () => | 1435 | try: () => |
| 1401 | fetch(input.endpoint, { | 1436 | fetch(endpoint, { |
| 1402 | method: "POST", | 1437 | method: "POST", |
| 1403 | headers: new Headers({ | 1438 | headers: new Headers(headers), |
| 1404 | "content-type": "application/json", | 1439 | body: JSON.stringify(requestBody), |
| 1405 | ...(input.defaultHeaders ?? {}), | ||
| 1406 | ...(input.requestHeaders ?? {}), | ||
| 1407 | ...(input.credentialHeaders ?? {}), | ||
| 1408 | }), | ||
| 1409 | body: JSON.stringify({ | ||
| 1410 | query: input.query, | ||
| 1411 | ...(input.variables !== undefined | ||
| 1412 | ? { variables: input.variables } | ||
| 1413 | : {}), | ||
| 1414 | ...(input.operationName | ||
| 1415 | ? { operationName: input.operationName } | ||
| 1416 | : {}), | ||
| 1417 | }), | ||
| 1418 | }), | 1440 | }), |
| 1419 | catch: (cause) => | 1441 | catch: (cause) => |
| 1420 | graphqlToolError(`GraphQL request failed for ${input.path}`, cause), | 1442 | graphqlToolError(`GraphQL request failed for ${input.path}`, cause), |
| @@ -1446,6 +1468,7 @@ const invokeRawGraphqlTool = (input: { · 25 unmodified lines | |||
| 1446 | path: string; | 1468 | path: string; |
| 1447 | defaultHeaders?: Readonly<Record<string, string>>; | 1469 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1448 | credentialHeaders?: Readonly<Record<string, string>>; | 1470 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1471 | credentialPlacements?: HttpRequestPlacements; | ||
| 1449 | args: unknown; | 1472 | args: unknown; |
| 1450 | }) => | 1473 | }) => |
| 1451 | Effect.gen(function* () { | 1474 | Effect.gen(function* () { |
| @@ -1462,6 +1485,7 @@ const invokeRawGraphqlTool = (input: { · 10 unmodified lines | |||
| 1462 | path: input.path, | 1485 | path: input.path, |
| 1463 | defaultHeaders: input.defaultHeaders, | 1486 | defaultHeaders: input.defaultHeaders, |
| 1464 | credentialHeaders: input.credentialHeaders, | 1487 | credentialHeaders: input.credentialHeaders, |
| 1488 | credentialPlacements: input.credentialPlacements, | ||
| 1465 | query, | 1489 | query, |
| 1466 | variables: | 1490 | variables: |
| 1467 | record.variables !== undefined ? asRecord(record.variables) : undefined, | 1491 | record.variables !== undefined ? asRecord(record.variables) : undefined, |
| @@ -1486,6 +1510,7 @@ const invokeGraphqlFieldTool = (input: { · 18 unmodified lines | |||
| 1486 | path: string; | 1510 | path: string; |
| 1487 | defaultHeaders?: Readonly<Record<string, string>>; | 1511 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1488 | credentialHeaders?: Readonly<Record<string, string>>; | 1512 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1513 | credentialPlacements?: HttpRequestPlacements; | ||
| 1489 | args: unknown; | 1514 | args: unknown; |
| 1490 | }) => | 1515 | }) => |
| 1491 | Effect.gen(function* () { | 1516 | Effect.gen(function* () { |
| @@ -1501,6 +1526,7 @@ const invokeGraphqlFieldTool = (input: { · 9 unmodified lines | |||
| 1501 | path: input.path, | 1526 | path: input.path, |
| 1502 | defaultHeaders: input.defaultHeaders, | 1527 | defaultHeaders: input.defaultHeaders, |
| 1503 | credentialHeaders: input.credentialHeaders, | 1528 | credentialHeaders: input.credentialHeaders, |
| 1529 | credentialPlacements: input.credentialPlacements, | ||
| 1504 | query: input.entry.operationDocument, | 1530 | query: input.entry.operationDocument, |
| 1505 | variables, | 1531 | variables, |
| 1506 | operationName: input.entry.operationName, | 1532 | operationName: input.entry.operationName, |
| @@ -1524,6 +1550,7 @@ export const createGraphqlToolsFromManifest = (input: { · 17 unmodified lines | |||
| 1524 | sourceKey: string; | 1550 | sourceKey: string; |
| 1525 | defaultHeaders?: Readonly<Record<string, string>>; | 1551 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1526 | credentialHeaders?: Readonly<Record<string, string>>; | 1552 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1553 | credentialPlacements?: HttpRequestPlacements; | ||
| 1527 | }): ToolMap => { | 1554 | }): ToolMap => { |
| 1528 | const endpoint = normalizeHttpUrl(input.endpoint); | 1555 | const endpoint = normalizeHttpUrl(input.endpoint); |
| 1529 | const definitions = compileGraphqlToolDefinitions(input.manifest); | 1556 | const definitions = compileGraphqlToolDefinitions(input.manifest); |
| @@ -1552,6 +1579,7 @@ export const createGraphqlToolsFromManifest = (input: { · 22 unmodified lines | |||
| 1552 | schemaRefTable: input.manifest.schemaRefTable, | 1579 | schemaRefTable: input.manifest.schemaRefTable, |
| 1553 | defaultHeaders: input.defaultHeaders, | 1580 | defaultHeaders: input.defaultHeaders, |
| 1554 | credentialHeaders: input.credentialHeaders, | 1581 | credentialHeaders: input.credentialHeaders, |
| 1582 | credentialPlacements: input.credentialPlacements, | ||
| 1555 | }), | 1583 | }), |
| 1556 | ] as const; | 1584 | ] as const; |
| 1557 | }), | 1585 | }), |
| @@ -0,0 +1,77 @@ | |||
| 1 | import { createServer } from "node:http"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "vitest"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | |||
| 6 | import { startOauthLoopbackRedirectServer } from "./oauth-loopback"; | ||
| 7 | |||
| 8 | const withCompletionServer = async <T>(handler: (input: { | ||
| 9 | completionUrl: string; | ||
| 10 | requests: string[]; | ||
| 11 | }) => Promise<T>): Promise<T> => { | ||
| 12 | const requests: string[] = []; | ||
| 13 | const server = createServer((request, response) => { | ||
| 14 | requests.push(request.url ?? "/"); | ||
| 15 | response.statusCode = 200; | ||
| 16 | response.end("ok"); | ||
| 17 | }); | ||
| 18 | |||
| 19 | await new Promise<void>((resolve, reject) => { | ||
| 20 | server.listen(0, "127.0.0.1", (error?: Error) => { | ||
| 21 | if (error) { | ||
| 22 | reject(error); | ||
| 23 | return; | ||
| 24 | } | ||
| 25 | resolve(); | ||
| 26 | }); | ||
| 27 | }); | ||
| 28 | |||
| 29 | try { | ||
| 30 | const address = server.address(); | ||
| 31 | if (!address || typeof address === "string") { | ||
| 32 | throw new Error("Failed to resolve completion server address"); | ||
| 33 | } | ||
| 34 | |||
| 35 | return await handler({ | ||
| 36 | completionUrl: `http://127.0.0.1:${address.port}/oauth/complete?sessionId=test-session`, | ||
| 37 | requests, | ||
| 38 | }); | ||
| 39 | } finally { | ||
| 40 | await new Promise<void>((resolve, reject) => { | ||
| 41 | server.close((error) => { | ||
| 42 | if (error) { | ||
| 43 | reject(error); | ||
| 44 | return; | ||
| 45 | } | ||
| 46 | resolve(); | ||
| 47 | }); | ||
| 48 | }); | ||
| 49 | } | ||
| 50 | }; | ||
| 51 | |||
| 52 | describe("oauth-loopback", () => { | ||
| 53 | it("redirects loopback callbacks to the app completion URL with query params intact", async () => { | ||
| 54 | await withCompletionServer(async ({ completionUrl, requests }) => { | ||
| 55 | const receiver = await Effect.runPromise( | ||
| 56 | startOauthLoopbackRedirectServer({ | ||
| 57 | completionUrl, | ||
| 58 | }), | ||
| 59 | ); | ||
| 60 | |||
| 61 | const response = await fetch( | ||
| 62 | `${receiver.redirectUri}?state=oauth-state&code=oauth-code`, | ||
| 63 | { | ||
| 64 | redirect: "follow", | ||
| 65 | signal: AbortSignal.timeout(10_000), | ||
| 66 | }, | ||
| 67 | ); | ||
| 68 | |||
| 69 | expect(response.ok).toBe(true); | ||
| 70 | expect(requests).toEqual([ | ||
| 71 | "/oauth/complete?sessionId=test-session&state=oauth-state&code=oauth-code", | ||
| 72 | ]); | ||
| 73 | |||
| 74 | await Effect.runPromise(receiver.close); | ||
| 75 | }); | ||
| 76 | }); | ||
| 77 | }); | ||
| @@ -0,0 +1,104 @@ | |||
| 1 | import { createServer } from "node:http"; | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | const DEFAULT_LOOPBACK_TIMEOUT_MS = 10 * 60_000; | ||
| 6 | |||
| 7 | export type OauthLoopbackRedirectServer = { | ||
| 8 | redirectUri: string; | ||
| 9 | close: Effect.Effect<void, Error, never>; | ||
| 10 | }; | ||
| 11 | |||
| 12 | const closeServer = ( | ||
| 13 | server: ReturnType<typeof createServer>, | ||
| 14 | ): Promise<void> => | ||
| 15 | new Promise<void>((resolve, reject) => { | ||
| 16 | server.close((error) => { | ||
| 17 | if (error) { | ||
| 18 | reject(error); | ||
| 19 | return; | ||
| 20 | } | ||
| 21 | resolve(); | ||
| 22 | }); | ||
| 23 | }); | ||
| 24 | |||
| 25 | export const startOauthLoopbackRedirectServer = (input: { | ||
| 26 | completionUrl: string; | ||
| 27 | publicHost?: "localhost" | "127.0.0.1"; | ||
| 28 | listenHost?: "127.0.0.1"; | ||
| 29 | timeoutMs?: number; | ||
| 30 | }): Effect.Effect<OauthLoopbackRedirectServer, Error, never> => | ||
| 31 | Effect.tryPromise({ | ||
| 32 | try: () => | ||
| 33 | new Promise<OauthLoopbackRedirectServer>((resolve, reject) => { | ||
| 34 | const publicHost = input.publicHost ?? "127.0.0.1"; | ||
| 35 | const listenHost = input.listenHost ?? "127.0.0.1"; | ||
| 36 | const completionUrl = new URL(input.completionUrl); | ||
| 37 | const server = createServer((request, response) => { | ||
| 38 | try { | ||
| 39 | const requestUrl = new URL( | ||
| 40 | request.url ?? "/", | ||
| 41 | `http://${publicHost}`, | ||
| 42 | ); | ||
| 43 | const redirectUrl = new URL(completionUrl.toString()); | ||
| 44 | |||
| 45 | for (const [key, value] of requestUrl.searchParams.entries()) { | ||
| 46 | redirectUrl.searchParams.set(key, value); | ||
| 47 | } | ||
| 48 | |||
| 49 | response.statusCode = 302; | ||
| 50 | response.setHeader("cache-control", "no-store"); | ||
| 51 | response.setHeader("location", redirectUrl.toString()); | ||
| 52 | response.end(); | ||
| 53 | } catch (cause) { | ||
| 54 | const error = cause instanceof Error ? cause : new Error(String(cause)); | ||
| 55 | response.statusCode = 500; | ||
| 56 | response.setHeader("content-type", "text/plain; charset=utf-8"); | ||
| 57 | response.end(`OAuth redirect failed: ${error.message}`); | ||
| 58 | } finally { | ||
| 59 | void closeServer(server).catch(() => {}); | ||
| 60 | } | ||
| 61 | }); | ||
| 62 | |||
| 63 | let timeout: NodeJS.Timeout | null = null; | ||
| 64 | const cleanup = (): Promise<void> => { | ||
| 65 | if (timeout !== null) { | ||
| 66 | clearTimeout(timeout); | ||
| 67 | timeout = null; | ||
| 68 | } | ||
| 69 | return closeServer(server).catch(() => undefined); | ||
| 70 | }; | ||
| 71 | |||
| 72 | server.once("error", (cause) => { | ||
| 73 | reject( | ||
| 74 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 75 | ); | ||
| 76 | }); | ||
| 77 | |||
| 78 | server.listen(0, listenHost, () => { | ||
| 79 | const address = server.address(); | ||
| 80 | if (!address || typeof address === "string") { | ||
| 81 | reject(new Error("Failed to resolve OAuth loopback port")); | ||
| 82 | return; | ||
| 83 | } | ||
| 84 | |||
| 85 | timeout = setTimeout(() => { | ||
| 86 | void cleanup(); | ||
| 87 | }, input.timeoutMs ?? DEFAULT_LOOPBACK_TIMEOUT_MS); | ||
| 88 | if (typeof timeout.unref === "function") { | ||
| 89 | timeout.unref(); | ||
| 90 | } | ||
| 91 | |||
| 92 | resolve({ | ||
| 93 | redirectUri: `http://${publicHost}:${address.port}`, | ||
| 94 | close: Effect.tryPromise({ | ||
| 95 | try: cleanup, | ||
| 96 | catch: (cause) => | ||
| 97 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 98 | }), | ||
| 99 | }); | ||
| 100 | }); | ||
| 101 | }), | ||
| 102 | catch: (cause) => | ||
| 103 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 104 | }); | ||
| @@ -0,0 +1,172 @@ | |||
| 1 | import { createHash, randomBytes } from "node:crypto"; | ||
| 2 | |||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | |||
| 5 | export type OAuth2TokenResponse = { | ||
| 6 | access_token: string; | ||
| 7 | token_type?: string; | ||
| 8 | refresh_token?: string; | ||
| 9 | expires_in?: number; | ||
| 10 | scope?: string; | ||
| 11 | }; | ||
| 12 | |||
| 13 | export type OAuth2ClientAuthenticationMethod = | ||
| 14 | | "none" | ||
| 15 | | "client_secret_post"; | ||
| 16 | |||
| 17 | const encodeBase64Url = (input: Buffer): string => | ||
| 18 | input.toString("base64") | ||
| 19 | .replaceAll("+", "-") | ||
| 20 | .replaceAll("/", "_") | ||
| 21 | .replaceAll("=", ""); | ||
| 22 | |||
| 23 | export const createPkceCodeVerifier = (): string => | ||
| 24 | encodeBase64Url(randomBytes(48)); | ||
| 25 | |||
| 26 | export const createPkceCodeChallenge = (verifier: string): string => | ||
| 27 | encodeBase64Url(createHash("sha256").update(verifier).digest()); | ||
| 28 | |||
| 29 | export const buildOAuth2AuthorizationUrl = (input: { | ||
| 30 | authorizationEndpoint: string; | ||
| 31 | clientId: string; | ||
| 32 | redirectUri: string; | ||
| 33 | scopes: ReadonlyArray<string>; | ||
| 34 | state: string; | ||
| 35 | codeVerifier: string; | ||
| 36 | extraParams?: Readonly<Record<string, string>>; | ||
| 37 | }): string => { | ||
| 38 | const url = new URL(input.authorizationEndpoint); | ||
| 39 | url.searchParams.set("client_id", input.clientId); | ||
| 40 | url.searchParams.set("redirect_uri", input.redirectUri); | ||
| 41 | url.searchParams.set("response_type", "code"); | ||
| 42 | url.searchParams.set("scope", input.scopes.join(" ")); | ||
| 43 | url.searchParams.set("state", input.state); | ||
| 44 | url.searchParams.set("code_challenge_method", "S256"); | ||
| 45 | url.searchParams.set("code_challenge", createPkceCodeChallenge(input.codeVerifier)); | ||
| 46 | |||
| 47 | for (const [key, value] of Object.entries(input.extraParams ?? {})) { | ||
| 48 | url.searchParams.set(key, value); | ||
| 49 | } | ||
| 50 | |||
| 51 | return url.toString(); | ||
| 52 | }; | ||
| 53 | |||
| 54 | const parseOAuth2TokenResponse = async (response: Response): Promise<OAuth2TokenResponse> => { | ||
| 55 | const rawText = await response.text(); | ||
| 56 | let parsed: unknown; | ||
| 57 | try { | ||
| 58 | parsed = JSON.parse(rawText); | ||
| 59 | } catch { | ||
| 60 | throw new Error(`OAuth token endpoint returned non-JSON response (${response.status})`); | ||
| 61 | } | ||
| 62 | |||
| 63 | if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { | ||
| 64 | throw new Error(`OAuth token endpoint returned invalid JSON payload (${response.status})`); | ||
| 65 | } | ||
| 66 | |||
| 67 | const record = parsed as Record<string, unknown>; | ||
| 68 | const accessToken = | ||
| 69 | typeof record.access_token === "string" && record.access_token.length > 0 | ||
| 70 | ? record.access_token | ||
| 71 | : null; | ||
| 72 | |||
| 73 | if (!response.ok) { | ||
| 74 | const description = | ||
| 75 | typeof record.error_description === "string" && record.error_description.length > 0 | ||
| 76 | ? record.error_description | ||
| 77 | : typeof record.error === "string" && record.error.length > 0 | ||
| 78 | ? record.error | ||
| 79 | : `status ${response.status}`; | ||
| 80 | throw new Error(`OAuth token exchange failed: ${description}`); | ||
| 81 | } | ||
| 82 | |||
| 83 | if (accessToken === null) { | ||
| 84 | throw new Error("OAuth token endpoint did not return an access_token"); | ||
| 85 | } | ||
| 86 | |||
| 87 | return { | ||
| 88 | access_token: accessToken, | ||
| 89 | token_type: typeof record.token_type === "string" ? record.token_type : undefined, | ||
| 90 | refresh_token: typeof record.refresh_token === "string" ? record.refresh_token : undefined, | ||
| 91 | expires_in: typeof record.expires_in === "number" | ||
| 92 | ? record.expires_in | ||
| 93 | : typeof record.expires_in === "string" | ||
| 94 | ? Number(record.expires_in) | ||
| 95 | : undefined, | ||
| 96 | scope: typeof record.scope === "string" ? record.scope : undefined, | ||
| 97 | }; | ||
| 98 | }; | ||
| 99 | |||
| 100 | const postFormToOAuth2TokenEndpoint = (input: { | ||
| 101 | tokenEndpoint: string; | ||
| 102 | body: URLSearchParams; | ||
| 103 | }): Effect.Effect<OAuth2TokenResponse, Error, never> => | ||
| 104 | Effect.tryPromise({ | ||
| 105 | try: async () => { | ||
| 106 | const response = await fetch(input.tokenEndpoint, { | ||
| 107 | method: "POST", | ||
| 108 | headers: { | ||
| 109 | "content-type": "application/x-www-form-urlencoded", | ||
| 110 | accept: "application/json", | ||
| 111 | }, | ||
| 112 | body: input.body, | ||
| 113 | signal: AbortSignal.timeout(20_000), | ||
| 114 | }); | ||
| 115 | |||
| 116 | return parseOAuth2TokenResponse(response); | ||
| 117 | }, | ||
| 118 | catch: (cause) => | ||
| 119 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 120 | }); | ||
| 121 | |||
| 122 | export const exchangeOAuth2AuthorizationCode = (input: { | ||
| 123 | tokenEndpoint: string; | ||
| 124 | clientId: string; | ||
| 125 | clientAuthentication: OAuth2ClientAuthenticationMethod; | ||
| 126 | clientSecret?: string | null; | ||
| 127 | redirectUri: string; | ||
| 128 | codeVerifier: string; | ||
| 129 | code: string; | ||
| 130 | }): Effect.Effect<OAuth2TokenResponse, Error, never> => | ||
| 131 | Effect.gen(function* () { | ||
| 132 | const body = new URLSearchParams({ | ||
| 133 | grant_type: "authorization_code", | ||
| 134 | client_id: input.clientId, | ||
| 135 | redirect_uri: input.redirectUri, | ||
| 136 | code_verifier: input.codeVerifier, | ||
| 137 | code: input.code, | ||
| 138 | }); | ||
| 139 | |||
| 140 | if (input.clientAuthentication === "client_secret_post" && input.clientSecret) { | ||
| 141 | body.set("client_secret", input.clientSecret); | ||
| 142 | } | ||
| 143 | |||
| 144 | return yield* postFormToOAuth2TokenEndpoint({ | ||
| 145 | tokenEndpoint: input.tokenEndpoint, | ||
| 146 | body, | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | export const refreshOAuth2AccessToken = (input: { | ||
| 151 | tokenEndpoint: string; | ||
| 152 | clientId: string; | ||
| 153 | clientAuthentication: OAuth2ClientAuthenticationMethod; | ||
| 154 | clientSecret?: string | null; | ||
| 155 | refreshToken: string; | ||
| 156 | }): Effect.Effect<OAuth2TokenResponse, Error, never> => | ||
| 157 | Effect.gen(function* () { | ||
| 158 | const body = new URLSearchParams({ | ||
| 159 | grant_type: "refresh_token", | ||
| 160 | client_id: input.clientId, | ||
| 161 | refresh_token: input.refreshToken, | ||
| 162 | }); | ||
| 163 | |||
| 164 | if (input.clientAuthentication === "client_secret_post" && input.clientSecret) { | ||
| 165 | body.set("client_secret", input.clientSecret); | ||
| 166 | } | ||
| 167 | |||
| 168 | return yield* postFormToOAuth2TokenEndpoint({ | ||
| 169 | tokenEndpoint: input.tokenEndpoint, | ||
| 170 | body, | ||
| 171 | }); | ||
| 172 | }); | ||
| @@ -23,14 +23,21 @@ const isUniqueViolation = (error: ControlPlanePersistenceError): boolean => · 22 unmodified lines | |||
| 23 | 23 | ||
| 24 | export const mapPersistenceError = <A>( | 24 | export const mapPersistenceError = <A>( |
| 25 | operation: OperationErrorsLike, | 25 | operation: OperationErrorsLike, |
| 26 | effect: Effect.Effect<A, ControlPlanePersistenceError>, | 26 | effect: Effect.Effect<A, ControlPlanePersistenceError | Error>, |
| 27 | ): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> => | 27 | ): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> => |
| 28 | effect.pipe( | 28 | effect.pipe( |
| 29 | Effect.mapError((error) => { | 29 | Effect.mapError((error) => { |
| 30 | const errors = asOperationErrors(operation); | 30 | const errors = asOperationErrors(operation); |
| 31 | return isUniqueViolation(error) | 31 | if (error instanceof ControlPlanePersistenceError) { |
| 32 | ? errors.badRequest("Unique constraint violation", error.details ?? "duplicate key") | 32 | return isUniqueViolation(error) |
| 33 | : errors.storage(error); | 33 | ? errors.badRequest("Unique constraint violation", error.details ?? "duplicate key") |
| 34 | : errors.storage(error); | ||
| 35 | } | ||
| 36 | |||
| 37 | return errors.unknownStorage( | ||
| 38 | error, | ||
| 39 | error.message, | ||
| 40 | ); | ||
| 34 | }), | 41 | }), |
| 35 | ); | 42 | ); |
| 36 | 43 | ||
| @@ -0,0 +1,119 @@ | |||
| 1 | import { createServer } from "node:http"; | ||
| 2 | |||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import { SourceIdSchema } from "#schema"; | ||
| 5 | |||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | |||
| 8 | import { googleDiscoverySourceAdapter } from "./google-discovery"; | ||
| 9 | import { createSourceFromPayload } from "../source-definitions"; | ||
| 10 | |||
| 11 | const fetchLiveDiscoveryDocument = async (): Promise<string> => { | ||
| 12 | const response = await fetch("https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", { | ||
| 13 | signal: AbortSignal.timeout(20_000), | ||
| 14 | }); | ||
| 15 | if (!response.ok) { | ||
| 16 | throw new Error(`Failed to fetch Sheets discovery doc: ${response.status}`); | ||
| 17 | } | ||
| 18 | |||
| 19 | return response.text(); | ||
| 20 | }; | ||
| 21 | |||
| 22 | const withStaticServer = async <T>(content: string, handler: (baseUrl: string) => Promise<T>): Promise<T> => { | ||
| 23 | const server = createServer((_, response) => { | ||
| 24 | response.statusCode = 200; | ||
| 25 | response.setHeader("content-type", "application/json"); | ||
| 26 | response.end(content); | ||
| 27 | }); | ||
| 28 | |||
| 29 | await new Promise<void>((resolve, reject) => { | ||
| 30 | server.listen(0, "127.0.0.1", (error?: Error) => { | ||
| 31 | if (error) { | ||
| 32 | reject(error); | ||
| 33 | return; | ||
| 34 | } | ||
| 35 | resolve(); | ||
| 36 | }); | ||
| 37 | }); | ||
| 38 | |||
| 39 | try { | ||
| 40 | const address = server.address(); | ||
| 41 | if (!address || typeof address === "string") { | ||
| 42 | throw new Error("Failed to resolve static server address"); | ||
| 43 | } | ||
| 44 | return await handler(`http://127.0.0.1:${address.port}/sheets.discovery.json`); | ||
| 45 | } finally { | ||
| 46 | await new Promise<void>((resolve, reject) => { | ||
| 47 | server.close((error) => { | ||
| 48 | if (error) { | ||
| 49 | reject(error); | ||
| 50 | return; | ||
| 51 | } | ||
| 52 | resolve(); | ||
| 53 | }); | ||
| 54 | }); | ||
| 55 | } | ||
| 56 | }; | ||
| 57 | |||
| 58 | describe("google discovery source adapter", () => { | ||
| 59 | it("materializes a real Google Sheets discovery doc into recipe content", () => | ||
| 60 | Effect.tryPromise({ | ||
| 61 | try: async () => { | ||
| 62 | const discoveryDocument = await fetchLiveDiscoveryDocument(); | ||
| 63 | |||
| 64 | await withStaticServer(discoveryDocument, async (discoveryUrl) => { | ||
| 65 | const source = await Effect.runPromise( | ||
| 66 | createSourceFromPayload({ | ||
| 67 | workspaceId: "ws_test" as any, | ||
| 68 | sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | ||
| 69 | payload: { | ||
| 70 | name: "Google Sheets", | ||
| 71 | kind: "google_discovery", | ||
| 72 | endpoint: discoveryUrl, | ||
| 73 | namespace: "google.sheets", | ||
| 74 | binding: { | ||
| 75 | service: "sheets", | ||
| 76 | version: "v4", | ||
| 77 | discoveryUrl, | ||
| 78 | }, | ||
| 79 | importAuthPolicy: "reuse_runtime", | ||
| 80 | importAuth: { kind: "none" }, | ||
| 81 | auth: { kind: "none" }, | ||
| 82 | status: "connected", | ||
| 83 | enabled: true, | ||
| 84 | }, | ||
| 85 | now: Date.now(), | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | |||
| 89 | const materialization = await Effect.runPromise( | ||
| 90 | googleDiscoverySourceAdapter.materializeSource({ | ||
| 91 | source, | ||
| 92 | resolveSecretMaterial: () => Effect.fail(new Error("unexpected secret lookup")), | ||
| 93 | resolveAuthMaterialForSlot: () => | ||
| 94 | Effect.succeed({ | ||
| 95 | placements: [], | ||
| 96 | headers: {}, | ||
| 97 | queryParams: {}, | ||
| 98 | cookies: {}, | ||
| 99 | bodyValues: {}, | ||
| 100 | expiresAt: null, | ||
| 101 | refreshAfter: null, | ||
| 102 | }), | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 106 | expect(materialization.documents[0]?.documentKind).toBe("google_discovery"); | ||
| 107 | expect(materialization.schemaBundles.length).toBeGreaterThan(0); | ||
| 108 | expect(materialization.operations.length).toBeGreaterThan(50); | ||
| 109 | expect( | ||
| 110 | materialization.operations.some( | ||
| 111 | (operation) => operation.toolId === "spreadsheets.values.get", | ||
| 112 | ), | ||
| 113 | ).toBe(true); | ||
| 114 | }); | ||
| 115 | }, | ||
| 116 | catch: (cause) => | ||
| 117 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 118 | })); | ||
| 119 | }); | ||
| @@ -0,0 +1,639 @@ | |||
| 1 | import { | ||
| 2 | FetchHttpClient, | ||
| 3 | HttpClient, | ||
| 4 | HttpClientRequest, | ||
| 5 | } from "@effect/platform"; | ||
| 6 | import { | ||
| 7 | makeToolInvokerFromTools, | ||
| 8 | typeSignatureFromSchemaJson, | ||
| 9 | } from "@executor/codemode-core"; | ||
| 10 | import { | ||
| 11 | compileGoogleDiscoveryToolDefinitions, | ||
| 12 | createGoogleDiscoveryToolFromDefinition, | ||
| 13 | decodeGoogleDiscoverySchemaRefTableJson, | ||
| 14 | extractGoogleDiscoveryManifest, | ||
| 15 | googleDiscoveryProviderDataJsonFromDefinition, | ||
| 16 | GoogleDiscoveryToolProviderDataSchema, | ||
| 17 | type GoogleDiscoveryToolManifest, | ||
| 18 | } from "@executor/codemode-google-discovery"; | ||
| 19 | import type { | ||
| 20 | Source, | ||
| 21 | SourceRecipeRevisionId, | ||
| 22 | StoredSourceRecipeDocumentRecord, | ||
| 23 | StoredSourceRecipeOperationRecord, | ||
| 24 | StoredSourceRecipeSchemaBundleRecord, | ||
| 25 | } from "#schema"; | ||
| 26 | import { | ||
| 27 | SourceRecipeSchemaBundleIdSchema, | ||
| 28 | StringMapSchema, | ||
| 29 | } from "#schema"; | ||
| 30 | import * as Either from "effect/Either"; | ||
| 31 | import * as Effect from "effect/Effect"; | ||
| 32 | import * as Schema from "effect/Schema"; | ||
| 33 | |||
| 34 | import { | ||
| 35 | contentHash, | ||
| 36 | normalizeSearchText, | ||
| 37 | } from "../source-recipe-support"; | ||
| 38 | import type { | ||
| 39 | SourceAdapter, | ||
| 40 | SourceAdapterMaterialization, | ||
| 41 | } from "./types"; | ||
| 42 | import { | ||
| 43 | ConnectHttpAuthSchema, | ||
| 44 | ConnectHttpImportAuthSchema, | ||
| 45 | createStandardToolDescriptor, | ||
| 46 | decodeBindingConfig, | ||
| 47 | decodeSourceBindingPayload, | ||
| 48 | emptySourceBindingState, | ||
| 49 | encodeBindingConfig, | ||
| 50 | isSourceCredentialRequiredError, | ||
| 51 | OptionalNullableStringSchema, | ||
| 52 | parseJsonValue, | ||
| 53 | SourceCredentialRequiredError, | ||
| 54 | } from "./shared"; | ||
| 55 | |||
| 56 | const GoogleDiscoveryConnectPayloadSchema = Schema.extend( | ||
| 57 | ConnectHttpImportAuthSchema, | ||
| 58 | Schema.Struct({ | ||
| 59 | kind: Schema.Literal("google_discovery"), | ||
| 60 | service: Schema.Trim.pipe(Schema.nonEmptyString()), | ||
| 61 | version: Schema.Trim.pipe(Schema.nonEmptyString()), | ||
| 62 | discoveryUrl: Schema.optional( | ||
| 63 | Schema.NullOr(Schema.Trim.pipe(Schema.nonEmptyString())), | ||
| 64 | ), | ||
| 65 | scopes: Schema.optional( | ||
| 66 | Schema.Array(Schema.Trim.pipe(Schema.nonEmptyString())), | ||
| 67 | ), | ||
| 68 | name: OptionalNullableStringSchema, | ||
| 69 | namespace: OptionalNullableStringSchema, | ||
| 70 | auth: Schema.optional(ConnectHttpAuthSchema), | ||
| 71 | }), | ||
| 72 | ); | ||
| 73 | |||
| 74 | const GoogleDiscoveryExecutorAddInputSchema = GoogleDiscoveryConnectPayloadSchema; | ||
| 75 | |||
| 76 | const GoogleDiscoveryBindingConfigSchema = Schema.Struct({ | ||
| 77 | service: Schema.Trim.pipe(Schema.nonEmptyString()), | ||
| 78 | version: Schema.Trim.pipe(Schema.nonEmptyString()), | ||
| 79 | discoveryUrl: Schema.Trim.pipe(Schema.nonEmptyString()), | ||
| 80 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), | ||
| 81 | scopes: Schema.optional( | ||
| 82 | Schema.Array(Schema.Trim.pipe(Schema.nonEmptyString())), | ||
| 83 | ), | ||
| 84 | }); | ||
| 85 | |||
| 86 | const GoogleDiscoverySourceBindingPayloadSchema = Schema.Struct({ | ||
| 87 | service: Schema.String, | ||
| 88 | version: Schema.String, | ||
| 89 | discoveryUrl: Schema.optional(Schema.String), | ||
| 90 | defaultHeaders: Schema.optional(Schema.NullOr(StringMapSchema)), | ||
| 91 | scopes: Schema.optional(Schema.Array(Schema.String)), | ||
| 92 | }); | ||
| 93 | |||
| 94 | type GoogleDiscoveryBindingConfig = typeof GoogleDiscoveryBindingConfigSchema.Type; | ||
| 95 | |||
| 96 | const GOOGLE_DISCOVERY_BINDING_CONFIG_VERSION = 1; | ||
| 97 | const GOOGLE_DISCOVERY_MATERIALIZATION_REVISION_ID = | ||
| 98 | "src_recipe_rev_materialization" as SourceRecipeRevisionId; | ||
| 99 | |||
| 100 | const decodeGoogleDiscoveryProviderDataJson = Schema.decodeUnknownEither( | ||
| 101 | Schema.parseJson(GoogleDiscoveryToolProviderDataSchema), | ||
| 102 | ); | ||
| 103 | |||
| 104 | const bindingHasAnyField = ( | ||
| 105 | value: unknown, | ||
| 106 | fields: readonly string[], | ||
| 107 | ): boolean => | ||
| 108 | value !== null | ||
| 109 | && typeof value === "object" | ||
| 110 | && !Array.isArray(value) | ||
| 111 | && fields.some((field) => Object.prototype.hasOwnProperty.call(value, field)); | ||
| 112 | |||
| 113 | const defaultGoogleDiscoveryUrl = (service: string, version: string): string => | ||
| 114 | `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`; | ||
| 115 | |||
| 116 | const googleDiscoveryBindingConfigFromSource = ( | ||
| 117 | source: Pick<Source, "id" | "bindingVersion" | "binding">, | ||
| 118 | ): Effect.Effect<GoogleDiscoveryBindingConfig, Error, never> => | ||
| 119 | Effect.gen(function* () { | ||
| 120 | if (bindingHasAnyField(source.binding, ["transport", "queryParams", "headers", "specUrl"])) { | ||
| 121 | return yield* Effect.fail( | ||
| 122 | new Error("Google Discovery sources cannot define MCP or OpenAPI binding fields"), | ||
| 123 | ); | ||
| 124 | } | ||
| 125 | |||
| 126 | const bindingConfig = yield* decodeSourceBindingPayload({ | ||
| 127 | sourceId: source.id, | ||
| 128 | label: "Google Discovery", | ||
| 129 | version: source.bindingVersion, | ||
| 130 | expectedVersion: GOOGLE_DISCOVERY_BINDING_CONFIG_VERSION, | ||
| 131 | schema: GoogleDiscoverySourceBindingPayloadSchema, | ||
| 132 | value: source.binding, | ||
| 133 | allowedKeys: ["service", "version", "discoveryUrl", "defaultHeaders", "scopes"], | ||
| 134 | }); | ||
| 135 | |||
| 136 | const service = bindingConfig.service.trim(); | ||
| 137 | const version = bindingConfig.version.trim(); | ||
| 138 | if (service.length === 0 || version.length === 0) { | ||
| 139 | return yield* Effect.fail( | ||
| 140 | new Error("Google Discovery sources require service and version"), | ||
| 141 | ); | ||
| 142 | } | ||
| 143 | |||
| 144 | const explicitDiscoveryUrl = | ||
| 145 | typeof bindingConfig.discoveryUrl === "string" && bindingConfig.discoveryUrl.trim().length > 0 | ||
| 146 | ? bindingConfig.discoveryUrl.trim() | ||
| 147 | : null; | ||
| 148 | |||
| 149 | return { | ||
| 150 | service, | ||
| 151 | version, | ||
| 152 | discoveryUrl: explicitDiscoveryUrl ?? defaultGoogleDiscoveryUrl(service, version), | ||
| 153 | defaultHeaders: bindingConfig.defaultHeaders ?? null, | ||
| 154 | scopes: (bindingConfig.scopes ?? []).map((scope) => scope.trim()).filter((scope) => scope.length > 0), | ||
| 155 | } satisfies GoogleDiscoveryBindingConfig; | ||
| 156 | }); | ||
| 157 | |||
| 158 | const fetchGoogleDiscoveryDocumentWithHeaders = (input: { | ||
| 159 | url: string; | ||
| 160 | headers?: Readonly<Record<string, string>>; | ||
| 161 | queryParams?: Readonly<Record<string, string>>; | ||
| 162 | cookies?: Readonly<Record<string, string>>; | ||
| 163 | }): Effect.Effect<string, Error, never> => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | const client = yield* HttpClient.HttpClient; | ||
| 166 | const request = HttpClientRequest.get(input.url).pipe( | ||
| 167 | HttpClientRequest.setHeaders({ | ||
| 168 | ...(input.headers ?? {}), | ||
| 169 | ...(input.cookies | ||
| 170 | ? { | ||
| 171 | cookie: Object.entries(input.cookies) | ||
| 172 | .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) | ||
| 173 | .join("; "), | ||
| 174 | } | ||
| 175 | : {}), | ||
| 176 | }), | ||
| 177 | ); | ||
| 178 | const response = yield* client.execute(request).pipe( | ||
| 179 | Effect.mapError((cause) => | ||
| 180 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 181 | ), | ||
| 182 | ); | ||
| 183 | |||
| 184 | if (response.status === 401 || response.status === 403) { | ||
| 185 | return yield* Effect.fail( | ||
| 186 | new SourceCredentialRequiredError( | ||
| 187 | "import", | ||
| 188 | `Google Discovery fetch requires credentials (status ${response.status})`, | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | } | ||
| 192 | if (response.status < 200 || response.status >= 300) { | ||
| 193 | return yield* Effect.fail( | ||
| 194 | new Error(`Google Discovery fetch failed with status ${response.status}`), | ||
| 195 | ); | ||
| 196 | } | ||
| 197 | |||
| 198 | return yield* response.text.pipe( | ||
| 199 | Effect.mapError((cause) => | ||
| 200 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 201 | ), | ||
| 202 | ); | ||
| 203 | }).pipe(Effect.provide(FetchHttpClient.layer)); | ||
| 204 | |||
| 205 | const toGoogleDiscoverySchemaBundleRecord = (input: { | ||
| 206 | recipeRevisionId: SourceRecipeRevisionId; | ||
| 207 | refTable: Readonly<Record<string, string>> | undefined; | ||
| 208 | now: number; | ||
| 209 | }): StoredSourceRecipeSchemaBundleRecord | null => { | ||
| 210 | const refEntries = Object.entries(input.refTable ?? {}); | ||
| 211 | if (refEntries.length === 0) { | ||
| 212 | return null; | ||
| 213 | } | ||
| 214 | |||
| 215 | const refs = Object.fromEntries( | ||
| 216 | refEntries.map(([ref, rawValue]) => { | ||
| 217 | try { | ||
| 218 | return [ref, JSON.parse(rawValue) as unknown]; | ||
| 219 | } catch { | ||
| 220 | return [ref, rawValue]; | ||
| 221 | } | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | const refsJson = JSON.stringify(refs); | ||
| 225 | |||
| 226 | return { | ||
| 227 | id: SourceRecipeSchemaBundleIdSchema.make( | ||
| 228 | `src_recipe_bundle_${crypto.randomUUID()}`, | ||
| 229 | ), | ||
| 230 | recipeRevisionId: input.recipeRevisionId, | ||
| 231 | bundleKind: "json_schema_ref_map", | ||
| 232 | refsJson, | ||
| 233 | contentHash: contentHash(refsJson), | ||
| 234 | createdAt: input.now, | ||
| 235 | updatedAt: input.now, | ||
| 236 | }; | ||
| 237 | }; | ||
| 238 | |||
| 239 | const toGoogleDiscoveryRecipeOperationRecord = (input: { | ||
| 240 | recipeRevisionId: SourceRecipeRevisionId; | ||
| 241 | manifest: GoogleDiscoveryToolManifest; | ||
| 242 | definition: ReturnType<typeof compileGoogleDiscoveryToolDefinitions>[number]; | ||
| 243 | now: number; | ||
| 244 | }): StoredSourceRecipeOperationRecord => ({ | ||
| 245 | id: `src_recipe_op_${crypto.randomUUID()}`, | ||
| 246 | recipeRevisionId: input.recipeRevisionId, | ||
| 247 | operationKey: input.definition.toolId, | ||
| 248 | transportKind: "http", | ||
| 249 | toolId: input.definition.toolId, | ||
| 250 | title: input.definition.name, | ||
| 251 | description: input.definition.description, | ||
| 252 | operationKind: | ||
| 253 | input.definition.method === "get" || input.definition.method === "head" | ||
| 254 | ? "read" | ||
| 255 | : input.definition.method === "delete" | ||
| 256 | ? "delete" | ||
| 257 | : "write", | ||
| 258 | searchText: normalizeSearchText( | ||
| 259 | input.definition.toolId, | ||
| 260 | input.definition.rawToolId, | ||
| 261 | input.definition.methodId, | ||
| 262 | input.definition.name, | ||
| 263 | input.definition.description ?? undefined, | ||
| 264 | input.definition.method, | ||
| 265 | input.definition.path, | ||
| 266 | input.definition.group ?? undefined, | ||
| 267 | input.definition.leaf, | ||
| 268 | input.manifest.service, | ||
| 269 | input.manifest.versionName, | ||
| 270 | input.definition.scopes.join(" "), | ||
| 271 | ), | ||
| 272 | inputSchemaJson: input.definition.inputSchemaJson ?? null, | ||
| 273 | outputSchemaJson: input.definition.outputSchemaJson ?? null, | ||
| 274 | providerKind: "google_discovery", | ||
| 275 | providerDataJson: googleDiscoveryProviderDataJsonFromDefinition({ | ||
| 276 | service: input.manifest.service, | ||
| 277 | version: input.manifest.versionName, | ||
| 278 | rootUrl: input.manifest.rootUrl, | ||
| 279 | servicePath: input.manifest.servicePath, | ||
| 280 | definition: input.definition, | ||
| 281 | }), | ||
| 282 | createdAt: input.now, | ||
| 283 | updatedAt: input.now, | ||
| 284 | }); | ||
| 285 | |||
| 286 | const googleDiscoveryDefinitionFromPersistedOperation = (input: { | ||
| 287 | path: string; | ||
| 288 | operation: StoredSourceRecipeOperationRecord; | ||
| 289 | }) => { | ||
| 290 | const decoded = input.operation.providerDataJson | ||
| 291 | ? decodeGoogleDiscoveryProviderDataJson(input.operation.providerDataJson) | ||
| 292 | : Either.left(new Error("Missing providerDataJson")); | ||
| 293 | |||
| 294 | if (Either.isLeft(decoded)) { | ||
| 295 | throw new Error(`Invalid Google Discovery provider data for ${input.path}`); | ||
| 296 | } | ||
| 297 | |||
| 298 | const providerData = decoded.right; | ||
| 299 | return { | ||
| 300 | definition: { | ||
| 301 | toolId: input.operation.toolId, | ||
| 302 | rawToolId: providerData.rawToolId, | ||
| 303 | methodId: providerData.methodId, | ||
| 304 | name: input.operation.title ?? input.operation.toolId, | ||
| 305 | description: input.operation.description, | ||
| 306 | group: providerData.group, | ||
| 307 | leaf: providerData.leaf, | ||
| 308 | method: providerData.invocation.method, | ||
| 309 | path: providerData.invocation.path, | ||
| 310 | flatPath: providerData.invocation.flatPath, | ||
| 311 | parameters: providerData.invocation.parameters, | ||
| 312 | requestSchemaId: providerData.invocation.requestSchemaId, | ||
| 313 | responseSchemaId: providerData.invocation.responseSchemaId, | ||
| 314 | scopes: providerData.invocation.scopes, | ||
| 315 | supportsMediaUpload: providerData.invocation.supportsMediaUpload, | ||
| 316 | supportsMediaDownload: providerData.invocation.supportsMediaDownload, | ||
| 317 | ...(input.operation.inputSchemaJson | ||
| 318 | ? { inputSchemaJson: input.operation.inputSchemaJson } | ||
| 319 | : {}), | ||
| 320 | ...(input.operation.outputSchemaJson | ||
| 321 | ? { outputSchemaJson: input.operation.outputSchemaJson } | ||
| 322 | : {}), | ||
| 323 | }, | ||
| 324 | providerData, | ||
| 325 | }; | ||
| 326 | }; | ||
| 327 | |||
| 328 | const googleDiscoveryOauth2SetupConfig = (source: Source) => | ||
| 329 | Effect.gen(function* () { | ||
| 330 | const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source); | ||
| 331 | const configuredScopes = bindingConfig.scopes ?? []; | ||
| 332 | const scopes = configuredScopes.length > 0 | ||
| 333 | ? configuredScopes | ||
| 334 | : yield* fetchGoogleDiscoveryDocumentWithHeaders({ | ||
| 335 | url: bindingConfig.discoveryUrl, | ||
| 336 | headers: bindingConfig.defaultHeaders ?? undefined, | ||
| 337 | }).pipe( | ||
| 338 | Effect.flatMap((document) => extractGoogleDiscoveryManifest(source.name, document)), | ||
| 339 | Effect.map((manifest) => Object.keys(manifest.oauthScopes ?? {})), | ||
| 340 | ); | ||
| 341 | |||
| 342 | if (scopes.length === 0) { | ||
| 343 | return null; | ||
| 344 | } | ||
| 345 | |||
| 346 | return { | ||
| 347 | providerKey: "google_workspace", | ||
| 348 | authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 349 | tokenEndpoint: "https://oauth2.googleapis.com/token", | ||
| 350 | scopes, | ||
| 351 | headerName: "Authorization", | ||
| 352 | prefix: "Bearer ", | ||
| 353 | clientAuthentication: "client_secret_post" as const, | ||
| 354 | authorizationParams: { | ||
| 355 | access_type: "offline", | ||
| 356 | prompt: "consent", | ||
| 357 | include_granted_scopes: "true", | ||
| 358 | }, | ||
| 359 | }; | ||
| 360 | }); | ||
| 361 | |||
| 362 | const trimEnv = (value: string | undefined): string | null => { | ||
| 363 | const trimmed = value?.trim(); | ||
| 364 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 365 | }; | ||
| 366 | |||
| 367 | export const googleDiscoverySourceAdapter: SourceAdapter = { | ||
| 368 | key: "google_discovery", | ||
| 369 | displayName: "Google Discovery", | ||
| 370 | family: "http_api", | ||
| 371 | bindingConfigVersion: GOOGLE_DISCOVERY_BINDING_CONFIG_VERSION, | ||
| 372 | providerKey: "google_workspace", | ||
| 373 | defaultImportAuthPolicy: "reuse_runtime", | ||
| 374 | primaryDocumentKind: "google_discovery", | ||
| 375 | primarySchemaBundleKind: "json_schema_ref_map", | ||
| 376 | connectPayloadSchema: GoogleDiscoveryConnectPayloadSchema, | ||
| 377 | executorAddInputSchema: GoogleDiscoveryExecutorAddInputSchema, | ||
| 378 | executorAddHelpText: [ | ||
| 379 | "service is the Discovery service name, e.g. sheets or drive. version is the API version, e.g. v4 or v3.", | ||
| 380 | ], | ||
| 381 | executorAddInputSignatureWidth: 420, | ||
| 382 | serializeBindingConfig: (source) => | ||
| 383 | encodeBindingConfig({ | ||
| 384 | adapterKey: "google_discovery", | ||
| 385 | version: GOOGLE_DISCOVERY_BINDING_CONFIG_VERSION, | ||
| 386 | payloadSchema: GoogleDiscoveryBindingConfigSchema, | ||
| 387 | payload: Effect.runSync(googleDiscoveryBindingConfigFromSource(source)), | ||
| 388 | }), | ||
| 389 | deserializeBindingConfig: ({ id, bindingConfigJson }) => | ||
| 390 | Effect.map( | ||
| 391 | decodeBindingConfig({ | ||
| 392 | sourceId: id, | ||
| 393 | label: "Google Discovery", | ||
| 394 | adapterKey: "google_discovery", | ||
| 395 | version: GOOGLE_DISCOVERY_BINDING_CONFIG_VERSION, | ||
| 396 | payloadSchema: GoogleDiscoveryBindingConfigSchema, | ||
| 397 | value: bindingConfigJson, | ||
| 398 | }), | ||
| 399 | ({ version, payload }) => ({ | ||
| 400 | version, | ||
| 401 | payload: { | ||
| 402 | service: payload.service, | ||
| 403 | version: payload.version, | ||
| 404 | discoveryUrl: payload.discoveryUrl, | ||
| 405 | defaultHeaders: payload.defaultHeaders ?? null, | ||
| 406 | scopes: payload.scopes ?? [], | ||
| 407 | }, | ||
| 408 | }), | ||
| 409 | ), | ||
| 410 | bindingStateFromSource: (source) => | ||
| 411 | Effect.map(googleDiscoveryBindingConfigFromSource(source), (bindingConfig) => ({ | ||
| 412 | ...emptySourceBindingState, | ||
| 413 | defaultHeaders: bindingConfig.defaultHeaders ?? null, | ||
| 414 | })), | ||
| 415 | sourceConfigFromSource: (source) => | ||
| 416 | Effect.runSync( | ||
| 417 | Effect.map(googleDiscoveryBindingConfigFromSource(source), (bindingConfig) => ({ | ||
| 418 | kind: "google_discovery", | ||
| 419 | service: bindingConfig.service, | ||
| 420 | version: bindingConfig.version, | ||
| 421 | discoveryUrl: bindingConfig.discoveryUrl, | ||
| 422 | defaultHeaders: bindingConfig.defaultHeaders, | ||
| 423 | scopes: bindingConfig.scopes, | ||
| 424 | })), | ||
| 425 | ), | ||
| 426 | validateSource: (source) => | ||
| 427 | Effect.gen(function* () { | ||
| 428 | const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source); | ||
| 429 | return { | ||
| 430 | ...source, | ||
| 431 | bindingVersion: GOOGLE_DISCOVERY_BINDING_CONFIG_VERSION, | ||
| 432 | binding: { | ||
| 433 | service: bindingConfig.service, | ||
| 434 | version: bindingConfig.version, | ||
| 435 | discoveryUrl: bindingConfig.discoveryUrl, | ||
| 436 | defaultHeaders: bindingConfig.defaultHeaders ?? null, | ||
| 437 | scopes: [...(bindingConfig.scopes ?? [])], | ||
| 438 | }, | ||
| 439 | }; | ||
| 440 | }), | ||
| 441 | shouldAutoProbe: (source) => | ||
| 442 | source.enabled && (source.status === "draft" || source.status === "probing"), | ||
| 443 | parseManifest: ({ source, manifestJson }) => | ||
| 444 | parseJsonValue<GoogleDiscoveryToolManifest>({ | ||
| 445 | label: `Google Discovery manifest for ${source.id}`, | ||
| 446 | value: manifestJson, | ||
| 447 | }), | ||
| 448 | describePersistedOperation: ({ path, operation }) => | ||
| 449 | Effect.gen(function* () { | ||
| 450 | const decoded = operation.providerDataJson | ||
| 451 | ? decodeGoogleDiscoveryProviderDataJson(operation.providerDataJson) | ||
| 452 | : Either.left(new Error("Missing providerDataJson")); | ||
| 453 | if (Either.isLeft(decoded)) { | ||
| 454 | return yield* Effect.fail( | ||
| 455 | new Error(`Invalid Google Discovery provider data for ${path}`), | ||
| 456 | ); | ||
| 457 | } | ||
| 458 | |||
| 459 | const providerData = decoded.right; | ||
| 460 | const method = providerData.invocation.method.toUpperCase(); | ||
| 461 | return { | ||
| 462 | method: providerData.invocation.method, | ||
| 463 | pathTemplate: providerData.invocation.path, | ||
| 464 | rawToolId: providerData.rawToolId, | ||
| 465 | operationId: providerData.methodId, | ||
| 466 | group: providerData.group, | ||
| 467 | leaf: providerData.leaf, | ||
| 468 | tags: [], | ||
| 469 | searchText: normalizeSearchText( | ||
| 470 | path, | ||
| 471 | operation.toolId, | ||
| 472 | operation.title ?? undefined, | ||
| 473 | operation.description ?? undefined, | ||
| 474 | providerData.rawToolId, | ||
| 475 | providerData.methodId, | ||
| 476 | method, | ||
| 477 | providerData.invocation.path, | ||
| 478 | providerData.group ?? undefined, | ||
| 479 | providerData.leaf, | ||
| 480 | providerData.service, | ||
| 481 | providerData.version, | ||
| 482 | providerData.invocation.scopes.join(" "), | ||
| 483 | operation.searchText, | ||
| 484 | ), | ||
| 485 | interaction: method === "GET" || method === "HEAD" ? "auto" : "required", | ||
| 486 | approvalLabel: `${method} ${providerData.invocation.path}`, | ||
| 487 | } as const; | ||
| 488 | }), | ||
| 489 | createToolDescriptor: ({ source, operation, path, includeSchemas, schemaBundleId }) => | ||
| 490 | createStandardToolDescriptor({ | ||
| 491 | source, | ||
| 492 | operation, | ||
| 493 | path, | ||
| 494 | includeSchemas, | ||
| 495 | interaction: operation.operationKind === "read" ? "auto" : "required", | ||
| 496 | outputType: typeSignatureFromSchemaJson( | ||
| 497 | operation.outputSchemaJson ?? undefined, | ||
| 498 | "unknown", | ||
| 499 | 320, | ||
| 500 | ), | ||
| 501 | schemaBundleId, | ||
| 502 | }), | ||
| 503 | materializeSource: ({ source, resolveAuthMaterialForSlot }): Effect.Effect< | ||
| 504 | SourceAdapterMaterialization, | ||
| 505 | Error, | ||
| 506 | never | ||
| 507 | > => | ||
| 508 | Effect.gen(function* () { | ||
| 509 | const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source); | ||
| 510 | const auth = yield* resolveAuthMaterialForSlot("import"); | ||
| 511 | const discoveryDocument = yield* fetchGoogleDiscoveryDocumentWithHeaders({ | ||
| 512 | url: bindingConfig.discoveryUrl, | ||
| 513 | headers: { | ||
| 514 | ...(bindingConfig.defaultHeaders ?? {}), | ||
| 515 | ...auth.headers, | ||
| 516 | }, | ||
| 517 | cookies: auth.cookies, | ||
| 518 | queryParams: auth.queryParams, | ||
| 519 | }).pipe( | ||
| 520 | Effect.mapError((cause) => | ||
| 521 | isSourceCredentialRequiredError(cause) | ||
| 522 | ? cause | ||
| 523 | : new Error( | ||
| 524 | `Failed fetching Google Discovery document for ${source.id}: ${cause.message}`, | ||
| 525 | ), | ||
| 526 | ), | ||
| 527 | ); | ||
| 528 | const manifest = yield* extractGoogleDiscoveryManifest( | ||
| 529 | source.name, | ||
| 530 | discoveryDocument, | ||
| 531 | ); | ||
| 532 | const definitions = compileGoogleDiscoveryToolDefinitions(manifest); | ||
| 533 | const now = Date.now(); | ||
| 534 | const schemaBundle = toGoogleDiscoverySchemaBundleRecord({ | ||
| 535 | recipeRevisionId: GOOGLE_DISCOVERY_MATERIALIZATION_REVISION_ID, | ||
| 536 | refTable: manifest.schemaRefTable, | ||
| 537 | now, | ||
| 538 | }); | ||
| 539 | |||
| 540 | return { | ||
| 541 | manifestJson: JSON.stringify(manifest), | ||
| 542 | manifestHash: manifest.sourceHash, | ||
| 543 | sourceHash: manifest.sourceHash, | ||
| 544 | documents: [ | ||
| 545 | { | ||
| 546 | id: `src_recipe_doc_${crypto.randomUUID()}`, | ||
| 547 | recipeRevisionId: GOOGLE_DISCOVERY_MATERIALIZATION_REVISION_ID, | ||
| 548 | documentKind: "google_discovery", | ||
| 549 | documentKey: bindingConfig.discoveryUrl, | ||
| 550 | contentText: discoveryDocument, | ||
| 551 | contentHash: contentHash(discoveryDocument), | ||
| 552 | fetchedAt: now, | ||
| 553 | createdAt: now, | ||
| 554 | updatedAt: now, | ||
| 555 | }, | ||
| 556 | ], | ||
| 557 | schemaBundles: schemaBundle ? [schemaBundle] : [], | ||
| 558 | operations: definitions.map((definition) => | ||
| 559 | toGoogleDiscoveryRecipeOperationRecord({ | ||
| 560 | recipeRevisionId: GOOGLE_DISCOVERY_MATERIALIZATION_REVISION_ID, | ||
| 561 | manifest, | ||
| 562 | definition, | ||
| 563 | now, | ||
| 564 | })), | ||
| 565 | }; | ||
| 566 | }), | ||
| 567 | getOauth2SetupConfig: ({ source }) => googleDiscoveryOauth2SetupConfig(source), | ||
| 568 | getDefaultOauthClient: () => | ||
| 569 | Effect.succeed( | ||
| 570 | trimEnv(process.env.GOOGLE_CLIENT_ID) | ||
| 571 | ? { | ||
| 572 | providerKey: "google_workspace", | ||
| 573 | clientId: trimEnv(process.env.GOOGLE_CLIENT_ID)!, | ||
| 574 | clientSecret: trimEnv(process.env.GOOGLE_CLIENT_SECRET), | ||
| 575 | redirectMode: "loopback", | ||
| 576 | } | ||
| 577 | : null, | ||
| 578 | ), | ||
| 579 | invokePersistedTool: ({ | ||
| 580 | source, | ||
| 581 | path, | ||
| 582 | operation, | ||
| 583 | schemaBundle, | ||
| 584 | auth, | ||
| 585 | args, | ||
| 586 | context, | ||
| 587 | onElicitation, | ||
| 588 | }) => | ||
| 589 | Effect.gen(function* () { | ||
| 590 | const bindingConfig = yield* googleDiscoveryBindingConfigFromSource(source); | ||
| 591 | const schemaRefTable = schemaBundle | ||
| 592 | ? decodeGoogleDiscoverySchemaRefTableJson(schemaBundle.refsJson) | ||
| 593 | : Either.right({}); | ||
| 594 | if (Either.isLeft(schemaRefTable)) { | ||
| 595 | return yield* Effect.fail( | ||
| 596 | new Error(`Invalid Google Discovery schema bundle for ${path}`), | ||
| 597 | ); | ||
| 598 | } | ||
| 599 | |||
| 600 | const { definition, providerData } = googleDiscoveryDefinitionFromPersistedOperation({ | ||
| 601 | path, | ||
| 602 | operation, | ||
| 603 | }); | ||
| 604 | const stringRefTable = Object.fromEntries( | ||
| 605 | Object.entries(schemaRefTable.right).map(([ref, value]) => [ | ||
| 606 | ref, | ||
| 607 | typeof value === "string" ? value : JSON.stringify(value), | ||
| 608 | ]), | ||
| 609 | ); | ||
| 610 | |||
| 611 | const tool = createGoogleDiscoveryToolFromDefinition({ | ||
| 612 | definition, | ||
| 613 | service: providerData.service, | ||
| 614 | version: providerData.version, | ||
| 615 | rootUrl: providerData.invocation.rootUrl, | ||
| 616 | servicePath: providerData.invocation.servicePath, | ||
| 617 | path, | ||
| 618 | sourceKey: source.id, | ||
| 619 | defaultHeaders: bindingConfig.defaultHeaders ?? {}, | ||
| 620 | credentialPlacements: auth, | ||
| 621 | schemaRefTable: stringRefTable, | ||
| 622 | }); | ||
| 623 | |||
| 624 | return yield* makeToolInvokerFromTools({ | ||
| 625 | tools: { | ||
| 626 | [path]: tool, | ||
| 627 | }, | ||
| 628 | onElicitation, | ||
| 629 | }).invoke({ | ||
| 630 | path, | ||
| 631 | args, | ||
| 632 | context, | ||
| 633 | }).pipe( | ||
| 634 | Effect.mapError((cause) => | ||
| 635 | cause instanceof Error ? cause : new Error(String(cause)), | ||
| 636 | ), | ||
| 637 | ); | ||
| 638 | }), | ||
| 639 | }; | ||
| @@ -1,4 +1,7 @@ | |||
| 1 | import { | 1 | import { |
| 2 | applyCookiePlacementsToHeaders, | ||
| 3 | applyHttpQueryPlacementsToUrl, | ||
| 4 | applyJsonBodyPlacements, | ||
| 2 | makeToolInvokerFromTools, | 5 | makeToolInvokerFromTools, |
| 3 | typeSignatureFromSchemaJson, | 6 | typeSignatureFromSchemaJson, |
| 4 | } from "@executor/codemode-core"; | 7 | } from "@executor/codemode-core"; |
| @@ -135,20 +138,38 @@ const graphqlBindingConfigFromSource = ( · 130 unmodified lines | |||
| 135 | const fetchGraphqlIntrospectionDocumentWithHeaders = (input: { | 138 | const fetchGraphqlIntrospectionDocumentWithHeaders = (input: { |
| 136 | url: string; | 139 | url: string; |
| 137 | headers?: Readonly<Record<string, string>>; | 140 | headers?: Readonly<Record<string, string>>; |
| 141 | queryParams?: Readonly<Record<string, string>>; | ||
| 142 | cookies?: Readonly<Record<string, string>>; | ||
| 143 | bodyValues?: Readonly<Record<string, string>>; | ||
| 138 | }): Effect.Effect<string, Error, never> => | 144 | }): Effect.Effect<string, Error, never> => |
| 139 | Effect.tryPromise({ | 145 | Effect.tryPromise({ |
| 140 | try: async () => { | 146 | try: async () => { |
| 141 | const response = await fetch(input.url, { | 147 | const response = await fetch( |
| 148 | applyHttpQueryPlacementsToUrl({ | ||
| 149 | url: input.url, | ||
| 150 | queryParams: input.queryParams, | ||
| 151 | }).toString(), | ||
| 152 | { | ||
| 142 | method: "POST", | 153 | method: "POST", |
| 143 | headers: { | 154 | headers: applyCookiePlacementsToHeaders({ |
| 144 | "content-type": "application/json", | 155 | headers: { |
| 145 | ...(input.headers ?? {}), | 156 | "content-type": "application/json", |
| 157 | ...(input.headers ?? {}), | ||
| 158 | }, | ||
| 159 | cookies: input.cookies, | ||
| 160 | }), | ||
| 161 | body: JSON.stringify( | ||
| 162 | applyJsonBodyPlacements({ | ||
| 163 | body: { | ||
| 164 | query: GRAPHQL_INTROSPECTION_QUERY, | ||
| 165 | operationName: "IntrospectionQuery", | ||
| 166 | }, | ||
| 167 | bodyValues: input.bodyValues, | ||
| 168 | label: `GraphQL introspection ${input.url}`, | ||
| 169 | }), | ||
| 170 | ), | ||
| 146 | }, | 171 | }, |
| 147 | body: JSON.stringify({ | 172 | ); |
| 148 | query: GRAPHQL_INTROSPECTION_QUERY, | ||
| 149 | operationName: "IntrospectionQuery", | ||
| 150 | }), | ||
| 151 | }); | ||
| 152 | const text = await response.text(); | 173 | const text = await response.text(); |
| 153 | 174 | ||
| 154 | let parsed: unknown; | 175 | let parsed: unknown; |
| @@ -470,6 +491,9 @@ export const graphqlSourceAdapter: SourceAdapter = { · 315 unmodified lines | |||
| 470 | ...(bindingConfig.defaultHeaders ?? {}), | 491 | ...(bindingConfig.defaultHeaders ?? {}), |
| 471 | ...auth.headers, | 492 | ...auth.headers, |
| 472 | }, | 493 | }, |
| 494 | queryParams: auth.queryParams, | ||
| 495 | cookies: auth.cookies, | ||
| 496 | bodyValues: auth.bodyValues, | ||
| 473 | }, | 497 | }, |
| 474 | ).pipe( | 498 | ).pipe( |
| 475 | Effect.mapError( | 499 | Effect.mapError( |
| @@ -564,7 +588,7 @@ export const graphqlSourceAdapter: SourceAdapter = { · 88 unmodified lines | |||
| 564 | providerDataJson: operation.providerDataJson, | 588 | providerDataJson: operation.providerDataJson, |
| 565 | schemaRefTable: schemaRefTable.right, | 589 | schemaRefTable: schemaRefTable.right, |
| 566 | defaultHeaders: bindingConfig.defaultHeaders ?? {}, | 590 | defaultHeaders: bindingConfig.defaultHeaders ?? {}, |
| 567 | credentialHeaders: auth.headers, | 591 | credentialPlacements: auth, |
| 568 | }); | 592 | }); |
| 569 | 593 | ||
| 570 | return yield* makeToolInvokerFromTools({ | 594 | return yield* makeToolInvokerFromTools({ |
| @@ -2,6 +2,7 @@ import type { Source, StoredSourceRecipeOperationRecord } from "#schema"; · 1 unmodified line | |||
| 2 | import * as Schema from "effect/Schema"; | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | ||
| 4 | import { graphqlSourceAdapter } from "./graphql"; | 4 | import { graphqlSourceAdapter } from "./graphql"; |
| 5 | import { googleDiscoverySourceAdapter } from "./google-discovery"; | ||
| 5 | import { internalSourceAdapter } from "./internal"; | 6 | import { internalSourceAdapter } from "./internal"; |
| 6 | import { mcpSourceAdapter } from "./mcp"; | 7 | import { mcpSourceAdapter } from "./mcp"; |
| 7 | import { openApiSourceAdapter } from "./openapi"; | 8 | import { openApiSourceAdapter } from "./openapi"; |
| @@ -10,6 +11,7 @@ import type { SourceAdapter } from "./types"; · 2 unmodified lines | |||
| 10 | export const builtInSourceAdapters = [ | 11 | export const builtInSourceAdapters = [ |
| 11 | openApiSourceAdapter, | 12 | openApiSourceAdapter, |
| 12 | graphqlSourceAdapter, | 13 | graphqlSourceAdapter, |
| 14 | googleDiscoverySourceAdapter, | ||
| 13 | mcpSourceAdapter, | 15 | mcpSourceAdapter, |
| 14 | internalSourceAdapter, | 16 | internalSourceAdapter, |
| 15 | ] as const satisfies readonly SourceAdapter[]; | 17 | ] as const satisfies readonly SourceAdapter[]; |
| @@ -18,12 +20,14 @@ export const connectableSourceAdapters = [ · 2 unmodified lines | |||
| 18 | mcpSourceAdapter, | 20 | mcpSourceAdapter, |
| 19 | openApiSourceAdapter, | 21 | openApiSourceAdapter, |
| 20 | graphqlSourceAdapter, | 22 | graphqlSourceAdapter, |
| 23 | googleDiscoverySourceAdapter, | ||
| 21 | ] as const; | 24 | ] as const; |
| 22 | 25 | ||
| 23 | export const ConnectSourcePayloadSchema = Schema.Union( | 26 | export const ConnectSourcePayloadSchema = Schema.Union( |
| 24 | mcpSourceAdapter.connectPayloadSchema!, | 27 | mcpSourceAdapter.connectPayloadSchema!, |
| 25 | openApiSourceAdapter.connectPayloadSchema!, | 28 | openApiSourceAdapter.connectPayloadSchema!, |
| 26 | graphqlSourceAdapter.connectPayloadSchema!, | 29 | graphqlSourceAdapter.connectPayloadSchema!, |
| 30 | googleDiscoverySourceAdapter.connectPayloadSchema!, | ||
| 27 | ); | 31 | ); |
| 28 | 32 | ||
| 29 | export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type; | 33 | export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type; |
| @@ -32,12 +36,14 @@ export const executorAddableSourceAdapters = [ · 2 unmodified lines | |||
| 32 | mcpSourceAdapter, | 36 | mcpSourceAdapter, |
| 33 | openApiSourceAdapter, | 37 | openApiSourceAdapter, |
| 34 | graphqlSourceAdapter, | 38 | graphqlSourceAdapter, |
| 39 | googleDiscoverySourceAdapter, | ||
| 35 | ] as const; | 40 | ] as const; |
| 36 | 41 | ||
| 37 | export const ExecutorAddSourceInputSchema = Schema.Union( | 42 | export const ExecutorAddSourceInputSchema = Schema.Union( |
| 38 | mcpSourceAdapter.executorAddInputSchema!, | 43 | mcpSourceAdapter.executorAddInputSchema!, |
| 39 | openApiSourceAdapter.executorAddInputSchema!, | 44 | openApiSourceAdapter.executorAddInputSchema!, |
| 40 | graphqlSourceAdapter.executorAddInputSchema!, | 45 | graphqlSourceAdapter.executorAddInputSchema!, |
| 46 | googleDiscoverySourceAdapter.executorAddInputSchema!, | ||
| 41 | ); | 47 | ); |
| 42 | 48 | ||
| 43 | export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type; | 49 | export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type; |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { | 1 | import { |
| 2 | applyCookiePlacementsToHeaders, | ||
| 2 | makeToolInvokerFromTools, | 3 | makeToolInvokerFromTools, |
| 3 | } from "@executor/codemode-core"; | 4 | } from "@executor/codemode-core"; |
| 4 | import { | 5 | import { |
| @@ -41,6 +42,19 @@ import { · 36 unmodified lines | |||
| 41 | SourceConnectCommonFieldsSchema, | 42 | SourceConnectCommonFieldsSchema, |
| 42 | } from "./shared"; | 43 | } from "./shared"; |
| 43 | 44 | ||
| 45 | const headersWithAuthCookies = (input: { | ||
| 46 | headers: Readonly<Record<string, string>>; | ||
| 47 | authHeaders: Readonly<Record<string, string>>; | ||
| 48 | authCookies: Readonly<Record<string, string>>; | ||
| 49 | }): Record<string, string> => | ||
| 50 | applyCookiePlacementsToHeaders({ | ||
| 51 | headers: { | ||
| 52 | ...input.headers, | ||
| 53 | ...input.authHeaders, | ||
| 54 | }, | ||
| 55 | cookies: input.authCookies, | ||
| 56 | }); | ||
| 57 | |||
| 44 | const McpConnectPayloadSchema = Schema.extend( | 58 | const McpConnectPayloadSchema = Schema.extend( |
| 45 | SourceConnectCommonFieldsSchema, | 59 | SourceConnectCommonFieldsSchema, |
| 46 | Schema.extend( | 60 | Schema.extend( |
| @@ -339,11 +353,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 292 unmodified lines | |||
| 339 | createSdkMcpConnector({ | 353 | createSdkMcpConnector({ |
| 340 | endpoint: source.endpoint, | 354 | endpoint: source.endpoint, |
| 341 | transport: bindingConfig.transport ?? undefined, | 355 | transport: bindingConfig.transport ?? undefined, |
| 342 | queryParams: bindingConfig.queryParams ?? undefined, | 356 | queryParams: { |
| 343 | headers: { | 357 | ...(bindingConfig.queryParams ?? {}), |
| 344 | ...(bindingConfig.headers ?? {}), | 358 | ...auth.queryParams, |
| 345 | ...auth.headers, | ||
| 346 | }, | 359 | }, |
| 360 | headers: headersWithAuthCookies({ | ||
| 361 | headers: bindingConfig.headers ?? {}, | ||
| 362 | authHeaders: auth.headers, | ||
| 363 | authCookies: auth.cookies, | ||
| 364 | }), | ||
| 347 | }), | 365 | }), |
| 348 | catch: (cause) => | 366 | catch: (cause) => |
| 349 | cause instanceof Error ? cause : new Error(String(cause)), | 367 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -394,11 +412,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 44 unmodified lines | |||
| 394 | connect: createSdkMcpConnector({ | 412 | connect: createSdkMcpConnector({ |
| 395 | endpoint: source.endpoint, | 413 | endpoint: source.endpoint, |
| 396 | transport: bindingConfig.transport ?? undefined, | 414 | transport: bindingConfig.transport ?? undefined, |
| 397 | queryParams: bindingConfig.queryParams ?? undefined, | 415 | queryParams: { |
| 398 | headers: { | 416 | ...(bindingConfig.queryParams ?? {}), |
| 399 | ...(bindingConfig.headers ?? {}), | 417 | ...auth.queryParams, |
| 400 | ...auth.headers, | ||
| 401 | }, | 418 | }, |
| 419 | headers: headersWithAuthCookies({ | ||
| 420 | headers: bindingConfig.headers ?? {}, | ||
| 421 | authHeaders: auth.headers, | ||
| 422 | authCookies: auth.cookies, | ||
| 423 | }), | ||
| 402 | }), | 424 | }), |
| 403 | namespace: source.namespace ?? namespaceFromSourceName(source.name), | 425 | namespace: source.namespace ?? namespaceFromSourceName(source.name), |
| 404 | sourceKey: source.id, | 426 | sourceKey: source.id, |
| @@ -5,6 +5,8 @@ import { · 4 unmodified lines | |||
| 5 | } from "@effect/platform"; | 5 | } from "@effect/platform"; |
| 6 | import type { SqlControlPlaneRows } from "#persistence"; | 6 | import type { SqlControlPlaneRows } from "#persistence"; |
| 7 | import { | 7 | import { |
| 8 | applyCookiePlacementsToHeaders, | ||
| 9 | applyHttpQueryPlacementsToUrl, | ||
| 8 | makeToolInvokerFromTools, | 10 | makeToolInvokerFromTools, |
| 9 | } from "@executor/codemode-core"; | 11 | } from "@executor/codemode-core"; |
| 10 | import { | 12 | import { |
| @@ -152,11 +154,23 @@ const openApiBindingConfigFromSource = ( · 141 unmodified lines | |||
| 152 | const fetchOpenApiDocumentWithHeaders = (input: { | 154 | const fetchOpenApiDocumentWithHeaders = (input: { |
| 153 | url: string; | 155 | url: string; |
| 154 | headers?: Readonly<Record<string, string>>; | 156 | headers?: Readonly<Record<string, string>>; |
| 157 | queryParams?: Readonly<Record<string, string>>; | ||
| 158 | cookies?: Readonly<Record<string, string>>; | ||
| 155 | }): Effect.Effect<string, Error, never> => | 159 | }): Effect.Effect<string, Error, never> => |
| 156 | Effect.gen(function* () { | 160 | Effect.gen(function* () { |
| 157 | const client = yield* HttpClient.HttpClient; | 161 | const client = yield* HttpClient.HttpClient; |
| 158 | const request = HttpClientRequest.get(input.url).pipe( | 162 | const request = HttpClientRequest.get( |
| 159 | HttpClientRequest.setHeaders(input.headers ?? {}), | 163 | applyHttpQueryPlacementsToUrl({ |
| 164 | url: input.url, | ||
| 165 | queryParams: input.queryParams, | ||
| 166 | }).toString(), | ||
| 167 | ).pipe( | ||
| 168 | HttpClientRequest.setHeaders( | ||
| 169 | applyCookiePlacementsToHeaders({ | ||
| 170 | headers: input.headers ?? {}, | ||
| 171 | cookies: input.cookies, | ||
| 172 | }), | ||
| 173 | ), | ||
| 160 | ); | 174 | ); |
| 161 | const response = yield* client.execute(request).pipe( | 175 | const response = yield* client.execute(request).pipe( |
| 162 | Effect.mapError((cause) => | 176 | Effect.mapError((cause) => |
| @@ -522,6 +536,8 @@ export const openApiSourceAdapter: SourceAdapter = { · 359 unmodified lines | |||
| 522 | ...(bindingConfig.defaultHeaders ?? {}), | 536 | ...(bindingConfig.defaultHeaders ?? {}), |
| 523 | ...auth.headers, | 537 | ...auth.headers, |
| 524 | }, | 538 | }, |
| 539 | queryParams: auth.queryParams, | ||
| 540 | cookies: auth.cookies, | ||
| 525 | }).pipe( | 541 | }).pipe( |
| 526 | Effect.mapError( | 542 | Effect.mapError( |
| 527 | (cause) => | 543 | (cause) => |
| @@ -609,7 +625,7 @@ export const openApiSourceAdapter: SourceAdapter = { · 81 unmodified lines | |||
| 609 | sourceKey: source.id, | 625 | sourceKey: source.id, |
| 610 | baseUrl: source.endpoint, | 626 | baseUrl: source.endpoint, |
| 611 | defaultHeaders: bindingConfig.defaultHeaders ?? {}, | 627 | defaultHeaders: bindingConfig.defaultHeaders ?? {}, |
| 612 | credentialHeaders: auth.headers, | 628 | credentialPlacements: auth, |
| 613 | refHintTable: refHintTable.right, | 629 | refHintTable: refHintTable.right, |
| 614 | }); | 630 | }); |
| 615 | 631 | ||
| @@ -7,6 +7,7 @@ import type { SqlControlPlaneRows } from "#persistence"; · 6 unmodified lines | |||
| 7 | import type { | 7 | import type { |
| 8 | AccountId, | 8 | AccountId, |
| 9 | CredentialSlot, | 9 | CredentialSlot, |
| 10 | OAuth2ClientAuthenticationMethod, | ||
| 10 | Source, | 11 | Source, |
| 11 | SourceBinding, | 12 | SourceBinding, |
| 12 | SourceImportAuthPolicy, | 13 | SourceImportAuthPolicy, |
| @@ -68,6 +69,24 @@ export type SourceAdapterPersistedOperationMetadata = { · 55 unmodified lines | |||
| 68 | approvalLabel: string | null; | 69 | approvalLabel: string | null; |
| 69 | }; | 70 | }; |
| 70 | 71 | ||
| 72 | export type SourceAdapterOauth2SetupConfig = { | ||
| 73 | providerKey: string; | ||
| 74 | authorizationEndpoint: string; | ||
| 75 | tokenEndpoint: string; | ||
| 76 | scopes: readonly string[]; | ||
| 77 | headerName: string; | ||
| 78 | prefix: string; | ||
| 79 | clientAuthentication: OAuth2ClientAuthenticationMethod; | ||
| 80 | authorizationParams?: Readonly<Record<string, string>>; | ||
| 81 | }; | ||
| 82 | |||
| 83 | export type SourceAdapterDefaultOauthClient = { | ||
| 84 | providerKey: string; | ||
| 85 | clientId: string; | ||
| 86 | clientSecret: string | null; | ||
| 87 | redirectMode?: "app_callback" | "loopback"; | ||
| 88 | }; | ||
| 89 | |||
| 71 | export type SourceAdapterRepairRevisionInput = { | 90 | export type SourceAdapterRepairRevisionInput = { |
| 72 | rows: SqlControlPlaneRows; | 91 | rows: SqlControlPlaneRows; |
| 73 | sourceRecord: StoredSourceRecord; | 92 | sourceRecord: StoredSourceRecord; |
| @@ -137,6 +156,13 @@ export type SourceAdapter = { · 63 unmodified lines | |||
| 137 | materializeSource: ( | 156 | materializeSource: ( |
| 138 | input: SourceAdapterSyncInput, | 157 | input: SourceAdapterSyncInput, |
| 139 | ) => Effect.Effect<SourceAdapterMaterialization, Error, never>; | 158 | ) => Effect.Effect<SourceAdapterMaterialization, Error, never>; |
| 159 | getOauth2SetupConfig?: (input: { | ||
| 160 | source: Source; | ||
| 161 | slot: CredentialSlot; | ||
| 162 | }) => Effect.Effect<SourceAdapterOauth2SetupConfig | null, Error, never>; | ||
| 163 | getDefaultOauthClient?: ( | ||
| 164 | source: Source, | ||
| 165 | ) => Effect.Effect<SourceAdapterDefaultOauthClient | null, Error, never>; | ||
| 140 | invokePersistedTool: ( | 166 | invokePersistedTool: ( |
| 141 | input: SourceAdapterInvokePersistedToolInput, | 167 | input: SourceAdapterInvokePersistedToolInput, |
| 142 | ) => Effect.Effect<unknown, Error, never>; | 168 | ) => Effect.Effect<unknown, Error, never>; |
| @@ -43,7 +43,15 @@ describe("source-auth-material", () => { · 42 unmodified lines | |||
| 43 | resolveSecretMaterial: () => Effect.die("should not be called"), | 43 | resolveSecretMaterial: () => Effect.die("should not be called"), |
| 44 | })); | 44 | })); |
| 45 | 45 | ||
| 46 | expect(auth).toEqual({ headers: {} }); | 46 | expect(auth).toEqual({ |
| 47 | placements: [], | ||
| 48 | headers: {}, | ||
| 49 | queryParams: {}, | ||
| 50 | cookies: {}, | ||
| 51 | bodyValues: {}, | ||
| 52 | expiresAt: null, | ||
| 53 | refreshAfter: null, | ||
| 54 | }); | ||
| 47 | }); | 55 | }); |
| 48 | 56 | ||
| 49 | it("resolves bearer auth headers from the configured token ref", async () => { | 57 | it("resolves bearer auth headers from the configured token ref", async () => { |
| @@ -69,9 +77,21 @@ describe("source-auth-material", () => { · 19 unmodified lines | |||
| 69 | 77 | ||
| 70 | expect(calls).toEqual(["postgres:sec_bearer"]); | 78 | expect(calls).toEqual(["postgres:sec_bearer"]); |
| 71 | expect(auth).toEqual({ | 79 | expect(auth).toEqual({ |
| 80 | placements: [ | ||
| 81 | { | ||
| 82 | location: "header", | ||
| 83 | name: "X-Api-Key", | ||
| 84 | value: "Token resolved-bearer-token", | ||
| 85 | }, | ||
| 86 | ], | ||
| 72 | headers: { | 87 | headers: { |
| 73 | "X-Api-Key": "Token resolved-bearer-token", | 88 | "X-Api-Key": "Token resolved-bearer-token", |
| 74 | }, | 89 | }, |
| 90 | queryParams: {}, | ||
| 91 | cookies: {}, | ||
| 92 | bodyValues: {}, | ||
| 93 | expiresAt: null, | ||
| 94 | refreshAfter: null, | ||
| 75 | }); | 95 | }); |
| 76 | }); | 96 | }); |
| 77 | 97 | ||
| @@ -107,9 +127,21 @@ describe("source-auth-material", () => { · 29 unmodified lines | |||
| 107 | 127 | ||
| 108 | expect(calls).toEqual(["postgres:sec_access"]); | 128 | expect(calls).toEqual(["postgres:sec_access"]); |
| 109 | expect(auth).toEqual({ | 129 | expect(auth).toEqual({ |
| 130 | placements: [ | ||
| 131 | { | ||
| 132 | location: "header", | ||
| 133 | name: "Authorization", | ||
| 134 | value: "Bearer resolved-access-token", | ||
| 135 | }, | ||
| 136 | ], | ||
| 110 | headers: { | 137 | headers: { |
| 111 | Authorization: "Bearer resolved-access-token", | 138 | Authorization: "Bearer resolved-access-token", |
| 112 | }, | 139 | }, |
| 140 | queryParams: {}, | ||
| 141 | cookies: {}, | ||
| 142 | bodyValues: {}, | ||
| 143 | expiresAt: null, | ||
| 144 | refreshAfter: null, | ||
| 113 | }); | 145 | }); |
| 114 | }); | 146 | }); |
| 115 | }); | 147 | }); |
| @@ -1,42 +1,24 @@ | |||
| 1 | import type { CredentialSlot, Source, SourceAuth } from "#schema"; | 1 | import type { SqlControlPlaneRows } from "#persistence"; |
| 2 | import type { | ||
| 3 | AccountId, | ||
| 4 | CredentialSlot, | ||
| 5 | Source, | ||
| 6 | SourceAuth, | ||
| 7 | } from "#schema"; | ||
| 2 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Option from "effect/Option"; | ||
| 3 | 10 | ||
| 11 | import { | ||
| 12 | authArtifactFromSourceAuth, | ||
| 13 | resolveAuthArtifactMaterial, | ||
| 14 | type ResolvedSourceAuthMaterial, | ||
| 15 | } from "./auth-artifacts"; | ||
| 16 | import { resolveAuthArtifactMaterialWithLeases } from "./auth-leases"; | ||
| 4 | import type { | 17 | import type { |
| 5 | ResolveSecretMaterial, | 18 | ResolveSecretMaterial, |
| 6 | SecretMaterialResolveContext, | 19 | SecretMaterialResolveContext, |
| 7 | } from "./secret-material-providers"; | 20 | } from "./secret-material-providers"; |
| 8 | 21 | ||
| 9 | export type ResolvedSourceAuthMaterial = { | ||
| 10 | headers: Readonly<Record<string, string>>; | ||
| 11 | }; | ||
| 12 | |||
| 13 | export const resolveAuthMaterial = (input: { | ||
| 14 | auth: SourceAuth; | ||
| 15 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 16 | context?: SecretMaterialResolveContext; | ||
| 17 | }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> => | ||
| 18 | Effect.gen(function* () { | ||
| 19 | if (input.auth.kind === "none") { | ||
| 20 | return { headers: {} } satisfies ResolvedSourceAuthMaterial; | ||
| 21 | } | ||
| 22 | |||
| 23 | const tokenRef = | ||
| 24 | input.auth.kind === "bearer" | ||
| 25 | ? input.auth.token | ||
| 26 | : input.auth.accessToken; | ||
| 27 | |||
| 28 | const token = yield* input.resolveSecretMaterial({ | ||
| 29 | ref: tokenRef, | ||
| 30 | context: input.context, | ||
| 31 | }); | ||
| 32 | |||
| 33 | return { | ||
| 34 | headers: { | ||
| 35 | [input.auth.headerName]: `${input.auth.prefix}${token}`, | ||
| 36 | }, | ||
| 37 | } satisfies ResolvedSourceAuthMaterial; | ||
| 38 | }); | ||
| 39 | |||
| 40 | const authForSlot = (input: { | 22 | const authForSlot = (input: { |
| 41 | source: Source; | 23 | source: Source; |
| 42 | slot: CredentialSlot; | 24 | slot: CredentialSlot; |
| @@ -59,14 +41,69 @@ const authForSlot = (input: { · 16 unmodified lines | |||
| 59 | export const resolveSourceAuthMaterial = (input: { | 41 | export const resolveSourceAuthMaterial = (input: { |
| 60 | source: Source; | 42 | source: Source; |
| 61 | slot?: CredentialSlot; | 43 | slot?: CredentialSlot; |
| 44 | actorAccountId?: AccountId | null; | ||
| 45 | rows?: SqlControlPlaneRows; | ||
| 62 | resolveSecretMaterial: ResolveSecretMaterial; | 46 | resolveSecretMaterial: ResolveSecretMaterial; |
| 63 | context?: SecretMaterialResolveContext; | 47 | context?: SecretMaterialResolveContext; |
| 64 | }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> => | 48 | }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> => |
| 65 | resolveAuthMaterial({ | 49 | Effect.gen(function* () { |
| 66 | auth: authForSlot({ | 50 | const slot = input.slot ?? "runtime"; |
| 51 | |||
| 52 | if (input.rows !== undefined) { | ||
| 53 | const candidateSlots = | ||
| 54 | slot === "import" && input.source.importAuthPolicy === "reuse_runtime" | ||
| 55 | ? ["import", "runtime"] satisfies ReadonlyArray<CredentialSlot> | ||
| 56 | : [slot] satisfies ReadonlyArray<CredentialSlot>; | ||
| 57 | |||
| 58 | for (const candidateSlot of candidateSlots) { | ||
| 59 | const artifactOption = yield* input.rows.authArtifacts.getByWorkspaceSourceAndActor({ | ||
| 60 | workspaceId: input.source.workspaceId, | ||
| 61 | sourceId: input.source.id, | ||
| 62 | actorAccountId: input.actorAccountId ?? null, | ||
| 63 | slot: candidateSlot, | ||
| 64 | }); | ||
| 65 | |||
| 66 | if (Option.isSome(artifactOption)) { | ||
| 67 | return yield* resolveAuthArtifactMaterialWithLeases({ | ||
| 68 | rows: input.rows, | ||
| 69 | artifact: artifactOption.value, | ||
| 70 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 71 | context: input.context, | ||
| 72 | }); | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | const artifact = authArtifactFromSourceAuth({ | ||
| 67 | source: input.source, | 78 | source: input.source, |
| 68 | slot: input.slot ?? "runtime", | 79 | auth: authForSlot({ |
| 69 | }), | 80 | source: input.source, |
| 70 | resolveSecretMaterial: input.resolveSecretMaterial, | 81 | slot, |
| 71 | context: input.context, | 82 | }), |
| 83 | slot, | ||
| 84 | actorAccountId: input.actorAccountId ?? null, | ||
| 85 | }); | ||
| 86 | |||
| 87 | if (input.rows !== undefined) { | ||
| 88 | return yield* resolveAuthArtifactMaterialWithLeases({ | ||
| 89 | rows: input.rows, | ||
| 90 | artifact, | ||
| 91 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 92 | context: input.context, | ||
| 93 | }); | ||
| 94 | } | ||
| 95 | |||
| 96 | if (artifact?.artifactKind === "oauth2_authorized_user") { | ||
| 97 | return yield* Effect.fail( | ||
| 98 | new Error("Dynamic auth artifacts require persistence-backed lease resolution"), | ||
| 99 | ); | ||
| 100 | } | ||
| 101 | |||
| 102 | return yield* resolveAuthArtifactMaterial({ | ||
| 103 | artifact, | ||
| 104 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 105 | context: input.context, | ||
| 106 | }); | ||
| 72 | }); | 107 | }); |
| 108 | |||
| 109 | export type { ResolvedSourceAuthMaterial } from "./auth-artifacts"; | ||
| @@ -1,14 +1,33 @@ | |||
| 1 | import { createServer } from "node:http"; | ||
| 2 | |||
| 1 | import { describe, expect, it } from "vitest"; | 3 | import { describe, expect, it } from "vitest"; |
| 2 | import * as Schema from "effect/Schema"; | 4 | import * as Schema from "effect/Schema"; |
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 3 | 7 | ||
| 4 | import { | 8 | import { |
| 9 | AccountIdSchema, | ||
| 5 | McpSourceAuthSessionDataJsonSchema, | 10 | McpSourceAuthSessionDataJsonSchema, |
| 11 | OAuth2PkceSourceAuthSessionDataJsonSchema, | ||
| 12 | SecretMaterialIdSchema, | ||
| 6 | type Source, | 13 | type Source, |
| 14 | type SourceAuthSession, | ||
| 15 | WorkspaceIdSchema, | ||
| 16 | decodeAuthLeasePlacementTemplates, | ||
| 17 | decodeBuiltInAuthArtifactConfig, | ||
| 7 | } from "#schema"; | 18 | } from "#schema"; |
| 19 | import { | ||
| 20 | createSqlControlPlanePersistence, | ||
| 21 | type SqlControlPlanePersistence, | ||
| 22 | } from "#persistence"; | ||
| 8 | 23 | ||
| 9 | import { | 24 | import { |
| 25 | createRuntimeSourceAuthService, | ||
| 10 | createTerminalSourceAuthSessionPatch, | 26 | createTerminalSourceAuthSessionPatch, |
| 11 | } from "./source-auth-service"; | 27 | } from "./source-auth-service"; |
| 28 | import { createLiveExecutionManager } from "./live-execution"; | ||
| 29 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | ||
| 30 | import { resolveSourceAuthMaterial } from "./source-auth-material"; | ||
| 12 | 31 | ||
| 13 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ | 32 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ |
| 14 | id: "src_test" as Source["id"], | 33 | id: "src_test" as Source["id"], |
| @@ -33,6 +52,117 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 18 unmodified lines | |||
| 33 | updatedAt: 1, | 52 | updatedAt: 1, |
| 34 | }); | 53 | }); |
| 35 | 54 | ||
| 55 | const fetchLiveDiscoveryDocument = async (): Promise<string> => { | ||
| 56 | const response = await fetch("https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", { | ||
| 57 | signal: AbortSignal.timeout(20_000), | ||
| 58 | }); | ||
| 59 | if (!response.ok) { | ||
| 60 | throw new Error(`Failed to fetch Sheets discovery doc: ${response.status}`); | ||
| 61 | } | ||
| 62 | |||
| 63 | return response.text(); | ||
| 64 | }; | ||
| 65 | |||
| 66 | const withGoogleAuthTestServer = async <T>(input: { | ||
| 67 | discoveryDocument: string; | ||
| 68 | handler: (server: { | ||
| 69 | discoveryUrl: string; | ||
| 70 | tokenUrl: string; | ||
| 71 | tokenRequests: Array<URLSearchParams>; | ||
| 72 | }) => Promise<T>; | ||
| 73 | }): Promise<T> => { | ||
| 74 | const tokenRequests: Array<URLSearchParams> = []; | ||
| 75 | const server = createServer(async (request, response) => { | ||
| 76 | const requestUrl = new URL( | ||
| 77 | request.url ?? "/", | ||
| 78 | "http://127.0.0.1", | ||
| 79 | ); | ||
| 80 | |||
| 81 | if (request.method === "GET" && requestUrl.pathname === "/discovery") { | ||
| 82 | response.statusCode = 200; | ||
| 83 | response.setHeader("content-type", "application/json"); | ||
| 84 | response.end(input.discoveryDocument); | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | |||
| 88 | if (request.method === "POST" && requestUrl.pathname === "/token") { | ||
| 89 | const chunks: Buffer[] = []; | ||
| 90 | for await (const chunk of request) { | ||
| 91 | chunks.push(Buffer.from(chunk)); | ||
| 92 | } | ||
| 93 | const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8")); | ||
| 94 | tokenRequests.push(form); | ||
| 95 | |||
| 96 | response.statusCode = 200; | ||
| 97 | response.setHeader("content-type", "application/json"); | ||
| 98 | if (form.get("grant_type") === "authorization_code") { | ||
| 99 | response.end( | ||
| 100 | JSON.stringify({ | ||
| 101 | access_token: "access-token-initial", | ||
| 102 | refresh_token: "refresh-token-live", | ||
| 103 | expires_in: 3600, | ||
| 104 | scope: "https://www.googleapis.com/auth/spreadsheets.readonly", | ||
| 105 | token_type: "Bearer", | ||
| 106 | }), | ||
| 107 | ); | ||
| 108 | return; | ||
| 109 | } | ||
| 110 | |||
| 111 | response.end( | ||
| 112 | JSON.stringify({ | ||
| 113 | access_token: "access-token-refreshed", | ||
| 114 | expires_in: 3600, | ||
| 115 | token_type: "Bearer", | ||
| 116 | }), | ||
| 117 | ); | ||
| 118 | return; | ||
| 119 | } | ||
| 120 | |||
| 121 | response.statusCode = 404; | ||
| 122 | response.end(); | ||
| 123 | }); | ||
| 124 | |||
| 125 | await new Promise<void>((resolve, reject) => { | ||
| 126 | server.listen(0, "127.0.0.1", (error?: Error) => { | ||
| 127 | if (error) { | ||
| 128 | reject(error); | ||
| 129 | return; | ||
| 130 | } | ||
| 131 | resolve(); | ||
| 132 | }); | ||
| 133 | }); | ||
| 134 | |||
| 135 | try { | ||
| 136 | const address = server.address(); | ||
| 137 | if (!address || typeof address === "string") { | ||
| 138 | throw new Error("Failed to resolve Google auth test server address"); | ||
| 139 | } | ||
| 140 | const baseUrl = `http://127.0.0.1:${address.port}`; | ||
| 141 | return await input.handler({ | ||
| 142 | discoveryUrl: `${baseUrl}/discovery`, | ||
| 143 | tokenUrl: `${baseUrl}/token`, | ||
| 144 | tokenRequests, | ||
| 145 | }); | ||
| 146 | } finally { | ||
| 147 | await new Promise<void>((resolve, reject) => { | ||
| 148 | server.close((error) => { | ||
| 149 | if (error) { | ||
| 150 | reject(error); | ||
| 151 | return; | ||
| 152 | } | ||
| 153 | resolve(); | ||
| 154 | }); | ||
| 155 | }); | ||
| 156 | } | ||
| 157 | }; | ||
| 158 | |||
| 159 | const makePersistence = async (): Promise<SqlControlPlanePersistence> => | ||
| 160 | Effect.runPromise( | ||
| 161 | createSqlControlPlanePersistence({ | ||
| 162 | localDataDir: ":memory:", | ||
| 163 | }), | ||
| 164 | ); | ||
| 165 | |||
| 36 | describe("source-auth-service", () => { | 166 | describe("source-auth-service", () => { |
| 37 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); | 167 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 38 | 168 | ||
| @@ -89,4 +219,391 @@ describe("source-auth-service", () => { · 50 unmodified lines | |||
| 89 | updatedAt: 456, | 219 | updatedAt: 456, |
| 90 | }); | 220 | }); |
| 91 | }); | 221 | }); |
| 222 | |||
| 223 | it("completes Google Discovery desktop OAuth into a refreshable auth artifact and lease", async () => { | ||
| 224 | const discoveryDocument = await fetchLiveDiscoveryDocument(); | ||
| 225 | const previousClientId = process.env.GOOGLE_CLIENT_ID; | ||
| 226 | const previousClientSecret = process.env.GOOGLE_CLIENT_SECRET; | ||
| 227 | process.env.GOOGLE_CLIENT_ID = "google-test-client"; | ||
| 228 | process.env.GOOGLE_CLIENT_SECRET = "google-test-secret"; | ||
| 229 | |||
| 230 | const persistence = await makePersistence(); | ||
| 231 | try { | ||
| 232 | const now = Date.now(); | ||
| 233 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth"); | ||
| 234 | const accountId = AccountIdSchema.make("acc_google_oauth"); | ||
| 235 | |||
| 236 | await Effect.runPromise( | ||
| 237 | persistence.rows.organizations.insert({ | ||
| 238 | id: "org_google_oauth" as any, | ||
| 239 | slug: "google-oauth", | ||
| 240 | name: "Google OAuth", | ||
| 241 | status: "active", | ||
| 242 | createdByAccountId: accountId, | ||
| 243 | createdAt: now, | ||
| 244 | updatedAt: now, | ||
| 245 | }), | ||
| 246 | ); | ||
| 247 | await Effect.runPromise( | ||
| 248 | persistence.rows.workspaces.insert({ | ||
| 249 | id: workspaceId, | ||
| 250 | organizationId: "org_google_oauth" as any, | ||
| 251 | name: "Workspace", | ||
| 252 | createdByAccountId: accountId, | ||
| 253 | createdAt: now, | ||
| 254 | updatedAt: now, | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | |||
| 258 | const service = createRuntimeSourceAuthService({ | ||
| 259 | rows: persistence.rows, | ||
| 260 | liveExecutionManager: createLiveExecutionManager(), | ||
| 261 | getLocalServerBaseUrl: () => "http://localhost:8788", | ||
| 262 | }); | ||
| 263 | |||
| 264 | await withGoogleAuthTestServer({ | ||
| 265 | discoveryDocument, | ||
| 266 | handler: async ({ discoveryUrl, tokenUrl, tokenRequests }) => { | ||
| 267 | const originalFetch = globalThis.fetch; | ||
| 268 | globalThis.fetch = (async (input, init) => { | ||
| 269 | const url = typeof input === "string" | ||
| 270 | ? input | ||
| 271 | : input instanceof URL | ||
| 272 | ? input.toString() | ||
| 273 | : input.url; | ||
| 274 | if (url === "https://oauth2.googleapis.com/token") { | ||
| 275 | return originalFetch(tokenUrl, init); | ||
| 276 | } | ||
| 277 | |||
| 278 | return originalFetch(input as any, init); | ||
| 279 | }) as typeof fetch; | ||
| 280 | |||
| 281 | try { | ||
| 282 | const addResult = await Effect.runPromise( | ||
| 283 | service.addExecutorSource({ | ||
| 284 | kind: "google_discovery", | ||
| 285 | workspaceId, | ||
| 286 | actorAccountId: accountId, | ||
| 287 | executionId: null, | ||
| 288 | interactionId: null, | ||
| 289 | service: "sheets", | ||
| 290 | version: "v4", | ||
| 291 | discoveryUrl, | ||
| 292 | scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"], | ||
| 293 | }), | ||
| 294 | ); | ||
| 295 | |||
| 296 | expect(addResult.kind).toBe("oauth_required"); | ||
| 297 | if (addResult.kind !== "oauth_required") { | ||
| 298 | return; | ||
| 299 | } | ||
| 300 | |||
| 301 | const sessionOption = await Effect.runPromise( | ||
| 302 | persistence.rows.sourceAuthSessions.getById(addResult.sessionId), | ||
| 303 | ); | ||
| 304 | expect(Option.isSome(sessionOption)).toBe(true); | ||
| 305 | const session = Option.getOrNull(sessionOption) as SourceAuthSession; | ||
| 306 | expect(session.providerKind).toBe("oauth2_pkce"); | ||
| 307 | const sessionData = Schema.decodeUnknownSync( | ||
| 308 | OAuth2PkceSourceAuthSessionDataJsonSchema, | ||
| 309 | )(session.sessionDataJson); | ||
| 310 | expect(sessionData.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); | ||
| 311 | expect(sessionData.tokenEndpoint).toBe("https://oauth2.googleapis.com/token"); | ||
| 312 | expect(sessionData.authorizationUrl).toContain( | ||
| 313 | encodeURIComponent(sessionData.redirectUri), | ||
| 314 | ); | ||
| 315 | |||
| 316 | const loopbackResponse = await fetch( | ||
| 317 | `${sessionData.redirectUri}/?state=${encodeURIComponent(session.state)}&code=loopback-code`, | ||
| 318 | { | ||
| 319 | redirect: "manual", | ||
| 320 | }, | ||
| 321 | ); | ||
| 322 | expect(loopbackResponse.status).toBe(302); | ||
| 323 | const completionLocation = loopbackResponse.headers.get("location"); | ||
| 324 | expect(completionLocation).toBeTruthy(); | ||
| 325 | if (!completionLocation) { | ||
| 326 | return; | ||
| 327 | } | ||
| 328 | const completionUrl = new URL(completionLocation); | ||
| 329 | expect(completionUrl.origin).toBe("http://localhost:8788"); | ||
| 330 | expect(completionUrl.searchParams.get("state")).toBe(session.state); | ||
| 331 | expect(completionUrl.searchParams.get("code")).toBe("loopback-code"); | ||
| 332 | |||
| 333 | const connectedSource = await Effect.runPromise( | ||
| 334 | service.completeSourceCredentialSetup({ | ||
| 335 | workspaceId, | ||
| 336 | sourceId: addResult.source.id, | ||
| 337 | actorAccountId: accountId, | ||
| 338 | state: session.state, | ||
| 339 | code: "authorization-code", | ||
| 340 | }), | ||
| 341 | ); | ||
| 342 | |||
| 343 | expect(connectedSource.status).toBe("connected"); | ||
| 344 | expect(connectedSource.auth.kind).toBe("oauth2_authorized_user"); | ||
| 345 | if (connectedSource.auth.kind !== "oauth2_authorized_user") { | ||
| 346 | return; | ||
| 347 | } | ||
| 348 | expect(connectedSource.auth.clientId).toBe("google-test-client"); | ||
| 349 | expect(connectedSource.auth.tokenEndpoint).toBe("https://oauth2.googleapis.com/token"); | ||
| 350 | expect(connectedSource.auth.grantSet).toEqual([ | ||
| 351 | "https://www.googleapis.com/auth/spreadsheets.readonly", | ||
| 352 | ]); | ||
| 353 | |||
| 354 | expect(tokenRequests).toHaveLength(1); | ||
| 355 | expect(tokenRequests[0]?.get("grant_type")).toBe("authorization_code"); | ||
| 356 | expect(tokenRequests[0]?.get("client_id")).toBe("google-test-client"); | ||
| 357 | expect(tokenRequests[0]?.get("client_secret")).toBe("google-test-secret"); | ||
| 358 | expect(tokenRequests[0]?.get("code")).toBe("authorization-code"); | ||
| 359 | expect(tokenRequests[0]?.get("redirect_uri")).toBe(sessionData.redirectUri); | ||
| 360 | expect(tokenRequests[0]?.get("code_verifier")).toBe(sessionData.codeVerifier); | ||
| 361 | |||
| 362 | const artifactOption = await Effect.runPromise( | ||
| 363 | persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({ | ||
| 364 | workspaceId, | ||
| 365 | sourceId: connectedSource.id, | ||
| 366 | actorAccountId: accountId, | ||
| 367 | slot: "runtime", | ||
| 368 | }), | ||
| 369 | ); | ||
| 370 | expect(Option.isSome(artifactOption)).toBe(true); | ||
| 371 | const artifact = Option.getOrNull(artifactOption)!; | ||
| 372 | const decodedArtifact = decodeBuiltInAuthArtifactConfig(artifact); | ||
| 373 | expect(decodedArtifact?.artifactKind).toBe("oauth2_authorized_user"); | ||
| 374 | |||
| 375 | const leaseOption = await Effect.runPromise( | ||
| 376 | persistence.rows.authLeases.getByAuthArtifactId(artifact.id), | ||
| 377 | ); | ||
| 378 | expect(Option.isSome(leaseOption)).toBe(true); | ||
| 379 | const initialLease = Option.getOrNull(leaseOption)!; | ||
| 380 | const initialTemplates = decodeAuthLeasePlacementTemplates(initialLease); | ||
| 381 | expect(initialTemplates).toEqual([ | ||
| 382 | { | ||
| 383 | location: "header", | ||
| 384 | name: "Authorization", | ||
| 385 | parts: [ | ||
| 386 | { | ||
| 387 | kind: "literal", | ||
| 388 | value: "Bearer ", | ||
| 389 | }, | ||
| 390 | { | ||
| 391 | kind: "secret_ref", | ||
| 392 | ref: { | ||
| 393 | providerId: "postgres", | ||
| 394 | handle: expect.any(String), | ||
| 395 | }, | ||
| 396 | }, | ||
| 397 | ], | ||
| 398 | }, | ||
| 399 | ]); | ||
| 400 | |||
| 401 | const resolver = createDefaultSecretMaterialResolver({ | ||
| 402 | rows: persistence.rows, | ||
| 403 | }); | ||
| 404 | const resolvedInitialAuth = await Effect.runPromise( | ||
| 405 | resolveSourceAuthMaterial({ | ||
| 406 | source: connectedSource, | ||
| 407 | slot: "runtime", | ||
| 408 | actorAccountId: accountId, | ||
| 409 | rows: persistence.rows, | ||
| 410 | resolveSecretMaterial: resolver, | ||
| 411 | }), | ||
| 412 | ); | ||
| 413 | expect(resolvedInitialAuth.headers.Authorization).toBe( | ||
| 414 | "Bearer access-token-initial", | ||
| 415 | ); | ||
| 416 | |||
| 417 | await Effect.runPromise( | ||
| 418 | persistence.rows.authLeases.upsert({ | ||
| 419 | ...initialLease, | ||
| 420 | refreshAfter: Date.now() - 1, | ||
| 421 | expiresAt: Date.now() + 10_000, | ||
| 422 | updatedAt: Date.now(), | ||
| 423 | }), | ||
| 424 | ); | ||
| 425 | |||
| 426 | const previousAccessTokenHandle = initialTemplates?.[0]?.parts[1]; | ||
| 427 | const refreshedAuth = await Effect.runPromise( | ||
| 428 | resolveSourceAuthMaterial({ | ||
| 429 | source: connectedSource, | ||
| 430 | slot: "runtime", | ||
| 431 | actorAccountId: accountId, | ||
| 432 | rows: persistence.rows, | ||
| 433 | resolveSecretMaterial: resolver, | ||
| 434 | }), | ||
| 435 | ); | ||
| 436 | expect(refreshedAuth.headers.Authorization).toBe( | ||
| 437 | "Bearer access-token-refreshed", | ||
| 438 | ); | ||
| 439 | expect(tokenRequests).toHaveLength(2); | ||
| 440 | expect(tokenRequests[1]?.get("grant_type")).toBe("refresh_token"); | ||
| 441 | expect(tokenRequests[1]?.get("refresh_token")).toBe("refresh-token-live"); | ||
| 442 | |||
| 443 | if (previousAccessTokenHandle?.kind === "secret_ref") { | ||
| 444 | const oldSecret = await Effect.runPromise( | ||
| 445 | persistence.rows.secretMaterials.getById( | ||
| 446 | SecretMaterialIdSchema.make(previousAccessTokenHandle.ref.handle), | ||
| 447 | ), | ||
| 448 | ); | ||
| 449 | expect(Option.isNone(oldSecret)).toBe(true); | ||
| 450 | } | ||
| 451 | |||
| 452 | const recipeOperations = await Effect.runPromise( | ||
| 453 | persistence.rows.sources.getByWorkspaceAndId( | ||
| 454 | workspaceId, | ||
| 455 | connectedSource.id, | ||
| 456 | ), | ||
| 457 | ); | ||
| 458 | expect(Option.isSome(recipeOperations)).toBe(true); | ||
| 459 | const storedSource = Option.getOrNull(recipeOperations)!; | ||
| 460 | const materializedOperations = await Effect.runPromise( | ||
| 461 | persistence.rows.sourceRecipeOperations.listByRevisionId( | ||
| 462 | storedSource.recipeRevisionId, | ||
| 463 | ), | ||
| 464 | ); | ||
| 465 | expect(materializedOperations.length).toBeGreaterThan(0); | ||
| 466 | expect( | ||
| 467 | materializedOperations.some( | ||
| 468 | (operation) => operation.toolId === "spreadsheets.values.get", | ||
| 469 | ), | ||
| 470 | ).toBe(true); | ||
| 471 | } finally { | ||
| 472 | globalThis.fetch = originalFetch; | ||
| 473 | } | ||
| 474 | }, | ||
| 475 | }); | ||
| 476 | } finally { | ||
| 477 | await persistence.close(); | ||
| 478 | if (previousClientId === undefined) { | ||
| 479 | delete process.env.GOOGLE_CLIENT_ID; | ||
| 480 | } else { | ||
| 481 | process.env.GOOGLE_CLIENT_ID = previousClientId; | ||
| 482 | } | ||
| 483 | if (previousClientSecret === undefined) { | ||
| 484 | delete process.env.GOOGLE_CLIENT_SECRET; | ||
| 485 | } else { | ||
| 486 | process.env.GOOGLE_CLIENT_SECRET = previousClientSecret; | ||
| 487 | } | ||
| 488 | } | ||
| 489 | }, 60_000); | ||
| 490 | |||
| 491 | it("uses the request origin callback for Google Discovery OAuth started from the web app", async () => { | ||
| 492 | const discoveryDocument = await fetchLiveDiscoveryDocument(); | ||
| 493 | const previousClientId = process.env.GOOGLE_CLIENT_ID; | ||
| 494 | const previousClientSecret = process.env.GOOGLE_CLIENT_SECRET; | ||
| 495 | process.env.GOOGLE_CLIENT_ID = "google-test-client"; | ||
| 496 | process.env.GOOGLE_CLIENT_SECRET = "google-test-secret"; | ||
| 497 | |||
| 498 | const persistence = await makePersistence(); | ||
| 499 | try { | ||
| 500 | const now = Date.now(); | ||
| 501 | const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web"); | ||
| 502 | const accountId = AccountIdSchema.make("acc_google_oauth_web"); | ||
| 503 | |||
| 504 | await Effect.runPromise( | ||
| 505 | persistence.rows.organizations.insert({ | ||
| 506 | id: "org_google_oauth_web" as any, | ||
| 507 | slug: "google-oauth-web", | ||
| 508 | name: "Google OAuth Web", | ||
| 509 | status: "active", | ||
| 510 | createdByAccountId: accountId, | ||
| 511 | createdAt: now, | ||
| 512 | updatedAt: now, | ||
| 513 | }), | ||
| 514 | ); | ||
| 515 | await Effect.runPromise( | ||
| 516 | persistence.rows.workspaces.insert({ | ||
| 517 | id: workspaceId, | ||
| 518 | organizationId: "org_google_oauth_web" as any, | ||
| 519 | name: "Workspace", | ||
| 520 | createdByAccountId: accountId, | ||
| 521 | createdAt: now, | ||
| 522 | updatedAt: now, | ||
| 523 | }), | ||
| 524 | ); | ||
| 525 | |||
| 526 | const service = createRuntimeSourceAuthService({ | ||
| 527 | rows: persistence.rows, | ||
| 528 | liveExecutionManager: createLiveExecutionManager(), | ||
| 529 | getLocalServerBaseUrl: () => "http://127.0.0.1:8788", | ||
| 530 | }); | ||
| 531 | |||
| 532 | await withGoogleAuthTestServer({ | ||
| 533 | discoveryDocument, | ||
| 534 | handler: async ({ discoveryUrl, tokenUrl }) => { | ||
| 535 | const originalFetch = globalThis.fetch; | ||
| 536 | globalThis.fetch = (async (input, init) => { | ||
| 537 | const url = typeof input === "string" | ||
| 538 | ? input | ||
| 539 | : input instanceof URL | ||
| 540 | ? input.toString() | ||
| 541 | : input.url; | ||
| 542 | if (url === "https://oauth2.googleapis.com/token") { | ||
| 543 | return originalFetch(tokenUrl, init); | ||
| 544 | } | ||
| 545 | |||
| 546 | return originalFetch(input as any, init); | ||
| 547 | }) as typeof fetch; | ||
| 548 | |||
| 549 | try { | ||
| 550 | const addResult = await Effect.runPromise( | ||
| 551 | service.addExecutorSource( | ||
| 552 | { | ||
| 553 | kind: "google_discovery", | ||
| 554 | workspaceId, | ||
| 555 | actorAccountId: accountId, | ||
| 556 | executionId: null, | ||
| 557 | interactionId: null, | ||
| 558 | service: "sheets", | ||
| 559 | version: "v4", | ||
| 560 | discoveryUrl, | ||
| 561 | scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"], | ||
| 562 | }, | ||
| 563 | { | ||
| 564 | baseUrl: "https://app.executor.dev", | ||
| 565 | }, | ||
| 566 | ), | ||
| 567 | ); | ||
| 568 | |||
| 569 | expect(addResult.kind).toBe("oauth_required"); | ||
| 570 | if (addResult.kind !== "oauth_required") { | ||
| 571 | return; | ||
| 572 | } | ||
| 573 | |||
| 574 | const sessionOption = await Effect.runPromise( | ||
| 575 | persistence.rows.sourceAuthSessions.getById(addResult.sessionId), | ||
| 576 | ); | ||
| 577 | expect(Option.isSome(sessionOption)).toBe(true); | ||
| 578 | const session = Option.getOrNull(sessionOption) as SourceAuthSession; | ||
| 579 | const sessionData = Schema.decodeUnknownSync( | ||
| 580 | OAuth2PkceSourceAuthSessionDataJsonSchema, | ||
| 581 | )(session.sessionDataJson); | ||
| 582 | |||
| 583 | expect(sessionData.redirectUri).toBe( | ||
| 584 | `https://app.executor.dev/v1/workspaces/${encodeURIComponent(workspaceId)}/sources/${encodeURIComponent(addResult.source.id)}/credentials/oauth/complete`, | ||
| 585 | ); | ||
| 586 | expect(sessionData.authorizationUrl).toContain( | ||
| 587 | encodeURIComponent(sessionData.redirectUri), | ||
| 588 | ); | ||
| 589 | expect(sessionData.redirectUri).not.toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); | ||
| 590 | } finally { | ||
| 591 | globalThis.fetch = originalFetch; | ||
| 592 | } | ||
| 593 | }, | ||
| 594 | }); | ||
| 595 | } finally { | ||
| 596 | await persistence.close(); | ||
| 597 | if (previousClientId === undefined) { | ||
| 598 | delete process.env.GOOGLE_CLIENT_ID; | ||
| 599 | } else { | ||
| 600 | process.env.GOOGLE_CLIENT_ID = previousClientId; | ||
| 601 | } | ||
| 602 | if (previousClientSecret === undefined) { | ||
| 603 | delete process.env.GOOGLE_CLIENT_SECRET; | ||
| 604 | } else { | ||
| 605 | process.env.GOOGLE_CLIENT_SECRET = previousClientSecret; | ||
| 606 | } | ||
| 607 | } | ||
| 608 | }, 60_000); | ||
| 92 | }); | 609 | }); |
| @@ -13,6 +13,8 @@ import { · 12 unmodified lines | |||
| 13 | ExecutionIdSchema, | 13 | ExecutionIdSchema, |
| 14 | McpSourceAuthSessionDataJsonSchema, | 14 | McpSourceAuthSessionDataJsonSchema, |
| 15 | type McpSourceAuthSessionData, | 15 | type McpSourceAuthSessionData, |
| 16 | OAuth2PkceSourceAuthSessionDataJsonSchema, | ||
| 17 | type OAuth2PkceSourceAuthSessionData, | ||
| 16 | type SecretMaterialPurpose, | 18 | type SecretMaterialPurpose, |
| 17 | Source, | 19 | Source, |
| 18 | type SourceImportAuthPolicy, | 20 | type SourceImportAuthPolicy, |
| @@ -23,6 +25,9 @@ import { · 4 unmodified lines | |||
| 23 | SourceSchema, | 25 | SourceSchema, |
| 24 | type SecretRef, | 26 | type SecretRef, |
| 25 | type StringMap, | 27 | type StringMap, |
| 28 | WorkspaceSourceOauthClientIdSchema, | ||
| 29 | WorkspaceSourceOauthClientMetadataJsonSchema, | ||
| 30 | type WorkspaceSourceOauthClientRedirectMode, | ||
| 26 | type WorkspaceId, | 31 | type WorkspaceId, |
| 27 | } from "#schema"; | 32 | } from "#schema"; |
| 28 | import * as Context from "effect/Context"; | 33 | import * as Context from "effect/Context"; |
| @@ -46,6 +51,7 @@ import { · 17 unmodified lines | |||
| 46 | updateSourceFromPayload, | 51 | updateSourceFromPayload, |
| 47 | } from "./source-definitions"; | 52 | } from "./source-definitions"; |
| 48 | import { | 53 | import { |
| 54 | getSourceAdapterForSource, | ||
| 49 | hasSourceAdapterFamily, | 55 | hasSourceAdapterFamily, |
| 50 | sourceBindingStateFromSource, | 56 | sourceBindingStateFromSource, |
| 51 | } from "./source-adapters"; | 57 | } from "./source-adapters"; |
| @@ -56,10 +62,17 @@ import { · 4 unmodified lines | |||
| 56 | type ResolveSecretMaterial, | 62 | type ResolveSecretMaterial, |
| 57 | type StoreSecretMaterial, | 63 | type StoreSecretMaterial, |
| 58 | } from "./secret-material-providers"; | 64 | } from "./secret-material-providers"; |
| 65 | import { upsertOauth2AuthorizedUserLeaseFromTokenResponse } from "./auth-leases"; | ||
| 59 | import { | 66 | import { |
| 60 | persistMcpRecipeMaterializationFromManifest, | 67 | persistMcpRecipeMaterializationFromManifest, |
| 61 | syncSourceMaterialization, | 68 | syncSourceMaterialization, |
| 62 | } from "./source-materialization"; | 69 | } from "./source-materialization"; |
| 70 | import { | ||
| 71 | buildOAuth2AuthorizationUrl, | ||
| 72 | createPkceCodeVerifier, | ||
| 73 | exchangeOAuth2AuthorizationCode, | ||
| 74 | } from "./oauth2-pkce"; | ||
| 75 | import { startOauthLoopbackRedirectServer } from "./oauth-loopback"; | ||
| 63 | import { | 76 | import { |
| 64 | loadSourceById, | 77 | loadSourceById, |
| 65 | loadSourcesInWorkspace, | 78 | loadSourcesInWorkspace, |
| @@ -113,6 +126,22 @@ const normalizeEndpoint = (endpoint: string): string => { · 47 unmodified lines | |||
| 113 | return url.toString(); | 126 | return url.toString(); |
| 114 | }; | 127 | }; |
| 115 | 128 | ||
| 129 | const defaultGoogleDiscoveryUrl = (service: string, version: string): string => | ||
| 130 | `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`; | ||
| 131 | |||
| 132 | const defaultGoogleDiscoverySourceName = (service: string, version: string): string => { | ||
| 133 | const titleService = service | ||
| 134 | .split(/[^a-zA-Z0-9]+/) | ||
| 135 | .filter(Boolean) | ||
| 136 | .map((segment) => segment[0]?.toUpperCase() + segment.slice(1)) | ||
| 137 | .join(" "); | ||
| 138 | |||
| 139 | return `Google ${titleService || service} ${version}`; | ||
| 140 | }; | ||
| 141 | |||
| 142 | const defaultGoogleDiscoveryNamespace = (service: string): string => | ||
| 143 | `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`; | ||
| 144 | |||
| 116 | const SourceOAuthSessionStatePayloadSchema = Schema.Struct({ | 145 | const SourceOAuthSessionStatePayloadSchema = Schema.Struct({ |
| 117 | kind: Schema.Literal("source_oauth"), | 146 | kind: Schema.Literal("source_oauth"), |
| 118 | nonce: Schema.String, | 147 | nonce: Schema.String, |
| @@ -228,6 +257,42 @@ const mergeMcpSourceAuthSessionData = (input: { · 109 unmodified lines | |||
| 228 | }); | 257 | }); |
| 229 | }; | 258 | }; |
| 230 | 259 | ||
| 260 | const encodeOauth2PkceSourceAuthSessionData = ( | ||
| 261 | sessionData: OAuth2PkceSourceAuthSessionData, | ||
| 262 | ): string => Schema.encodeSync(OAuth2PkceSourceAuthSessionDataJsonSchema)(sessionData); | ||
| 263 | |||
| 264 | const decodeOauth2PkceSourceAuthSessionDataJson = Schema.decodeUnknownEither( | ||
| 265 | OAuth2PkceSourceAuthSessionDataJsonSchema, | ||
| 266 | ); | ||
| 267 | |||
| 268 | const decodeOauth2PkceSourceAuthSessionData = ( | ||
| 269 | session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">, | ||
| 270 | ): OAuth2PkceSourceAuthSessionData => { | ||
| 271 | if (session.providerKind !== "oauth2_pkce") { | ||
| 272 | throw new Error(`Unsupported source auth provider for session ${session.id}`); | ||
| 273 | } | ||
| 274 | |||
| 275 | const decoded = decodeOauth2PkceSourceAuthSessionDataJson(session.sessionDataJson); | ||
| 276 | if (Either.isLeft(decoded)) { | ||
| 277 | throw new Error( | ||
| 278 | `Invalid source auth session data for ${session.id}: ${ParseResult.TreeFormatter.formatErrorSync(decoded.left)}`, | ||
| 279 | ); | ||
| 280 | } | ||
| 281 | |||
| 282 | return decoded.right; | ||
| 283 | }; | ||
| 284 | |||
| 285 | const mergeOauth2PkceSourceAuthSessionData = (input: { | ||
| 286 | session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">; | ||
| 287 | patch: Partial<OAuth2PkceSourceAuthSessionData>; | ||
| 288 | }): string => { | ||
| 289 | const existing = decodeOauth2PkceSourceAuthSessionData(input.session); | ||
| 290 | return encodeOauth2PkceSourceAuthSessionData({ | ||
| 291 | ...existing, | ||
| 292 | ...input.patch, | ||
| 293 | }); | ||
| 294 | }; | ||
| 295 | |||
| 231 | 296 | ||
| 232 | const completeLiveInteraction = (input: { | 297 | const completeLiveInteraction = (input: { |
| 233 | liveExecutionManager: LiveExecutionManager; | 298 | liveExecutionManager: LiveExecutionManager; |
| @@ -361,6 +426,22 @@ export type ExecutorAddSourceInput = · 127 unmodified lines | |||
| 361 | importAuthPolicy?: SourceImportAuthPolicy | null; | 426 | importAuthPolicy?: SourceImportAuthPolicy | null; |
| 362 | importAuth?: ExecutorHttpSourceAuthInput | null; | 427 | importAuth?: ExecutorHttpSourceAuthInput | null; |
| 363 | auth?: ExecutorHttpSourceAuthInput | null; | 428 | auth?: ExecutorHttpSourceAuthInput | null; |
| 429 | } | ||
| 430 | | { | ||
| 431 | kind: "google_discovery"; | ||
| 432 | workspaceId: WorkspaceId; | ||
| 433 | actorAccountId?: AccountId | null; | ||
| 434 | executionId: SourceAuthSession["executionId"]; | ||
| 435 | interactionId: SourceAuthSession["interactionId"]; | ||
| 436 | service: string; | ||
| 437 | version: string; | ||
| 438 | discoveryUrl?: string | null; | ||
| 439 | scopes?: ReadonlyArray<string> | null; | ||
| 440 | name?: string | null; | ||
| 441 | namespace?: string | null; | ||
| 442 | importAuthPolicy?: SourceImportAuthPolicy | null; | ||
| 443 | importAuth?: ExecutorHttpSourceAuthInput | null; | ||
| 444 | auth?: ExecutorHttpSourceAuthInput | null; | ||
| 364 | }; | 445 | }; |
| 365 | 446 | ||
| 366 | export type ConnectMcpSourceInput = { | 447 | export type ConnectMcpSourceInput = { |
| @@ -525,7 +606,7 @@ const materializeExecutorHttpAuth = (input: { · 158 unmodified lines | |||
| 525 | }); | 606 | }); |
| 526 | 607 | ||
| 527 | const materializeExecutorHttpImportAuth = (input: { | 608 | const materializeExecutorHttpImportAuth = (input: { |
| 528 | sourceKind: "openapi" | "graphql"; | 609 | sourceKind: "openapi" | "graphql" | "google_discovery"; |
| 529 | existing?: Source; | 610 | existing?: Source; |
| 530 | importAuthPolicy?: SourceImportAuthPolicy | null; | 611 | importAuthPolicy?: SourceImportAuthPolicy | null; |
| 531 | importAuth?: ExecutorHttpSourceAuthInput | null; | 612 | importAuth?: ExecutorHttpSourceAuthInput | null; |
| @@ -535,7 +616,10 @@ const materializeExecutorHttpImportAuth = (input: { · 3 unmodified lines | |||
| 535 | importAuth: Source["importAuth"]; | 616 | importAuth: Source["importAuth"]; |
| 536 | }, Error, never> => | 617 | }, Error, never> => |
| 537 | Effect.gen(function* () { | 618 | Effect.gen(function* () { |
| 538 | const adapterDefault = input.sourceKind === "openapi" || input.sourceKind === "graphql" | 619 | const adapterDefault = |
| 620 | input.sourceKind === "openapi" | ||
| 621 | || input.sourceKind === "graphql" | ||
| 622 | || input.sourceKind === "google_discovery" | ||
| 539 | ? "reuse_runtime" | 623 | ? "reuse_runtime" |
| 540 | : "none"; | 624 | : "none"; |
| 541 | const importAuthPolicy = input.importAuthPolicy ?? input.existing?.importAuthPolicy ?? adapterDefault; | 625 | const importAuthPolicy = input.importAuthPolicy ?? input.existing?.importAuthPolicy ?? adapterDefault; |
| @@ -579,6 +663,234 @@ const shouldPromptForExecutorHttpRuntimeCredentialSetup = (input: { · 37 unmodified lines | |||
| 579 | && input.auth.kind === "none" | 663 | && input.auth.kind === "none" |
| 580 | && (input.existing?.auth.kind ?? "none") === "none"; | 664 | && (input.existing?.auth.kind ?? "none") === "none"; |
| 581 | 665 | ||
| 666 | type ResolvedSourceOauthClient = { | ||
| 667 | providerKey: string; | ||
| 668 | clientId: string; | ||
| 669 | clientSecret: SecretRef | null; | ||
| 670 | redirectMode: WorkspaceSourceOauthClientRedirectMode; | ||
| 671 | }; | ||
| 672 | |||
| 673 | const decodeWorkspaceSourceOauthClientMetadataOption = Schema.decodeUnknownOption( | ||
| 674 | WorkspaceSourceOauthClientMetadataJsonSchema, | ||
| 675 | ); | ||
| 676 | |||
| 677 | const encodeWorkspaceSourceOauthClientMetadataJson = Schema.encodeSync( | ||
| 678 | WorkspaceSourceOauthClientMetadataJsonSchema, | ||
| 679 | ); | ||
| 680 | |||
| 681 | const sourceOauthClientRedirectMode = (client: { | ||
| 682 | clientMetadataJson: string | null; | ||
| 683 | }): WorkspaceSourceOauthClientRedirectMode => { | ||
| 684 | if (client.clientMetadataJson === null) { | ||
| 685 | return "app_callback"; | ||
| 686 | } | ||
| 687 | |||
| 688 | const decoded = decodeWorkspaceSourceOauthClientMetadataOption( | ||
| 689 | client.clientMetadataJson, | ||
| 690 | ); | ||
| 691 | if (Option.isNone(decoded)) { | ||
| 692 | return "app_callback"; | ||
| 693 | } | ||
| 694 | |||
| 695 | return decoded.value.redirectMode ?? "app_callback"; | ||
| 696 | }; | ||
| 697 | |||
| 698 | const sourceOauthClientSecretRef = (client: { | ||
| 699 | clientSecretProviderId: string | null; | ||
| 700 | clientSecretHandle: string | null; | ||
| 701 | }): SecretRef | null => | ||
| 702 | client.clientSecretProviderId && client.clientSecretHandle | ||
| 703 | ? { | ||
| 704 | providerId: client.clientSecretProviderId, | ||
| 705 | handle: client.clientSecretHandle, | ||
| 706 | } | ||
| 707 | : null; | ||
| 708 | |||
| 709 | const resolveOrCreateSourceOauthClient = (input: { | ||
| 710 | rows: SqlControlPlaneRows; | ||
| 711 | source: Source; | ||
| 712 | storeSecretMaterial: StoreSecretMaterial; | ||
| 713 | }): Effect.Effect<ResolvedSourceOauthClient | null, Error, never> => | ||
| 714 | Effect.gen(function* () { | ||
| 715 | const adapter = getSourceAdapterForSource(input.source); | ||
| 716 | const setupConfig = adapter.getOauth2SetupConfig | ||
| 717 | ? yield* adapter.getOauth2SetupConfig({ | ||
| 718 | source: input.source, | ||
| 719 | slot: "runtime", | ||
| 720 | }) | ||
| 721 | : null; | ||
| 722 | if (setupConfig === null) { | ||
| 723 | return null; | ||
| 724 | } | ||
| 725 | |||
| 726 | const existing = yield* input.rows.sourceOauthClients.getByWorkspaceSourceAndProvider({ | ||
| 727 | workspaceId: input.source.workspaceId, | ||
| 728 | sourceId: input.source.id, | ||
| 729 | providerKey: setupConfig.providerKey, | ||
| 730 | }); | ||
| 731 | if (Option.isSome(existing)) { | ||
| 732 | return { | ||
| 733 | providerKey: existing.value.providerKey, | ||
| 734 | clientId: existing.value.clientId, | ||
| 735 | clientSecret: sourceOauthClientSecretRef(existing.value), | ||
| 736 | redirectMode: sourceOauthClientRedirectMode(existing.value), | ||
| 737 | }; | ||
| 738 | } | ||
| 739 | |||
| 740 | const fallback = adapter.getDefaultOauthClient | ||
| 741 | ? yield* adapter.getDefaultOauthClient(input.source) | ||
| 742 | : null; | ||
| 743 | if (fallback === null) { | ||
| 744 | return null; | ||
| 745 | } | ||
| 746 | |||
| 747 | const clientSecretRef = fallback.clientSecret | ||
| 748 | ? yield* input.storeSecretMaterial({ | ||
| 749 | purpose: "oauth_client_info", | ||
| 750 | value: fallback.clientSecret, | ||
| 751 | }) | ||
| 752 | : null; | ||
| 753 | const now = Date.now(); | ||
| 754 | const clientId = WorkspaceSourceOauthClientIdSchema.make( | ||
| 755 | `src_oauth_client_${crypto.randomUUID()}`, | ||
| 756 | ); | ||
| 757 | yield* input.rows.sourceOauthClients.upsert({ | ||
| 758 | id: clientId, | ||
| 759 | workspaceId: input.source.workspaceId, | ||
| 760 | sourceId: input.source.id, | ||
| 761 | providerKey: fallback.providerKey, | ||
| 762 | clientId: fallback.clientId, | ||
| 763 | clientSecretProviderId: clientSecretRef?.providerId ?? null, | ||
| 764 | clientSecretHandle: clientSecretRef?.handle ?? null, | ||
| 765 | clientMetadataJson: encodeWorkspaceSourceOauthClientMetadataJson({ | ||
| 766 | redirectMode: fallback.redirectMode ?? "app_callback", | ||
| 767 | }), | ||
| 768 | createdAt: now, | ||
| 769 | updatedAt: now, | ||
| 770 | }); | ||
| 771 | |||
| 772 | return { | ||
| 773 | providerKey: fallback.providerKey, | ||
| 774 | clientId: fallback.clientId, | ||
| 775 | clientSecret: clientSecretRef, | ||
| 776 | redirectMode: fallback.redirectMode ?? "app_callback", | ||
| 777 | }; | ||
| 778 | }); | ||
| 779 | |||
| 780 | const startOauth2PkceSourceCredentialSetup = (input: { | ||
| 781 | rows: SqlControlPlaneRows; | ||
| 782 | source: Source; | ||
| 783 | actorAccountId?: AccountId | null; | ||
| 784 | executionId?: SourceAuthSession["executionId"]; | ||
| 785 | interactionId?: SourceAuthSession["interactionId"]; | ||
| 786 | baseUrl: string; | ||
| 787 | redirectModeOverride?: WorkspaceSourceOauthClientRedirectMode; | ||
| 788 | storeSecretMaterial: StoreSecretMaterial; | ||
| 789 | }): Effect.Effect<Extract<ExecutorSourceAddResult, { kind: "oauth_required" }> | null, Error, never> => | ||
| 790 | Effect.gen(function* () { | ||
| 791 | const adapter = getSourceAdapterForSource(input.source); | ||
| 792 | const setupConfig = adapter.getOauth2SetupConfig | ||
| 793 | ? yield* adapter.getOauth2SetupConfig({ | ||
| 794 | source: input.source, | ||
| 795 | slot: "runtime", | ||
| 796 | }) | ||
| 797 | : null; | ||
| 798 | if (setupConfig === null) { | ||
| 799 | return null; | ||
| 800 | } | ||
| 801 | |||
| 802 | const oauthClient = yield* resolveOrCreateSourceOauthClient({ | ||
| 803 | rows: input.rows, | ||
| 804 | source: input.source, | ||
| 805 | storeSecretMaterial: input.storeSecretMaterial, | ||
| 806 | }); | ||
| 807 | if (oauthClient === null) { | ||
| 808 | return null; | ||
| 809 | } | ||
| 810 | |||
| 811 | const sessionId = SourceAuthSessionIdSchema.make(`src_auth_${crypto.randomUUID()}`); | ||
| 812 | const state = crypto.randomUUID(); | ||
| 813 | const completionUrl = resolveSourceCredentialOauthCompleteUrl({ | ||
| 814 | baseUrl: input.baseUrl, | ||
| 815 | workspaceId: input.source.workspaceId, | ||
| 816 | sourceId: input.source.id, | ||
| 817 | }); | ||
| 818 | const redirectMode = input.redirectModeOverride ?? oauthClient.redirectMode; | ||
| 819 | const redirectServer = redirectMode === "loopback" | ||
| 820 | ? yield* startOauthLoopbackRedirectServer({ | ||
| 821 | completionUrl, | ||
| 822 | }) | ||
| 823 | : null; | ||
| 824 | const redirectUri = redirectServer?.redirectUri ?? completionUrl; | ||
| 825 | const codeVerifier = createPkceCodeVerifier(); | ||
| 826 | return yield* Effect.gen(function* () { | ||
| 827 | const authorizationUrl = buildOAuth2AuthorizationUrl({ | ||
| 828 | authorizationEndpoint: setupConfig.authorizationEndpoint, | ||
| 829 | clientId: oauthClient.clientId, | ||
| 830 | redirectUri, | ||
| 831 | scopes: [...setupConfig.scopes], | ||
| 832 | state, | ||
| 833 | codeVerifier, | ||
| 834 | extraParams: setupConfig.authorizationParams, | ||
| 835 | }); | ||
| 836 | const now = Date.now(); | ||
| 837 | |||
| 838 | yield* input.rows.sourceAuthSessions.upsert({ | ||
| 839 | id: sessionId, | ||
| 840 | workspaceId: input.source.workspaceId, | ||
| 841 | sourceId: input.source.id, | ||
| 842 | actorAccountId: input.actorAccountId ?? null, | ||
| 843 | credentialSlot: "runtime", | ||
| 844 | executionId: input.executionId ?? null, | ||
| 845 | interactionId: input.interactionId ?? null, | ||
| 846 | providerKind: "oauth2_pkce", | ||
| 847 | status: "pending", | ||
| 848 | state, | ||
| 849 | sessionDataJson: encodeOauth2PkceSourceAuthSessionData({ | ||
| 850 | kind: "oauth2_pkce", | ||
| 851 | providerKey: setupConfig.providerKey, | ||
| 852 | authorizationEndpoint: setupConfig.authorizationEndpoint, | ||
| 853 | tokenEndpoint: setupConfig.tokenEndpoint, | ||
| 854 | redirectUri, | ||
| 855 | clientId: oauthClient.clientId, | ||
| 856 | clientAuthentication: setupConfig.clientAuthentication, | ||
| 857 | clientSecret: oauthClient.clientSecret, | ||
| 858 | scopes: [...setupConfig.scopes], | ||
| 859 | headerName: setupConfig.headerName, | ||
| 860 | prefix: setupConfig.prefix, | ||
| 861 | authorizationParams: { | ||
| 862 | ...(setupConfig.authorizationParams ?? {}), | ||
| 863 | }, | ||
| 864 | codeVerifier, | ||
| 865 | authorizationUrl, | ||
| 866 | }), | ||
| 867 | errorText: null, | ||
| 868 | completedAt: null, | ||
| 869 | createdAt: now, | ||
| 870 | updatedAt: now, | ||
| 871 | }); | ||
| 872 | |||
| 873 | const authRequiredSource = yield* updateSourceStatus(input.rows, input.source, { | ||
| 874 | actorAccountId: input.actorAccountId, | ||
| 875 | status: "auth_required", | ||
| 876 | lastError: null, | ||
| 877 | }); | ||
| 878 | |||
| 879 | return { | ||
| 880 | kind: "oauth_required", | ||
| 881 | source: authRequiredSource, | ||
| 882 | sessionId, | ||
| 883 | authorizationUrl, | ||
| 884 | } satisfies Extract<ExecutorSourceAddResult, { kind: "oauth_required" }>; | ||
| 885 | }).pipe( | ||
| 886 | Effect.onError(() => | ||
| 887 | redirectServer | ||
| 888 | ? redirectServer.close.pipe(Effect.orDie) | ||
| 889 | : Effect.void | ||
| 890 | ), | ||
| 891 | ); | ||
| 892 | }); | ||
| 893 | |||
| 582 | const connectMcpSourceInternal = (input: { | 894 | const connectMcpSourceInternal = (input: { |
| 583 | rows: SqlControlPlaneRows; | 895 | rows: SqlControlPlaneRows; |
| 584 | getLocalServerBaseUrl?: () => string | undefined; | 896 | getLocalServerBaseUrl?: () => string | undefined; |
| @@ -692,6 +1004,7 @@ const connectMcpSourceInternal = (input: { · 107 unmodified lines | |||
| 692 | yield* syncSourceMaterialization({ | 1004 | yield* syncSourceMaterialization({ |
| 693 | rows: input.rows, | 1005 | rows: input.rows, |
| 694 | source: persistedDraft, | 1006 | source: persistedDraft, |
| 1007 | actorAccountId: input.actorAccountId, | ||
| 695 | resolveSecretMaterial: input.resolveSecretMaterial, | 1008 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 696 | }); | 1009 | }); |
| 697 | 1010 | ||
| @@ -812,6 +1125,8 @@ const addExecutorHttpSource = (input: { · 114 unmodified lines | |||
| 812 | sourceInput: Extract<ExecutorAddSourceInput, { kind: "openapi" | "graphql" }>; | 1125 | sourceInput: Extract<ExecutorAddSourceInput, { kind: "openapi" | "graphql" }>; |
| 813 | storeSecretMaterial: StoreSecretMaterial; | 1126 | storeSecretMaterial: StoreSecretMaterial; |
| 814 | resolveSecretMaterial: ResolveSecretMaterial; | 1127 | resolveSecretMaterial: ResolveSecretMaterial; |
| 1128 | getLocalServerBaseUrl?: () => string | undefined; | ||
| 1129 | baseUrl?: string | null; | ||
| 815 | }): Effect.Effect<ExecutorSourceAddResult, Error, never> => | 1130 | }): Effect.Effect<ExecutorSourceAddResult, Error, never> => |
| 816 | Effect.gen(function* () { | 1131 | Effect.gen(function* () { |
| 817 | const normalizedEndpoint = normalizeEndpoint(input.sourceInput.endpoint); | 1132 | const normalizedEndpoint = normalizeEndpoint(input.sourceInput.endpoint); |
| @@ -926,6 +1241,24 @@ const addExecutorHttpSource = (input: { · 108 unmodified lines | |||
| 926 | explicitAuthProvided: input.sourceInput.auth !== undefined, | 1241 | explicitAuthProvided: input.sourceInput.auth !== undefined, |
| 927 | auth: persistedDraft.auth, | 1242 | auth: persistedDraft.auth, |
| 928 | })) { | 1243 | })) { |
| 1244 | const requestBaseUrl = trimOrNull(input.baseUrl); | ||
| 1245 | const baseUrl = requestBaseUrl ?? input.getLocalServerBaseUrl?.() ?? null; | ||
| 1246 | if (baseUrl) { | ||
| 1247 | const oauthRequired = yield* startOauth2PkceSourceCredentialSetup({ | ||
| 1248 | rows: input.rows, | ||
| 1249 | source: persistedDraft, | ||
| 1250 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1251 | executionId: input.sourceInput.executionId, | ||
| 1252 | interactionId: input.sourceInput.interactionId, | ||
| 1253 | baseUrl, | ||
| 1254 | redirectModeOverride: requestBaseUrl ? "app_callback" : undefined, | ||
| 1255 | storeSecretMaterial: input.storeSecretMaterial, | ||
| 1256 | }); | ||
| 1257 | if (oauthRequired) { | ||
| 1258 | return oauthRequired; | ||
| 1259 | } | ||
| 1260 | } | ||
| 1261 | |||
| 929 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { | 1262 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { |
| 930 | actorAccountId: input.sourceInput.actorAccountId, | 1263 | actorAccountId: input.sourceInput.actorAccountId, |
| 931 | status: "auth_required", | 1264 | status: "auth_required", |
| @@ -946,6 +1279,218 @@ const addExecutorHttpSource = (input: { · 14 unmodified lines | |||
| 946 | ...persistedDraft, | 1279 | ...persistedDraft, |
| 947 | status: "connected", | 1280 | status: "connected", |
| 948 | }, | 1281 | }, |
| 1282 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1283 | resolveSecretMaterial: input.resolveSecretMaterial, | ||
| 1284 | }), | ||
| 1285 | ); | ||
| 1286 | |||
| 1287 | return yield* Either.match(synced, { | ||
| 1288 | onLeft: (error) => | ||
| 1289 | isSourceCredentialRequiredError(error) | ||
| 1290 | ? updateSourceStatus(input.rows, persistedDraft, { | ||
| 1291 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1292 | status: "auth_required", | ||
| 1293 | lastError: null, | ||
| 1294 | }).pipe( | ||
| 1295 | Effect.map((source) => | ||
| 1296 | ({ | ||
| 1297 | kind: "credential_required", | ||
| 1298 | source, | ||
| 1299 | credentialSlot: error.slot, | ||
| 1300 | } satisfies ExecutorSourceAddResult) | ||
| 1301 | ), | ||
| 1302 | ) | ||
| 1303 | : updateSourceStatus(input.rows, persistedDraft, { | ||
| 1304 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1305 | status: "error", | ||
| 1306 | lastError: error.message, | ||
| 1307 | }).pipe( | ||
| 1308 | Effect.zipRight(Effect.fail(error)), | ||
| 1309 | ), | ||
| 1310 | onRight: () => | ||
| 1311 | updateSourceStatus(input.rows, persistedDraft, { | ||
| 1312 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1313 | status: "connected", | ||
| 1314 | lastError: null, | ||
| 1315 | }).pipe( | ||
| 1316 | Effect.map((source) => | ||
| 1317 | ({ | ||
| 1318 | kind: "connected", | ||
| 1319 | source, | ||
| 1320 | } satisfies ExecutorSourceAddResult) | ||
| 1321 | ), | ||
| 1322 | ), | ||
| 1323 | }); | ||
| 1324 | }); | ||
| 1325 | |||
| 1326 | const addExecutorGoogleDiscoverySource = (input: { | ||
| 1327 | rows: SqlControlPlaneRows; | ||
| 1328 | sourceInput: Extract<ExecutorAddSourceInput, { kind: "google_discovery" }>; | ||
| 1329 | storeSecretMaterial: StoreSecretMaterial; | ||
| 1330 | resolveSecretMaterial: ResolveSecretMaterial; | ||
| 1331 | getLocalServerBaseUrl?: () => string | undefined; | ||
| 1332 | baseUrl?: string | null; | ||
| 1333 | }): Effect.Effect<ExecutorSourceAddResult, Error, never> => | ||
| 1334 | Effect.gen(function* () { | ||
| 1335 | const normalizedService = input.sourceInput.service.trim(); | ||
| 1336 | const normalizedVersion = input.sourceInput.version.trim(); | ||
| 1337 | const normalizedDiscoveryUrl = normalizeEndpoint( | ||
| 1338 | trimOrNull(input.sourceInput.discoveryUrl) | ||
| 1339 | ?? defaultGoogleDiscoveryUrl(normalizedService, normalizedVersion), | ||
| 1340 | ); | ||
| 1341 | const existingSources = yield* loadSourcesInWorkspace( | ||
| 1342 | input.rows, | ||
| 1343 | input.sourceInput.workspaceId, | ||
| 1344 | { | ||
| 1345 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1346 | }, | ||
| 1347 | ); | ||
| 1348 | const existing = existingSources.find((source) => { | ||
| 1349 | if (source.kind !== "google_discovery") { | ||
| 1350 | return false; | ||
| 1351 | } | ||
| 1352 | |||
| 1353 | const binding = source.binding; | ||
| 1354 | if (typeof binding.service !== "string" || typeof binding.version !== "string") { | ||
| 1355 | return false; | ||
| 1356 | } | ||
| 1357 | |||
| 1358 | const existingDiscoveryUrl = | ||
| 1359 | typeof binding.discoveryUrl === "string" && binding.discoveryUrl.trim().length > 0 | ||
| 1360 | ? normalizeEndpoint(binding.discoveryUrl) | ||
| 1361 | : defaultGoogleDiscoveryUrl(binding.service.trim(), binding.version.trim()); | ||
| 1362 | |||
| 1363 | return binding.service.trim() === normalizedService | ||
| 1364 | && binding.version.trim() === normalizedVersion | ||
| 1365 | && existingDiscoveryUrl === normalizedDiscoveryUrl; | ||
| 1366 | }); | ||
| 1367 | |||
| 1368 | const chosenName = | ||
| 1369 | trimOrNull(input.sourceInput.name) | ||
| 1370 | ?? existing?.name | ||
| 1371 | ?? defaultGoogleDiscoverySourceName(normalizedService, normalizedVersion); | ||
| 1372 | const chosenNamespace = | ||
| 1373 | trimOrNull(input.sourceInput.namespace) | ||
| 1374 | ?? existing?.namespace | ||
| 1375 | ?? defaultGoogleDiscoveryNamespace(normalizedService); | ||
| 1376 | const chosenScopes = (input.sourceInput.scopes ?? (Array.isArray(existing?.binding.scopes) | ||
| 1377 | ? existing?.binding.scopes as ReadonlyArray<string> | ||
| 1378 | : [])) | ||
| 1379 | .map((scope) => scope.trim()) | ||
| 1380 | .filter((scope) => scope.length > 0); | ||
| 1381 | const existingBinding = existing | ||
| 1382 | ? yield* sourceBindingStateFromSource(existing) | ||
| 1383 | : null; | ||
| 1384 | const now = Date.now(); | ||
| 1385 | |||
| 1386 | const auth = yield* materializeExecutorHttpAuth({ | ||
| 1387 | existing, | ||
| 1388 | auth: input.sourceInput.auth, | ||
| 1389 | storeSecretMaterial: input.storeSecretMaterial, | ||
| 1390 | }); | ||
| 1391 | const importAuth = yield* materializeExecutorHttpImportAuth({ | ||
| 1392 | sourceKind: input.sourceInput.kind, | ||
| 1393 | existing, | ||
| 1394 | importAuthPolicy: input.sourceInput.importAuthPolicy ?? null, | ||
| 1395 | importAuth: input.sourceInput.importAuth ?? null, | ||
| 1396 | storeSecretMaterial: input.storeSecretMaterial, | ||
| 1397 | }); | ||
| 1398 | |||
| 1399 | const draftSource = existing | ||
| 1400 | ? yield* updateSourceFromPayload({ | ||
| 1401 | source: existing, | ||
| 1402 | payload: { | ||
| 1403 | name: chosenName, | ||
| 1404 | endpoint: normalizedDiscoveryUrl, | ||
| 1405 | namespace: chosenNamespace, | ||
| 1406 | status: "probing", | ||
| 1407 | enabled: true, | ||
| 1408 | binding: { | ||
| 1409 | service: normalizedService, | ||
| 1410 | version: normalizedVersion, | ||
| 1411 | discoveryUrl: normalizedDiscoveryUrl, | ||
| 1412 | defaultHeaders: existingBinding?.defaultHeaders ?? null, | ||
| 1413 | scopes: [...chosenScopes], | ||
| 1414 | }, | ||
| 1415 | importAuthPolicy: importAuth.importAuthPolicy, | ||
| 1416 | importAuth: importAuth.importAuth, | ||
| 1417 | auth, | ||
| 1418 | lastError: null, | ||
| 1419 | }, | ||
| 1420 | now, | ||
| 1421 | }) | ||
| 1422 | : yield* createSourceFromPayload({ | ||
| 1423 | workspaceId: input.sourceInput.workspaceId, | ||
| 1424 | sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`), | ||
| 1425 | payload: { | ||
| 1426 | name: chosenName, | ||
| 1427 | kind: "google_discovery", | ||
| 1428 | endpoint: normalizedDiscoveryUrl, | ||
| 1429 | namespace: chosenNamespace, | ||
| 1430 | status: "probing", | ||
| 1431 | enabled: true, | ||
| 1432 | binding: { | ||
| 1433 | service: normalizedService, | ||
| 1434 | version: normalizedVersion, | ||
| 1435 | discoveryUrl: normalizedDiscoveryUrl, | ||
| 1436 | defaultHeaders: existingBinding?.defaultHeaders ?? null, | ||
| 1437 | scopes: [...chosenScopes], | ||
| 1438 | }, | ||
| 1439 | importAuthPolicy: importAuth.importAuthPolicy, | ||
| 1440 | importAuth: importAuth.importAuth, | ||
| 1441 | auth, | ||
| 1442 | }, | ||
| 1443 | now, | ||
| 1444 | }); | ||
| 1445 | |||
| 1446 | const persistedDraft = yield* persistSource(input.rows, draftSource, { | ||
| 1447 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1448 | }); | ||
| 1449 | |||
| 1450 | if (shouldPromptForExecutorHttpRuntimeCredentialSetup({ | ||
| 1451 | existing, | ||
| 1452 | explicitAuthProvided: input.sourceInput.auth !== undefined, | ||
| 1453 | auth: persistedDraft.auth, | ||
| 1454 | })) { | ||
| 1455 | const requestBaseUrl = trimOrNull(input.baseUrl); | ||
| 1456 | const baseUrl = requestBaseUrl ?? input.getLocalServerBaseUrl?.() ?? null; | ||
| 1457 | if (baseUrl) { | ||
| 1458 | const oauthRequired = yield* startOauth2PkceSourceCredentialSetup({ | ||
| 1459 | rows: input.rows, | ||
| 1460 | source: persistedDraft, | ||
| 1461 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1462 | executionId: input.sourceInput.executionId, | ||
| 1463 | interactionId: input.sourceInput.interactionId, | ||
| 1464 | baseUrl, | ||
| 1465 | redirectModeOverride: requestBaseUrl ? "app_callback" : undefined, | ||
| 1466 | storeSecretMaterial: input.storeSecretMaterial, | ||
| 1467 | }); | ||
| 1468 | if (oauthRequired) { | ||
| 1469 | return oauthRequired; | ||
| 1470 | } | ||
| 1471 | } | ||
| 1472 | |||
| 1473 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { | ||
| 1474 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 1475 | status: "auth_required", | ||
| 1476 | lastError: null, | ||
| 1477 | }); | ||
| 1478 | |||
| 1479 | return { | ||
| 1480 | kind: "credential_required", | ||
| 1481 | source: authRequiredSource, | ||
| 1482 | credentialSlot: "runtime", | ||
| 1483 | } satisfies ExecutorSourceAddResult; | ||
| 1484 | } | ||
| 1485 | |||
| 1486 | const synced = yield* Effect.either( | ||
| 1487 | syncSourceMaterialization({ | ||
| 1488 | rows: input.rows, | ||
| 1489 | source: { | ||
| 1490 | ...persistedDraft, | ||
| 1491 | status: "connected", | ||
| 1492 | }, | ||
| 1493 | actorAccountId: input.sourceInput.actorAccountId, | ||
| 949 | resolveSecretMaterial: input.resolveSecretMaterial, | 1494 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 950 | }), | 1495 | }), |
| 951 | ); | 1496 | ); |
| @@ -1058,8 +1603,17 @@ export const createRuntimeSourceAuthService = (input: { · 106 unmodified lines | |||
| 1058 | actorAccountId, | 1603 | actorAccountId, |
| 1059 | }), | 1604 | }), |
| 1060 | 1605 | ||
| 1061 | addExecutorSource: (sourceInput, options) => | 1606 | addExecutorSource: (sourceInput, options = undefined) => |
| 1062 | hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") | 1607 | sourceInput.kind === "google_discovery" |
| 1608 | ? addExecutorGoogleDiscoverySource({ | ||
| 1609 | rows: input.rows, | ||
| 1610 | sourceInput, | ||
| 1611 | storeSecretMaterial, | ||
| 1612 | resolveSecretMaterial, | ||
| 1613 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | ||
| 1614 | baseUrl: options?.baseUrl, | ||
| 1615 | }) | ||
| 1616 | : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") | ||
| 1063 | ? addExecutorHttpSource({ | 1617 | ? addExecutorHttpSource({ |
| 1064 | rows: input.rows, | 1618 | rows: input.rows, |
| 1065 | sourceInput: sourceInput as Extract< | 1619 | sourceInput: sourceInput as Extract< |
| @@ -1068,6 +1622,8 @@ export const createRuntimeSourceAuthService = (input: { · 2 unmodified lines | |||
| 1068 | >, | 1622 | >, |
| 1069 | storeSecretMaterial, | 1623 | storeSecretMaterial, |
| 1070 | resolveSecretMaterial, | 1624 | resolveSecretMaterial, |
| 1625 | getLocalServerBaseUrl: input.getLocalServerBaseUrl, | ||
| 1626 | baseUrl: options?.baseUrl, | ||
| 1071 | }) | 1627 | }) |
| 1072 | : connectMcpSourceInternal({ | 1628 | : connectMcpSourceInternal({ |
| 1073 | rows: input.rows, | 1629 | rows: input.rows, |
| @@ -1297,7 +1853,6 @@ export const createRuntimeSourceAuthService = (input: { · 223 unmodified lines | |||
| 1297 | } | 1853 | } |
| 1298 | 1854 | ||
| 1299 | const session = sessionOption.value; | 1855 | const session = sessionOption.value; |
| 1300 | const sessionData = decodeMcpSourceAuthSessionData(session); | ||
| 1301 | if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { | 1856 | if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { |
| 1302 | return yield* Effect.fail( | 1857 | return yield* Effect.fail( |
| 1303 | new Error( | 1858 | new Error( |
| @@ -1330,6 +1885,10 @@ export const createRuntimeSourceAuthService = (input: { · 26 unmodified lines | |||
| 1330 | ); | 1885 | ); |
| 1331 | } | 1886 | } |
| 1332 | 1887 | ||
| 1888 | const sessionData = session.providerKind === "oauth2_pkce" | ||
| 1889 | ? decodeOauth2PkceSourceAuthSessionData(session) | ||
| 1890 | : decodeMcpSourceAuthSessionData(session); | ||
| 1891 | |||
| 1333 | if (trimOrNull(error) !== null) { | 1892 | if (trimOrNull(error) !== null) { |
| 1334 | const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed"; | 1893 | const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed"; |
| 1335 | const failedAt = Date.now(); | 1894 | const failedAt = Date.now(); |
| @@ -1351,6 +1910,7 @@ export const createRuntimeSourceAuthService = (input: { · 15 unmodified lines | |||
| 1351 | yield* syncSourceMaterialization({ | 1910 | yield* syncSourceMaterialization({ |
| 1352 | rows: input.rows, | 1911 | rows: input.rows, |
| 1353 | source: failedSource, | 1912 | source: failedSource, |
| 1913 | actorAccountId: session.actorAccountId, | ||
| 1354 | resolveSecretMaterial, | 1914 | resolveSecretMaterial, |
| 1355 | }); | 1915 | }); |
| 1356 | yield* completeLiveInteraction({ | 1916 | yield* completeLiveInteraction({ |
| @@ -1374,54 +1934,157 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines | |||
| 1374 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); | 1934 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); |
| 1375 | } | 1935 | } |
| 1376 | 1936 | ||
| 1377 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ | 1937 | const now = Date.now(); |
| 1378 | session: { | 1938 | let connectedSource: Source; |
| 1379 | endpoint: sessionData.endpoint, | 1939 | if (session.providerKind === "oauth2_pkce") { |
| 1380 | redirectUrl: sessionData.redirectUri, | 1940 | const oauthSessionData = decodeOauth2PkceSourceAuthSessionData(session); |
| 1381 | codeVerifier: sessionData.codeVerifier, | 1941 | let clientSecret: string | null = null; |
| 1382 | resourceMetadataUrl: sessionData.resourceMetadataUrl, | 1942 | if (oauthSessionData.clientSecret) { |
| 1383 | authorizationServerUrl: sessionData.authorizationServerUrl, | 1943 | clientSecret = yield* resolveSecretMaterial({ |
| 1384 | resourceMetadata: sessionData.resourceMetadata, | 1944 | ref: oauthSessionData.clientSecret, |
| 1385 | authorizationServerMetadata: sessionData.authorizationServerMetadata, | 1945 | }); |
| 1386 | clientInformation: sessionData.clientInformation, | 1946 | } |
| 1387 | }, | 1947 | |
| 1388 | code: authorizationCode, | 1948 | const exchanged = yield* exchangeOAuth2AuthorizationCode({ |
| 1389 | }); | 1949 | tokenEndpoint: oauthSessionData.tokenEndpoint, |
| 1950 | clientId: oauthSessionData.clientId, | ||
| 1951 | clientAuthentication: oauthSessionData.clientAuthentication, | ||
| 1952 | clientSecret, | ||
| 1953 | redirectUri: oauthSessionData.redirectUri, | ||
| 1954 | codeVerifier: oauthSessionData.codeVerifier ?? "", | ||
| 1955 | code: authorizationCode, | ||
| 1956 | }); | ||
| 1957 | const refreshToken = trimOrNull(exchanged.refresh_token); | ||
| 1958 | if (refreshToken === null) { | ||
| 1959 | return yield* Effect.fail( | ||
| 1960 | new Error("OAuth authorization did not return a refresh token"), | ||
| 1961 | ); | ||
| 1962 | } | ||
| 1390 | 1963 | ||
| 1391 | const oauthSecretName = resolveSourceOAuthSecretName({ | 1964 | const refreshTokenRef = yield* storeSecretMaterial({ |
| 1392 | displayName: source.name, | 1965 | purpose: "oauth_refresh_token", |
| 1393 | endpoint: source.endpoint, | 1966 | value: refreshToken, |
| 1394 | }); | 1967 | name: `${source.name} Refresh`, |
| 1395 | const accessTokenRef = yield* storeSecretMaterial({ | 1968 | }); |
| 1396 | purpose: "oauth_access_token", | 1969 | const grantedScopes = trimOrNull(exchanged.scope) |
| 1397 | value: exchanged.tokens.access_token, | 1970 | ? exchanged.scope!.split(/\s+/).filter((scope) => scope.length > 0) |
| 1398 | name: oauthSecretName, | 1971 | : [...oauthSessionData.scopes]; |
| 1399 | }); | 1972 | connectedSource = yield* updateSourceStatus(input.rows, source, { |
| 1400 | const refreshTokenRef = exchanged.tokens.refresh_token | 1973 | actorAccountId: session.actorAccountId, |
| 1401 | ? yield* storeSecretMaterial({ | 1974 | status: "connected", |
| 1402 | purpose: "oauth_refresh_token", | 1975 | lastError: null, |
| 1403 | value: exchanged.tokens.refresh_token, | 1976 | auth: { |
| 1404 | name: `${oauthSecretName} Refresh`, | 1977 | kind: "oauth2_authorized_user", |
| 1405 | }) | 1978 | headerName: oauthSessionData.headerName, |
| 1406 | : null; | 1979 | prefix: oauthSessionData.prefix, |
| 1980 | tokenEndpoint: oauthSessionData.tokenEndpoint, | ||
| 1981 | clientId: oauthSessionData.clientId, | ||
| 1982 | clientAuthentication: oauthSessionData.clientAuthentication, | ||
| 1983 | clientSecret: oauthSessionData.clientSecret, | ||
| 1984 | refreshToken: refreshTokenRef, | ||
| 1985 | grantSet: grantedScopes, | ||
| 1986 | }, | ||
| 1987 | }); | ||
| 1988 | const authArtifact = yield* input.rows.authArtifacts.getByWorkspaceSourceAndActor({ | ||
| 1989 | workspaceId: connectedSource.workspaceId, | ||
| 1990 | sourceId: connectedSource.id, | ||
| 1991 | actorAccountId: session.actorAccountId ?? null, | ||
| 1992 | slot: "runtime", | ||
| 1993 | }); | ||
| 1994 | if (Option.isSome(authArtifact)) { | ||
| 1995 | yield* upsertOauth2AuthorizedUserLeaseFromTokenResponse({ | ||
| 1996 | rows: input.rows, | ||
| 1997 | artifact: authArtifact.value, | ||
| 1998 | tokenResponse: exchanged, | ||
| 1999 | }); | ||
| 2000 | } | ||
| 1407 | 2001 | ||
| 1408 | const now = Date.now(); | 2002 | yield* input.rows.sourceAuthSessions.update( |
| 1409 | const connectedSource = yield* updateSourceStatus(input.rows, source, { | 2003 | session.id, |
| 1410 | actorAccountId: session.actorAccountId, | 2004 | createTerminalSourceAuthSessionPatch({ |
| 1411 | status: "connected", | 2005 | sessionDataJson: mergeOauth2PkceSourceAuthSessionData({ |
| 1412 | lastError: null, | 2006 | session, |
| 1413 | auth: { | 2007 | patch: { |
| 1414 | kind: "oauth2", | 2008 | codeVerifier: null, |
| 1415 | headerName: "Authorization", | 2009 | authorizationUrl: null, |
| 1416 | prefix: "Bearer ", | 2010 | }, |
| 1417 | accessToken: accessTokenRef, | 2011 | }), |
| 1418 | refreshToken: refreshTokenRef, | 2012 | status: "completed", |
| 1419 | }, | 2013 | now, |
| 1420 | }); | 2014 | errorText: null, |
| 2015 | }), | ||
| 2016 | ); | ||
| 2017 | } else { | ||
| 2018 | const mcpSessionData = decodeMcpSourceAuthSessionData(session); | ||
| 2019 | const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ | ||
| 2020 | session: { | ||
| 2021 | endpoint: mcpSessionData.endpoint, | ||
| 2022 | redirectUrl: mcpSessionData.redirectUri, | ||
| 2023 | codeVerifier: mcpSessionData.codeVerifier ?? "", | ||
| 2024 | resourceMetadataUrl: mcpSessionData.resourceMetadataUrl, | ||
| 2025 | authorizationServerUrl: mcpSessionData.authorizationServerUrl, | ||
| 2026 | resourceMetadata: mcpSessionData.resourceMetadata, | ||
| 2027 | authorizationServerMetadata: mcpSessionData.authorizationServerMetadata, | ||
| 2028 | clientInformation: mcpSessionData.clientInformation, | ||
| 2029 | }, | ||
| 2030 | code: authorizationCode, | ||
| 2031 | }); | ||
| 2032 | |||
| 2033 | const oauthSecretName = resolveSourceOAuthSecretName({ | ||
| 2034 | displayName: source.name, | ||
| 2035 | endpoint: source.endpoint, | ||
| 2036 | }); | ||
| 2037 | const accessTokenRef = yield* storeSecretMaterial({ | ||
| 2038 | purpose: "oauth_access_token", | ||
| 2039 | value: exchanged.tokens.access_token, | ||
| 2040 | name: oauthSecretName, | ||
| 2041 | }); | ||
| 2042 | const refreshTokenRef = exchanged.tokens.refresh_token | ||
| 2043 | ? yield* storeSecretMaterial({ | ||
| 2044 | purpose: "oauth_refresh_token", | ||
| 2045 | value: exchanged.tokens.refresh_token, | ||
| 2046 | name: `${oauthSecretName} Refresh`, | ||
| 2047 | }) | ||
| 2048 | : null; | ||
| 2049 | |||
| 2050 | connectedSource = yield* updateSourceStatus(input.rows, source, { | ||
| 2051 | actorAccountId: session.actorAccountId, | ||
| 2052 | status: "connected", | ||
| 2053 | lastError: null, | ||
| 2054 | auth: { | ||
| 2055 | kind: "oauth2", | ||
| 2056 | headerName: "Authorization", | ||
| 2057 | prefix: "Bearer ", | ||
| 2058 | accessToken: accessTokenRef, | ||
| 2059 | refreshToken: refreshTokenRef, | ||
| 2060 | }, | ||
| 2061 | }); | ||
| 2062 | |||
| 2063 | yield* input.rows.sourceAuthSessions.update( | ||
| 2064 | session.id, | ||
| 2065 | createTerminalSourceAuthSessionPatch({ | ||
| 2066 | sessionDataJson: mergeMcpSourceAuthSessionData({ | ||
| 2067 | session, | ||
| 2068 | patch: { | ||
| 2069 | codeVerifier: null, | ||
| 2070 | authorizationUrl: null, | ||
| 2071 | resourceMetadataUrl: exchanged.resourceMetadataUrl, | ||
| 2072 | authorizationServerUrl: exchanged.authorizationServerUrl, | ||
| 2073 | resourceMetadata: exchanged.resourceMetadata, | ||
| 2074 | authorizationServerMetadata: exchanged.authorizationServerMetadata, | ||
| 2075 | }, | ||
| 2076 | }), | ||
| 2077 | status: "completed", | ||
| 2078 | now, | ||
| 2079 | errorText: null, | ||
| 2080 | }), | ||
| 2081 | ); | ||
| 2082 | } | ||
| 1421 | const indexed = yield* Effect.either( | 2083 | const indexed = yield* Effect.either( |
| 1422 | syncSourceMaterialization({ | 2084 | syncSourceMaterialization({ |
| 1423 | rows: input.rows, | 2085 | rows: input.rows, |
| 1424 | source: connectedSource, | 2086 | source: connectedSource, |
| 2087 | actorAccountId: session.actorAccountId, | ||
| 1425 | resolveSecretMaterial, | 2088 | resolveSecretMaterial, |
| 1426 | }), | 2089 | }), |
| 1427 | ); | 2090 | ); |
| @@ -1437,26 +2100,6 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines | |||
| 1437 | onRight: () => Effect.succeed(undefined), | 2100 | onRight: () => Effect.succeed(undefined), |
| 1438 | }); | 2101 | }); |
| 1439 | 2102 | ||
| 1440 | yield* input.rows.sourceAuthSessions.update( | ||
| 1441 | session.id, | ||
| 1442 | createTerminalSourceAuthSessionPatch({ | ||
| 1443 | sessionDataJson: mergeMcpSourceAuthSessionData({ | ||
| 1444 | session, | ||
| 1445 | patch: { | ||
| 1446 | codeVerifier: null, | ||
| 1447 | authorizationUrl: null, | ||
| 1448 | resourceMetadataUrl: exchanged.resourceMetadataUrl, | ||
| 1449 | authorizationServerUrl: exchanged.authorizationServerUrl, | ||
| 1450 | resourceMetadata: exchanged.resourceMetadata, | ||
| 1451 | authorizationServerMetadata: exchanged.authorizationServerMetadata, | ||
| 1452 | }, | ||
| 1453 | }), | ||
| 1454 | status: "completed", | ||
| 1455 | now, | ||
| 1456 | errorText: null, | ||
| 1457 | }), | ||
| 1458 | ); | ||
| 1459 | |||
| 1460 | yield* completeLiveInteraction({ | 2103 | yield* completeLiveInteraction({ |
| 1461 | liveExecutionManager: input.liveExecutionManager, | 2104 | liveExecutionManager: input.liveExecutionManager, |
| 1462 | session, | 2105 | session, |
| @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | |||
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 5 | CredentialIdSchema, | 5 | CredentialIdSchema, |
| 6 | decodeBuiltInAuthArtifactConfig, | ||
| 6 | SourceIdSchema, | 7 | SourceIdSchema, |
| 7 | WorkspaceIdSchema, | 8 | WorkspaceIdSchema, |
| 8 | type Source, | 9 | type Source, |
| @@ -361,14 +362,16 @@ describe("source-definitions", () => { · 352 unmodified lines | |||
| 361 | const recipeRevisionId = stableSourceRecipeRevisionId(source); | 362 | const recipeRevisionId = stableSourceRecipeRevisionId(source); |
| 362 | const existingCredentialId = CredentialIdSchema.make("cred_existing"); | 363 | const existingCredentialId = CredentialIdSchema.make("cred_existing"); |
| 363 | 364 | ||
| 364 | const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | 365 | const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({ |
| 365 | source, | 366 | source, |
| 366 | recipeId, | 367 | recipeId, |
| 367 | recipeRevisionId, | 368 | recipeRevisionId, |
| 368 | existingRuntimeCredentialId: existingCredentialId, | 369 | existingRuntimeAuthArtifactId: existingCredentialId, |
| 369 | }); | 370 | }); |
| 370 | 371 | ||
| 371 | expect(runtimeCredential?.id).toBe(existingCredentialId); | 372 | expect(runtimeAuthArtifact?.id).toBe(existingCredentialId); |
| 373 | const decoded = runtimeAuthArtifact ? decodeBuiltInAuthArtifactConfig(runtimeAuthArtifact) : null; | ||
| 374 | expect(decoded?.artifactKind).toBe("static_bearer"); | ||
| 372 | expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({ | 375 | expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({ |
| 373 | adapterKey: "mcp", | 376 | adapterKey: "mcp", |
| 374 | version: 1, | 377 | version: 1, |
| @@ -377,8 +380,8 @@ describe("source-definitions", () => { · 2 unmodified lines | |||
| 377 | 380 | ||
| 378 | const projected = await Effect.runPromise(projectSourceFromStorage({ | 381 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 379 | sourceRecord, | 382 | sourceRecord, |
| 380 | runtimeCredential: runtimeCredential ?? null, | 383 | runtimeAuthArtifact: runtimeAuthArtifact ?? null, |
| 381 | importCredential: null, | 384 | importAuthArtifact: null, |
| 382 | })); | 385 | })); |
| 383 | 386 | ||
| 384 | expect(projected).toEqual(source); | 387 | expect(projected).toEqual(source); |
| @@ -421,15 +424,15 @@ describe("source-definitions", () => { · 36 unmodified lines | |||
| 421 | }); | 424 | }); |
| 422 | 425 | ||
| 423 | for (const source of [withRefresh, withoutRefresh]) { | 426 | for (const source of [withRefresh, withoutRefresh]) { |
| 424 | const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | 427 | const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({ |
| 425 | source, | 428 | source, |
| 426 | recipeId: stableSourceRecipeId(source), | 429 | recipeId: stableSourceRecipeId(source), |
| 427 | recipeRevisionId: stableSourceRecipeRevisionId(source), | 430 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 428 | }); | 431 | }); |
| 429 | const projected = await Effect.runPromise(projectSourceFromStorage({ | 432 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 430 | sourceRecord, | 433 | sourceRecord, |
| 431 | runtimeCredential: runtimeCredential ?? null, | 434 | runtimeAuthArtifact: runtimeAuthArtifact ?? null, |
| 432 | importCredential: null, | 435 | importAuthArtifact: null, |
| 433 | })); | 436 | })); |
| 434 | 437 | ||
| 435 | expect(projected).toEqual(source); | 438 | expect(projected).toEqual(source); |
| @@ -440,18 +443,18 @@ describe("source-definitions", () => { · 4 unmodified lines | |||
| 440 | const source = makeSource({ | 443 | const source = makeSource({ |
| 441 | auth: { kind: "none" }, | 444 | auth: { kind: "none" }, |
| 442 | }); | 445 | }); |
| 443 | const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | 446 | const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({ |
| 444 | source, | 447 | source, |
| 445 | recipeId: stableSourceRecipeId(source), | 448 | recipeId: stableSourceRecipeId(source), |
| 446 | recipeRevisionId: stableSourceRecipeRevisionId(source), | 449 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 447 | }); | 450 | }); |
| 448 | 451 | ||
| 449 | expect(runtimeCredential).toBeNull(); | 452 | expect(runtimeAuthArtifact).toBeNull(); |
| 450 | 453 | ||
| 451 | const projected = await Effect.runPromise(projectSourceFromStorage({ | 454 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 452 | sourceRecord, | 455 | sourceRecord, |
| 453 | runtimeCredential: null, | 456 | runtimeAuthArtifact: null, |
| 454 | importCredential: null, | 457 | importAuthArtifact: null, |
| 455 | })); | 458 | })); |
| 456 | 459 | ||
| 457 | expect(projected.auth).toEqual({ kind: "none" }); | 460 | expect(projected.auth).toEqual({ kind: "none" }); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { createHash } from "node:crypto"; | 1 | import { sha256Hex } from "@executor/codemode-core"; |
| 2 | 2 | ||
| 3 | import type { | 3 | import type { |
| 4 | CreateSourcePayload, | 4 | CreateSourcePayload, |
| @@ -6,7 +6,7 @@ import type { · 1 unmodified line | |||
| 6 | } from "#api"; | 6 | } from "#api"; |
| 7 | import type { | 7 | import type { |
| 8 | AccountId, | 8 | AccountId, |
| 9 | Credential, | 9 | AuthArtifact, |
| 10 | CredentialSlot, | 10 | CredentialSlot, |
| 11 | Source, | 11 | Source, |
| 12 | SourceAuth, | 12 | SourceAuth, |
| @@ -21,13 +21,17 @@ import type { · 8 unmodified lines | |||
| 21 | WorkspaceId, | 21 | WorkspaceId, |
| 22 | } from "#schema"; | 22 | } from "#schema"; |
| 23 | import { | 23 | import { |
| 24 | CredentialIdSchema, | 24 | AuthArtifactIdSchema, |
| 25 | SourceRecipeIdSchema, | 25 | SourceRecipeIdSchema, |
| 26 | SourceRecipeRevisionIdSchema, | 26 | SourceRecipeRevisionIdSchema, |
| 27 | } from "#schema"; | 27 | } from "#schema"; |
| 28 | import * as Effect from "effect/Effect"; | 28 | import * as Effect from "effect/Effect"; |
| 29 | 29 | ||
| 30 | import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters"; | 30 | import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters"; |
| 31 | import { | ||
| 32 | authArtifactFromSourceAuth, | ||
| 33 | sourceAuthFromAuthArtifact, | ||
| 34 | } from "./auth-artifacts"; | ||
| 31 | 35 | ||
| 32 | const trimOrNull = (value: string | null | undefined): string | null => { | 36 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 33 | if (value === null || value === undefined) { | 37 | if (value === null || value === undefined) { |
| @@ -57,7 +61,7 @@ const sourceRecipeProviderKeyFromSource = (source: Source): string => { · 23 unmodified lines | |||
| 57 | }; | 61 | }; |
| 58 | 62 | ||
| 59 | const stableHash = (value: string): string => | 63 | const stableHash = (value: string): string => |
| 60 | createHash("sha256").update(value).digest("hex").slice(0, 24); | 64 | sha256Hex(value).slice(0, 24); |
| 61 | 65 | ||
| 62 | const sourceRecipeSignature = (source: Source): string => | 66 | const sourceRecipeSignature = (source: Source): string => |
| 63 | JSON.stringify({ | 67 | JSON.stringify({ |
| @@ -89,7 +93,7 @@ const normalizeAuth = ( · 25 unmodified lines | |||
| 89 | const headerName = trimOrNull(auth.headerName) ?? "Authorization"; | 93 | const headerName = trimOrNull(auth.headerName) ?? "Authorization"; |
| 90 | const prefix = auth.prefix ?? "Bearer "; | 94 | const prefix = auth.prefix ?? "Bearer "; |
| 91 | 95 | ||
| 92 | if (auth.kind === "bearer") { | 96 | if (auth.kind === "bearer") { |
| 93 | const providerId = trimOrNull(auth.token.providerId); | 97 | const providerId = trimOrNull(auth.token.providerId); |
| 94 | const handle = trimOrNull(auth.token.handle); | 98 | const handle = trimOrNull(auth.token.handle); |
| 95 | if (providerId === null || handle === null) { | 99 | if (providerId === null || handle === null) { |
| @@ -107,6 +111,54 @@ const normalizeAuth = ( · 11 unmodified lines | |||
| 107 | } satisfies SourceAuth; | 111 | } satisfies SourceAuth; |
| 108 | } | 112 | } |
| 109 | 113 | ||
| 114 | if (auth.kind === "oauth2_authorized_user") { | ||
| 115 | const refreshProviderId = trimOrNull(auth.refreshToken.providerId); | ||
| 116 | const refreshHandle = trimOrNull(auth.refreshToken.handle); | ||
| 117 | if (refreshProviderId === null || refreshHandle === null) { | ||
| 118 | return yield* Effect.fail( | ||
| 119 | new Error("OAuth2 authorized-user auth requires a refresh token secret ref"), | ||
| 120 | ); | ||
| 121 | } | ||
| 122 | |||
| 123 | let clientSecret: { providerId: string; handle: string } | null = null; | ||
| 124 | if (auth.clientSecret !== null) { | ||
| 125 | const clientSecretProviderId = trimOrNull(auth.clientSecret.providerId); | ||
| 126 | const clientSecretHandle = trimOrNull(auth.clientSecret.handle); | ||
| 127 | if (clientSecretProviderId === null || clientSecretHandle === null) { | ||
| 128 | return yield* Effect.fail( | ||
| 129 | new Error("OAuth2 authorized-user client secret ref must include providerId and handle"), | ||
| 130 | ); | ||
| 131 | } | ||
| 132 | clientSecret = { | ||
| 133 | providerId: clientSecretProviderId, | ||
| 134 | handle: clientSecretHandle, | ||
| 135 | }; | ||
| 136 | } | ||
| 137 | |||
| 138 | const tokenEndpoint = trimOrNull(auth.tokenEndpoint); | ||
| 139 | const clientId = trimOrNull(auth.clientId); | ||
| 140 | if (tokenEndpoint === null || clientId === null) { | ||
| 141 | return yield* Effect.fail( | ||
| 142 | new Error("OAuth2 authorized-user auth requires tokenEndpoint and clientId"), | ||
| 143 | ); | ||
| 144 | } | ||
| 145 | |||
| 146 | return { | ||
| 147 | kind: "oauth2_authorized_user", | ||
| 148 | headerName, | ||
| 149 | prefix, | ||
| 150 | tokenEndpoint, | ||
| 151 | clientId, | ||
| 152 | clientAuthentication: auth.clientAuthentication, | ||
| 153 | clientSecret, | ||
| 154 | refreshToken: { | ||
| 155 | providerId: refreshProviderId, | ||
| 156 | handle: refreshHandle, | ||
| 157 | }, | ||
| 158 | grantSet: auth.grantSet ?? null, | ||
| 159 | } satisfies SourceAuth; | ||
| 160 | } | ||
| 161 | |||
| 110 | const accessProviderId = trimOrNull(auth.accessToken.providerId); | 162 | const accessProviderId = trimOrNull(auth.accessToken.providerId); |
| 111 | const accessHandle = trimOrNull(auth.accessToken.handle); | 163 | const accessHandle = trimOrNull(auth.accessToken.handle); |
| 112 | if (accessProviderId === null || accessHandle === null) { | 164 | if (accessProviderId === null || accessHandle === null) { |
| @@ -146,86 +198,6 @@ const normalizeImportAuthPolicy = ( · 33 unmodified lines | |||
| 146 | policy: SourceImportAuthPolicy | undefined, | 198 | policy: SourceImportAuthPolicy | undefined, |
| 147 | ): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy; | 199 | ): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy; |
| 148 | 200 | ||
| 149 | const credentialFromAuth = (input: { | ||
| 150 | source: Source; | ||
| 151 | auth: SourceAuth; | ||
| 152 | slot: CredentialSlot; | ||
| 153 | actorAccountId?: AccountId | null; | ||
| 154 | existingCredentialId?: Credential["id"] | null; | ||
| 155 | }): Credential | null => { | ||
| 156 | if (input.auth.kind === "none") { | ||
| 157 | return null; | ||
| 158 | } | ||
| 159 | |||
| 160 | const credentialId = input.existingCredentialId | ||
| 161 | ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`); | ||
| 162 | |||
| 163 | return { | ||
| 164 | id: credentialId, | ||
| 165 | workspaceId: input.source.workspaceId, | ||
| 166 | sourceId: input.source.id, | ||
| 167 | actorAccountId: input.actorAccountId ?? null, | ||
| 168 | slot: input.slot, | ||
| 169 | authKind: input.auth.kind, | ||
| 170 | authHeaderName: input.auth.headerName, | ||
| 171 | authPrefix: input.auth.prefix, | ||
| 172 | tokenProviderId: | ||
| 173 | input.auth.kind === "bearer" | ||
| 174 | ? input.auth.token.providerId | ||
| 175 | : input.auth.accessToken.providerId, | ||
| 176 | tokenHandle: | ||
| 177 | input.auth.kind === "bearer" | ||
| 178 | ? input.auth.token.handle | ||
| 179 | : input.auth.accessToken.handle, | ||
| 180 | refreshTokenProviderId: | ||
| 181 | input.auth.kind === "oauth2" && input.auth.refreshToken !== null | ||
| 182 | ? input.auth.refreshToken.providerId | ||
| 183 | : null, | ||
| 184 | refreshTokenHandle: | ||
| 185 | input.auth.kind === "oauth2" && input.auth.refreshToken !== null | ||
| 186 | ? input.auth.refreshToken.handle | ||
| 187 | : null, | ||
| 188 | createdAt: input.source.createdAt, | ||
| 189 | updatedAt: input.source.updatedAt, | ||
| 190 | } satisfies Credential; | ||
| 191 | }; | ||
| 192 | |||
| 193 | const authFromCredential = (credential: Credential | null): SourceAuth => { | ||
| 194 | if (credential === null) { | ||
| 195 | return { kind: "none" }; | ||
| 196 | } | ||
| 197 | |||
| 198 | if (credential.authKind === "bearer") { | ||
| 199 | return { | ||
| 200 | kind: "bearer", | ||
| 201 | headerName: credential.authHeaderName, | ||
| 202 | prefix: credential.authPrefix, | ||
| 203 | token: { | ||
| 204 | providerId: credential.tokenProviderId, | ||
| 205 | handle: credential.tokenHandle, | ||
| 206 | }, | ||
| 207 | }; | ||
| 208 | } | ||
| 209 | |||
| 210 | return { | ||
| 211 | kind: "oauth2", | ||
| 212 | headerName: credential.authHeaderName, | ||
| 213 | prefix: credential.authPrefix, | ||
| 214 | accessToken: { | ||
| 215 | providerId: credential.tokenProviderId, | ||
| 216 | handle: credential.tokenHandle, | ||
| 217 | }, | ||
| 218 | refreshToken: | ||
| 219 | credential.refreshTokenProviderId !== null | ||
| 220 | && credential.refreshTokenHandle !== null | ||
| 221 | ? { | ||
| 222 | providerId: credential.refreshTokenProviderId, | ||
| 223 | handle: credential.refreshTokenHandle, | ||
| 224 | } | ||
| 225 | : null, | ||
| 226 | }; | ||
| 227 | }; | ||
| 228 | |||
| 229 | const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> => | 201 | const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> => |
| 230 | Effect.gen(function* () { | 202 | Effect.gen(function* () { |
| 231 | if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") { | 203 | if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") { |
| @@ -370,12 +342,12 @@ export const splitSourceForStorage = (input: { · 138 unmodified lines | |||
| 370 | recipeId: SourceRecipeId; | 342 | recipeId: SourceRecipeId; |
| 371 | recipeRevisionId: SourceRecipeRevisionId; | 343 | recipeRevisionId: SourceRecipeRevisionId; |
| 372 | actorAccountId?: AccountId | null; | 344 | actorAccountId?: AccountId | null; |
| 373 | existingRuntimeCredentialId?: Credential["id"] | null; | 345 | existingRuntimeAuthArtifactId?: AuthArtifact["id"] | null; |
| 374 | existingImportCredentialId?: Credential["id"] | null; | 346 | existingImportAuthArtifactId?: AuthArtifact["id"] | null; |
| 375 | }): { | 347 | }): { |
| 376 | sourceRecord: StoredSourceRecord; | 348 | sourceRecord: StoredSourceRecord; |
| 377 | runtimeCredential: Credential | null; | 349 | runtimeAuthArtifact: AuthArtifact | null; |
| 378 | importCredential: Credential | null; | 350 | importAuthArtifact: AuthArtifact | null; |
| 379 | } => { | 351 | } => { |
| 380 | const sourceRecord: StoredSourceRecord = { | 352 | const sourceRecord: StoredSourceRecord = { |
| 381 | id: input.source.id, | 353 | id: input.source.id, |
| @@ -398,20 +370,22 @@ export const splitSourceForStorage = (input: { · 16 unmodified lines | |||
| 398 | 370 | ||
| 399 | return { | 371 | return { |
| 400 | sourceRecord, | 372 | sourceRecord, |
| 401 | runtimeCredential: credentialFromAuth({ | 373 | runtimeAuthArtifact: authArtifactFromSourceAuth({ |
| 402 | source: input.source, | 374 | source: input.source, |
| 403 | auth: input.source.auth, | 375 | auth: input.source.auth, |
| 404 | slot: "runtime", | 376 | slot: "runtime", |
| 405 | actorAccountId: input.actorAccountId, | 377 | actorAccountId: input.actorAccountId, |
| 406 | existingCredentialId: input.existingRuntimeCredentialId, | 378 | existingAuthArtifactId: input.existingRuntimeAuthArtifactId |
| 379 | ?? AuthArtifactIdSchema.make(`auth_art_${crypto.randomUUID()}`), | ||
| 407 | }), | 380 | }), |
| 408 | importCredential: input.source.importAuthPolicy === "separate" | 381 | importAuthArtifact: input.source.importAuthPolicy === "separate" |
| 409 | ? credentialFromAuth({ | 382 | ? authArtifactFromSourceAuth({ |
| 410 | source: input.source, | 383 | source: input.source, |
| 411 | auth: input.source.importAuth, | 384 | auth: input.source.importAuth, |
| 412 | slot: "import", | 385 | slot: "import", |
| 413 | actorAccountId: input.actorAccountId, | 386 | actorAccountId: input.actorAccountId, |
| 414 | existingCredentialId: input.existingImportCredentialId, | 387 | existingAuthArtifactId: input.existingImportAuthArtifactId |
| 388 | ?? AuthArtifactIdSchema.make(`auth_art_${crypto.randomUUID()}`), | ||
| 415 | }) | 389 | }) |
| 416 | : null, | 390 | : null, |
| 417 | }; | 391 | }; |
| @@ -419,8 +393,8 @@ export const splitSourceForStorage = (input: { · 1 unmodified line | |||
| 419 | 393 | ||
| 420 | export const projectSourceFromStorage = (input: { | 394 | export const projectSourceFromStorage = (input: { |
| 421 | sourceRecord: StoredSourceRecord; | 395 | sourceRecord: StoredSourceRecord; |
| 422 | runtimeCredential: Credential | null; | 396 | runtimeAuthArtifact: AuthArtifact | null; |
| 423 | importCredential: Credential | null; | 397 | importAuthArtifact: AuthArtifact | null; |
| 424 | }): Effect.Effect<Source, Error, never> => | 398 | }): Effect.Effect<Source, Error, never> => |
| 425 | Effect.gen(function* () { | 399 | Effect.gen(function* () { |
| 426 | const adapter = getSourceAdapter(input.sourceRecord.kind); | 400 | const adapter = getSourceAdapter(input.sourceRecord.kind); |
| @@ -443,9 +417,9 @@ export const projectSourceFromStorage = (input: { · 16 unmodified lines | |||
| 443 | importAuthPolicy: input.sourceRecord.importAuthPolicy, | 417 | importAuthPolicy: input.sourceRecord.importAuthPolicy, |
| 444 | importAuth: | 418 | importAuth: |
| 445 | input.sourceRecord.importAuthPolicy === "separate" | 419 | input.sourceRecord.importAuthPolicy === "separate" |
| 446 | ? authFromCredential(input.importCredential) | 420 | ? sourceAuthFromAuthArtifact(input.importAuthArtifact) |
| 447 | : { kind: "none" }, | 421 | : { kind: "none" }, |
| 448 | auth: authFromCredential(input.runtimeCredential), | 422 | auth: sourceAuthFromAuthArtifact(input.runtimeAuthArtifact), |
| 449 | sourceHash: input.sourceRecord.sourceHash, | 423 | sourceHash: input.sourceRecord.sourceHash, |
| 450 | lastError: input.sourceRecord.lastError, | 424 | lastError: input.sourceRecord.lastError, |
| 451 | createdAt: input.sourceRecord.createdAt, | 425 | createdAt: input.sourceRecord.createdAt, |
| @@ -459,23 +433,23 @@ export const projectSourceFromStorage = (input: { · 7 unmodified lines | |||
| 459 | 433 | ||
| 460 | export const projectSourcesFromStorage = (input: { | 434 | export const projectSourcesFromStorage = (input: { |
| 461 | sourceRecords: ReadonlyArray<StoredSourceRecord>; | 435 | sourceRecords: ReadonlyArray<StoredSourceRecord>; |
| 462 | credentials: ReadonlyArray<Credential>; | 436 | authArtifacts: ReadonlyArray<AuthArtifact>; |
| 463 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { | 437 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { |
| 464 | const credentialsBySourceId = new Map<string, { | 438 | const authArtifactsBySourceId = new Map<string, { |
| 465 | runtime: Credential | null; | 439 | runtime: AuthArtifact | null; |
| 466 | import: Credential | null; | 440 | import: AuthArtifact | null; |
| 467 | }>(); | 441 | }>(); |
| 468 | 442 | ||
| 469 | for (const credential of input.credentials) { | 443 | for (const authArtifact of input.authArtifacts) { |
| 470 | const existing = credentialsBySourceId.get(credential.sourceId) ?? { | 444 | const existing = authArtifactsBySourceId.get(authArtifact.sourceId) ?? { |
| 471 | runtime: null, | 445 | runtime: null, |
| 472 | import: null, | 446 | import: null, |
| 473 | }; | 447 | }; |
| 474 | const current = credential.slot === "runtime" ? existing.runtime : existing.import; | 448 | const current = authArtifact.slot === "runtime" ? existing.runtime : existing.import; |
| 475 | if (current === null || (current.actorAccountId === null && credential.actorAccountId !== null)) { | 449 | if (current === null || (current.actorAccountId === null && authArtifact.actorAccountId !== null)) { |
| 476 | credentialsBySourceId.set(credential.sourceId, { | 450 | authArtifactsBySourceId.set(authArtifact.sourceId, { |
| 477 | ...existing, | 451 | ...existing, |
| 478 | [credential.slot]: credential, | 452 | [authArtifact.slot]: authArtifact, |
| 479 | }); | 453 | }); |
| 480 | } | 454 | } |
| 481 | } | 455 | } |
| @@ -483,7 +457,7 @@ export const projectSourcesFromStorage = (input: { · 1 unmodified line | |||
| 483 | return Effect.forEach(input.sourceRecords, (sourceRecord) => | 457 | return Effect.forEach(input.sourceRecords, (sourceRecord) => |
| 484 | projectSourceFromStorage({ | 458 | projectSourceFromStorage({ |
| 485 | sourceRecord, | 459 | sourceRecord, |
| 486 | runtimeCredential: credentialsBySourceId.get(sourceRecord.id)?.runtime ?? null, | 460 | runtimeAuthArtifact: authArtifactsBySourceId.get(sourceRecord.id)?.runtime ?? null, |
| 487 | importCredential: credentialsBySourceId.get(sourceRecord.id)?.import ?? null, | 461 | importAuthArtifact: authArtifactsBySourceId.get(sourceRecord.id)?.import ?? null, |
| 488 | })); | 462 | })); |
| 489 | }; | 463 | }; |
| @@ -148,4 +148,81 @@ describe("source-discovery", () => { · 147 unmodified lines | |||
| 148 | await server.close(); | 148 | await server.close(); |
| 149 | } | 149 | } |
| 150 | }); | 150 | }); |
| 151 | |||
| 152 | it("detects Google Discovery documents", async () => { | ||
| 153 | const server = await withServer((request, response) => { | ||
| 154 | if (request.url !== "/gmail/$discovery/rest?version=v1") { | ||
| 155 | response.statusCode = 404; | ||
| 156 | response.end(); | ||
| 157 | return; | ||
| 158 | } | ||
| 159 | |||
| 160 | response.statusCode = 200; | ||
| 161 | response.setHeader("content-type", "application/json"); | ||
| 162 | response.end(JSON.stringify({ | ||
| 163 | name: "gmail", | ||
| 164 | version: "v1", | ||
| 165 | title: "Gmail API", | ||
| 166 | description: "Access Gmail mailboxes and settings.", | ||
| 167 | rootUrl: `${server.url}/`, | ||
| 168 | servicePath: "gmail/v1/users/", | ||
| 169 | auth: { | ||
| 170 | oauth2: { | ||
| 171 | scopes: { | ||
| 172 | "https://www.googleapis.com/auth/gmail.readonly": { | ||
| 173 | description: "View your email messages and settings", | ||
| 174 | }, | ||
| 175 | }, | ||
| 176 | }, | ||
| 177 | }, | ||
| 178 | methods: { | ||
| 179 | getProfile: { | ||
| 180 | id: "gmail.users.getProfile", | ||
| 181 | path: "{userId}/profile", | ||
| 182 | httpMethod: "GET", | ||
| 183 | parameters: { | ||
| 184 | userId: { | ||
| 185 | type: "string", | ||
| 186 | required: true, | ||
| 187 | location: "path", | ||
| 188 | }, | ||
| 189 | }, | ||
| 190 | response: { | ||
| 191 | $ref: "Profile", | ||
| 192 | }, | ||
| 193 | }, | ||
| 194 | }, | ||
| 195 | schemas: { | ||
| 196 | Profile: { | ||
| 197 | id: "Profile", | ||
| 198 | type: "object", | ||
| 199 | properties: { | ||
| 200 | emailAddress: { | ||
| 201 | type: "string", | ||
| 202 | }, | ||
| 203 | }, | ||
| 204 | }, | ||
| 205 | }, | ||
| 206 | })); | ||
| 207 | }); | ||
| 208 | |||
| 209 | try { | ||
| 210 | const result = await Effect.runPromise(discoverSource({ | ||
| 211 | url: `${server.url}/gmail/$discovery/rest?version=v1`, | ||
| 212 | })); | ||
| 213 | |||
| 214 | expect(result.detectedKind).toBe("google_discovery"); | ||
| 215 | expect(result.specUrl).toBe(`${server.url}/gmail/$discovery/rest?version=v1`); | ||
| 216 | expect(result.endpoint).toBe(`${server.url}/gmail/v1/users/`); | ||
| 217 | expect(result.name).toBe("Gmail API"); | ||
| 218 | expect(result.namespace).toBe("gmail"); | ||
| 219 | expect(result.authInference.suggestedKind).toBe("oauth2"); | ||
| 220 | expect(result.authInference.oauthScopes).toEqual([ | ||
| 221 | "https://www.googleapis.com/auth/gmail.readonly", | ||
| 222 | ]); | ||
| 223 | expect(result.toolCount).toBe(1); | ||
| 224 | } finally { | ||
| 225 | await server.close(); | ||
| 226 | } | ||
| 227 | }); | ||
| 151 | }); | 228 | }); |
| @@ -7,6 +7,9 @@ import { · 6 unmodified lines | |||
| 7 | createSdkMcpConnector, | 7 | createSdkMcpConnector, |
| 8 | discoverMcpToolsFromConnector, | 8 | discoverMcpToolsFromConnector, |
| 9 | } from "@executor/codemode-mcp"; | 9 | } from "@executor/codemode-mcp"; |
| 10 | import { | ||
| 11 | extractGoogleDiscoveryManifest, | ||
| 12 | } from "@executor/codemode-google-discovery"; | ||
| 10 | import { | 13 | import { |
| 11 | extractOpenApiManifest, | 14 | extractOpenApiManifest, |
| 12 | parseOpenApiDocument, | 15 | parseOpenApiDocument, |
| @@ -33,6 +36,10 @@ type OpenApiProbeResult = { · 20 unmodified lines | |||
| 33 | result: SourceDiscoveryResult; | 36 | result: SourceDiscoveryResult; |
| 34 | }; | 37 | }; |
| 35 | 38 | ||
| 39 | type GoogleDiscoveryProbeResult = { | ||
| 40 | result: SourceDiscoveryResult; | ||
| 41 | }; | ||
| 42 | |||
| 36 | type GraphqlProbeResult = { | 43 | type GraphqlProbeResult = { |
| 37 | result: SourceDiscoveryResult; | 44 | result: SourceDiscoveryResult; |
| 38 | }; | 45 | }; |
| @@ -607,6 +614,89 @@ const deriveOpenApiEndpoint = (input: { · 568 unmodified lines | |||
| 607 | return new URL(input.normalizedUrl).origin; | 614 | return new URL(input.normalizedUrl).origin; |
| 608 | }; | 615 | }; |
| 609 | 616 | ||
| 617 | const googleDiscoveryEndpoint = (input: { | ||
| 618 | rootUrl: string; | ||
| 619 | servicePath: string; | ||
| 620 | }): string => { | ||
| 621 | try { | ||
| 622 | return new URL(input.servicePath || "", input.rootUrl).toString(); | ||
| 623 | } catch { | ||
| 624 | return input.rootUrl; | ||
| 625 | } | ||
| 626 | }; | ||
| 627 | |||
| 628 | const inferGoogleDiscoveryAuth = (input: { | ||
| 629 | scopes: ReadonlyArray<string>; | ||
| 630 | }): SourceAuthInference => | ||
| 631 | input.scopes.length > 0 | ||
| 632 | ? supportedAuthInference("oauth2", { | ||
| 633 | confidence: "high", | ||
| 634 | reason: "Google Discovery document declares OAuth scopes", | ||
| 635 | headerName: "Authorization", | ||
| 636 | prefix: "Bearer ", | ||
| 637 | parameterName: null, | ||
| 638 | parameterLocation: null, | ||
| 639 | oauthAuthorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth", | ||
| 640 | oauthTokenUrl: "https://oauth2.googleapis.com/token", | ||
| 641 | oauthScopes: [...input.scopes], | ||
| 642 | }) | ||
| 643 | : noneAuthInference( | ||
| 644 | "Google Discovery document does not declare OAuth scopes", | ||
| 645 | "medium", | ||
| 646 | ); | ||
| 647 | |||
| 648 | const tryDetectGoogleDiscovery = (input: { | ||
| 649 | normalizedUrl: string; | ||
| 650 | headers: Readonly<Record<string, string>>; | ||
| 651 | }): Effect.Effect<GoogleDiscoveryProbeResult | null, never, never> => | ||
| 652 | Effect.gen(function* () { | ||
| 653 | const response = yield* Effect.either(executeHttpProbe({ | ||
| 654 | method: "GET", | ||
| 655 | url: input.normalizedUrl, | ||
| 656 | headers: input.headers, | ||
| 657 | })); | ||
| 658 | |||
| 659 | if (response._tag === "Left") { | ||
| 660 | return null; | ||
| 661 | } | ||
| 662 | |||
| 663 | if (response.right.status < 200 || response.right.status >= 300) { | ||
| 664 | return null; | ||
| 665 | } | ||
| 666 | |||
| 667 | const manifest = yield* Effect.either( | ||
| 668 | extractGoogleDiscoveryManifest(input.normalizedUrl, response.right.text), | ||
| 669 | ); | ||
| 670 | if (manifest._tag === "Left") { | ||
| 671 | return null; | ||
| 672 | } | ||
| 673 | |||
| 674 | const endpoint = googleDiscoveryEndpoint({ | ||
| 675 | rootUrl: manifest.right.rootUrl, | ||
| 676 | servicePath: manifest.right.servicePath, | ||
| 677 | }); | ||
| 678 | const name = trimOrNull(manifest.right.title) | ||
| 679 | ?? `${manifest.right.service}.${manifest.right.versionName}.googleapis.com`; | ||
| 680 | const oauthScopes = Object.keys(manifest.right.oauthScopes ?? {}); | ||
| 681 | |||
| 682 | return { | ||
| 683 | result: { | ||
| 684 | detectedKind: "google_discovery", | ||
| 685 | confidence: "high", | ||
| 686 | endpoint, | ||
| 687 | specUrl: input.normalizedUrl, | ||
| 688 | name, | ||
| 689 | namespace: namespaceFromSourceName(manifest.right.service), | ||
| 690 | transport: null, | ||
| 691 | authInference: inferGoogleDiscoveryAuth({ | ||
| 692 | scopes: oauthScopes, | ||
| 693 | }), | ||
| 694 | toolCount: manifest.right.methods.length, | ||
| 695 | warnings: [], | ||
| 696 | }, | ||
| 697 | } satisfies GoogleDiscoveryProbeResult; | ||
| 698 | }).pipe(Effect.catchAll(() => Effect.succeed(null))); | ||
| 699 | |||
| 610 | const tryDetectOpenApi = (input: { | 700 | const tryDetectOpenApi = (input: { |
| 611 | normalizedUrl: string; | 701 | normalizedUrl: string; |
| 612 | headers: Readonly<Record<string, string>>; | 702 | headers: Readonly<Record<string, string>>; |
| @@ -851,7 +941,7 @@ const fallbackDiscoveryResult = (normalizedUrl: string): SourceDiscoveryResult = · 238 unmodified lines | |||
| 851 | transport: null, | 941 | transport: null, |
| 852 | authInference: unknownAuthInference("Could not infer source kind or auth requirements from the provided URL"), | 942 | authInference: unknownAuthInference("Could not infer source kind or auth requirements from the provided URL"), |
| 853 | toolCount: null, | 943 | toolCount: null, |
| 854 | warnings: ["Could not confirm whether the URL is OpenAPI, GraphQL, or MCP."], | 944 | warnings: ["Could not confirm whether the URL is Google Discovery, OpenAPI, GraphQL, or MCP."], |
| 855 | }; | 945 | }; |
| 856 | }; | 946 | }; |
| 857 | 947 | ||
| @@ -863,6 +953,14 @@ export const discoverSource = (input: { · 5 unmodified lines | |||
| 863 | const normalizedUrl = normalizeUrl(input.url); | 953 | const normalizedUrl = normalizeUrl(input.url); |
| 864 | const headers = probeHeadersFromAuth(input.probeAuth); | 954 | const headers = probeHeadersFromAuth(input.probeAuth); |
| 865 | 955 | ||
| 956 | const googleDiscovery = yield* tryDetectGoogleDiscovery({ | ||
| 957 | normalizedUrl, | ||
| 958 | headers, | ||
| 959 | }); | ||
| 960 | if (googleDiscovery) { | ||
| 961 | return googleDiscovery.result; | ||
| 962 | } | ||
| 963 | |||
| 866 | const openApi = yield* tryDetectOpenApi({ | 964 | const openApi = yield* tryDetectOpenApi({ |
| 867 | normalizedUrl, | 965 | normalizedUrl, |
| 868 | headers, | 966 | headers, |
| @@ -1,5 +1,6 @@ | |||
| 1 | import type { SqlControlPlaneRows } from "#persistence"; | 1 | import type { SqlControlPlaneRows } from "#persistence"; |
| 2 | import type { | 2 | import type { |
| 3 | AccountId, | ||
| 3 | Source, | 4 | Source, |
| 4 | } from "#schema"; | 5 | } from "#schema"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| @@ -22,6 +23,7 @@ const shouldIndexSource = (source: Source): boolean => · 16 unmodified lines | |||
| 22 | export const syncSourceMaterialization = (input: { | 23 | export const syncSourceMaterialization = (input: { |
| 23 | rows: SqlControlPlaneRows; | 24 | rows: SqlControlPlaneRows; |
| 24 | source: Source; | 25 | source: Source; |
| 26 | actorAccountId?: AccountId | null; | ||
| 25 | resolveSecretMaterial: ResolveSourceSecretMaterial; | 27 | resolveSecretMaterial: ResolveSourceSecretMaterial; |
| 26 | }): Effect.Effect<void, Error, never> => | 28 | }): Effect.Effect<void, Error, never> => |
| 27 | Effect.gen(function* () { | 29 | Effect.gen(function* () { |
| @@ -35,8 +37,10 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines | |||
| 35 | resolveSecretMaterial: input.resolveSecretMaterial, | 37 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 36 | resolveAuthMaterialForSlot: (slot) => | 38 | resolveAuthMaterialForSlot: (slot) => |
| 37 | resolveSourceAuthMaterial({ | 39 | resolveSourceAuthMaterial({ |
| 40 | rows: input.rows, | ||
| 38 | source: input.source, | 41 | source: input.source, |
| 39 | slot, | 42 | slot, |
| 43 | actorAccountId: input.actorAccountId, | ||
| 40 | resolveSecretMaterial: input.resolveSecretMaterial, | 44 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 41 | }), | 45 | }), |
| 42 | }); | 46 | }); |
| @@ -1,4 +1,4 @@ | |||
| 1 | import { createHash } from "node:crypto"; | 1 | import { sha256Hex } from "@executor/codemode-core"; |
| 2 | 2 | ||
| 3 | import type { | 3 | import type { |
| 4 | Source, | 4 | Source, |
| @@ -28,7 +28,7 @@ export const normalizeSearchText = ( · 23 unmodified lines | |||
| 28 | .toLowerCase(); | 28 | .toLowerCase(); |
| 29 | 29 | ||
| 30 | export const contentHash = (value: string): string => | 30 | export const contentHash = (value: string): string => |
| 31 | createHash("sha256").update(value).digest("hex"); | 31 | sha256Hex(value); |
| 32 | 32 | ||
| 33 | export const loadSourceBindingRecord = (input: { | 33 | export const loadSourceBindingRecord = (input: { |
| 34 | rows: SqlControlPlaneRows; | 34 | rows: SqlControlPlaneRows; |
| @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 4 | AccountIdSchema, | 4 | AccountIdSchema, |
| 5 | decodeBuiltInAuthArtifactConfig, | ||
| 5 | McpSourceAuthSessionDataJsonSchema, | 6 | McpSourceAuthSessionDataJsonSchema, |
| 6 | OrganizationIdSchema, | 7 | OrganizationIdSchema, |
| 7 | SecretMaterialIdSchema, | 8 | SecretMaterialIdSchema, |
| @@ -186,10 +187,14 @@ describe("source-store", () => { · 178 unmodified lines | |||
| 186 | ); | 187 | ); |
| 187 | 188 | ||
| 188 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true); | 189 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true); |
| 189 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(1); | 190 | const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId); |
| 190 | expect((yield* persistence.rows.credentials.listByWorkspaceId(workspaceId))[0]?.tokenHandle).toBe( | 191 | expect(authArtifacts).toHaveLength(1); |
| 191 | secondTokenId, | 192 | const decoded = authArtifacts[0] ? decodeBuiltInAuthArtifactConfig(authArtifacts[0]) : null; |
| 192 | ); | 193 | expect( |
| 194 | decoded !== null && decoded.artifactKind === "static_bearer" | ||
| 195 | ? decoded.config.token.handle | ||
| 196 | : null, | ||
| 197 | ).toBe(secondTokenId); | ||
| 193 | 198 | ||
| 194 | const removed = yield* removeSourceById(persistence.rows, { | 199 | const removed = yield* removeSourceById(persistence.rows, { |
| 195 | workspaceId, | 200 | workspaceId, |
| @@ -199,7 +204,7 @@ describe("source-store", () => { · 3 unmodified lines | |||
| 199 | 204 | ||
| 200 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); | 205 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); |
| 201 | expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0); | 206 | expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 202 | expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0); | 207 | expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 203 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); | 208 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 204 | }), | 209 | }), |
| 205 | ); | 210 | ); |
| @@ -267,14 +272,14 @@ describe("source-store", () => { · 61 unmodified lines | |||
| 267 | }, | 272 | }, |
| 268 | ); | 273 | ); |
| 269 | 274 | ||
| 270 | const credentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({ | 275 | const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 271 | workspaceId, | 276 | workspaceId, |
| 272 | sourceId, | 277 | sourceId, |
| 273 | }); | 278 | }); |
| 274 | expect(credentials).toHaveLength(2); | 279 | expect(authArtifacts).toHaveLength(2); |
| 275 | expect(credentials.some((credential) => credential.actorAccountId === null)).toBe(true); | 280 | expect(authArtifacts.some((artifact) => artifact.actorAccountId === null)).toBe(true); |
| 276 | expect(credentials.some((credential) => credential.actorAccountId === accountId)).toBe(true); | 281 | expect(authArtifacts.some((artifact) => artifact.actorAccountId === accountId)).toBe(true); |
| 277 | expect(new Set(credentials.map((credential) => credential.id)).size).toBe(2); | 282 | expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2); |
| 278 | }), | 283 | }), |
| 279 | ); | 284 | ); |
| 280 | 285 | ||
| @@ -1,8 +1,7 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | AccountId, | 2 | AccountId, |
| 3 | Credential, | 3 | AuthArtifact, |
| 4 | CredentialSlot, | 4 | CredentialSlot, |
| 5 | SecretRef, | ||
| 6 | Source, | 5 | Source, |
| 7 | SourceRecipeId, | 6 | SourceRecipeId, |
| 8 | SourceRecipeRevisionId, | 7 | SourceRecipeRevisionId, |
| @@ -22,25 +21,15 @@ import { · 13 unmodified lines | |||
| 22 | splitSourceForStorage, | 21 | splitSourceForStorage, |
| 23 | } from "./source-definitions"; | 22 | } from "./source-definitions"; |
| 24 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; | 23 | import { createDefaultSecretMaterialDeleter } from "./secret-material-providers"; |
| 24 | import { authArtifactSecretMaterialRefs } from "./auth-artifacts"; | ||
| 25 | import { removeAuthLeaseAndSecrets } from "./auth-leases"; | ||
| 25 | 26 | ||
| 26 | const credentialSecretRefs = (credential: Credential): ReadonlyArray<SecretRef> => [ | 27 | const secretRefKey = (ref: { providerId: string; handle: string }): string => |
| 27 | { | 28 | `${ref.providerId}:${ref.handle}`; |
| 28 | providerId: credential.tokenProviderId, | 29 | |
| 29 | handle: credential.tokenHandle, | 30 | const cleanupAuthArtifactSecretRefs = (rows: SqlControlPlaneRows, input: { |
| 30 | }, | 31 | previous: AuthArtifact | null; |
| 31 | ...(credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null | 32 | next: AuthArtifact | null; |
| 32 | ? [{ | ||
| 33 | providerId: credential.refreshTokenProviderId, | ||
| 34 | handle: credential.refreshTokenHandle, | ||
| 35 | } satisfies SecretRef] | ||
| 36 | : []), | ||
| 37 | ]; | ||
| 38 | |||
| 39 | const secretRefKey = (ref: SecretRef): string => `${ref.providerId}:${ref.handle}`; | ||
| 40 | |||
| 41 | const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { | ||
| 42 | previous: Credential | null; | ||
| 43 | next: Credential | null; | ||
| 44 | }) => | 33 | }) => |
| 45 | Effect.gen(function* () { | 34 | Effect.gen(function* () { |
| 46 | if (input.previous === null) { | 35 | if (input.previous === null) { |
| @@ -49,9 +38,9 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 2 unmodified lines | |||
| 49 | 38 | ||
| 50 | const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows }); | 39 | const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows }); |
| 51 | const nextRefKeys = new Set( | 40 | const nextRefKeys = new Set( |
| 52 | (input.next === null ? [] : credentialSecretRefs(input.next)).map(secretRefKey), | 41 | (input.next === null ? [] : authArtifactSecretMaterialRefs(input.next)).map(secretRefKey), |
| 53 | ); | 42 | ); |
| 54 | const refsToDelete = credentialSecretRefs(input.previous).filter( | 43 | const refsToDelete = authArtifactSecretMaterialRefs(input.previous).filter( |
| 55 | (ref) => !nextRefKeys.has(secretRefKey(ref)), | 44 | (ref) => !nextRefKeys.has(secretRefKey(ref)), |
| 56 | ); | 45 | ); |
| 57 | 46 | ||
| @@ -62,32 +51,32 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 4 unmodified lines | |||
| 62 | ); | 51 | ); |
| 63 | }); | 52 | }); |
| 64 | 53 | ||
| 65 | const selectPreferredCredential = (input: { | 54 | const selectPreferredAuthArtifact = (input: { |
| 66 | credentials: ReadonlyArray<Credential>; | 55 | authArtifacts: ReadonlyArray<AuthArtifact>; |
| 67 | actorAccountId?: AccountId | null; | 56 | actorAccountId?: AccountId | null; |
| 68 | slot: CredentialSlot; | 57 | slot: CredentialSlot; |
| 69 | }): Credential | null => { | 58 | }): AuthArtifact | null => { |
| 70 | const matchingSlot = input.credentials.filter((credential) => credential.slot === input.slot); | 59 | const matchingSlot = input.authArtifacts.filter((artifact) => artifact.slot === input.slot); |
| 71 | 60 | ||
| 72 | if (input.actorAccountId !== undefined) { | 61 | if (input.actorAccountId !== undefined) { |
| 73 | const exact = matchingSlot.find((credential) => credential.actorAccountId === input.actorAccountId); | 62 | const exact = matchingSlot.find((artifact) => artifact.actorAccountId === input.actorAccountId); |
| 74 | if (exact) { | 63 | if (exact) { |
| 75 | return exact; | 64 | return exact; |
| 76 | } | 65 | } |
| 77 | } | 66 | } |
| 78 | 67 | ||
| 79 | return matchingSlot.find((credential) => credential.actorAccountId === null) ?? null; | 68 | return matchingSlot.find((artifact) => artifact.actorAccountId === null) ?? null; |
| 80 | }; | 69 | }; |
| 81 | 70 | ||
| 82 | const selectExactCredential = (input: { | 71 | const selectExactAuthArtifact = (input: { |
| 83 | credentials: ReadonlyArray<Credential>; | 72 | authArtifacts: ReadonlyArray<AuthArtifact>; |
| 84 | actorAccountId?: AccountId | null; | 73 | actorAccountId?: AccountId | null; |
| 85 | slot: CredentialSlot; | 74 | slot: CredentialSlot; |
| 86 | }): Credential | null => | 75 | }): AuthArtifact | null => |
| 87 | input.credentials.find( | 76 | input.authArtifacts.find( |
| 88 | (credential) => | 77 | (artifact) => |
| 89 | credential.slot === input.slot | 78 | artifact.slot === input.slot |
| 90 | && credential.actorAccountId === (input.actorAccountId ?? null), | 79 | && artifact.actorAccountId === (input.actorAccountId ?? null), |
| 91 | ) ?? null; | 80 | ) ?? null; |
| 92 | 81 | ||
| 93 | export const loadSourcesInWorkspace = ( | 82 | export const loadSourcesInWorkspace = ( |
| @@ -99,27 +88,27 @@ export const loadSourcesInWorkspace = ( · 5 unmodified lines | |||
| 99 | ) => | 88 | ) => |
| 100 | Effect.gen(function* () { | 89 | Effect.gen(function* () { |
| 101 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); | 90 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); |
| 102 | const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId); | 91 | const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId); |
| 103 | const filteredCredentials = sourceRecords.flatMap((sourceRecord) => { | 92 | const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => { |
| 104 | const matches = credentials.filter((credential) => credential.sourceId === sourceRecord.id); | 93 | const matches = authArtifacts.filter((artifact) => artifact.sourceId === sourceRecord.id); |
| 105 | const preferred = selectPreferredCredential({ | 94 | const preferred = selectPreferredAuthArtifact({ |
| 106 | credentials: matches, | 95 | authArtifacts: matches, |
| 107 | actorAccountId: options.actorAccountId, | 96 | actorAccountId: options.actorAccountId, |
| 108 | slot: "runtime", | 97 | slot: "runtime", |
| 109 | }); | 98 | }); |
| 110 | const preferredImport = selectPreferredCredential({ | 99 | const preferredImport = selectPreferredAuthArtifact({ |
| 111 | credentials: matches, | 100 | authArtifacts: matches, |
| 112 | actorAccountId: options.actorAccountId, | 101 | actorAccountId: options.actorAccountId, |
| 113 | slot: "import", | 102 | slot: "import", |
| 114 | }); | 103 | }); |
| 115 | return [preferred, preferredImport].filter( | 104 | return [preferred, preferredImport].filter( |
| 116 | (credential): credential is Credential => credential !== null, | 105 | (artifact): artifact is AuthArtifact => artifact !== null, |
| 117 | ); | 106 | ); |
| 118 | }); | 107 | }); |
| 119 | 108 | ||
| 120 | return yield* projectSourcesFromStorage({ | 109 | return yield* projectSourcesFromStorage({ |
| 121 | sourceRecords, | 110 | sourceRecords, |
| 122 | credentials: filteredCredentials, | 111 | authArtifacts: filteredAuthArtifacts, |
| 123 | }); | 112 | }); |
| 124 | }); | 113 | }); |
| 125 | 114 | ||
| @@ -140,54 +129,63 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 14 unmodified lines | |||
| 140 | ); | 129 | ); |
| 141 | } | 130 | } |
| 142 | 131 | ||
| 143 | const credentials = yield* rows.credentials.listByWorkspaceAndSourceId({ | 132 | const authArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 144 | workspaceId: input.workspaceId, | 133 | workspaceId: input.workspaceId, |
| 145 | sourceId: input.sourceId, | 134 | sourceId: input.sourceId, |
| 146 | }); | 135 | }); |
| 147 | const credential = selectPreferredCredential({ | 136 | const authArtifact = selectPreferredAuthArtifact({ |
| 148 | credentials, | 137 | authArtifacts, |
| 149 | actorAccountId: input.actorAccountId, | 138 | actorAccountId: input.actorAccountId, |
| 150 | slot: "runtime", | 139 | slot: "runtime", |
| 151 | }); | 140 | }); |
| 152 | const importCredential = selectPreferredCredential({ | 141 | const importAuthArtifact = selectPreferredAuthArtifact({ |
| 153 | credentials, | 142 | authArtifacts, |
| 154 | actorAccountId: input.actorAccountId, | 143 | actorAccountId: input.actorAccountId, |
| 155 | slot: "import", | 144 | slot: "import", |
| 156 | }); | 145 | }); |
| 157 | 146 | ||
| 158 | return yield* projectSourceFromStorage({ | 147 | return yield* projectSourceFromStorage({ |
| 159 | sourceRecord: sourceRecord.value, | 148 | sourceRecord: sourceRecord.value, |
| 160 | runtimeCredential: credential, | 149 | runtimeAuthArtifact: authArtifact, |
| 161 | importCredential, | 150 | importAuthArtifact, |
| 162 | }); | 151 | }); |
| 163 | }); | 152 | }); |
| 164 | 153 | ||
| 165 | const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: { | 154 | const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { |
| 166 | workspaceId: WorkspaceId; | 155 | workspaceId: WorkspaceId; |
| 167 | sourceId: Source["id"]; | 156 | sourceId: Source["id"]; |
| 168 | }) => | 157 | }) => |
| 169 | Effect.gen(function* () { | 158 | Effect.gen(function* () { |
| 170 | const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({ | 159 | const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 171 | workspaceId: input.workspaceId, | 160 | workspaceId: input.workspaceId, |
| 172 | sourceId: input.sourceId, | 161 | sourceId: input.sourceId, |
| 173 | }); | 162 | }); |
| 174 | 163 | ||
| 175 | yield* rows.credentials.removeByWorkspaceAndSourceId({ | 164 | yield* rows.authArtifacts.removeByWorkspaceAndSourceId({ |
| 176 | workspaceId: input.workspaceId, | 165 | workspaceId: input.workspaceId, |
| 177 | sourceId: input.sourceId, | 166 | sourceId: input.sourceId, |
| 178 | }); | 167 | }); |
| 179 | 168 | ||
| 180 | yield* Effect.forEach( | 169 | yield* Effect.forEach( |
| 181 | existingCredentials, | 170 | existingAuthArtifacts, |
| 182 | (credential) => | 171 | (artifact) => |
| 183 | cleanupCredentialSecretRefs(rows, { | 172 | removeAuthLeaseAndSecrets(rows, { |
| 184 | previous: credential, | 173 | authArtifactId: artifact.id, |
| 174 | }), | ||
| 175 | { discard: true }, | ||
| 176 | ); | ||
| 177 | |||
| 178 | yield* Effect.forEach( | ||
| 179 | existingAuthArtifacts, | ||
| 180 | (artifact) => | ||
| 181 | cleanupAuthArtifactSecretRefs(rows, { | ||
| 182 | previous: artifact, | ||
| 185 | next: null, | 183 | next: null, |
| 186 | }), | 184 | }), |
| 187 | { discard: true }, | 185 | { discard: true }, |
| 188 | ); | 186 | ); |
| 189 | 187 | ||
| 190 | return existingCredentials.length; | 188 | return existingAuthArtifacts.length; |
| 191 | }); | 189 | }); |
| 192 | 190 | ||
| 193 | const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { | 191 | const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { |
| @@ -242,7 +240,7 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 48 unmodified lines | |||
| 242 | workspaceId: input.workspaceId, | 240 | workspaceId: input.workspaceId, |
| 243 | sourceId: input.sourceId, | 241 | sourceId: input.sourceId, |
| 244 | }); | 242 | }); |
| 245 | yield* removeCredentialsForSource(rows, input); | 243 | yield* removeAuthArtifactsForSource(rows, input); |
| 246 | const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId); | 244 | const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId); |
| 247 | if (!removed) { | 245 | if (!removed) { |
| 248 | return false; | 246 | return false; |
| @@ -265,17 +263,17 @@ export const persistSource = ( · 16 unmodified lines | |||
| 265 | ) => | 263 | ) => |
| 266 | Effect.gen(function* () { | 264 | Effect.gen(function* () { |
| 267 | const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id); | 265 | const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id); |
| 268 | const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({ | 266 | const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ |
| 269 | workspaceId: source.workspaceId, | 267 | workspaceId: source.workspaceId, |
| 270 | sourceId: source.id, | 268 | sourceId: source.id, |
| 271 | }); | 269 | }); |
| 272 | const existingCredential = selectExactCredential({ | 270 | const existingRuntimeAuthArtifact = selectExactAuthArtifact({ |
| 273 | credentials: existingCredentials, | 271 | authArtifacts: existingAuthArtifacts, |
| 274 | actorAccountId: options.actorAccountId, | 272 | actorAccountId: options.actorAccountId, |
| 275 | slot: "runtime", | 273 | slot: "runtime", |
| 276 | }); | 274 | }); |
| 277 | const existingImportCredential = selectExactCredential({ | 275 | const existingImportAuthArtifact = selectExactAuthArtifact({ |
| 278 | credentials: existingCredentials, | 276 | authArtifacts: existingAuthArtifacts, |
| 279 | actorAccountId: options.actorAccountId, | 277 | actorAccountId: options.actorAccountId, |
| 280 | slot: "import", | 278 | slot: "import", |
| 281 | }); | 279 | }); |
| @@ -309,13 +307,13 @@ export const persistSource = ( · 27 unmodified lines | |||
| 309 | latestRevisionId: nextRevision.id, | 307 | latestRevisionId: nextRevision.id, |
| 310 | }); | 308 | }); |
| 311 | 309 | ||
| 312 | const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({ | 310 | const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({ |
| 313 | source, | 311 | source, |
| 314 | recipeId: nextRecipe.id, | 312 | recipeId: nextRecipe.id, |
| 315 | recipeRevisionId: nextRevision.id, | 313 | recipeRevisionId: nextRevision.id, |
| 316 | actorAccountId: options.actorAccountId, | 314 | actorAccountId: options.actorAccountId, |
| 317 | existingRuntimeCredentialId: existingCredential?.id ?? null, | 315 | existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null, |
| 318 | existingImportCredentialId: existingImportCredential?.id ?? null, | 316 | existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null, |
| 319 | }); | 317 | }); |
| 320 | 318 | ||
| 321 | if (Option.isNone(existing)) { | 319 | if (Option.isNone(existing)) { |
| @@ -346,36 +344,62 @@ export const persistSource = ( · 24 unmodified lines | |||
| 346 | }); | 344 | }); |
| 347 | } | 345 | } |
| 348 | 346 | ||
| 349 | if (runtimeCredential === null) { | 347 | if (runtimeAuthArtifact === null) { |
| 350 | yield* rows.credentials.removeByWorkspaceSourceAndActor({ | 348 | if (existingRuntimeAuthArtifact !== null) { |
| 349 | yield* removeAuthLeaseAndSecrets(rows, { | ||
| 350 | authArtifactId: existingRuntimeAuthArtifact.id, | ||
| 351 | }); | ||
| 352 | } | ||
| 353 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | ||
| 351 | workspaceId: source.workspaceId, | 354 | workspaceId: source.workspaceId, |
| 352 | sourceId: source.id, | 355 | sourceId: source.id, |
| 353 | actorAccountId: options.actorAccountId ?? null, | 356 | actorAccountId: options.actorAccountId ?? null, |
| 354 | slot: "runtime", | 357 | slot: "runtime", |
| 355 | }); | 358 | }); |
| 356 | } else { | 359 | } else { |
| 357 | yield* rows.credentials.upsert(runtimeCredential); | 360 | yield* rows.authArtifacts.upsert(runtimeAuthArtifact); |
| 361 | if ( | ||
| 362 | existingRuntimeAuthArtifact !== null | ||
| 363 | && existingRuntimeAuthArtifact.id !== runtimeAuthArtifact.id | ||
| 364 | ) { | ||
| 365 | yield* removeAuthLeaseAndSecrets(rows, { | ||
| 366 | authArtifactId: existingRuntimeAuthArtifact.id, | ||
| 367 | }); | ||
| 368 | } | ||
| 358 | } | 369 | } |
| 359 | 370 | ||
| 360 | yield* cleanupCredentialSecretRefs(rows, { | 371 | yield* cleanupAuthArtifactSecretRefs(rows, { |
| 361 | previous: existingCredential ?? null, | 372 | previous: existingRuntimeAuthArtifact ?? null, |
| 362 | next: runtimeCredential, | 373 | next: runtimeAuthArtifact, |
| 363 | }); | 374 | }); |
| 364 | 375 | ||
| 365 | if (importCredential === null) { | 376 | if (importAuthArtifact === null) { |
| 366 | yield* rows.credentials.removeByWorkspaceSourceAndActor({ | 377 | if (existingImportAuthArtifact !== null) { |
| 378 | yield* removeAuthLeaseAndSecrets(rows, { | ||
| 379 | authArtifactId: existingImportAuthArtifact.id, | ||
| 380 | }); | ||
| 381 | } | ||
| 382 | yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | ||
| 367 | workspaceId: source.workspaceId, | 383 | workspaceId: source.workspaceId, |
| 368 | sourceId: source.id, | 384 | sourceId: source.id, |
| 369 | actorAccountId: options.actorAccountId ?? null, | 385 | actorAccountId: options.actorAccountId ?? null, |
| 370 | slot: "import", | 386 | slot: "import", |
| 371 | }); | 387 | }); |
| 372 | } else { | 388 | } else { |
| 373 | yield* rows.credentials.upsert(importCredential); | 389 | yield* rows.authArtifacts.upsert(importAuthArtifact); |
| 390 | if ( | ||
| 391 | existingImportAuthArtifact !== null | ||
| 392 | && existingImportAuthArtifact.id !== importAuthArtifact.id | ||
| 393 | ) { | ||
| 394 | yield* removeAuthLeaseAndSecrets(rows, { | ||
| 395 | authArtifactId: existingImportAuthArtifact.id, | ||
| 396 | }); | ||
| 397 | } | ||
| 374 | } | 398 | } |
| 375 | 399 | ||
| 376 | yield* cleanupCredentialSecretRefs(rows, { | 400 | yield* cleanupAuthArtifactSecretRefs(rows, { |
| 377 | previous: existingImportCredential ?? null, | 401 | previous: existingImportAuthArtifact ?? null, |
| 378 | next: importCredential, | 402 | next: importAuthArtifact, |
| 379 | }); | 403 | }); |
| 380 | 404 | ||
| 381 | return source; | 405 | return source; |
| @@ -72,6 +72,7 @@ const syncArtifactsForSource = (input: { · 71 unmodified lines | |||
| 72 | syncSourceMaterialization({ | 72 | syncSourceMaterialization({ |
| 73 | rows: input.store, | 73 | rows: input.store, |
| 74 | source: sourceForSync, | 74 | source: sourceForSync, |
| 75 | actorAccountId: input.actorAccountId, | ||
| 75 | resolveSecretMaterial, | 76 | resolveSecretMaterial, |
| 76 | }), | 77 | }), |
| 77 | ); | 78 | ); |
| @@ -276,7 +277,8 @@ export const removeSource = (input: { · 198 unmodified lines | |||
| 276 | }) => | 277 | }) => |
| 277 | Effect.flatMap(ControlPlaneStore, (store) => | 278 | Effect.flatMap(ControlPlaneStore, (store) => |
| 278 | Effect.gen(function* () { | 279 | Effect.gen(function* () { |
| 279 | const removed = yield* sourceOps.remove.mapStorage( | 280 | const removed = yield* mapPersistenceError( |
| 281 | sourceOps.remove.child("remove"), | ||
| 280 | removeSourceById(store, { | 282 | removeSourceById(store, { |
| 281 | workspaceId: input.workspaceId, | 283 | workspaceId: input.workspaceId, |
| 282 | sourceId: input.sourceId, | 284 | sourceId: input.sourceId, |
| @@ -687,7 +687,9 @@ const createWorkspaceToolInvoker = (input: { · 686 unmodified lines | |||
| 687 | }); | 687 | }); |
| 688 | 688 | ||
| 689 | const auth = yield* resolveSourceAuthMaterial({ | 689 | const auth = yield* resolveSourceAuthMaterial({ |
| 690 | rows: input.rows, | ||
| 690 | source: recipeTool.source, | 691 | source: recipeTool.source, |
| 692 | actorAccountId: input.accountId, | ||
| 691 | resolveSecretMaterial: input.resolveSecretMaterial, | 693 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 692 | context: toSecretResolutionContext(invocation.context), | 694 | context: toSecretResolutionContext(invocation.context), |
| 693 | }); | 695 | }); |
| @@ -9,20 +9,22 @@ export { · 8 unmodified lines | |||
| 9 | type Role, | 9 | type Role, |
| 10 | } from "./models/auth/organization-membership"; | 10 | } from "./models/auth/organization-membership"; |
| 11 | export { | 11 | export { |
| 12 | SecretRefSchema, | ||
| 13 | SourceAuthSchema, | 12 | SourceAuthSchema, |
| 14 | SourceBindingSchema, | 13 | SourceBindingSchema, |
| 15 | SourceBindingVersionSchema, | 14 | SourceBindingVersionSchema, |
| 16 | SourceKindSchema, | 15 | SourceKindSchema, |
| 17 | SourceStatusSchema, | 16 | SourceStatusSchema, |
| 18 | SourceTransportSchema, | 17 | SourceTransportSchema, |
| 19 | type SecretRef, | ||
| 20 | type SourceAuth, | 18 | type SourceAuth, |
| 21 | type SourceBinding, | 19 | type SourceBinding, |
| 22 | type SourceKind, | 20 | type SourceKind, |
| 23 | type SourceStatus, | 21 | type SourceStatus, |
| 24 | type SourceTransport, | 22 | type SourceTransport, |
| 25 | } from "./models/source"; | 23 | } from "./models/source"; |
| 24 | export { | ||
| 25 | SecretRefSchema, | ||
| 26 | type SecretRef, | ||
| 27 | } from "./models/auth-artifact"; | ||
| 26 | export { | 28 | export { |
| 27 | SourceRecipeAdapterKeySchema, | 29 | SourceRecipeAdapterKeySchema, |
| 28 | SourceRecipeDocumentKindSchema, | 30 | SourceRecipeDocumentKindSchema, |
| @@ -56,9 +58,13 @@ export { · 27 unmodified lines | |||
| 56 | type SourceProbeAuth, | 58 | type SourceProbeAuth, |
| 57 | } from "./models/source-discovery"; | 59 | } from "./models/source-discovery"; |
| 58 | export { | 60 | export { |
| 59 | CredentialAuthKindSchema, | 61 | AuthArtifactKindSchema, |
| 60 | type CredentialAuthKind, | 62 | AuthArtifactSlotSchema, |
| 61 | } from "./models/credential"; | 63 | BuiltInAuthArtifactKindSchema, |
| 64 | type AuthArtifactKind, | ||
| 65 | type AuthArtifactSlot, | ||
| 66 | type BuiltInAuthArtifactKind, | ||
| 67 | } from "./models/auth-artifact"; | ||
| 62 | export { | 68 | export { |
| 63 | SourceAuthSessionProviderKindSchema, | 69 | SourceAuthSessionProviderKindSchema, |
| 64 | SourceAuthSessionStatusSchema, | 70 | SourceAuthSessionStatusSchema, |
| @@ -17,9 +17,13 @@ export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe( · 16 unmodified lines | |||
| 17 | export const SourceAuthSessionIdSchema = Schema.String.pipe( | 17 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 18 | Schema.brand("SourceAuthSessionId"), | 18 | Schema.brand("SourceAuthSessionId"), |
| 19 | ); | 19 | ); |
| 20 | export const CredentialIdSchema = Schema.String.pipe( | 20 | export const AuthArtifactIdSchema = Schema.String.pipe( |
| 21 | Schema.brand("CredentialId"), | 21 | Schema.brand("AuthArtifactId"), |
| 22 | ); | 22 | ); |
| 23 | export const AuthLeaseIdSchema = Schema.String.pipe( | ||
| 24 | Schema.brand("AuthLeaseId"), | ||
| 25 | ); | ||
| 26 | export const CredentialIdSchema = AuthArtifactIdSchema; | ||
| 23 | export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe( | 27 | export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe( |
| 24 | Schema.brand("WorkspaceSourceOauthClientId"), | 28 | Schema.brand("WorkspaceSourceOauthClientId"), |
| 25 | ); | 29 | ); |
| @@ -42,6 +46,8 @@ export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; · 16 unmodified lines | |||
| 42 | export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; | 46 | export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; |
| 43 | export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type; | 47 | export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type; |
| 44 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; | 48 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 49 | export type AuthArtifactId = typeof AuthArtifactIdSchema.Type; | ||
| 50 | export type AuthLeaseId = typeof AuthLeaseIdSchema.Type; | ||
| 45 | export type CredentialId = typeof CredentialIdSchema.Type; | 51 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 46 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; | 52 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; |
| 47 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; | 53 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| @@ -6,7 +6,8 @@ export * from "./models/auth"; · 5 unmodified lines | |||
| 6 | export * from "./models/workspace"; | 6 | export * from "./models/workspace"; |
| 7 | export * from "./models/source"; | 7 | export * from "./models/source"; |
| 8 | export * from "./models/source-recipe"; | 8 | export * from "./models/source-recipe"; |
| 9 | export * from "./models/credential"; | 9 | export * from "./models/auth-artifact"; |
| 10 | export * from "./models/auth-lease"; | ||
| 10 | export * from "./models/source-oauth-client"; | 11 | export * from "./models/source-oauth-client"; |
| 11 | export * from "./models/secret-material"; | 12 | export * from "./models/secret-material"; |
| 12 | export * from "./models/source-auth-session"; | 13 | export * from "./models/source-auth-session"; |
| @@ -0,0 +1,306 @@ | |||
| 1 | import { createSelectSchema } from "drizzle-orm/effect-schema"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | |||
| 4 | import { authArtifactsTable } from "../../persistence/schema"; | ||
| 5 | import { TimestampMsSchema } from "../common"; | ||
| 6 | import { | ||
| 7 | AccountIdSchema, | ||
| 8 | AuthArtifactIdSchema, | ||
| 9 | SourceIdSchema, | ||
| 10 | WorkspaceIdSchema, | ||
| 11 | } from "../ids"; | ||
| 12 | |||
| 13 | export const SecretRefSchema = Schema.Struct({ | ||
| 14 | providerId: Schema.String, | ||
| 15 | handle: Schema.String, | ||
| 16 | }); | ||
| 17 | |||
| 18 | export const AuthArtifactSlotSchema = Schema.Literal("runtime", "import"); | ||
| 19 | export const CredentialSlotSchema = AuthArtifactSlotSchema; | ||
| 20 | |||
| 21 | export const AuthArtifactKindSchema = Schema.String; | ||
| 22 | export const AuthGrantSetSchema = Schema.Array(Schema.String); | ||
| 23 | |||
| 24 | export const RequestPlacementPartSchema = Schema.Union( | ||
| 25 | Schema.Struct({ | ||
| 26 | kind: Schema.Literal("literal"), | ||
| 27 | value: Schema.String, | ||
| 28 | }), | ||
| 29 | Schema.Struct({ | ||
| 30 | kind: Schema.Literal("secret_ref"), | ||
| 31 | ref: SecretRefSchema, | ||
| 32 | }), | ||
| 33 | ); | ||
| 34 | |||
| 35 | export const RequestPlacementTemplateSchema = Schema.Union( | ||
| 36 | Schema.Struct({ | ||
| 37 | location: Schema.Literal("header"), | ||
| 38 | name: Schema.String, | ||
| 39 | parts: Schema.Array(RequestPlacementPartSchema), | ||
| 40 | }), | ||
| 41 | Schema.Struct({ | ||
| 42 | location: Schema.Literal("query"), | ||
| 43 | name: Schema.String, | ||
| 44 | parts: Schema.Array(RequestPlacementPartSchema), | ||
| 45 | }), | ||
| 46 | Schema.Struct({ | ||
| 47 | location: Schema.Literal("cookie"), | ||
| 48 | name: Schema.String, | ||
| 49 | parts: Schema.Array(RequestPlacementPartSchema), | ||
| 50 | }), | ||
| 51 | Schema.Struct({ | ||
| 52 | location: Schema.Literal("body"), | ||
| 53 | path: Schema.String, | ||
| 54 | parts: Schema.Array(RequestPlacementPartSchema), | ||
| 55 | }), | ||
| 56 | ); | ||
| 57 | |||
| 58 | export const RequestPlacementSchema = Schema.Union( | ||
| 59 | Schema.Struct({ | ||
| 60 | location: Schema.Literal("header"), | ||
| 61 | name: Schema.String, | ||
| 62 | value: Schema.String, | ||
| 63 | }), | ||
| 64 | Schema.Struct({ | ||
| 65 | location: Schema.Literal("query"), | ||
| 66 | name: Schema.String, | ||
| 67 | value: Schema.String, | ||
| 68 | }), | ||
| 69 | Schema.Struct({ | ||
| 70 | location: Schema.Literal("cookie"), | ||
| 71 | name: Schema.String, | ||
| 72 | value: Schema.String, | ||
| 73 | }), | ||
| 74 | Schema.Struct({ | ||
| 75 | location: Schema.Literal("body"), | ||
| 76 | path: Schema.String, | ||
| 77 | value: Schema.String, | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | |||
| 81 | export const RequestPlacementTemplatesJsonSchema = Schema.parseJson( | ||
| 82 | Schema.Array(RequestPlacementTemplateSchema), | ||
| 83 | ); | ||
| 84 | |||
| 85 | export const StaticBearerAuthArtifactKind = "static_bearer" as const; | ||
| 86 | export const StaticOAuth2AuthArtifactKind = "static_oauth2" as const; | ||
| 87 | export const StaticPlacementsAuthArtifactKind = "static_placements" as const; | ||
| 88 | export const RefreshableOAuth2AuthorizedUserAuthArtifactKind = | ||
| 89 | "oauth2_authorized_user" as const; | ||
| 90 | |||
| 91 | export const OAuth2ClientAuthenticationMethodSchema = Schema.Literal( | ||
| 92 | "none", | ||
| 93 | "client_secret_post", | ||
| 94 | ); | ||
| 95 | |||
| 96 | export const BuiltInAuthArtifactKindSchema = Schema.Literal( | ||
| 97 | StaticBearerAuthArtifactKind, | ||
| 98 | StaticOAuth2AuthArtifactKind, | ||
| 99 | StaticPlacementsAuthArtifactKind, | ||
| 100 | RefreshableOAuth2AuthorizedUserAuthArtifactKind, | ||
| 101 | ); | ||
| 102 | |||
| 103 | export const StaticBearerAuthArtifactConfigSchema = Schema.Struct({ | ||
| 104 | headerName: Schema.String, | ||
| 105 | prefix: Schema.String, | ||
| 106 | token: SecretRefSchema, | ||
| 107 | }); | ||
| 108 | |||
| 109 | export const StaticOAuth2AuthArtifactConfigSchema = Schema.Struct({ | ||
| 110 | headerName: Schema.String, | ||
| 111 | prefix: Schema.String, | ||
| 112 | accessToken: SecretRefSchema, | ||
| 113 | refreshToken: Schema.NullOr(SecretRefSchema), | ||
| 114 | }); | ||
| 115 | |||
| 116 | export const StaticPlacementsAuthArtifactConfigSchema = Schema.Struct({ | ||
| 117 | placements: Schema.Array(RequestPlacementTemplateSchema), | ||
| 118 | }); | ||
| 119 | |||
| 120 | export const RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema = Schema.Struct({ | ||
| 121 | headerName: Schema.String, | ||
| 122 | prefix: Schema.String, | ||
| 123 | tokenEndpoint: Schema.String, | ||
| 124 | clientId: Schema.String, | ||
| 125 | clientAuthentication: OAuth2ClientAuthenticationMethodSchema, | ||
| 126 | clientSecret: Schema.NullOr(SecretRefSchema), | ||
| 127 | refreshToken: SecretRefSchema, | ||
| 128 | }); | ||
| 129 | |||
| 130 | export const StaticBearerAuthArtifactConfigJsonSchema = Schema.parseJson( | ||
| 131 | StaticBearerAuthArtifactConfigSchema, | ||
| 132 | ); | ||
| 133 | |||
| 134 | export const StaticOAuth2AuthArtifactConfigJsonSchema = Schema.parseJson( | ||
| 135 | StaticOAuth2AuthArtifactConfigSchema, | ||
| 136 | ); | ||
| 137 | |||
| 138 | export const StaticPlacementsAuthArtifactConfigJsonSchema = Schema.parseJson( | ||
| 139 | StaticPlacementsAuthArtifactConfigSchema, | ||
| 140 | ); | ||
| 141 | |||
| 142 | export const RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema = Schema.parseJson( | ||
| 143 | RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema, | ||
| 144 | ); | ||
| 145 | |||
| 146 | export const RequestPlacementsJsonSchema = Schema.parseJson( | ||
| 147 | Schema.Array(RequestPlacementSchema), | ||
| 148 | ); | ||
| 149 | |||
| 150 | export const AuthGrantSetJsonSchema = Schema.parseJson(AuthGrantSetSchema); | ||
| 151 | |||
| 152 | const decodeStaticBearerAuthArtifactConfigOption = Schema.decodeUnknownOption( | ||
| 153 | StaticBearerAuthArtifactConfigJsonSchema, | ||
| 154 | ); | ||
| 155 | |||
| 156 | const decodeStaticOAuth2AuthArtifactConfigOption = Schema.decodeUnknownOption( | ||
| 157 | StaticOAuth2AuthArtifactConfigJsonSchema, | ||
| 158 | ); | ||
| 159 | |||
| 160 | const decodeStaticPlacementsAuthArtifactConfigOption = Schema.decodeUnknownOption( | ||
| 161 | StaticPlacementsAuthArtifactConfigJsonSchema, | ||
| 162 | ); | ||
| 163 | |||
| 164 | const decodeRefreshableOAuth2AuthorizedUserAuthArtifactConfigOption = Schema.decodeUnknownOption( | ||
| 165 | RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema, | ||
| 166 | ); | ||
| 167 | |||
| 168 | const decodeAuthGrantSetOption = Schema.decodeUnknownOption(AuthGrantSetJsonSchema); | ||
| 169 | |||
| 170 | const authArtifactSchemaOverrides = { | ||
| 171 | id: AuthArtifactIdSchema, | ||
| 172 | workspaceId: WorkspaceIdSchema, | ||
| 173 | sourceId: SourceIdSchema, | ||
| 174 | actorAccountId: Schema.NullOr(AccountIdSchema), | ||
| 175 | slot: AuthArtifactSlotSchema, | ||
| 176 | artifactKind: AuthArtifactKindSchema, | ||
| 177 | configJson: Schema.String, | ||
| 178 | grantSetJson: Schema.NullOr(Schema.String), | ||
| 179 | createdAt: TimestampMsSchema, | ||
| 180 | updatedAt: TimestampMsSchema, | ||
| 181 | } as const; | ||
| 182 | |||
| 183 | export const AuthArtifactSchema = createSelectSchema( | ||
| 184 | authArtifactsTable, | ||
| 185 | authArtifactSchemaOverrides, | ||
| 186 | ); | ||
| 187 | |||
| 188 | export type SecretRef = typeof SecretRefSchema.Type; | ||
| 189 | export type AuthArtifactSlot = typeof AuthArtifactSlotSchema.Type; | ||
| 190 | export type CredentialSlot = typeof CredentialSlotSchema.Type; | ||
| 191 | export type AuthArtifactKind = typeof AuthArtifactKindSchema.Type; | ||
| 192 | export type AuthGrantSet = typeof AuthGrantSetSchema.Type; | ||
| 193 | export type RequestPlacementPart = typeof RequestPlacementPartSchema.Type; | ||
| 194 | export type RequestPlacementTemplate = typeof RequestPlacementTemplateSchema.Type; | ||
| 195 | export type RequestPlacement = typeof RequestPlacementSchema.Type; | ||
| 196 | export type OAuth2ClientAuthenticationMethod = | ||
| 197 | typeof OAuth2ClientAuthenticationMethodSchema.Type; | ||
| 198 | export type BuiltInAuthArtifactKind = typeof BuiltInAuthArtifactKindSchema.Type; | ||
| 199 | export type StaticBearerAuthArtifactConfig = typeof StaticBearerAuthArtifactConfigSchema.Type; | ||
| 200 | export type StaticOAuth2AuthArtifactConfig = typeof StaticOAuth2AuthArtifactConfigSchema.Type; | ||
| 201 | export type StaticPlacementsAuthArtifactConfig = typeof StaticPlacementsAuthArtifactConfigSchema.Type; | ||
| 202 | export type RefreshableOAuth2AuthorizedUserAuthArtifactConfig = | ||
| 203 | typeof RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema.Type; | ||
| 204 | export type AuthArtifact = typeof AuthArtifactSchema.Type; | ||
| 205 | |||
| 206 | export type DecodedBuiltInAuthArtifactConfig = | ||
| 207 | | { | ||
| 208 | artifactKind: typeof StaticBearerAuthArtifactKind; | ||
| 209 | config: StaticBearerAuthArtifactConfig; | ||
| 210 | } | ||
| 211 | | { | ||
| 212 | artifactKind: typeof StaticOAuth2AuthArtifactKind; | ||
| 213 | config: StaticOAuth2AuthArtifactConfig; | ||
| 214 | } | ||
| 215 | | { | ||
| 216 | artifactKind: typeof StaticPlacementsAuthArtifactKind; | ||
| 217 | config: StaticPlacementsAuthArtifactConfig; | ||
| 218 | } | ||
| 219 | | { | ||
| 220 | artifactKind: typeof RefreshableOAuth2AuthorizedUserAuthArtifactKind; | ||
| 221 | config: RefreshableOAuth2AuthorizedUserAuthArtifactConfig; | ||
| 222 | }; | ||
| 223 | |||
| 224 | export const decodeBuiltInAuthArtifactConfig = ( | ||
| 225 | artifact: Pick<AuthArtifact, "artifactKind" | "configJson">, | ||
| 226 | ): DecodedBuiltInAuthArtifactConfig | null => { | ||
| 227 | switch (artifact.artifactKind) { | ||
| 228 | case StaticBearerAuthArtifactKind: { | ||
| 229 | const decoded = decodeStaticBearerAuthArtifactConfigOption(artifact.configJson); | ||
| 230 | return decoded._tag === "Some" | ||
| 231 | ? { | ||
| 232 | artifactKind: StaticBearerAuthArtifactKind, | ||
| 233 | config: decoded.value, | ||
| 234 | } | ||
| 235 | : null; | ||
| 236 | } | ||
| 237 | case StaticOAuth2AuthArtifactKind: { | ||
| 238 | const decoded = decodeStaticOAuth2AuthArtifactConfigOption(artifact.configJson); | ||
| 239 | return decoded._tag === "Some" | ||
| 240 | ? { | ||
| 241 | artifactKind: StaticOAuth2AuthArtifactKind, | ||
| 242 | config: decoded.value, | ||
| 243 | } | ||
| 244 | : null; | ||
| 245 | } | ||
| 246 | case StaticPlacementsAuthArtifactKind: { | ||
| 247 | const decoded = decodeStaticPlacementsAuthArtifactConfigOption(artifact.configJson); | ||
| 248 | return decoded._tag === "Some" | ||
| 249 | ? { | ||
| 250 | artifactKind: StaticPlacementsAuthArtifactKind, | ||
| 251 | config: decoded.value, | ||
| 252 | } | ||
| 253 | : null; | ||
| 254 | } | ||
| 255 | case RefreshableOAuth2AuthorizedUserAuthArtifactKind: { | ||
| 256 | const decoded = decodeRefreshableOAuth2AuthorizedUserAuthArtifactConfigOption( | ||
| 257 | artifact.configJson, | ||
| 258 | ); | ||
| 259 | return decoded._tag === "Some" | ||
| 260 | ? { | ||
| 261 | artifactKind: RefreshableOAuth2AuthorizedUserAuthArtifactKind, | ||
| 262 | config: decoded.value, | ||
| 263 | } | ||
| 264 | : null; | ||
| 265 | } | ||
| 266 | default: | ||
| 267 | return null; | ||
| 268 | } | ||
| 269 | }; | ||
| 270 | |||
| 271 | export const decodeAuthGrantSet = (grantSetJson: string | null): AuthGrantSet | null => { | ||
| 272 | if (grantSetJson === null) { | ||
| 273 | return null; | ||
| 274 | } | ||
| 275 | |||
| 276 | const decoded = decodeAuthGrantSetOption(grantSetJson); | ||
| 277 | return decoded._tag === "Some" ? decoded.value : null; | ||
| 278 | }; | ||
| 279 | |||
| 280 | export const authArtifactSecretRefs = ( | ||
| 281 | artifact: Pick<AuthArtifact, "artifactKind" | "configJson">, | ||
| 282 | ): ReadonlyArray<SecretRef> => { | ||
| 283 | const decoded = decodeBuiltInAuthArtifactConfig(artifact); | ||
| 284 | if (decoded === null) { | ||
| 285 | return []; | ||
| 286 | } | ||
| 287 | |||
| 288 | switch (decoded.artifactKind) { | ||
| 289 | case StaticBearerAuthArtifactKind: | ||
| 290 | return [decoded.config.token]; | ||
| 291 | case StaticOAuth2AuthArtifactKind: | ||
| 292 | return [ | ||
| 293 | decoded.config.accessToken, | ||
| 294 | ...(decoded.config.refreshToken === null ? [] : [decoded.config.refreshToken]), | ||
| 295 | ]; | ||
| 296 | case StaticPlacementsAuthArtifactKind: | ||
| 297 | return decoded.config.placements.flatMap((placement) => | ||
| 298 | placement.parts.flatMap((part) => part.kind === "secret_ref" ? [part.ref] : []), | ||
| 299 | ); | ||
| 300 | case RefreshableOAuth2AuthorizedUserAuthArtifactKind: | ||
| 301 | return [ | ||
| 302 | ...(decoded.config.clientSecret === null ? [] : [decoded.config.clientSecret]), | ||
| 303 | decoded.config.refreshToken, | ||
| 304 | ]; | ||
| 305 | } | ||
| 306 | }; | ||
| @@ -0,0 +1,57 @@ | |||
| 1 | import { createSelectSchema } from "drizzle-orm/effect-schema"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | |||
| 4 | import { authLeasesTable } from "../../persistence/schema"; | ||
| 5 | import { TimestampMsSchema } from "../common"; | ||
| 6 | import { | ||
| 7 | AccountIdSchema, | ||
| 8 | AuthArtifactIdSchema, | ||
| 9 | AuthLeaseIdSchema, | ||
| 10 | SourceIdSchema, | ||
| 11 | WorkspaceIdSchema, | ||
| 12 | } from "../ids"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | AuthArtifactSlotSchema, | ||
| 16 | RequestPlacementTemplatesJsonSchema, | ||
| 17 | type RequestPlacementTemplate, | ||
| 18 | } from "./auth-artifact"; | ||
| 19 | |||
| 20 | const authLeaseSchemaOverrides = { | ||
| 21 | id: AuthLeaseIdSchema, | ||
| 22 | authArtifactId: AuthArtifactIdSchema, | ||
| 23 | workspaceId: WorkspaceIdSchema, | ||
| 24 | sourceId: SourceIdSchema, | ||
| 25 | actorAccountId: Schema.NullOr(AccountIdSchema), | ||
| 26 | slot: AuthArtifactSlotSchema, | ||
| 27 | placementsTemplateJson: Schema.String, | ||
| 28 | expiresAt: Schema.NullOr(TimestampMsSchema), | ||
| 29 | refreshAfter: Schema.NullOr(TimestampMsSchema), | ||
| 30 | createdAt: TimestampMsSchema, | ||
| 31 | updatedAt: TimestampMsSchema, | ||
| 32 | } as const; | ||
| 33 | |||
| 34 | export const AuthLeaseSchema = createSelectSchema( | ||
| 35 | authLeasesTable, | ||
| 36 | authLeaseSchemaOverrides, | ||
| 37 | ); | ||
| 38 | |||
| 39 | export type AuthLease = typeof AuthLeaseSchema.Type; | ||
| 40 | |||
| 41 | const decodeLeasePlacementTemplatesOption = Schema.decodeUnknownOption( | ||
| 42 | RequestPlacementTemplatesJsonSchema, | ||
| 43 | ); | ||
| 44 | |||
| 45 | export const decodeAuthLeasePlacementTemplates = ( | ||
| 46 | lease: Pick<AuthLease, "placementsTemplateJson">, | ||
| 47 | ): ReadonlyArray<RequestPlacementTemplate> | null => { | ||
| 48 | const decoded = decodeLeasePlacementTemplatesOption(lease.placementsTemplateJson); | ||
| 49 | return decoded._tag === "Some" ? decoded.value : null; | ||
| 50 | }; | ||
| 51 | |||
| 52 | export const authLeaseSecretRefs = ( | ||
| 53 | lease: Pick<AuthLease, "placementsTemplateJson">, | ||
| 54 | ) => | ||
| 55 | (decodeAuthLeasePlacementTemplates(lease) ?? []).flatMap((placement) => | ||
| 56 | placement.parts.flatMap((part) => (part.kind === "secret_ref" ? [part.ref] : [])), | ||
| 57 | ); | ||
| @@ -1,40 +1,4 @@ | |||
| 1 | import { createSelectSchema } from "drizzle-orm/effect-schema"; | 1 | export { |
| 2 | import { Schema } from "effect"; | 2 | AuthArtifactSlotSchema as CredentialSlotSchema, |
| 3 | 3 | type AuthArtifactSlot as CredentialSlot, | |
| 4 | import { credentialsTable } from "../../persistence/schema"; | 4 | } from "./auth-artifact"; |
| 5 | import { TimestampMsSchema } from "../common"; | ||
| 6 | import { | ||
| 7 | AccountIdSchema, | ||
| 8 | CredentialIdSchema, | ||
| 9 | SourceIdSchema, | ||
| 10 | WorkspaceIdSchema, | ||
| 11 | } from "../ids"; | ||
| 12 | |||
| 13 | export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); | ||
| 14 | export const CredentialSlotSchema = Schema.Literal("runtime", "import"); | ||
| 15 | |||
| 16 | const credentialSchemaOverrides = { | ||
| 17 | id: CredentialIdSchema, | ||
| 18 | workspaceId: WorkspaceIdSchema, | ||
| 19 | sourceId: SourceIdSchema, | ||
| 20 | actorAccountId: Schema.NullOr(AccountIdSchema), | ||
| 21 | slot: CredentialSlotSchema, | ||
| 22 | authKind: CredentialAuthKindSchema, | ||
| 23 | authHeaderName: Schema.String, | ||
| 24 | authPrefix: Schema.String, | ||
| 25 | tokenProviderId: Schema.String, | ||
| 26 | tokenHandle: Schema.String, | ||
| 27 | refreshTokenProviderId: Schema.NullOr(Schema.String), | ||
| 28 | refreshTokenHandle: Schema.NullOr(Schema.String), | ||
| 29 | createdAt: TimestampMsSchema, | ||
| 30 | updatedAt: TimestampMsSchema, | ||
| 31 | } as const; | ||
| 32 | |||
| 33 | export const CredentialSchema = createSelectSchema( | ||
| 34 | credentialsTable, | ||
| 35 | credentialSchemaOverrides, | ||
| 36 | ); | ||
| 37 | |||
| 38 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; | ||
| 39 | export type CredentialSlot = typeof CredentialSlotSchema.Type; | ||
| 40 | export type Credential = typeof CredentialSchema.Type; | ||
| @@ -11,12 +11,13 @@ import { · 10 unmodified lines | |||
| 11 | SourceIdSchema, | 11 | SourceIdSchema, |
| 12 | WorkspaceIdSchema, | 12 | WorkspaceIdSchema, |
| 13 | } from "../ids"; | 13 | } from "../ids"; |
| 14 | import { CredentialSlotSchema } from "./credential"; | 14 | import { |
| 15 | CredentialSlotSchema, | ||
| 16 | OAuth2ClientAuthenticationMethodSchema, | ||
| 17 | SecretRefSchema, | ||
| 18 | } from "./auth-artifact"; | ||
| 15 | 19 | ||
| 16 | export const SourceAuthSessionProviderKindSchema = Schema.Literal( | 20 | export const SourceAuthSessionProviderKindSchema = Schema.String; |
| 17 | "mcp_oauth", | ||
| 18 | "oauth2_pkce", | ||
| 19 | ); | ||
| 20 | 21 | ||
| 21 | export const SourceAuthSessionStatusSchema = Schema.Literal( | 22 | export const SourceAuthSessionStatusSchema = Schema.Literal( |
| 22 | "pending", | 23 | "pending", |
| @@ -74,6 +75,30 @@ export const McpSourceAuthSessionDataJsonSchema = Schema.parseJson( · 51 unmodified lines | |||
| 74 | McpSourceAuthSessionDataSchema, | 75 | McpSourceAuthSessionDataSchema, |
| 75 | ); | 76 | ); |
| 76 | 77 | ||
| 78 | export const OAuth2PkceSourceAuthSessionDataSchema = Schema.Struct({ | ||
| 79 | kind: Schema.Literal("oauth2_pkce"), | ||
| 80 | providerKey: Schema.String, | ||
| 81 | authorizationEndpoint: Schema.String, | ||
| 82 | tokenEndpoint: Schema.String, | ||
| 83 | redirectUri: Schema.String, | ||
| 84 | clientId: Schema.String, | ||
| 85 | clientAuthentication: OAuth2ClientAuthenticationMethodSchema, | ||
| 86 | clientSecret: Schema.NullOr(SecretRefSchema), | ||
| 87 | scopes: Schema.Array(Schema.String), | ||
| 88 | headerName: Schema.String, | ||
| 89 | prefix: Schema.String, | ||
| 90 | authorizationParams: Schema.Record({ | ||
| 91 | key: Schema.String, | ||
| 92 | value: Schema.String, | ||
| 93 | }), | ||
| 94 | codeVerifier: Schema.NullOr(Schema.String), | ||
| 95 | authorizationUrl: Schema.NullOr(Schema.String), | ||
| 96 | }); | ||
| 97 | |||
| 98 | export const OAuth2PkceSourceAuthSessionDataJsonSchema = Schema.parseJson( | ||
| 99 | OAuth2PkceSourceAuthSessionDataSchema, | ||
| 100 | ); | ||
| 101 | |||
| 77 | const sourceAuthSessionSchemaOverrides = { | 102 | const sourceAuthSessionSchemaOverrides = { |
| 78 | id: SourceAuthSessionIdSchema, | 103 | id: SourceAuthSessionIdSchema, |
| 79 | workspaceId: WorkspaceIdSchema, | 104 | workspaceId: WorkspaceIdSchema, |
| @@ -97,4 +122,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 17 unmodified lines | |||
| 97 | export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type; | 122 | export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type; |
| 98 | export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; | 123 | export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; |
| 99 | export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type; | 124 | export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type; |
| 125 | export type OAuth2PkceSourceAuthSessionData = | ||
| 126 | typeof OAuth2PkceSourceAuthSessionDataSchema.Type; | ||
| 100 | export type SourceAuthSession = typeof SourceAuthSessionSchema.Type; | 127 | export type SourceAuthSession = typeof SourceAuthSessionSchema.Type; |
| @@ -5,6 +5,7 @@ import { StringMapSchema, SourceTransportSchema } from "./source"; · 4 unmodified lines | |||
| 5 | export const SourceDiscoveryKindSchema = Schema.Literal( | 5 | export const SourceDiscoveryKindSchema = Schema.Literal( |
| 6 | "mcp", | 6 | "mcp", |
| 7 | "openapi", | 7 | "openapi", |
| 8 | "google_discovery", | ||
| 8 | "graphql", | 9 | "graphql", |
| 9 | "unknown", | 10 | "unknown", |
| 10 | ); | 11 | ); |
| @@ -9,6 +9,19 @@ import { · 8 unmodified lines | |||
| 9 | WorkspaceSourceOauthClientIdSchema, | 9 | WorkspaceSourceOauthClientIdSchema, |
| 10 | } from "../ids"; | 10 | } from "../ids"; |
| 11 | 11 | ||
| 12 | export const WorkspaceSourceOauthClientRedirectModeSchema = Schema.Literal( | ||
| 13 | "app_callback", | ||
| 14 | "loopback", | ||
| 15 | ); | ||
| 16 | |||
| 17 | export const WorkspaceSourceOauthClientMetadataSchema = Schema.Struct({ | ||
| 18 | redirectMode: Schema.optional(WorkspaceSourceOauthClientRedirectModeSchema), | ||
| 19 | }); | ||
| 20 | |||
| 21 | export const WorkspaceSourceOauthClientMetadataJsonSchema = Schema.parseJson( | ||
| 22 | WorkspaceSourceOauthClientMetadataSchema, | ||
| 23 | ); | ||
| 24 | |||
| 12 | export const WorkspaceSourceOauthClientSchema = createSelectSchema( | 25 | export const WorkspaceSourceOauthClientSchema = createSelectSchema( |
| 13 | workspaceSourceOauthClientsTable, | 26 | workspaceSourceOauthClientsTable, |
| 14 | { | 27 | { |
| @@ -21,3 +34,7 @@ export const WorkspaceSourceOauthClientSchema = createSelectSchema( · 6 unmodified lines | |||
| 21 | ); | 34 | ); |
| 22 | 35 | ||
| 23 | export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type; | 36 | export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type; |
| 37 | export type WorkspaceSourceOauthClientRedirectMode = | ||
| 38 | typeof WorkspaceSourceOauthClientRedirectModeSchema.Type; | ||
| 39 | export type WorkspaceSourceOauthClientMetadata = | ||
| 40 | typeof WorkspaceSourceOauthClientMetadataSchema.Type; | ||
| @@ -11,6 +11,7 @@ import { · 10 unmodified lines | |||
| 11 | SourceRecipeRevisionIdSchema, | 11 | SourceRecipeRevisionIdSchema, |
| 12 | WorkspaceIdSchema, | 12 | WorkspaceIdSchema, |
| 13 | } from "../ids"; | 13 | } from "../ids"; |
| 14 | import { SecretRefSchema } from "./auth-artifact"; | ||
| 14 | import { JsonObjectSchema } from "./source-auth-session"; | 15 | import { JsonObjectSchema } from "./source-auth-session"; |
| 15 | 16 | ||
| 16 | export const SourceKindSchema = Schema.String; | 17 | export const SourceKindSchema = Schema.String; |
| @@ -35,11 +36,6 @@ export const SourceImportAuthPolicySchema = Schema.Literal( · 18 unmodified lines | |||
| 35 | "separate", | 36 | "separate", |
| 36 | ); | 37 | ); |
| 37 | 38 | ||
| 38 | export const SecretRefSchema = Schema.Struct({ | ||
| 39 | providerId: Schema.String, | ||
| 40 | handle: Schema.String, | ||
| 41 | }); | ||
| 42 | |||
| 43 | export const SourceAuthSchema = Schema.Union( | 39 | export const SourceAuthSchema = Schema.Union( |
| 44 | Schema.Struct({ | 40 | Schema.Struct({ |
| 45 | kind: Schema.Literal("none"), | 41 | kind: Schema.Literal("none"), |
| @@ -57,6 +53,17 @@ export const SourceAuthSchema = Schema.Union( · 11 unmodified lines | |||
| 57 | accessToken: SecretRefSchema, | 53 | accessToken: SecretRefSchema, |
| 58 | refreshToken: Schema.NullOr(SecretRefSchema), | 54 | refreshToken: Schema.NullOr(SecretRefSchema), |
| 59 | }), | 55 | }), |
| 56 | Schema.Struct({ | ||
| 57 | kind: Schema.Literal("oauth2_authorized_user"), | ||
| 58 | headerName: Schema.String, | ||
| 59 | prefix: Schema.String, | ||
| 60 | tokenEndpoint: Schema.String, | ||
| 61 | clientId: Schema.String, | ||
| 62 | clientAuthentication: Schema.Literal("none", "client_secret_post"), | ||
| 63 | clientSecret: Schema.NullOr(SecretRefSchema), | ||
| 64 | refreshToken: SecretRefSchema, | ||
| 65 | grantSet: Schema.NullOr(Schema.Array(Schema.String)), | ||
| 66 | }), | ||
| 60 | ); | 67 | ); |
| 61 | 68 | ||
| 62 | export const StringMapSchema = Schema.Record({ | 69 | export const StringMapSchema = Schema.Record({ |
| @@ -170,7 +177,6 @@ export type SourceKind = typeof SourceKindSchema.Type; · 107 unmodified lines | |||
| 170 | export type SourceStatus = typeof SourceStatusSchema.Type; | 177 | export type SourceStatus = typeof SourceStatusSchema.Type; |
| 171 | export type SourceTransport = typeof SourceTransportSchema.Type; | 178 | export type SourceTransport = typeof SourceTransportSchema.Type; |
| 172 | export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type; | 179 | export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type; |
| 173 | export type SecretRef = typeof SecretRefSchema.Type; | ||
| 174 | export type SourceAuth = typeof SourceAuthSchema.Type; | 180 | export type SourceAuth = typeof SourceAuthSchema.Type; |
| 175 | export type SourceBinding = typeof SourceBindingSchema.Type; | 181 | export type SourceBinding = typeof SourceBindingSchema.Type; |
| 176 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; | 182 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; |
| @@ -1,6 +1,10 @@ | |||
| 1 | import { homedir } from "node:os"; | 1 | import { homedir } from "node:os"; |
| 2 | import { join } from "node:path"; | 2 | import { join } from "node:path"; |
| 3 | 3 | ||
| 4 | import { loadMonorepoRootEnv } from "./env"; | ||
| 5 | |||
| 6 | loadMonorepoRootEnv(); | ||
| 7 | |||
| 4 | export const EXECUTOR_HOME_ENV = "EXECUTOR_HOME"; | 8 | export const EXECUTOR_HOME_ENV = "EXECUTOR_HOME"; |
| 5 | export const EXECUTOR_DATA_DIR_ENV = "EXECUTOR_DATA_DIR"; | 9 | export const EXECUTOR_DATA_DIR_ENV = "EXECUTOR_DATA_DIR"; |
| 6 | export const EXECUTOR_LOCAL_DATA_DIR_ENV = "EXECUTOR_LOCAL_DATA_DIR"; | 10 | export const EXECUTOR_LOCAL_DATA_DIR_ENV = "EXECUTOR_LOCAL_DATA_DIR"; |
| @@ -0,0 +1,65 @@ | |||
| 1 | import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; | ||
| 2 | import { tmpdir } from "node:os"; | ||
| 3 | import { join } from "node:path"; | ||
| 4 | import { afterEach, describe, expect, it } from "bun:test"; | ||
| 5 | |||
| 6 | import { loadMonorepoRootEnv } from "./env"; | ||
| 7 | |||
| 8 | const tempDirs: string[] = []; | ||
| 9 | |||
| 10 | const makeTempDir = async (): Promise<string> => { | ||
| 11 | const directory = await mkdtemp(join(tmpdir(), "executor-server-env-")); | ||
| 12 | tempDirs.push(directory); | ||
| 13 | return directory; | ||
| 14 | }; | ||
| 15 | |||
| 16 | afterEach(async () => { | ||
| 17 | await Promise.all(tempDirs.splice(0).map((directory) => | ||
| 18 | rm(directory, { recursive: true, force: true }) | ||
| 19 | )); | ||
| 20 | }); | ||
| 21 | |||
| 22 | describe("loadMonorepoRootEnv", () => { | ||
| 23 | it("loads root .env.local values when running from a workspace child directory", async () => { | ||
| 24 | const root = await makeTempDir(); | ||
| 25 | await writeFile(join(root, "package.json"), JSON.stringify({ | ||
| 26 | private: true, | ||
| 27 | workspaces: ["apps/*", "packages/*"], | ||
| 28 | })); | ||
| 29 | await writeFile(join(root, ".env.local"), "GOOGLE_CLIENT_ID=test-client\nGOOGLE_CLIENT_SECRET=test-secret\n"); | ||
| 30 | const child = join(root, "apps", "executor"); | ||
| 31 | await mkdir(child, { recursive: true }); | ||
| 32 | |||
| 33 | const env: NodeJS.ProcessEnv = {}; | ||
| 34 | const result = loadMonorepoRootEnv({ | ||
| 35 | cwd: child, | ||
| 36 | env, | ||
| 37 | }); | ||
| 38 | |||
| 39 | expect(result.rootDir).toBe(root); | ||
| 40 | expect(result.loadedFiles).toContain(join(root, ".env.local")); | ||
| 41 | expect(env.GOOGLE_CLIENT_ID).toBe("test-client"); | ||
| 42 | expect(env.GOOGLE_CLIENT_SECRET).toBe("test-secret"); | ||
| 43 | }); | ||
| 44 | |||
| 45 | it("does not override explicit process environment values", async () => { | ||
| 46 | const root = await makeTempDir(); | ||
| 47 | await writeFile(join(root, "package.json"), JSON.stringify({ | ||
| 48 | private: true, | ||
| 49 | workspaces: ["apps/*", "packages/*"], | ||
| 50 | })); | ||
| 51 | await writeFile(join(root, ".env.local"), "GOOGLE_CLIENT_ID=file-client\n"); | ||
| 52 | const child = join(root, "apps", "executor"); | ||
| 53 | await mkdir(child, { recursive: true }); | ||
| 54 | |||
| 55 | const env: NodeJS.ProcessEnv = { | ||
| 56 | GOOGLE_CLIENT_ID: "shell-client", | ||
| 57 | }; | ||
| 58 | loadMonorepoRootEnv({ | ||
| 59 | cwd: child, | ||
| 60 | env, | ||
| 61 | }); | ||
| 62 | |||
| 63 | expect(env.GOOGLE_CLIENT_ID).toBe("shell-client"); | ||
| 64 | }); | ||
| 65 | }); | ||
| @@ -0,0 +1,121 @@ | |||
| 1 | import { existsSync, readFileSync } from "node:fs"; | ||
| 2 | import { dirname, join, resolve } from "node:path"; | ||
| 3 | |||
| 4 | type LoadMonorepoRootEnvOptions = { | ||
| 5 | cwd?: string; | ||
| 6 | env?: NodeJS.ProcessEnv; | ||
| 7 | }; | ||
| 8 | |||
| 9 | const parseEnvFile = (content: string): Record<string, string> => { | ||
| 10 | const values: Record<string, string> = {}; | ||
| 11 | |||
| 12 | for (const rawLine of content.split(/\r?\n/u)) { | ||
| 13 | const line = rawLine.trim(); | ||
| 14 | if (line.length === 0 || line.startsWith("#")) { | ||
| 15 | continue; | ||
| 16 | } | ||
| 17 | |||
| 18 | const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u.exec(rawLine); | ||
| 19 | if (!match) { | ||
| 20 | continue; | ||
| 21 | } | ||
| 22 | |||
| 23 | const key = match[1]!; | ||
| 24 | let value = match[2] ?? ""; | ||
| 25 | value = value.trim(); | ||
| 26 | |||
| 27 | if ( | ||
| 28 | (value.startsWith("\"") && value.endsWith("\"")) | ||
| 29 | || (value.startsWith("'") && value.endsWith("'")) | ||
| 30 | ) { | ||
| 31 | const quote = value[0]!; | ||
| 32 | value = value.slice(1, -1); | ||
| 33 | if (quote === "\"") { | ||
| 34 | value = value | ||
| 35 | .replace(/\\n/gu, "\n") | ||
| 36 | .replace(/\\r/gu, "\r") | ||
| 37 | .replace(/\\t/gu, "\t") | ||
| 38 | .replace(/\\"/gu, "\"") | ||
| 39 | .replace(/\\\\/gu, "\\"); | ||
| 40 | } | ||
| 41 | } else { | ||
| 42 | value = value.replace(/\s+#.*$/u, ""); | ||
| 43 | } | ||
| 44 | |||
| 45 | values[key] = value; | ||
| 46 | } | ||
| 47 | |||
| 48 | return values; | ||
| 49 | }; | ||
| 50 | |||
| 51 | const isWorkspaceRoot = (directory: string): boolean => { | ||
| 52 | const packageJsonPath = join(directory, "package.json"); | ||
| 53 | if (!existsSync(packageJsonPath)) { | ||
| 54 | return false; | ||
| 55 | } | ||
| 56 | |||
| 57 | try { | ||
| 58 | const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { | ||
| 59 | workspaces?: unknown; | ||
| 60 | }; | ||
| 61 | return Array.isArray(parsed.workspaces); | ||
| 62 | } catch { | ||
| 63 | return false; | ||
| 64 | } | ||
| 65 | }; | ||
| 66 | |||
| 67 | const findMonorepoRoot = (startDir: string): string | null => { | ||
| 68 | let current = resolve(startDir); | ||
| 69 | |||
| 70 | while (true) { | ||
| 71 | if (isWorkspaceRoot(current)) { | ||
| 72 | return current; | ||
| 73 | } | ||
| 74 | |||
| 75 | const parent = dirname(current); | ||
| 76 | if (parent === current) { | ||
| 77 | return null; | ||
| 78 | } | ||
| 79 | |||
| 80 | current = parent; | ||
| 81 | } | ||
| 82 | }; | ||
| 83 | |||
| 84 | export const loadMonorepoRootEnv = ( | ||
| 85 | options: LoadMonorepoRootEnvOptions = {}, | ||
| 86 | ): { rootDir: string | null; loadedFiles: string[] } => { | ||
| 87 | const cwd = options.cwd ?? process.cwd(); | ||
| 88 | const env = options.env ?? process.env; | ||
| 89 | const rootDir = findMonorepoRoot(cwd); | ||
| 90 | if (rootDir === null) { | ||
| 91 | return { | ||
| 92 | rootDir: null, | ||
| 93 | loadedFiles: [], | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 | const initialKeys = new Set(Object.keys(env)); | ||
| 98 | const loadedFiles: string[] = []; | ||
| 99 | |||
| 100 | for (const fileName of [".env", ".env.local"]) { | ||
| 101 | const filePath = join(rootDir, fileName); | ||
| 102 | if (!existsSync(filePath)) { | ||
| 103 | continue; | ||
| 104 | } | ||
| 105 | |||
| 106 | const parsed = parseEnvFile(readFileSync(filePath, "utf8")); | ||
| 107 | for (const [key, value] of Object.entries(parsed)) { | ||
| 108 | if (initialKeys.has(key)) { | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | env[key] = value; | ||
| 112 | } | ||
| 113 | loadedFiles.push(filePath); | ||
| 114 | } | ||
| 115 | |||
| 116 | return { | ||
| 117 | rootDir, | ||
| 118 | loadedFiles, | ||
| 119 | }; | ||
| 120 | }; | ||
| 121 | |||