Unified · Split
| @@ -5,20 +5,36 @@ · 4 unmodified lines | ||
| 5 | 5 | * control-plane handler. Everything else (frontend assets, HMR) is |
| 6 | 6 | * handled by Vite itself. |
| 7 | 7 | */ |
| 8 | +import { join } from "node:path"; | |
| 8 | 9 | import * as Cause from "effect/Cause"; |
| 9 | 10 | import * as Effect from "effect/Effect"; |
| 10 | 11 | import * as Exit from "effect/Exit"; |
| 11 | 12 | import * as Runtime from "effect/Runtime"; |
| 12 | 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 | 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 | 26 | const truncateForLog = (value: string): string => |
| 18 | 27 | value.length > MAX_LOGGED_ERROR_BODY_LENGTH |
| 19 | 28 | ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]` |
| 20 | 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 | 39 | * Extract a detailed, human-readable description from an error. |
| 24 | 40 | * |
| @@ -45,7 +61,9 @@ const handlerPromise = (async () => { · 20 unmodified lines | ||
| 45 | 61 | const exit = await Effect.runPromiseExit( |
| 46 | 62 | Effect.gen(function* () { |
| 47 | 63 | const scope = yield* Scope.make(); |
| 48 | − const handler = yield* createLocalExecutorRequestHandler().pipe( | |
| 64 | + const handler = yield* createLocalExecutorRequestHandler({ | |
| 65 | + localDataDir: resolveWebDevLocalDataDir(), | |
| 66 | + }).pipe( | |
| 49 | 67 | Effect.provideService(Scope.Scope, scope), |
| 50 | 68 | ); |
| 51 | 69 | return handler; |
| @@ -57,9 +57,12 @@ type ProbeAuthState = { · 56 unmodified lines | ||
| 57 | 57 | }; |
| 58 | 58 | |
| 59 | 59 | type ConnectFormState = { |
| 60 | − kind: "mcp" | "openapi" | "graphql"; | |
| 60 | + kind: "mcp" | "openapi" | "graphql" | "google_discovery"; | |
| 61 | 61 | endpoint: string; |
| 62 | 62 | specUrl: string; |
| 63 | + service: string; | |
| 64 | + version: string; | |
| 65 | + discoveryUrl: string; | |
| 63 | 66 | name: string; |
| 64 | 67 | namespace: string; |
| 65 | 68 | transport: "" | "auto" | "streamable-http" | "sse"; |
| @@ -83,7 +86,12 @@ type OAuthRequiredInfo = { · 17 unmodified lines | ||
| 83 | 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 | 96 | const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [ |
| 89 | 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 | 168 | const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => { |
| 123 | 169 | const trimmed = text.trim(); |
| 124 | 170 | if (trimmed.length === 0) { |
| @@ -158,6 +204,9 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 33 unmodified lines | ||
| 158 | 204 | kind: "openapi", |
| 159 | 205 | endpoint: discovery?.endpoint ?? "", |
| 160 | 206 | specUrl: discovery?.specUrl ?? "", |
| 207 | + service: "", | |
| 208 | + version: "", | |
| 209 | + discoveryUrl: "", | |
| 161 | 210 | name: discovery?.name ?? "", |
| 162 | 211 | namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""), |
| 163 | 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 | 253 | return { |
| 201 | 254 | kind, |
| 202 | 255 | endpoint: discovery.endpoint, |
| 203 | 256 | specUrl: discovery.specUrl ?? "", |
| 257 | + service: googleDiscoveryDefaults?.service ?? "", | |
| 258 | + version: googleDiscoveryDefaults?.version ?? "", | |
| 259 | + discoveryUrl: googleDiscoveryDefaults?.discoveryUrl ?? "", | |
| 204 | 260 | name: discovery.name ?? "", |
| 205 | 261 | namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint), |
| 206 | 262 | transport: kind === "mcp" ? (discovery.transport ?? "auto") : "", |
| @@ -220,11 +276,25 @@ const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => · 13 unmodified lines | ||
| 220 | 276 | kind: template.kind as ConnectFormState["kind"], |
| 221 | 277 | endpoint: template.endpoint, |
| 222 | 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 | 282 | name: template.name, |
| 224 | − namespace: namespaceFromUrl(template.endpoint), | |
| 283 | + namespace: "service" in template | |
| 284 | + ? googleDiscoveryNamespace(template.service) | |
| 285 | + : namespaceFromUrl(template.endpoint), | |
| 225 | 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 | 298 | const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => { |
| 229 | 299 | if (state.kind === "none") return { kind: "none" }; |
| 230 | 300 | if (state.kind === "bearer") { |
| @@ -251,10 +321,9 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 20 unmodified lines | ||
| 251 | 321 | }; |
| 252 | 322 | |
| 253 | 323 | const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { |
| 254 | − const endpoint = form.endpoint.trim(); | |
| 255 | − if (!endpoint) throw new Error("Endpoint is required."); | |
| 256 | − | |
| 257 | 324 | if (form.kind === "mcp") { |
| 325 | + const endpoint = form.endpoint.trim(); | |
| 326 | + if (!endpoint) throw new Error("Endpoint is required."); | |
| 258 | 327 | return { |
| 259 | 328 | kind: "mcp", |
| 260 | 329 | endpoint, |
| @@ -270,6 +339,8 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 9 unmodified lines | ||
| 270 | 339 | const auth = buildHttpAuth(form); |
| 271 | 340 | |
| 272 | 341 | if (form.kind === "openapi") { |
| 342 | + const endpoint = form.endpoint.trim(); | |
| 343 | + if (!endpoint) throw new Error("Endpoint is required."); | |
| 273 | 344 | const specUrl = form.specUrl.trim(); |
| 274 | 345 | if (!specUrl) throw new Error("OpenAPI sources require a spec URL."); |
| 275 | 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 | 374 | return { |
| 286 | 375 | kind: "graphql", |
| 287 | 376 | endpoint, |
| @@ -346,6 +435,16 @@ type SourceOAuthPopupMessage = · 58 unmodified lines | ||
| 346 | 435 | ok: false; |
| 347 | 436 | sessionId: string | null; |
| 348 | 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 | 450 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { |
| @@ -369,7 +468,7 @@ const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { · 17 unmodified lines | ||
| 369 | 468 | const startSourceOAuthPopup = async (input: { |
| 370 | 469 | authorizationUrl: string; |
| 371 | 470 | sessionId: string; |
| 372 | −}): Promise<CompleteSourceOAuthResult["auth"]> => { | |
| 471 | +}): Promise<void> => { | |
| 373 | 472 | if (typeof window === "undefined") { |
| 374 | 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 | 488 | popup.focus(); |
| 390 | 489 | |
| 391 | − return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => { | |
| 490 | + return await new Promise<void>((resolve, reject) => { | |
| 392 | 491 | let settled = false; |
| 393 | 492 | let closedPoll = 0; |
| 394 | 493 | let resultTimeout = 0; |
| @@ -416,15 +515,19 @@ const startSourceOAuthPopup = async (input: { · 21 unmodified lines | ||
| 416 | 515 | if (settled) return; |
| 417 | 516 | settled = true; |
| 418 | 517 | cleanup(); |
| 419 | − resolve(data.auth); | |
| 518 | + resolve(); | |
| 420 | 519 | }; |
| 421 | 520 | |
| 422 | 521 | const onMessage = (event: MessageEvent) => { |
| 423 | 522 | if (event.origin !== window.location.origin) return; |
| 424 | 523 | const data = event.data as SourceOAuthPopupMessage | undefined; |
| 425 | − if (!data || data.type !== "executor:oauth-result") return; | |
| 426 | − if (data.ok && data.sessionId !== input.sessionId) return; | |
| 427 | − if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) return; | |
| 524 | + if (!data) return; | |
| 525 | + if (data.type === "executor:oauth-result") { | |
| 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 | 531 | settleFromPayload(data); |
| 429 | 532 | }; |
| 430 | 533 | |
| @@ -517,6 +620,14 @@ export function AddSourcePage() { · 86 unmodified lines | ||
| 517 | 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 | 631 | const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => { |
| 521 | 632 | setProbeAuth((current) => ({ ...current, [key]: value })); |
| 522 | 633 | }; |
| @@ -879,10 +990,10 @@ export function AddSourcePage() { · 356 unmodified lines | ||
| 879 | 990 | <div className="space-y-3 border-t border-border pt-4"> |
| 880 | 991 | <div> |
| 881 | 992 | <p className="text-[12px] font-medium text-foreground">Start from a known source</p> |
| 882 | − <p className="text-[11px] text-muted-foreground"> | |
| 883 | − Load a real MCP, OpenAPI, or GraphQL template without running discovery first. | |
| 884 | − </p> | |
| 885 | − </div> | |
| 993 | + <p className="text-[11px] text-muted-foreground"> | |
| 994 | + Load a real MCP, OpenAPI, GraphQL, or Google Discovery template without running discovery first. | |
| 995 | + </p> | |
| 996 | + </div> | |
| 886 | 997 | <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
| 887 | 998 | {sourceTemplates.map((template) => ( |
| 888 | 999 | <button |
| @@ -930,18 +1041,20 @@ export function AddSourcePage() { · 41 unmodified lines | ||
| 930 | 1041 | <Field label="Kind"> |
| 931 | 1042 | <SelectInput |
| 932 | 1043 | value={connectForm.kind} |
| 933 | − onChange={(v) => setFormField("kind", v as ConnectFormState["kind"])} | |
| 1044 | + onChange={(v) => setSourceKind(v as ConnectFormState["kind"])} | |
| 934 | 1045 | options={kindOptions.map((v) => ({ value: v, label: v }))} |
| 935 | 1046 | /> |
| 936 | 1047 | </Field> |
| 937 | − <Field label="Endpoint" className="sm:col-span-2"> | |
| 938 | − <TextInput | |
| 939 | − value={connectForm.endpoint} | |
| 940 | − onChange={(v) => setFormField("endpoint", v)} | |
| 941 | − placeholder="https://api.example.com" | |
| 942 | − mono | |
| 943 | − /> | |
| 944 | − </Field> | |
| 1048 | + {connectForm.kind !== "google_discovery" && ( | |
| 1049 | + <Field label="Endpoint" className="sm:col-span-2"> | |
| 1050 | + <TextInput | |
| 1051 | + value={connectForm.endpoint} | |
| 1052 | + onChange={(v) => setFormField("endpoint", v)} | |
| 1053 | + placeholder="https://api.example.com" | |
| 1054 | + mono | |
| 1055 | + /> | |
| 1056 | + </Field> | |
| 1057 | + )} | |
| 945 | 1058 | <Field label="Namespace"> |
| 946 | 1059 | <TextInput |
| 947 | 1060 | value={connectForm.namespace} |
| @@ -959,6 +1072,35 @@ export function AddSourcePage() { · 11 unmodified lines | ||
| 959 | 1072 | /> |
| 960 | 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 | 1104 | </div> |
| 963 | 1105 | </Section> |
| 964 | 1106 | |
| @@ -1004,6 +1146,13 @@ export function AddSourcePage() { · 39 unmodified lines | ||
| 1004 | 1146 | options={authOptions.map((v) => ({ value: v, label: v }))} |
| 1005 | 1147 | /> |
| 1006 | 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 | 1156 | {connectForm.authKind !== "none" && ( |
| 1008 | 1157 | <> |
| 1009 | 1158 | <Field label="Header name"> |
| @@ -74,6 +74,8 @@ type SourceFormState = { · 73 unmodified lines | ||
| 74 | 74 | queryParamsText: string; |
| 75 | 75 | headersText: string; |
| 76 | 76 | specUrl: string; |
| 77 | + service: string; | |
| 78 | + version: string; | |
| 77 | 79 | defaultHeadersText: string; |
| 78 | 80 | authKind: Source["auth"]["kind"]; |
| 79 | 81 | authHeaderName: string; |
| @@ -86,7 +88,13 @@ type SourceFormState = { · 6 unmodified lines | ||
| 86 | 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 | 99 | const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [ |
| 92 | 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 | 126 | const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => { |
| 116 | 127 | if (typeof window === "undefined") { |
| 117 | 128 | return null; |
| @@ -288,14 +299,20 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 170 unmodified lines | ||
| 288 | 299 | name: template?.name ?? "", |
| 289 | 300 | kind: template?.kind ?? "openapi", |
| 290 | 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 | 307 | enabled: true, |
| 293 | 308 | transport: template?.kind === "mcp" ? "auto" : "", |
| 294 | 309 | queryParamsText: "", |
| 295 | 310 | headersText: "", |
| 296 | 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 | 314 | defaultHeadersText: "", |
| 298 | − authKind: "none", | |
| 315 | + authKind: template?.kind === "google_discovery" ? "oauth2" : "none", | |
| 299 | 316 | authHeaderName: "Authorization", |
| 300 | 317 | authPrefix: "Bearer ", |
| 301 | 318 | bearerProviderId: "", |
| @@ -316,6 +333,8 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 14 unmodified lines | ||
| 316 | 333 | queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")), |
| 317 | 334 | headersText: stringMapToEditor(readBindingStringMap(source, "headers")), |
| 318 | 335 | specUrl: readBindingString(source, "specUrl"), |
| 336 | + service: readBindingString(source, "service"), | |
| 337 | + version: readBindingString(source, "version"), | |
| 319 | 338 | defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")), |
| 320 | 339 | authKind: source.auth.kind, |
| 321 | 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 | 527 | return { |
| 488 | 528 | ...shared, |
| 489 | 529 | binding: {}, |
| @@ -784,6 +824,8 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 294 unmodified lines | ||
| 784 | 824 | ? "https://api.github.com" |
| 785 | 825 | : formState.kind === "graphql" |
| 786 | 826 | ? "https://api.linear.app/graphql" |
| 827 | + : formState.kind === "google_discovery" | |
| 828 | + ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" | |
| 787 | 829 | : "https://mcp.deepwiki.com/mcp" |
| 788 | 830 | } |
| 789 | 831 | mono |
| @@ -871,6 +913,34 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 81 unmodified lines | ||
| 871 | 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 | 944 | <Section title="Authentication"> |
| 875 | 945 | <div className="grid gap-4 sm:grid-cols-2"> |
| 876 | 946 | {formState.kind === "mcp" && ( |
| @@ -12,11 +12,45 @@ export type OpenApiSourceTemplate = SourceTemplateBase & { · 11 unmodified lines | ||
| 12 | 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 | 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 | 55 | export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ |
| 22 | 56 | { |
| @@ -115,4 +149,147 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 92 unmodified lines | ||
| 115 | 149 | kind: "graphql", |
| 116 | 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 | 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 | 128 | "packages/codemode-mcp": { |
| 114 | 129 | "name": "@executor/codemode-mcp", |
| 115 | 130 | "dependencies": { |
| @@ -148,6 +163,7 @@ · 32 unmodified lines | ||
| 148 | 163 | "@effect/platform": "catalog:", |
| 149 | 164 | "@electric-sql/pglite": "^0.3.12", |
| 150 | 165 | "@executor/codemode-core": "workspace:*", |
| 166 | + "@executor/codemode-google-discovery": "workspace:*", | |
| 151 | 167 | "@executor/codemode-mcp": "workspace:*", |
| 152 | 168 | "@executor/codemode-openapi": "workspace:*", |
| 153 | 169 | "@executor/runtime-deno-subprocess": "workspace:*", |
| @@ -472,6 +488,8 @@ · 318 unmodified lines | ||
| 472 | 488 | |
| 473 | 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 | 493 | "@executor/codemode-mcp": ["@executor/codemode-mcp@workspace:packages/codemode-mcp"], |
| 476 | 494 | |
| 477 | 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 | 4 | export * from "./tool-map"; |
| 5 | 5 | export * from "./discovery"; |
| 6 | 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 | 1 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; |
| 4 | 2 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; |
| 5 | 3 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; |
| @@ -30,6 +30,7 @@ type TestServer = { · 29 unmodified lines | ||
| 30 | 30 | path: string; |
| 31 | 31 | query: string; |
| 32 | 32 | body: string; |
| 33 | + headers: Record<string, string>; | |
| 33 | 34 | }>; |
| 34 | 35 | close: () => Promise<void>; |
| 35 | 36 | }; |
| @@ -53,6 +54,7 @@ const makeEffectServerHandler = ( · 17 unmodified lines | ||
| 53 | 54 | path: `/repos/${path.owner}/${path.repo}`, |
| 54 | 55 | query: include ? `?include=${encodeURIComponent(include)}` : "", |
| 55 | 56 | body: "", |
| 57 | + headers: {}, | |
| 56 | 58 | }); |
| 57 | 59 | |
| 58 | 60 | return { |
| @@ -69,6 +71,7 @@ const makeEffectServerHandler = ( · 10 unmodified lines | ||
| 69 | 71 | path: `/repos/${path.owner}/${path.repo}/issues`, |
| 70 | 72 | query: "", |
| 71 | 73 | body: bodyText, |
| 74 | + headers: {}, | |
| 72 | 75 | }); |
| 73 | 76 | |
| 74 | 77 | return { |
| @@ -207,11 +210,21 @@ const makeTestServer = Effect.acquireRelease( · 132 unmodified lines | ||
| 207 | 210 | catch: (error: unknown) => |
| 208 | 211 | error instanceof Error ? error : new Error(String(error)), |
| 209 | 212 | }).pipe(Effect.orDie), |
| 210 | −); | |
| 213 | + ); | |
| 211 | 214 | |
| 212 | 215 | const ownerParam = HttpApiSchema.param("owner", Schema.String); |
| 213 | 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 | 228 | class GeneratedReposApi extends HttpApiGroup.make("repos") |
| 216 | 229 | .add( |
| 217 | 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 | 602 | it.scoped("falls back to path-template args when extracted path parameters are missing", () => |
| 479 | 603 | Effect.gen(function* () { |
| 480 | 604 | const server = yield* makeTestServer; |
| @@ -8,8 +8,12 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines | ||
| 8 | 8 | import * as Layer from "effect/Layer"; |
| 9 | 9 | |
| 10 | 10 | import { |
| 11 | + applyCookiePlacementsToHeaders, | |
| 12 | + applyHttpQueryPlacementsToUrl, | |
| 13 | + applyJsonBodyPlacements, | |
| 11 | 14 | standardSchemaFromJsonSchema, |
| 12 | 15 | toTool, |
| 16 | + type HttpRequestPlacements, | |
| 13 | 17 | type ToolMap, |
| 14 | 18 | unknownInputSchema, |
| 15 | 19 | } from "@executor/codemode-core"; |
| @@ -288,6 +292,7 @@ export type CreateOpenApiToolsFromManifestInput = { · 272 unmodified lines | ||
| 288 | 292 | sourceKey?: string; |
| 289 | 293 | defaultHeaders?: Readonly<Record<string, string>>; |
| 290 | 294 | credentialHeaders?: Readonly<Record<string, string>>; |
| 295 | + credentialPlacements?: HttpRequestPlacements; | |
| 291 | 296 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 292 | 297 | }; |
| 293 | 298 | |
| @@ -317,7 +322,7 @@ const buildFetchRequest = (input: { · 23 unmodified lines | ||
| 317 | 322 | args: OpenApiToolArgs; |
| 318 | 323 | baseUrl: string; |
| 319 | 324 | defaultHeaders: Readonly<Record<string, string>>; |
| 320 | − credentialHeaders: Readonly<Record<string, string>>; | |
| 325 | + credentialPlacements: HttpRequestPlacements; | |
| 321 | 326 | }): { |
| 322 | 327 | url: URL; |
| 323 | 328 | method: string; |
| @@ -369,9 +374,11 @@ const buildFetchRequest = (input: { · 45 unmodified lines | ||
| 369 | 374 | } |
| 370 | 375 | |
| 371 | 376 | let body: string | undefined; |
| 377 | + const bodyValues = input.credentialPlacements.bodyValues ?? {}; | |
| 378 | + const hasCredentialBodyValues = Object.keys(bodyValues).length > 0; | |
| 372 | 379 | |
| 373 | 380 | if (input.payload.requestBody !== null) { |
| 374 | − if (!hasRequestBody(input.args)) { | |
| 381 | + if (!hasRequestBody(input.args) && !hasCredentialBodyValues) { | |
| 375 | 382 | if (input.payload.requestBody.required) { |
| 376 | 383 | throw new OpenApiToolInvocationError({ |
| 377 | 384 | operation: "validate_args", |
| @@ -380,7 +387,13 @@ const buildFetchRequest = (input: { · 2 unmodified lines | ||
| 380 | 387 | }); |
| 381 | 388 | } |
| 382 | 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 | 398 | const preferredContentType = input.payload.requestBody.contentTypes[0]; |
| 386 | 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)) { | |
| 395 | − headers[key] = value; | |
| 407 | + const urlWithAuth = applyHttpQueryPlacementsToUrl({ | |
| 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 | 420 | return { |
| 399 | − url, | |
| 421 | + url: urlWithAuth, | |
| 400 | 422 | method: input.payload.method.toUpperCase(), |
| 401 | − headers, | |
| 423 | + headers: headersWithAuthCookies, | |
| 402 | 424 | body, |
| 403 | 425 | }; |
| 404 | 426 | }; |
| @@ -413,15 +435,32 @@ export type CreateOpenApiToolFromDefinitionInput = { · 8 unmodified lines | ||
| 413 | 435 | baseUrl: string; |
| 414 | 436 | defaultHeaders?: Readonly<Record<string, string>>; |
| 415 | 437 | credentialHeaders?: Readonly<Record<string, string>>; |
| 438 | + credentialPlacements?: HttpRequestPlacements; | |
| 416 | 439 | refHintTable?: Readonly<OpenApiRefHintTable>; |
| 417 | 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 | 456 | export const createOpenApiToolFromDefinition = ( |
| 421 | 457 | input: CreateOpenApiToolFromDefinitionInput, |
| 422 | 458 | ) => { |
| 423 | 459 | const defaultHeaders = input.defaultHeaders ?? {}; |
| 424 | − const credentialHeaders = input.credentialHeaders ?? {}; | |
| 460 | + const credentialPlacements = normalizeCredentialPlacements({ | |
| 461 | + credentialHeaders: input.credentialHeaders, | |
| 462 | + credentialPlacements: input.credentialPlacements, | |
| 463 | + }); | |
| 425 | 464 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 426 | 465 | const presentation = buildOpenApiToolPresentation({ |
| 427 | 466 | definition: input.definition, |
| @@ -441,7 +480,7 @@ export const createOpenApiToolFromDefinition = ( · 13 unmodified lines | ||
| 441 | 480 | args: decodedArgs, |
| 442 | 481 | baseUrl: input.baseUrl, |
| 443 | 482 | defaultHeaders, |
| 444 | − credentialHeaders, | |
| 483 | + credentialPlacements, | |
| 445 | 484 | }); |
| 446 | 485 | |
| 447 | 486 | return Effect.runPromise( |
| @@ -503,7 +542,6 @@ export const createOpenApiToolsFromManifest = ( · 55 unmodified lines | ||
| 503 | 542 | const baseUrl = normalizeHttpUrl(input.baseUrl); |
| 504 | 543 | const sourceKey = input.sourceKey ?? "openapi.generated"; |
| 505 | 544 | const defaultHeaders = input.defaultHeaders ?? {}; |
| 506 | − const credentialHeaders = input.credentialHeaders ?? {}; | |
| 507 | 545 | const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer; |
| 508 | 546 | |
| 509 | 547 | const definitions = compileOpenApiToolDefinitions(input.manifest); |
| @@ -517,7 +555,8 @@ export const createOpenApiToolsFromManifest = ( · 7 unmodified lines | ||
| 517 | 555 | sourceKey, |
| 518 | 556 | baseUrl, |
| 519 | 557 | defaultHeaders, |
| 520 | − credentialHeaders, | |
| 558 | + credentialHeaders: input.credentialHeaders, | |
| 559 | + credentialPlacements: input.credentialPlacements, | |
| 521 | 560 | refHintTable: input.manifest.refHintTable, |
| 522 | 561 | httpClientLayer, |
| 523 | 562 | }); |
| @@ -534,6 +573,7 @@ export const createOpenApiToolsFromSpec = (input: { · 10 unmodified lines | ||
| 534 | 573 | sourceKey?: string; |
| 535 | 574 | defaultHeaders?: Readonly<Record<string, string>>; |
| 536 | 575 | credentialHeaders?: Readonly<Record<string, string>>; |
| 576 | + credentialPlacements?: HttpRequestPlacements; | |
| 537 | 577 | httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 538 | 578 | }): Effect.Effect< |
| 539 | 579 | { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap }, |
| @@ -551,6 +591,7 @@ export const createOpenApiToolsFromSpec = (input: { · 11 unmodified lines | ||
| 551 | 591 | sourceKey: input.sourceKey, |
| 552 | 592 | defaultHeaders: input.defaultHeaders, |
| 553 | 593 | credentialHeaders: input.credentialHeaders, |
| 594 | + credentialPlacements: input.credentialPlacements, | |
| 554 | 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 | 26 | "@effect/platform": "catalog:", |
| 27 | 27 | "@electric-sql/pglite": "^0.3.12", |
| 28 | 28 | "@executor/codemode-core": "workspace:*", |
| 29 | + "@executor/codemode-google-discovery": "workspace:*", | |
| 29 | 30 | "@executor/codemode-mcp": "workspace:*", |
| 30 | 31 | "@executor/runtime-ses": "workspace:*", |
| 31 | 32 | "@executor/codemode-openapi": "workspace:*", |
| @@ -64,9 +64,23 @@ describe("control-plane payload schemas", () => { · 63 unmodified lines | ||
| 64 | 64 | specUrl: " https://example.com/openapi.json ", |
| 65 | 65 | }), |
| 66 | 66 | ).toEqual({ |
| 67 | − kind: "openapi", | |
| 68 | − endpoint: "https://api.github.com", | |
| 69 | − specUrl: "https://example.com/openapi.json", | |
| 67 | + kind: "openapi", | |
| 68 | + endpoint: "https://api.github.com", | |
| 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 | 86 | expect( |
| @@ -8,6 +8,9 @@ import { · 7 unmodified lines | ||
| 8 | 8 | OrganizationMemberIdSchema, |
| 9 | 9 | PolicyIdSchema, |
| 10 | 10 | SecretMaterialIdSchema, |
| 11 | + StaticBearerAuthArtifactConfigJsonSchema, | |
| 12 | + StaticOAuth2AuthArtifactConfigJsonSchema, | |
| 13 | + decodeBuiltInAuthArtifactConfig, | |
| 11 | 14 | SourceAuthSessionIdSchema, |
| 12 | 15 | SourceIdSchema, |
| 13 | 16 | SourceRecipeIdSchema, |
| @@ -39,6 +42,12 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 25 unmodified lines | ||
| 39 | 42 | ); |
| 40 | 43 | |
| 41 | 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 | 52 | const openApiBindingConfigJson = (specUrl: string): string => |
| 44 | 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 | 161 | persistence: SqlControlPlanePersistence; |
| 153 | 162 | accountId: ReturnType<typeof AccountIdSchema.make>; |
| 154 | 163 | organizationId: ReturnType<typeof OrganizationIdSchema.make>; |
| @@ -160,7 +169,7 @@ const seedWorkspaceCredentialState = (input: { · 5 unmodified lines | ||
| 160 | 169 | }, unknown, never> => |
| 161 | 170 | Effect.gen(function* () { |
| 162 | 171 | const now = Date.now(); |
| 163 | − const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`); | |
| 172 | + const authArtifactId = CredentialIdSchema.make(`cred_${input.workspaceId}`); | |
| 164 | 173 | const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`); |
| 165 | 174 | const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`); |
| 166 | 175 | |
| @@ -181,19 +190,26 @@ const seedWorkspaceCredentialState = (input: { · 14 unmodified lines | ||
| 181 | 190 | createdAt: now, |
| 182 | 191 | updatedAt: now, |
| 183 | 192 | }); |
| 184 | − yield* input.persistence.rows.credentials.upsert({ | |
| 185 | − id: credentialId, | |
| 193 | + yield* input.persistence.rows.authArtifacts.upsert({ | |
| 194 | + id: authArtifactId, | |
| 186 | 195 | workspaceId: input.workspaceId, |
| 187 | 196 | sourceId: input.sourceId, |
| 188 | 197 | actorAccountId: input.accountId, |
| 189 | 198 | slot: "runtime", |
| 190 | − authKind: "oauth2", | |
| 191 | − authHeaderName: "Authorization", | |
| 192 | − authPrefix: "Bearer ", | |
| 193 | − tokenProviderId: "postgres", | |
| 194 | − tokenHandle: tokenId, | |
| 195 | − refreshTokenProviderId: "postgres", | |
| 196 | − refreshTokenHandle: refreshId, | |
| 199 | + artifactKind: "static_oauth2", | |
| 200 | + configJson: encodeStaticOAuth2ArtifactConfig({ | |
| 201 | + headerName: "Authorization", | |
| 202 | + prefix: "Bearer ", | |
| 203 | + accessToken: { | |
| 204 | + providerId: "postgres", | |
| 205 | + handle: tokenId, | |
| 206 | + }, | |
| 207 | + refreshToken: { | |
| 208 | + providerId: "postgres", | |
| 209 | + handle: refreshId, | |
| 210 | + }, | |
| 211 | + }), | |
| 212 | + grantSetJson: null, | |
| 197 | 213 | createdAt: now, |
| 198 | 214 | updatedAt: now, |
| 199 | 215 | }); |
| @@ -463,20 +479,23 @@ describe("control-plane-persistence-drizzle", () => { · 263 unmodified lines | ||
| 463 | 479 | }); |
| 464 | 480 | |
| 465 | 481 | yield* Effect.tryPromise(async () => { |
| 466 | − await persistence.db.insert(drizzleSchema.credentialsTable).values([ | |
| 482 | + await persistence.db.insert(drizzleSchema.authArtifactsTable).values([ | |
| 467 | 483 | { |
| 468 | 484 | id: nullCredentialA, |
| 469 | 485 | workspaceId, |
| 470 | 486 | sourceId, |
| 471 | 487 | actorAccountId: null, |
| 472 | 488 | slot: "runtime", |
| 473 | − authKind: "bearer", | |
| 474 | − authHeaderName: "Authorization", | |
| 475 | − authPrefix: "Bearer ", | |
| 476 | − tokenProviderId: "postgres", | |
| 477 | − tokenHandle: "sec_null_a", | |
| 478 | − refreshTokenProviderId: null, | |
| 479 | − refreshTokenHandle: null, | |
| 489 | + artifactKind: "static_bearer", | |
| 490 | + configJson: encodeStaticBearerArtifactConfig({ | |
| 491 | + headerName: "Authorization", | |
| 492 | + prefix: "Bearer ", | |
| 493 | + token: { | |
| 494 | + providerId: "postgres", | |
| 495 | + handle: "sec_null_a", | |
| 496 | + }, | |
| 497 | + }), | |
| 498 | + grantSetJson: null, | |
| 480 | 499 | createdAt: now, |
| 481 | 500 | updatedAt: now, |
| 482 | 501 | }, |
| @@ -486,79 +505,95 @@ describe("control-plane-persistence-drizzle", () => { · 3 unmodified lines | ||
| 486 | 505 | sourceId, |
| 487 | 506 | actorAccountId: null, |
| 488 | 507 | slot: "runtime", |
| 489 | − authKind: "bearer", | |
| 490 | − authHeaderName: "Authorization", | |
| 491 | − authPrefix: "Bearer ", | |
| 492 | − tokenProviderId: "postgres", | |
| 493 | − tokenHandle: "sec_null_b", | |
| 494 | − refreshTokenProviderId: null, | |
| 495 | − refreshTokenHandle: null, | |
| 508 | + artifactKind: "static_bearer", | |
| 509 | + configJson: encodeStaticBearerArtifactConfig({ | |
| 510 | + headerName: "Authorization", | |
| 511 | + prefix: "Bearer ", | |
| 512 | + token: { | |
| 513 | + providerId: "postgres", | |
| 514 | + handle: "sec_null_b", | |
| 515 | + }, | |
| 516 | + }), | |
| 517 | + grantSetJson: null, | |
| 496 | 518 | createdAt: now + 1, |
| 497 | 519 | updatedAt: now + 1, |
| 498 | 520 | }, |
| 499 | 521 | ]); |
| 500 | 522 | }).pipe(Effect.orDie); |
| 501 | 523 | |
| 502 | − yield* persistence.rows.credentials.upsert({ | |
| 524 | + yield* persistence.rows.authArtifacts.upsert({ | |
| 503 | 525 | id: CredentialIdSchema.make("cred_null_credentials_replacement"), |
| 504 | 526 | workspaceId, |
| 505 | 527 | sourceId, |
| 506 | 528 | actorAccountId: null, |
| 507 | 529 | slot: "runtime", |
| 508 | − authKind: "oauth2", | |
| 509 | − authHeaderName: "X-Auth", | |
| 510 | − authPrefix: "Token ", | |
| 511 | − tokenProviderId: "postgres", | |
| 512 | − tokenHandle: "sec_null_replacement", | |
| 513 | − refreshTokenProviderId: "postgres", | |
| 514 | − refreshTokenHandle: "sec_null_refresh", | |
| 530 | + artifactKind: "static_oauth2", | |
| 531 | + configJson: encodeStaticOAuth2ArtifactConfig({ | |
| 532 | + headerName: "X-Auth", | |
| 533 | + prefix: "Token ", | |
| 534 | + accessToken: { | |
| 535 | + providerId: "postgres", | |
| 536 | + handle: "sec_null_replacement", | |
| 537 | + }, | |
| 538 | + refreshToken: { | |
| 539 | + providerId: "postgres", | |
| 540 | + handle: "sec_null_refresh", | |
| 541 | + }, | |
| 542 | + }), | |
| 543 | + grantSetJson: null, | |
| 515 | 544 | createdAt: now + 2, |
| 516 | 545 | updatedAt: now + 2, |
| 517 | 546 | }); |
| 518 | 547 | |
| 519 | − yield* persistence.rows.credentials.upsert({ | |
| 548 | + yield* persistence.rows.authArtifacts.upsert({ | |
| 520 | 549 | id: actorCredentialId, |
| 521 | 550 | workspaceId, |
| 522 | 551 | sourceId, |
| 523 | 552 | actorAccountId: accountId, |
| 524 | 553 | slot: "runtime", |
| 525 | − authKind: "bearer", | |
| 526 | − authHeaderName: "Authorization", | |
| 527 | − authPrefix: "Bearer ", | |
| 528 | − tokenProviderId: "postgres", | |
| 529 | − tokenHandle: "sec_actor", | |
| 530 | − refreshTokenProviderId: null, | |
| 531 | − refreshTokenHandle: null, | |
| 554 | + artifactKind: "static_bearer", | |
| 555 | + configJson: encodeStaticBearerArtifactConfig({ | |
| 556 | + headerName: "Authorization", | |
| 557 | + prefix: "Bearer ", | |
| 558 | + token: { | |
| 559 | + providerId: "postgres", | |
| 560 | + handle: "sec_actor", | |
| 561 | + }, | |
| 562 | + }), | |
| 563 | + grantSetJson: null, | |
| 532 | 564 | createdAt: now + 3, |
| 533 | 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 | 569 | workspaceId, |
| 538 | 570 | sourceId, |
| 539 | 571 | }); |
| 540 | − expect(allCredentials).toHaveLength(2); | |
| 541 | − const nullActorCredentials = allCredentials.filter((credential) => credential.actorAccountId === null); | |
| 542 | − expect(nullActorCredentials).toHaveLength(1); | |
| 543 | − expect(nullActorCredentials[0]?.id).toBe(nullCredentialA); | |
| 544 | − expect(nullActorCredentials[0]?.authKind).toBe("oauth2"); | |
| 545 | − expect(nullActorCredentials[0]?.authHeaderName).toBe("X-Auth"); | |
| 546 | − expect(nullActorCredentials[0]?.tokenHandle).toBe("sec_null_replacement"); | |
| 547 | − expect(allCredentials.map((credential) => credential.id).sort()).toEqual( | |
| 572 | + expect(allAuthArtifacts).toHaveLength(2); | |
| 573 | + const nullActorArtifacts = allAuthArtifacts.filter((artifact) => artifact.actorAccountId === null); | |
| 574 | + expect(nullActorArtifacts).toHaveLength(1); | |
| 575 | + expect(nullActorArtifacts[0]?.id).toBe(nullCredentialA); | |
| 576 | + const nullActorConfig = decodeBuiltInAuthArtifactConfig(nullActorArtifacts[0]!); | |
| 577 | + expect(nullActorConfig?.artifactKind).toBe("static_oauth2"); | |
| 578 | + if (nullActorConfig !== null && nullActorConfig.artifactKind === "static_oauth2") { | |
| 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 | 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 | 587 | workspaceId, |
| 553 | 588 | sourceId, |
| 554 | 589 | actorAccountId: accountId, |
| 555 | 590 | }); |
| 556 | 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 | 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 | 597 | workspaceId, |
| 563 | 598 | sourceId, |
| 564 | 599 | actorAccountId: null, |
| @@ -569,7 +604,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines | ||
| 569 | 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 | 608 | workspaceId, |
| 574 | 609 | sourceId, |
| 575 | 610 | actorAccountId: AccountIdSchema.make("acc_missing_credentials"), |
| @@ -588,7 +623,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines | ||
| 588 | 623 | const sourceId = SourceIdSchema.make("src_ws_cleanup"); |
| 589 | 624 | const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`); |
| 590 | 625 | |
| 591 | − const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({ | |
| 626 | + const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({ | |
| 592 | 627 | persistence, |
| 593 | 628 | accountId, |
| 594 | 629 | organizationId, |
| @@ -670,7 +705,7 @@ describe("control-plane-persistence-drizzle", () => { · 75 unmodified lines | ||
| 670 | 705 | const removed = yield* persistence.rows.workspaces.removeById(workspaceId); |
| 671 | 706 | expect(removed).toBe(true); |
| 672 | 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 | 709 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 675 | 710 | expect( |
| 676 | 711 | yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId), |
| @@ -892,7 +927,7 @@ describe("control-plane-persistence-drizzle", () => { · 215 unmodified lines | ||
| 892 | 927 | const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup"); |
| 893 | 928 | const sourceId = SourceIdSchema.make("src_org_cleanup"); |
| 894 | 929 | |
| 895 | − const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({ | |
| 930 | + const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({ | |
| 896 | 931 | persistence, |
| 897 | 932 | accountId, |
| 898 | 933 | organizationId, |
| @@ -903,7 +938,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines | ||
| 903 | 938 | const removed = yield* persistence.rows.organizations.removeTreeById(organizationId); |
| 904 | 939 | expect(removed).toBe(true); |
| 905 | 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 | 942 | expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0); |
| 908 | 943 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true); |
| 909 | 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 | 6 | import { createDrizzleClient, type DrizzleClient } from "./client"; |
| 7 | 7 | import { |
| 8 | 8 | createAccountsRepo, |
| 9 | − createCredentialsRepo, | |
| 9 | + createAuthArtifactsRepo, | |
| 10 | + createAuthLeasesRepo, | |
| 10 | 11 | createCodeMigrationsRepo, |
| 11 | 12 | createExecutionInteractionsRepo, |
| 12 | 13 | createExecutionsRepo, |
| @@ -64,7 +65,8 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 51 unmodified lines | ||
| 64 | 65 | sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables), |
| 65 | 66 | sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables), |
| 66 | 67 | codeMigrations: createCodeMigrationsRepo(client, tables), |
| 67 | − credentials: createCredentialsRepo(client, tables), | |
| 68 | + authArtifacts: createAuthArtifactsRepo(client, tables), | |
| 69 | + authLeases: createAuthLeasesRepo(client, tables), | |
| 68 | 70 | sourceOauthClients: createSourceOauthClientsRepo(client, tables), |
| 69 | 71 | secretMaterials: createSecretMaterialsRepo(client, tables), |
| 70 | 72 | sourceAuthSessions: createSourceAuthSessionsRepo(client, tables), |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | ||
| 9 | 9 | McpSourceAuthSessionDataJsonSchema, |
| 10 | 10 | OrganizationIdSchema, |
| 11 | 11 | SourceAuthSessionIdSchema, |
| 12 | + decodeAuthLeasePlacementTemplates, | |
| 12 | 13 | SourceIdSchema, |
| 13 | 14 | SourceRecipeIdSchema, |
| 14 | 15 | SourceRecipeRevisionIdSchema, |
| @@ -753,4 +754,184 @@ describe("code-migrations", () => { · 738 unmodified lines | ||
| 753 | 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 | 9 | export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo"; |
| 10 | 10 | export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo"; |
| 11 | 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 | 14 | export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo"; |
| 14 | 15 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| 15 | 16 | export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo"; |
| @@ -13,7 +13,7 @@ import type { DrizzleTables } from "../schema"; · 12 unmodified lines | ||
| 13 | 13 | import { |
| 14 | 14 | cleanupOrphanedSourceRecipes, |
| 15 | 15 | firstOption, |
| 16 | − postgresSecretHandlesFromCredentials, | |
| 16 | + postgresSecretHandlesFromAuthArtifacts, | |
| 17 | 17 | withoutCreatedAt, |
| 18 | 18 | } from "./shared"; |
| 19 | 19 | |
| @@ -144,20 +144,18 @@ export const createOrganizationsRepo = ( · 124 unmodified lines | ||
| 144 | 144 | }) |
| 145 | 145 | .from(tables.sourcesTable) |
| 146 | 146 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| 147 | − const credentials = await tx | |
| 147 | + const authArtifacts = await tx | |
| 148 | 148 | .select({ |
| 149 | − tokenProviderId: tables.credentialsTable.tokenProviderId, | |
| 150 | − tokenHandle: tables.credentialsTable.tokenHandle, | |
| 151 | − refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | |
| 152 | − refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | |
| 149 | + artifactKind: tables.authArtifactsTable.artifactKind, | |
| 150 | + configJson: tables.authArtifactsTable.configJson, | |
| 153 | 151 | }) |
| 154 | − .from(tables.credentialsTable) | |
| 155 | − .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | |
| 152 | + .from(tables.authArtifactsTable) | |
| 153 | + .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds)); | |
| 156 | 154 | |
| 157 | 155 | const executionIds = executionRows.map((execution) => execution.id); |
| 158 | 156 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 159 | 157 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); |
| 160 | − const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | |
| 158 | + const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts); | |
| 161 | 159 | |
| 162 | 160 | if (executionIds.length > 0) { |
| 163 | 161 | await tx |
| @@ -171,6 +169,10 @@ export const createOrganizationsRepo = ( · 7 unmodified lines | ||
| 171 | 169 | .delete(tables.executionsTable) |
| 172 | 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 | 176 | await tx |
| 175 | 177 | .delete(tables.sourceAuthSessionsTable) |
| 176 | 178 | .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds)); |
| @@ -180,8 +182,8 @@ export const createOrganizationsRepo = ( · 3 unmodified lines | ||
| 180 | 182 | .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds)); |
| 181 | 183 | |
| 182 | 184 | await tx |
| 183 | − .delete(tables.credentialsTable) | |
| 184 | − .where(inArray(tables.credentialsTable.workspaceId, workspaceIds)); | |
| 185 | + .delete(tables.authArtifactsTable) | |
| 186 | + .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds)); | |
| 185 | 187 | |
| 186 | 188 | await tx |
| 187 | 189 | .delete(tables.sourcesTable) |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { |
| 2 | + authArtifactSecretRefs, | |
| 2 | 3 | type SecretMaterial, |
| 3 | 4 | SecretMaterialSchema, |
| 4 | 5 | } from "#schema"; |
| 5 | 6 | import * as Option from "effect/Option"; |
| 6 | 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 | 10 | import type { DrizzleClient } from "../client"; |
| 10 | 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 | 97 | * Returns a map of secretId -> Array<{ sourceId, sourceName }>. |
| 97 | 98 | */ |
| 98 | 99 | listLinkedSources: () => |
| 99 | 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 | 101 | const rows = await db |
| 103 | 102 | .select({ |
| 104 | − tokenHandle: tables.credentialsTable.tokenHandle, | |
| 105 | − tokenProviderId: tables.credentialsTable.tokenProviderId, | |
| 106 | − refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | |
| 107 | − refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | |
| 108 | − sourceId: tables.credentialsTable.sourceId, | |
| 103 | + artifactKind: tables.authArtifactsTable.artifactKind, | |
| 104 | + configJson: tables.authArtifactsTable.configJson, | |
| 105 | + sourceId: tables.authArtifactsTable.sourceId, | |
| 109 | 106 | sourceName: tables.sourcesTable.name, |
| 110 | 107 | }) |
| 111 | − .from(tables.credentialsTable) | |
| 108 | + .from(tables.authArtifactsTable) | |
| 112 | 109 | .innerJoin( |
| 113 | 110 | tables.sourcesTable, |
| 114 | 111 | and( |
| 115 | − eq(tables.credentialsTable.workspaceId, tables.sourcesTable.workspaceId), | |
| 116 | − eq(tables.credentialsTable.sourceId, tables.sourcesTable.sourceId), | |
| 117 | − ), | |
| 118 | − ) | |
| 119 | − .where( | |
| 120 | − or( | |
| 121 | − eq(tables.credentialsTable.tokenProviderId, "postgres"), | |
| 122 | − eq(tables.credentialsTable.refreshTokenProviderId, "postgres"), | |
| 112 | + eq(tables.authArtifactsTable.workspaceId, tables.sourcesTable.workspaceId), | |
| 113 | + eq(tables.authArtifactsTable.sourceId, tables.sourcesTable.sourceId), | |
| 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") { | |
| 142 | − addLink(row.tokenHandle); | |
| 143 | − } | |
| 144 | − if (row.refreshTokenProviderId === "postgres" && row.refreshTokenHandle) { | |
| 145 | − addLink(row.refreshTokenHandle); | |
| 132 | + for (const ref of authArtifactSecretRefs({ | |
| 133 | + artifactKind: row.artifactKind, | |
| 134 | + configJson: row.configJson, | |
| 135 | + })) { | |
| 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 | 2 | import { inArray } from "drizzle-orm"; |
| 2 | 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 | 18 | const POSTGRES_SECRET_PROVIDER_ID = "postgres"; |
| 18 | 19 | |
| 19 | −export const postgresSecretHandlesFromCredentials = ( | |
| 20 | − credentials: ReadonlyArray<{ | |
| 21 | − tokenProviderId: string; | |
| 22 | − tokenHandle: string; | |
| 23 | − refreshTokenProviderId: string | null; | |
| 24 | − refreshTokenHandle: string | null; | |
| 25 | − }>, | |
| 20 | +export const postgresSecretHandlesFromAuthArtifacts = ( | |
| 21 | + artifacts: ReadonlyArray<Pick<AuthArtifact, "artifactKind" | "configJson">>, | |
| 26 | 22 | ): ReadonlyArray<string> => { |
| 27 | 23 | const handles = new Set<string>(); |
| 28 | 24 | |
| 29 | − for (const credential of credentials) { | |
| 30 | − if (credential.tokenProviderId === POSTGRES_SECRET_PROVIDER_ID) { | |
| 31 | − handles.add(credential.tokenHandle); | |
| 32 | − } | |
| 33 | − | |
| 34 | − if ( | |
| 35 | − credential.refreshTokenProviderId === POSTGRES_SECRET_PROVIDER_ID | |
| 36 | − && credential.refreshTokenHandle !== null | |
| 37 | − ) { | |
| 38 | − handles.add(credential.refreshTokenHandle); | |
| 25 | + for (const artifact of artifacts) { | |
| 26 | + for (const ref of authArtifactSecretRefs(artifact)) { | |
| 27 | + if (ref.providerId === POSTGRES_SECRET_PROVIDER_ID) { | |
| 28 | + handles.add(ref.handle); | |
| 29 | + } | |
| 39 | 30 | } |
| 40 | 31 | } |
| 41 | 32 | |
| @@ -8,7 +8,7 @@ import type { DrizzleTables } from "../schema"; · 7 unmodified lines | ||
| 8 | 8 | import { |
| 9 | 9 | cleanupOrphanedSourceRecipes, |
| 10 | 10 | firstOption, |
| 11 | − postgresSecretHandlesFromCredentials, | |
| 11 | + postgresSecretHandlesFromAuthArtifacts, | |
| 12 | 12 | } from "./shared"; |
| 13 | 13 | |
| 14 | 14 | const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema); |
| @@ -83,16 +83,14 @@ export const createWorkspacesRepo = ( · 68 unmodified lines | ||
| 83 | 83 | const executionIds = executionRows.map((execution) => execution.id); |
| 84 | 84 | const recipeIds = sourceRows.map((source) => source.recipeId); |
| 85 | 85 | const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId); |
| 86 | − const credentials = await tx | |
| 86 | + const authArtifacts = await tx | |
| 87 | 87 | .select({ |
| 88 | − tokenProviderId: tables.credentialsTable.tokenProviderId, | |
| 89 | − tokenHandle: tables.credentialsTable.tokenHandle, | |
| 90 | − refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId, | |
| 91 | − refreshTokenHandle: tables.credentialsTable.refreshTokenHandle, | |
| 88 | + artifactKind: tables.authArtifactsTable.artifactKind, | |
| 89 | + configJson: tables.authArtifactsTable.configJson, | |
| 92 | 90 | }) |
| 93 | − .from(tables.credentialsTable) | |
| 94 | − .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | |
| 95 | − const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials); | |
| 91 | + .from(tables.authArtifactsTable) | |
| 92 | + .where(eq(tables.authArtifactsTable.workspaceId, workspaceId)); | |
| 93 | + const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts); | |
| 96 | 94 | |
| 97 | 95 | if (executionIds.length > 0) { |
| 98 | 96 | await tx |
| @@ -104,6 +102,10 @@ export const createWorkspacesRepo = ( · 5 unmodified lines | ||
| 104 | 102 | .delete(tables.executionsTable) |
| 105 | 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 | 109 | await tx |
| 108 | 110 | .delete(tables.sourceAuthSessionsTable) |
| 109 | 111 | .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId)); |
| @@ -113,8 +115,8 @@ export const createWorkspacesRepo = ( · 3 unmodified lines | ||
| 113 | 115 | .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId)); |
| 114 | 116 | |
| 115 | 117 | await tx |
| 116 | − .delete(tables.credentialsTable) | |
| 117 | − .where(eq(tables.credentialsTable.workspaceId, workspaceId)); | |
| 118 | + .delete(tables.authArtifactsTable) | |
| 119 | + .where(eq(tables.authArtifactsTable.workspaceId, workspaceId)); | |
| 118 | 120 | |
| 119 | 121 | await tx |
| 120 | 122 | .delete(tables.sourcesTable) |
| @@ -22,7 +22,8 @@ export const tableNames = { · 21 unmodified lines | ||
| 22 | 22 | sourceRecipeSchemaBundles: "source_recipe_schema_bundles", |
| 23 | 23 | sourceRecipeOperations: "source_recipe_operations", |
| 24 | 24 | codeMigrations: "control_plane_code_migrations", |
| 25 | − credentials: "workspace_source_credentials", | |
| 25 | + authArtifacts: "workspace_source_auth_artifacts", | |
| 26 | + authLeases: "workspace_source_auth_leases", | |
| 26 | 27 | workspaceSourceOauthClients: "workspace_source_oauth_clients", |
| 27 | 28 | secretMaterials: "secret_materials", |
| 28 | 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 | − tableNames.credentials, | |
| 355 | +export const authArtifactsTable = pgTable( | |
| 356 | + tableNames.authArtifacts, | |
| 356 | 357 | { |
| 357 | 358 | id: text("id").notNull().primaryKey(), |
| 358 | 359 | workspaceId: text("workspace_id").notNull(), |
| 359 | 360 | sourceId: text("source_id").notNull(), |
| 360 | 361 | actorAccountId: text("actor_account_id"), |
| 361 | 362 | slot: text("slot").notNull(), |
| 362 | − authKind: text("auth_kind").notNull(), | |
| 363 | − authHeaderName: text("auth_header_name").notNull(), | |
| 364 | − authPrefix: text("auth_prefix").notNull(), | |
| 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"), | |
| 363 | + artifactKind: text("artifact_kind").notNull(), | |
| 364 | + configJson: text("config_json").notNull(), | |
| 365 | + grantSetJson: text("grant_set_json"), | |
| 369 | 366 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 370 | 367 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 371 | 368 | }, |
| 372 | 369 | (table) => [ |
| 373 | − index("credentials_workspace_idx").on( | |
| 370 | + index("auth_artifacts_workspace_idx").on( | |
| 374 | 371 | table.workspaceId, |
| 375 | 372 | table.updatedAt, |
| 376 | 373 | table.id, |
| 377 | 374 | ), |
| 378 | − uniqueIndex("credentials_workspace_source_actor_idx").on( | |
| 375 | + uniqueIndex("auth_artifacts_workspace_source_actor_idx").on( | |
| 379 | 376 | table.workspaceId, |
| 380 | 377 | table.sourceId, |
| 381 | 378 | table.actorAccountId, |
| 382 | 379 | table.slot, |
| 383 | 380 | ), |
| 384 | − index("credentials_workspace_source_idx").on( | |
| 381 | + index("auth_artifacts_workspace_source_idx").on( | |
| 385 | 382 | table.workspaceId, |
| 386 | 383 | table.sourceId, |
| 387 | 384 | table.updatedAt, |
| 388 | 385 | table.id, |
| 389 | 386 | ), |
| 390 | 387 | check( |
| 391 | − "credentials_auth_kind_check", | |
| 392 | − sql`${table.authKind} in ('bearer', 'oauth2')`, | |
| 388 | + "auth_artifacts_slot_check", | |
| 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 | 419 | check( |
| 395 | − "credentials_slot_check", | |
| 420 | + "auth_leases_slot_check", | |
| 396 | 421 | sql`${table.slot} in ('runtime', 'import')`, |
| 397 | 422 | ), |
| 398 | 423 | ], |
| @@ -638,7 +663,8 @@ export const drizzleSchema = { · 239 unmodified lines | ||
| 638 | 663 | sourceRecipeSchemaBundlesTable, |
| 639 | 664 | sourceRecipeOperationsTable, |
| 640 | 665 | codeMigrationsTable, |
| 641 | − credentialsTable, | |
| 666 | + authArtifactsTable, | |
| 667 | + authLeasesTable, | |
| 642 | 668 | workspaceSourceOauthClientsTable, |
| 643 | 669 | secretMaterialsTable, |
| 644 | 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 | 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 | 109 | const resolveLocalCredentialUrl = (input: { |
| 115 | 110 | baseUrl: string; |
| 116 | 111 | workspaceId: WorkspaceId; |
| @@ -127,9 +122,12 @@ const promptForSourceCredentialSelection = (input: { · 10 unmodified lines | ||
| 127 | 122 | args: { |
| 128 | 123 | workspaceId: WorkspaceId; |
| 129 | 124 | sourceId: Source["id"]; |
| 130 | − kind: "openapi" | "graphql"; | |
| 131 | − endpoint: string; | |
| 125 | + kind: "openapi" | "graphql" | "google_discovery"; | |
| 126 | + endpoint?: string; | |
| 132 | 127 | specUrl?: string; |
| 128 | + service?: string; | |
| 129 | + version?: string; | |
| 130 | + discoveryUrl?: string | null; | |
| 133 | 131 | name?: string | null; |
| 134 | 132 | namespace?: string | null; |
| 135 | 133 | }; |
| @@ -233,6 +231,14 @@ export const createExecutorToolMap = (input: { · 97 unmodified lines | ||
| 233 | 231 | endpoint: string; |
| 234 | 232 | name?: string | null; |
| 235 | 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 | 243 | context, |
| 238 | 244 | ): Promise<Source> => { |
| @@ -241,27 +247,19 @@ export const createExecutorToolMap = (input: { · 2 unmodified lines | ||
| 241 | 247 | `executor.sources.add:${crypto.randomUUID()}`, |
| 242 | 248 | ); |
| 243 | 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 | 256 | workspaceId: input.workspaceId, |
| 248 | 257 | actorAccountId: input.accountId, |
| 249 | 258 | executionId, |
| 250 | 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 | − endpoint: args.endpoint, | |
| 263 | − name: args.name ?? null, | |
| 264 | − namespace: args.namespace ?? null, | |
| 262 | + ...args, | |
| 265 | 263 | workspaceId: input.workspaceId, |
| 266 | 264 | actorAccountId: input.accountId, |
| 267 | 265 | executionId, |
| @@ -293,7 +291,7 @@ export const createExecutorToolMap = (input: { · 25 unmodified lines | ||
| 293 | 291 | let pendingResult = result; |
| 294 | 292 | let pendingArgs = preparedArgs as Extract< |
| 295 | 293 | ExecutorAddSourceInput, |
| 296 | − { kind: "openapi" | "graphql" } | |
| 294 | + { kind: "openapi" | "graphql" | "google_discovery" } | |
| 297 | 295 | >; |
| 298 | 296 | |
| 299 | 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 | 1 | import { |
| 2 | + applyCookiePlacementsToHeaders, | |
| 3 | + applyHttpQueryPlacementsToUrl, | |
| 4 | + applyJsonBodyPlacements, | |
| 5 | + sha256Hex, | |
| 4 | 6 | standardSchemaFromJsonSchema, |
| 5 | 7 | toTool, |
| 8 | + type HttpRequestPlacements, | |
| 6 | 9 | type ToolDescriptor, |
| 7 | 10 | type ToolMap, |
| 8 | 11 | type ToolMetadata, |
| @@ -137,6 +140,7 @@ type GraphqlHttpInvocation = { · 128 unmodified lines | ||
| 137 | 140 | path: string; |
| 138 | 141 | defaultHeaders?: Readonly<Record<string, string>>; |
| 139 | 142 | credentialHeaders?: Readonly<Record<string, string>>; |
| 143 | + credentialPlacements?: HttpRequestPlacements; | |
| 140 | 144 | query: string; |
| 141 | 145 | variables?: Record<string, unknown>; |
| 142 | 146 | operationName?: string; |
| @@ -998,7 +1002,7 @@ const resolveGraphqlFieldToolIds = ( · 855 unmodified lines | ||
| 998 | 1002 | ); |
| 999 | 1003 | applyDuplicates( |
| 1000 | 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 | 1008 | return staged |
| @@ -1071,7 +1075,7 @@ const fieldToolDraftsFromRootType = (input: { · 66 unmodified lines | ||
| 1071 | 1075 | }; |
| 1072 | 1076 | |
| 1073 | 1077 | export const createGraphqlSourceHash = (documentText: string): string => |
| 1074 | − createHash("sha256").update(documentText).digest("hex"); | |
| 1078 | + sha256Hex(documentText); | |
| 1075 | 1079 | |
| 1076 | 1080 | export const extractGraphqlManifest = ( |
| 1077 | 1081 | sourceName: string, |
| @@ -1272,6 +1276,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 194 unmodified lines | ||
| 1272 | 1276 | schemaRefTable?: Readonly<Record<string, unknown>>; |
| 1273 | 1277 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1274 | 1278 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1279 | + credentialPlacements?: HttpRequestPlacements; | |
| 1275 | 1280 | }) => { |
| 1276 | 1281 | const decodedProviderData = decodeGraphqlToolProviderDataJson( |
| 1277 | 1282 | input.providerDataJson, |
| @@ -1340,6 +1345,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 62 unmodified lines | ||
| 1340 | 1345 | path: input.path, |
| 1341 | 1346 | defaultHeaders: input.defaultHeaders, |
| 1342 | 1347 | credentialHeaders: input.credentialHeaders, |
| 1348 | + credentialPlacements: input.credentialPlacements, | |
| 1343 | 1349 | args, |
| 1344 | 1350 | }), |
| 1345 | 1351 | ); |
| @@ -1351,6 +1357,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 5 unmodified lines | ||
| 1351 | 1357 | path: input.path, |
| 1352 | 1358 | defaultHeaders: input.defaultHeaders, |
| 1353 | 1359 | credentialHeaders: input.credentialHeaders, |
| 1360 | + credentialPlacements: input.credentialPlacements, | |
| 1354 | 1361 | args, |
| 1355 | 1362 | }), |
| 1356 | 1363 | ); |
| @@ -1396,25 +1403,40 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 39 unmodified lines | ||
| 1396 | 1403 | |
| 1397 | 1404 | const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) => |
| 1398 | 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 | 1434 | const response = yield* Effect.tryPromise({ |
| 1400 | 1435 | try: () => |
| 1401 | − fetch(input.endpoint, { | |
| 1436 | + fetch(endpoint, { | |
| 1402 | 1437 | method: "POST", |
| 1403 | − headers: new Headers({ | |
| 1404 | − "content-type": "application/json", | |
| 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 | − }), | |
| 1438 | + headers: new Headers(headers), | |
| 1439 | + body: JSON.stringify(requestBody), | |
| 1418 | 1440 | }), |
| 1419 | 1441 | catch: (cause) => |
| 1420 | 1442 | graphqlToolError(`GraphQL request failed for ${input.path}`, cause), |
| @@ -1446,6 +1468,7 @@ const invokeRawGraphqlTool = (input: { · 25 unmodified lines | ||
| 1446 | 1468 | path: string; |
| 1447 | 1469 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1448 | 1470 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1471 | + credentialPlacements?: HttpRequestPlacements; | |
| 1449 | 1472 | args: unknown; |
| 1450 | 1473 | }) => |
| 1451 | 1474 | Effect.gen(function* () { |
| @@ -1462,6 +1485,7 @@ const invokeRawGraphqlTool = (input: { · 10 unmodified lines | ||
| 1462 | 1485 | path: input.path, |
| 1463 | 1486 | defaultHeaders: input.defaultHeaders, |
| 1464 | 1487 | credentialHeaders: input.credentialHeaders, |
| 1488 | + credentialPlacements: input.credentialPlacements, | |
| 1465 | 1489 | query, |
| 1466 | 1490 | variables: |
| 1467 | 1491 | record.variables !== undefined ? asRecord(record.variables) : undefined, |
| @@ -1486,6 +1510,7 @@ const invokeGraphqlFieldTool = (input: { · 18 unmodified lines | ||
| 1486 | 1510 | path: string; |
| 1487 | 1511 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1488 | 1512 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1513 | + credentialPlacements?: HttpRequestPlacements; | |
| 1489 | 1514 | args: unknown; |
| 1490 | 1515 | }) => |
| 1491 | 1516 | Effect.gen(function* () { |
| @@ -1501,6 +1526,7 @@ const invokeGraphqlFieldTool = (input: { · 9 unmodified lines | ||
| 1501 | 1526 | path: input.path, |
| 1502 | 1527 | defaultHeaders: input.defaultHeaders, |
| 1503 | 1528 | credentialHeaders: input.credentialHeaders, |
| 1529 | + credentialPlacements: input.credentialPlacements, | |
| 1504 | 1530 | query: input.entry.operationDocument, |
| 1505 | 1531 | variables, |
| 1506 | 1532 | operationName: input.entry.operationName, |
| @@ -1524,6 +1550,7 @@ export const createGraphqlToolsFromManifest = (input: { · 17 unmodified lines | ||
| 1524 | 1550 | sourceKey: string; |
| 1525 | 1551 | defaultHeaders?: Readonly<Record<string, string>>; |
| 1526 | 1552 | credentialHeaders?: Readonly<Record<string, string>>; |
| 1553 | + credentialPlacements?: HttpRequestPlacements; | |
| 1527 | 1554 | }): ToolMap => { |
| 1528 | 1555 | const endpoint = normalizeHttpUrl(input.endpoint); |
| 1529 | 1556 | const definitions = compileGraphqlToolDefinitions(input.manifest); |
| @@ -1552,6 +1579,7 @@ export const createGraphqlToolsFromManifest = (input: { · 22 unmodified lines | ||
| 1552 | 1579 | schemaRefTable: input.manifest.schemaRefTable, |
| 1553 | 1580 | defaultHeaders: input.defaultHeaders, |
| 1554 | 1581 | credentialHeaders: input.credentialHeaders, |
| 1582 | + credentialPlacements: input.credentialPlacements, | |
| 1555 | 1583 | }), |
| 1556 | 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 | 24 | export const mapPersistenceError = <A>( |
| 25 | 25 | operation: OperationErrorsLike, |
| 26 | − effect: Effect.Effect<A, ControlPlanePersistenceError>, | |
| 26 | + effect: Effect.Effect<A, ControlPlanePersistenceError | Error>, | |
| 27 | 27 | ): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> => |
| 28 | 28 | effect.pipe( |
| 29 | 29 | Effect.mapError((error) => { |
| 30 | 30 | const errors = asOperationErrors(operation); |
| 31 | − return isUniqueViolation(error) | |
| 32 | − ? errors.badRequest("Unique constraint violation", error.details ?? "duplicate key") | |
| 33 | − : errors.storage(error); | |
| 31 | + if (error instanceof ControlPlanePersistenceError) { | |
| 32 | + return isUniqueViolation(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 | 1 | import { |
| 2 | + applyCookiePlacementsToHeaders, | |
| 3 | + applyHttpQueryPlacementsToUrl, | |
| 4 | + applyJsonBodyPlacements, | |
| 2 | 5 | makeToolInvokerFromTools, |
| 3 | 6 | typeSignatureFromSchemaJson, |
| 4 | 7 | } from "@executor/codemode-core"; |
| @@ -135,20 +138,38 @@ const graphqlBindingConfigFromSource = ( · 130 unmodified lines | ||
| 135 | 138 | const fetchGraphqlIntrospectionDocumentWithHeaders = (input: { |
| 136 | 139 | url: string; |
| 137 | 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 | 144 | }): Effect.Effect<string, Error, never> => |
| 139 | 145 | Effect.tryPromise({ |
| 140 | 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 | 153 | method: "POST", |
| 143 | − headers: { | |
| 144 | − "content-type": "application/json", | |
| 145 | − ...(input.headers ?? {}), | |
| 154 | + headers: applyCookiePlacementsToHeaders({ | |
| 155 | + 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({ | |
| 148 | − query: GRAPHQL_INTROSPECTION_QUERY, | |
| 149 | − operationName: "IntrospectionQuery", | |
| 150 | − }), | |
| 151 | − }); | |
| 172 | + ); | |
| 152 | 173 | const text = await response.text(); |
| 153 | 174 | |
| 154 | 175 | let parsed: unknown; |
| @@ -470,6 +491,9 @@ export const graphqlSourceAdapter: SourceAdapter = { · 315 unmodified lines | ||
| 470 | 491 | ...(bindingConfig.defaultHeaders ?? {}), |
| 471 | 492 | ...auth.headers, |
| 472 | 493 | }, |
| 494 | + queryParams: auth.queryParams, | |
| 495 | + cookies: auth.cookies, | |
| 496 | + bodyValues: auth.bodyValues, | |
| 473 | 497 | }, |
| 474 | 498 | ).pipe( |
| 475 | 499 | Effect.mapError( |
| @@ -564,7 +588,7 @@ export const graphqlSourceAdapter: SourceAdapter = { · 88 unmodified lines | ||
| 564 | 588 | providerDataJson: operation.providerDataJson, |
| 565 | 589 | schemaRefTable: schemaRefTable.right, |
| 566 | 590 | defaultHeaders: bindingConfig.defaultHeaders ?? {}, |
| 567 | − credentialHeaders: auth.headers, | |
| 591 | + credentialPlacements: auth, | |
| 568 | 592 | }); |
| 569 | 593 | |
| 570 | 594 | return yield* makeToolInvokerFromTools({ |
| @@ -2,6 +2,7 @@ import type { Source, StoredSourceRecipeOperationRecord } from "#schema"; · 1 unmodified line | ||
| 2 | 2 | import * as Schema from "effect/Schema"; |
| 3 | 3 | |
| 4 | 4 | import { graphqlSourceAdapter } from "./graphql"; |
| 5 | +import { googleDiscoverySourceAdapter } from "./google-discovery"; | |
| 5 | 6 | import { internalSourceAdapter } from "./internal"; |
| 6 | 7 | import { mcpSourceAdapter } from "./mcp"; |
| 7 | 8 | import { openApiSourceAdapter } from "./openapi"; |
| @@ -10,6 +11,7 @@ import type { SourceAdapter } from "./types"; · 2 unmodified lines | ||
| 10 | 11 | export const builtInSourceAdapters = [ |
| 11 | 12 | openApiSourceAdapter, |
| 12 | 13 | graphqlSourceAdapter, |
| 14 | + googleDiscoverySourceAdapter, | |
| 13 | 15 | mcpSourceAdapter, |
| 14 | 16 | internalSourceAdapter, |
| 15 | 17 | ] as const satisfies readonly SourceAdapter[]; |
| @@ -18,12 +20,14 @@ export const connectableSourceAdapters = [ · 2 unmodified lines | ||
| 18 | 20 | mcpSourceAdapter, |
| 19 | 21 | openApiSourceAdapter, |
| 20 | 22 | graphqlSourceAdapter, |
| 23 | + googleDiscoverySourceAdapter, | |
| 21 | 24 | ] as const; |
| 22 | 25 | |
| 23 | 26 | export const ConnectSourcePayloadSchema = Schema.Union( |
| 24 | 27 | mcpSourceAdapter.connectPayloadSchema!, |
| 25 | 28 | openApiSourceAdapter.connectPayloadSchema!, |
| 26 | 29 | graphqlSourceAdapter.connectPayloadSchema!, |
| 30 | + googleDiscoverySourceAdapter.connectPayloadSchema!, | |
| 27 | 31 | ); |
| 28 | 32 | |
| 29 | 33 | export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type; |
| @@ -32,12 +36,14 @@ export const executorAddableSourceAdapters = [ · 2 unmodified lines | ||
| 32 | 36 | mcpSourceAdapter, |
| 33 | 37 | openApiSourceAdapter, |
| 34 | 38 | graphqlSourceAdapter, |
| 39 | + googleDiscoverySourceAdapter, | |
| 35 | 40 | ] as const; |
| 36 | 41 | |
| 37 | 42 | export const ExecutorAddSourceInputSchema = Schema.Union( |
| 38 | 43 | mcpSourceAdapter.executorAddInputSchema!, |
| 39 | 44 | openApiSourceAdapter.executorAddInputSchema!, |
| 40 | 45 | graphqlSourceAdapter.executorAddInputSchema!, |
| 46 | + googleDiscoverySourceAdapter.executorAddInputSchema!, | |
| 41 | 47 | ); |
| 42 | 48 | |
| 43 | 49 | export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type; |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { |
| 2 | + applyCookiePlacementsToHeaders, | |
| 2 | 3 | makeToolInvokerFromTools, |
| 3 | 4 | } from "@executor/codemode-core"; |
| 4 | 5 | import { |
| @@ -41,6 +42,19 @@ import { · 36 unmodified lines | ||
| 41 | 42 | SourceConnectCommonFieldsSchema, |
| 42 | 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 | 58 | const McpConnectPayloadSchema = Schema.extend( |
| 45 | 59 | SourceConnectCommonFieldsSchema, |
| 46 | 60 | Schema.extend( |
| @@ -339,11 +353,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 292 unmodified lines | ||
| 339 | 353 | createSdkMcpConnector({ |
| 340 | 354 | endpoint: source.endpoint, |
| 341 | 355 | transport: bindingConfig.transport ?? undefined, |
| 342 | − queryParams: bindingConfig.queryParams ?? undefined, | |
| 343 | − headers: { | |
| 344 | − ...(bindingConfig.headers ?? {}), | |
| 345 | − ...auth.headers, | |
| 356 | + queryParams: { | |
| 357 | + ...(bindingConfig.queryParams ?? {}), | |
| 358 | + ...auth.queryParams, | |
| 346 | 359 | }, |
| 360 | + headers: headersWithAuthCookies({ | |
| 361 | + headers: bindingConfig.headers ?? {}, | |
| 362 | + authHeaders: auth.headers, | |
| 363 | + authCookies: auth.cookies, | |
| 364 | + }), | |
| 347 | 365 | }), |
| 348 | 366 | catch: (cause) => |
| 349 | 367 | cause instanceof Error ? cause : new Error(String(cause)), |
| @@ -394,11 +412,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 44 unmodified lines | ||
| 394 | 412 | connect: createSdkMcpConnector({ |
| 395 | 413 | endpoint: source.endpoint, |
| 396 | 414 | transport: bindingConfig.transport ?? undefined, |
| 397 | − queryParams: bindingConfig.queryParams ?? undefined, | |
| 398 | − headers: { | |
| 399 | − ...(bindingConfig.headers ?? {}), | |
| 400 | − ...auth.headers, | |
| 415 | + queryParams: { | |
| 416 | + ...(bindingConfig.queryParams ?? {}), | |
| 417 | + ...auth.queryParams, | |
| 401 | 418 | }, |
| 419 | + headers: headersWithAuthCookies({ | |
| 420 | + headers: bindingConfig.headers ?? {}, | |
| 421 | + authHeaders: auth.headers, | |
| 422 | + authCookies: auth.cookies, | |
| 423 | + }), | |
| 402 | 424 | }), |
| 403 | 425 | namespace: source.namespace ?? namespaceFromSourceName(source.name), |
| 404 | 426 | sourceKey: source.id, |
| @@ -5,6 +5,8 @@ import { · 4 unmodified lines | ||
| 5 | 5 | } from "@effect/platform"; |
| 6 | 6 | import type { SqlControlPlaneRows } from "#persistence"; |
| 7 | 7 | import { |
| 8 | + applyCookiePlacementsToHeaders, | |
| 9 | + applyHttpQueryPlacementsToUrl, | |
| 8 | 10 | makeToolInvokerFromTools, |
| 9 | 11 | } from "@executor/codemode-core"; |
| 10 | 12 | import { |
| @@ -152,11 +154,23 @@ const openApiBindingConfigFromSource = ( · 141 unmodified lines | ||
| 152 | 154 | const fetchOpenApiDocumentWithHeaders = (input: { |
| 153 | 155 | url: string; |
| 154 | 156 | headers?: Readonly<Record<string, string>>; |
| 157 | + queryParams?: Readonly<Record<string, string>>; | |
| 158 | + cookies?: Readonly<Record<string, string>>; | |
| 155 | 159 | }): Effect.Effect<string, Error, never> => |
| 156 | 160 | Effect.gen(function* () { |
| 157 | 161 | const client = yield* HttpClient.HttpClient; |
| 158 | − const request = HttpClientRequest.get(input.url).pipe( | |
| 159 | − HttpClientRequest.setHeaders(input.headers ?? {}), | |
| 162 | + const request = HttpClientRequest.get( | |
| 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 | 175 | const response = yield* client.execute(request).pipe( |
| 162 | 176 | Effect.mapError((cause) => |
| @@ -522,6 +536,8 @@ export const openApiSourceAdapter: SourceAdapter = { · 359 unmodified lines | ||
| 522 | 536 | ...(bindingConfig.defaultHeaders ?? {}), |
| 523 | 537 | ...auth.headers, |
| 524 | 538 | }, |
| 539 | + queryParams: auth.queryParams, | |
| 540 | + cookies: auth.cookies, | |
| 525 | 541 | }).pipe( |
| 526 | 542 | Effect.mapError( |
| 527 | 543 | (cause) => |
| @@ -609,7 +625,7 @@ export const openApiSourceAdapter: SourceAdapter = { · 81 unmodified lines | ||
| 609 | 625 | sourceKey: source.id, |
| 610 | 626 | baseUrl: source.endpoint, |
| 611 | 627 | defaultHeaders: bindingConfig.defaultHeaders ?? {}, |
| 612 | − credentialHeaders: auth.headers, | |
| 628 | + credentialPlacements: auth, | |
| 613 | 629 | refHintTable: refHintTable.right, |
| 614 | 630 | }); |
| 615 | 631 | |
| @@ -7,6 +7,7 @@ import type { SqlControlPlaneRows } from "#persistence"; · 6 unmodified lines | ||
| 7 | 7 | import type { |
| 8 | 8 | AccountId, |
| 9 | 9 | CredentialSlot, |
| 10 | + OAuth2ClientAuthenticationMethod, | |
| 10 | 11 | Source, |
| 11 | 12 | SourceBinding, |
| 12 | 13 | SourceImportAuthPolicy, |
| @@ -68,6 +69,24 @@ export type SourceAdapterPersistedOperationMetadata = { · 55 unmodified lines | ||
| 68 | 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 | 90 | export type SourceAdapterRepairRevisionInput = { |
| 72 | 91 | rows: SqlControlPlaneRows; |
| 73 | 92 | sourceRecord: StoredSourceRecord; |
| @@ -137,6 +156,13 @@ export type SourceAdapter = { · 63 unmodified lines | ||
| 137 | 156 | materializeSource: ( |
| 138 | 157 | input: SourceAdapterSyncInput, |
| 139 | 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 | 166 | invokePersistedTool: ( |
| 141 | 167 | input: SourceAdapterInvokePersistedToolInput, |
| 142 | 168 | ) => Effect.Effect<unknown, Error, never>; |
| @@ -43,7 +43,15 @@ describe("source-auth-material", () => { · 42 unmodified lines | ||
| 43 | 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 | 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 | 78 | expect(calls).toEqual(["postgres:sec_bearer"]); |
| 71 | 79 | expect(auth).toEqual({ |
| 80 | + placements: [ | |
| 81 | + { | |
| 82 | + location: "header", | |
| 83 | + name: "X-Api-Key", | |
| 84 | + value: "Token resolved-bearer-token", | |
| 85 | + }, | |
| 86 | + ], | |
| 72 | 87 | headers: { |
| 73 | 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 | 128 | expect(calls).toEqual(["postgres:sec_access"]); |
| 109 | 129 | expect(auth).toEqual({ |
| 130 | + placements: [ | |
| 131 | + { | |
| 132 | + location: "header", | |
| 133 | + name: "Authorization", | |
| 134 | + value: "Bearer resolved-access-token", | |
| 135 | + }, | |
| 136 | + ], | |
| 110 | 137 | headers: { |
| 111 | 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 | 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 | 17 | import type { |
| 5 | 18 | ResolveSecretMaterial, |
| 6 | 19 | SecretMaterialResolveContext, |
| 7 | 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 | 22 | const authForSlot = (input: { |
| 41 | 23 | source: Source; |
| 42 | 24 | slot: CredentialSlot; |
| @@ -59,14 +41,69 @@ const authForSlot = (input: { · 16 unmodified lines | ||
| 59 | 41 | export const resolveSourceAuthMaterial = (input: { |
| 60 | 42 | source: Source; |
| 61 | 43 | slot?: CredentialSlot; |
| 44 | + actorAccountId?: AccountId | null; | |
| 45 | + rows?: SqlControlPlaneRows; | |
| 62 | 46 | resolveSecretMaterial: ResolveSecretMaterial; |
| 63 | 47 | context?: SecretMaterialResolveContext; |
| 64 | 48 | }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> => |
| 65 | − resolveAuthMaterial({ | |
| 66 | − auth: authForSlot({ | |
| 49 | + Effect.gen(function* () { | |
| 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 | 78 | source: input.source, |
| 68 | − slot: input.slot ?? "runtime", | |
| 69 | − }), | |
| 70 | − resolveSecretMaterial: input.resolveSecretMaterial, | |
| 71 | − context: input.context, | |
| 79 | + auth: authForSlot({ | |
| 80 | + source: input.source, | |
| 81 | + slot, | |
| 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 | 3 | import { describe, expect, it } from "vitest"; |
| 2 | 4 | import * as Schema from "effect/Schema"; |
| 5 | +import * as Effect from "effect/Effect"; | |
| 6 | +import * as Option from "effect/Option"; | |
| 3 | 7 | |
| 4 | 8 | import { |
| 9 | + AccountIdSchema, | |
| 5 | 10 | McpSourceAuthSessionDataJsonSchema, |
| 11 | + OAuth2PkceSourceAuthSessionDataJsonSchema, | |
| 12 | + SecretMaterialIdSchema, | |
| 6 | 13 | type Source, |
| 14 | + type SourceAuthSession, | |
| 15 | + WorkspaceIdSchema, | |
| 16 | + decodeAuthLeasePlacementTemplates, | |
| 17 | + decodeBuiltInAuthArtifactConfig, | |
| 7 | 18 | } from "#schema"; |
| 19 | +import { | |
| 20 | + createSqlControlPlanePersistence, | |
| 21 | + type SqlControlPlanePersistence, | |
| 22 | +} from "#persistence"; | |
| 8 | 23 | |
| 9 | 24 | import { |
| 25 | + createRuntimeSourceAuthService, | |
| 10 | 26 | createTerminalSourceAuthSessionPatch, |
| 11 | 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 | 32 | const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ |
| 14 | 33 | id: "src_test" as Source["id"], |
| @@ -33,6 +52,117 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 18 unmodified lines | ||
| 33 | 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 | 166 | describe("source-auth-service", () => { |
| 37 | 167 | const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema); |
| 38 | 168 | |
| @@ -89,4 +219,391 @@ describe("source-auth-service", () => { · 50 unmodified lines | ||
| 89 | 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 | 13 | ExecutionIdSchema, |
| 14 | 14 | McpSourceAuthSessionDataJsonSchema, |
| 15 | 15 | type McpSourceAuthSessionData, |
| 16 | + OAuth2PkceSourceAuthSessionDataJsonSchema, | |
| 17 | + type OAuth2PkceSourceAuthSessionData, | |
| 16 | 18 | type SecretMaterialPurpose, |
| 17 | 19 | Source, |
| 18 | 20 | type SourceImportAuthPolicy, |
| @@ -23,6 +25,9 @@ import { · 4 unmodified lines | ||
| 23 | 25 | SourceSchema, |
| 24 | 26 | type SecretRef, |
| 25 | 27 | type StringMap, |
| 28 | + WorkspaceSourceOauthClientIdSchema, | |
| 29 | + WorkspaceSourceOauthClientMetadataJsonSchema, | |
| 30 | + type WorkspaceSourceOauthClientRedirectMode, | |
| 26 | 31 | type WorkspaceId, |
| 27 | 32 | } from "#schema"; |
| 28 | 33 | import * as Context from "effect/Context"; |
| @@ -46,6 +51,7 @@ import { · 17 unmodified lines | ||
| 46 | 51 | updateSourceFromPayload, |
| 47 | 52 | } from "./source-definitions"; |
| 48 | 53 | import { |
| 54 | + getSourceAdapterForSource, | |
| 49 | 55 | hasSourceAdapterFamily, |
| 50 | 56 | sourceBindingStateFromSource, |
| 51 | 57 | } from "./source-adapters"; |
| @@ -56,10 +62,17 @@ import { · 4 unmodified lines | ||
| 56 | 62 | type ResolveSecretMaterial, |
| 57 | 63 | type StoreSecretMaterial, |
| 58 | 64 | } from "./secret-material-providers"; |
| 65 | +import { upsertOauth2AuthorizedUserLeaseFromTokenResponse } from "./auth-leases"; | |
| 59 | 66 | import { |
| 60 | 67 | persistMcpRecipeMaterializationFromManifest, |
| 61 | 68 | syncSourceMaterialization, |
| 62 | 69 | } from "./source-materialization"; |
| 70 | +import { | |
| 71 | + buildOAuth2AuthorizationUrl, | |
| 72 | + createPkceCodeVerifier, | |
| 73 | + exchangeOAuth2AuthorizationCode, | |
| 74 | +} from "./oauth2-pkce"; | |
| 75 | +import { startOauthLoopbackRedirectServer } from "./oauth-loopback"; | |
| 63 | 76 | import { |
| 64 | 77 | loadSourceById, |
| 65 | 78 | loadSourcesInWorkspace, |
| @@ -113,6 +126,22 @@ const normalizeEndpoint = (endpoint: string): string => { · 47 unmodified lines | ||
| 113 | 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 | 145 | const SourceOAuthSessionStatePayloadSchema = Schema.Struct({ |
| 117 | 146 | kind: Schema.Literal("source_oauth"), |
| 118 | 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 | 297 | const completeLiveInteraction = (input: { |
| 233 | 298 | liveExecutionManager: LiveExecutionManager; |
| @@ -361,6 +426,22 @@ export type ExecutorAddSourceInput = · 127 unmodified lines | ||
| 361 | 426 | importAuthPolicy?: SourceImportAuthPolicy | null; |
| 362 | 427 | importAuth?: ExecutorHttpSourceAuthInput | null; |
| 363 | 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 | 447 | export type ConnectMcpSourceInput = { |
| @@ -525,7 +606,7 @@ const materializeExecutorHttpAuth = (input: { · 158 unmodified lines | ||
| 525 | 606 | }); |
| 526 | 607 | |
| 527 | 608 | const materializeExecutorHttpImportAuth = (input: { |
| 528 | − sourceKind: "openapi" | "graphql"; | |
| 609 | + sourceKind: "openapi" | "graphql" | "google_discovery"; | |
| 529 | 610 | existing?: Source; |
| 530 | 611 | importAuthPolicy?: SourceImportAuthPolicy | null; |
| 531 | 612 | importAuth?: ExecutorHttpSourceAuthInput | null; |
| @@ -535,7 +616,10 @@ const materializeExecutorHttpImportAuth = (input: { · 3 unmodified lines | ||
| 535 | 616 | importAuth: Source["importAuth"]; |
| 536 | 617 | }, Error, never> => |
| 537 | 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 | 623 | ? "reuse_runtime" |
| 540 | 624 | : "none"; |
| 541 | 625 | const importAuthPolicy = input.importAuthPolicy ?? input.existing?.importAuthPolicy ?? adapterDefault; |
| @@ -579,6 +663,234 @@ const shouldPromptForExecutorHttpRuntimeCredentialSetup = (input: { · 37 unmodified lines | ||
| 579 | 663 | && input.auth.kind === "none" |
| 580 | 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 | 894 | const connectMcpSourceInternal = (input: { |
| 583 | 895 | rows: SqlControlPlaneRows; |
| 584 | 896 | getLocalServerBaseUrl?: () => string | undefined; |
| @@ -692,6 +1004,7 @@ const connectMcpSourceInternal = (input: { · 107 unmodified lines | ||
| 692 | 1004 | yield* syncSourceMaterialization({ |
| 693 | 1005 | rows: input.rows, |
| 694 | 1006 | source: persistedDraft, |
| 1007 | + actorAccountId: input.actorAccountId, | |
| 695 | 1008 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 696 | 1009 | }); |
| 697 | 1010 | |
| @@ -812,6 +1125,8 @@ const addExecutorHttpSource = (input: { · 114 unmodified lines | ||
| 812 | 1125 | sourceInput: Extract<ExecutorAddSourceInput, { kind: "openapi" | "graphql" }>; |
| 813 | 1126 | storeSecretMaterial: StoreSecretMaterial; |
| 814 | 1127 | resolveSecretMaterial: ResolveSecretMaterial; |
| 1128 | + getLocalServerBaseUrl?: () => string | undefined; | |
| 1129 | + baseUrl?: string | null; | |
| 815 | 1130 | }): Effect.Effect<ExecutorSourceAddResult, Error, never> => |
| 816 | 1131 | Effect.gen(function* () { |
| 817 | 1132 | const normalizedEndpoint = normalizeEndpoint(input.sourceInput.endpoint); |
| @@ -926,6 +1241,24 @@ const addExecutorHttpSource = (input: { · 108 unmodified lines | ||
| 926 | 1241 | explicitAuthProvided: input.sourceInput.auth !== undefined, |
| 927 | 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 | 1262 | const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, { |
| 930 | 1263 | actorAccountId: input.sourceInput.actorAccountId, |
| 931 | 1264 | status: "auth_required", |
| @@ -946,6 +1279,218 @@ const addExecutorHttpSource = (input: { · 14 unmodified lines | ||
| 946 | 1279 | ...persistedDraft, |
| 947 | 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 | 1494 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 950 | 1495 | }), |
| 951 | 1496 | ); |
| @@ -1058,8 +1603,17 @@ export const createRuntimeSourceAuthService = (input: { · 106 unmodified lines | ||
| 1058 | 1603 | actorAccountId, |
| 1059 | 1604 | }), |
| 1060 | 1605 | |
| 1061 | − addExecutorSource: (sourceInput, options) => | |
| 1062 | − hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api") | |
| 1606 | + addExecutorSource: (sourceInput, options = undefined) => | |
| 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 | 1617 | ? addExecutorHttpSource({ |
| 1064 | 1618 | rows: input.rows, |
| 1065 | 1619 | sourceInput: sourceInput as Extract< |
| @@ -1068,6 +1622,8 @@ export const createRuntimeSourceAuthService = (input: { · 2 unmodified lines | ||
| 1068 | 1622 | >, |
| 1069 | 1623 | storeSecretMaterial, |
| 1070 | 1624 | resolveSecretMaterial, |
| 1625 | + getLocalServerBaseUrl: input.getLocalServerBaseUrl, | |
| 1626 | + baseUrl: options?.baseUrl, | |
| 1071 | 1627 | }) |
| 1072 | 1628 | : connectMcpSourceInternal({ |
| 1073 | 1629 | rows: input.rows, |
| @@ -1297,7 +1853,6 @@ export const createRuntimeSourceAuthService = (input: { · 223 unmodified lines | ||
| 1297 | 1853 | } |
| 1298 | 1854 | |
| 1299 | 1855 | const session = sessionOption.value; |
| 1300 | − const sessionData = decodeMcpSourceAuthSessionData(session); | |
| 1301 | 1856 | if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) { |
| 1302 | 1857 | return yield* Effect.fail( |
| 1303 | 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 | 1892 | if (trimOrNull(error) !== null) { |
| 1334 | 1893 | const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed"; |
| 1335 | 1894 | const failedAt = Date.now(); |
| @@ -1351,6 +1910,7 @@ export const createRuntimeSourceAuthService = (input: { · 15 unmodified lines | ||
| 1351 | 1910 | yield* syncSourceMaterialization({ |
| 1352 | 1911 | rows: input.rows, |
| 1353 | 1912 | source: failedSource, |
| 1913 | + actorAccountId: session.actorAccountId, | |
| 1354 | 1914 | resolveSecretMaterial, |
| 1355 | 1915 | }); |
| 1356 | 1916 | yield* completeLiveInteraction({ |
| @@ -1374,54 +1934,157 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines | ||
| 1374 | 1934 | return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier")); |
| 1375 | 1935 | } |
| 1376 | 1936 | |
| 1377 | − const exchanged = yield* exchangeMcpOAuthAuthorizationCode({ | |
| 1378 | − session: { | |
| 1379 | − endpoint: sessionData.endpoint, | |
| 1380 | − redirectUrl: sessionData.redirectUri, | |
| 1381 | − codeVerifier: sessionData.codeVerifier, | |
| 1382 | − resourceMetadataUrl: sessionData.resourceMetadataUrl, | |
| 1383 | − authorizationServerUrl: sessionData.authorizationServerUrl, | |
| 1384 | − resourceMetadata: sessionData.resourceMetadata, | |
| 1385 | − authorizationServerMetadata: sessionData.authorizationServerMetadata, | |
| 1386 | − clientInformation: sessionData.clientInformation, | |
| 1387 | − }, | |
| 1388 | − code: authorizationCode, | |
| 1389 | − }); | |
| 1937 | + const now = Date.now(); | |
| 1938 | + let connectedSource: Source; | |
| 1939 | + if (session.providerKind === "oauth2_pkce") { | |
| 1940 | + const oauthSessionData = decodeOauth2PkceSourceAuthSessionData(session); | |
| 1941 | + let clientSecret: string | null = null; | |
| 1942 | + if (oauthSessionData.clientSecret) { | |
| 1943 | + clientSecret = yield* resolveSecretMaterial({ | |
| 1944 | + ref: oauthSessionData.clientSecret, | |
| 1945 | + }); | |
| 1946 | + } | |
| 1947 | + | |
| 1948 | + const exchanged = yield* exchangeOAuth2AuthorizationCode({ | |
| 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({ | |
| 1392 | − displayName: source.name, | |
| 1393 | − endpoint: source.endpoint, | |
| 1394 | − }); | |
| 1395 | − const accessTokenRef = yield* storeSecretMaterial({ | |
| 1396 | − purpose: "oauth_access_token", | |
| 1397 | − value: exchanged.tokens.access_token, | |
| 1398 | − name: oauthSecretName, | |
| 1399 | − }); | |
| 1400 | − const refreshTokenRef = exchanged.tokens.refresh_token | |
| 1401 | − ? yield* storeSecretMaterial({ | |
| 1402 | − purpose: "oauth_refresh_token", | |
| 1403 | − value: exchanged.tokens.refresh_token, | |
| 1404 | − name: `${oauthSecretName} Refresh`, | |
| 1405 | − }) | |
| 1406 | − : null; | |
| 1964 | + const refreshTokenRef = yield* storeSecretMaterial({ | |
| 1965 | + purpose: "oauth_refresh_token", | |
| 1966 | + value: refreshToken, | |
| 1967 | + name: `${source.name} Refresh`, | |
| 1968 | + }); | |
| 1969 | + const grantedScopes = trimOrNull(exchanged.scope) | |
| 1970 | + ? exchanged.scope!.split(/\s+/).filter((scope) => scope.length > 0) | |
| 1971 | + : [...oauthSessionData.scopes]; | |
| 1972 | + connectedSource = yield* updateSourceStatus(input.rows, source, { | |
| 1973 | + actorAccountId: session.actorAccountId, | |
| 1974 | + status: "connected", | |
| 1975 | + lastError: null, | |
| 1976 | + auth: { | |
| 1977 | + kind: "oauth2_authorized_user", | |
| 1978 | + headerName: oauthSessionData.headerName, | |
| 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(); | |
| 1409 | − const connectedSource = yield* updateSourceStatus(input.rows, source, { | |
| 1410 | − actorAccountId: session.actorAccountId, | |
| 1411 | − status: "connected", | |
| 1412 | − lastError: null, | |
| 1413 | − auth: { | |
| 1414 | − kind: "oauth2", | |
| 1415 | − headerName: "Authorization", | |
| 1416 | − prefix: "Bearer ", | |
| 1417 | − accessToken: accessTokenRef, | |
| 1418 | − refreshToken: refreshTokenRef, | |
| 1419 | − }, | |
| 1420 | − }); | |
| 2002 | + yield* input.rows.sourceAuthSessions.update( | |
| 2003 | + session.id, | |
| 2004 | + createTerminalSourceAuthSessionPatch({ | |
| 2005 | + sessionDataJson: mergeOauth2PkceSourceAuthSessionData({ | |
| 2006 | + session, | |
| 2007 | + patch: { | |
| 2008 | + codeVerifier: null, | |
| 2009 | + authorizationUrl: null, | |
| 2010 | + }, | |
| 2011 | + }), | |
| 2012 | + status: "completed", | |
| 2013 | + now, | |
| 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 | 2083 | const indexed = yield* Effect.either( |
| 1422 | 2084 | syncSourceMaterialization({ |
| 1423 | 2085 | rows: input.rows, |
| 1424 | 2086 | source: connectedSource, |
| 2087 | + actorAccountId: session.actorAccountId, | |
| 1425 | 2088 | resolveSecretMaterial, |
| 1426 | 2089 | }), |
| 1427 | 2090 | ); |
| @@ -1437,26 +2100,6 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines | ||
| 1437 | 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 | 2103 | yield* completeLiveInteraction({ |
| 1461 | 2104 | liveExecutionManager: input.liveExecutionManager, |
| 1462 | 2105 | session, |
| @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | CredentialIdSchema, |
| 6 | + decodeBuiltInAuthArtifactConfig, | |
| 6 | 7 | SourceIdSchema, |
| 7 | 8 | WorkspaceIdSchema, |
| 8 | 9 | type Source, |
| @@ -361,14 +362,16 @@ describe("source-definitions", () => { · 352 unmodified lines | ||
| 361 | 362 | const recipeRevisionId = stableSourceRecipeRevisionId(source); |
| 362 | 363 | const existingCredentialId = CredentialIdSchema.make("cred_existing"); |
| 363 | 364 | |
| 364 | − const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | |
| 365 | + const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({ | |
| 365 | 366 | source, |
| 366 | 367 | recipeId, |
| 367 | 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 | 375 | expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({ |
| 373 | 376 | adapterKey: "mcp", |
| 374 | 377 | version: 1, |
| @@ -377,8 +380,8 @@ describe("source-definitions", () => { · 2 unmodified lines | ||
| 377 | 380 | |
| 378 | 381 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 379 | 382 | sourceRecord, |
| 380 | − runtimeCredential: runtimeCredential ?? null, | |
| 381 | − importCredential: null, | |
| 383 | + runtimeAuthArtifact: runtimeAuthArtifact ?? null, | |
| 384 | + importAuthArtifact: null, | |
| 382 | 385 | })); |
| 383 | 386 | |
| 384 | 387 | expect(projected).toEqual(source); |
| @@ -421,15 +424,15 @@ describe("source-definitions", () => { · 36 unmodified lines | ||
| 421 | 424 | }); |
| 422 | 425 | |
| 423 | 426 | for (const source of [withRefresh, withoutRefresh]) { |
| 424 | − const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | |
| 427 | + const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({ | |
| 425 | 428 | source, |
| 426 | 429 | recipeId: stableSourceRecipeId(source), |
| 427 | 430 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 428 | 431 | }); |
| 429 | 432 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 430 | 433 | sourceRecord, |
| 431 | − runtimeCredential: runtimeCredential ?? null, | |
| 432 | − importCredential: null, | |
| 434 | + runtimeAuthArtifact: runtimeAuthArtifact ?? null, | |
| 435 | + importAuthArtifact: null, | |
| 433 | 436 | })); |
| 434 | 437 | |
| 435 | 438 | expect(projected).toEqual(source); |
| @@ -440,18 +443,18 @@ describe("source-definitions", () => { · 4 unmodified lines | ||
| 440 | 443 | const source = makeSource({ |
| 441 | 444 | auth: { kind: "none" }, |
| 442 | 445 | }); |
| 443 | − const { sourceRecord, runtimeCredential } = splitSourceForStorage({ | |
| 446 | + const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({ | |
| 444 | 447 | source, |
| 445 | 448 | recipeId: stableSourceRecipeId(source), |
| 446 | 449 | recipeRevisionId: stableSourceRecipeRevisionId(source), |
| 447 | 450 | }); |
| 448 | 451 | |
| 449 | − expect(runtimeCredential).toBeNull(); | |
| 452 | + expect(runtimeAuthArtifact).toBeNull(); | |
| 450 | 453 | |
| 451 | 454 | const projected = await Effect.runPromise(projectSourceFromStorage({ |
| 452 | 455 | sourceRecord, |
| 453 | − runtimeCredential: null, | |
| 454 | − importCredential: null, | |
| 456 | + runtimeAuthArtifact: null, | |
| 457 | + importAuthArtifact: null, | |
| 455 | 458 | })); |
| 456 | 459 | |
| 457 | 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 | 3 | import type { |
| 4 | 4 | CreateSourcePayload, |
| @@ -6,7 +6,7 @@ import type { · 1 unmodified line | ||
| 6 | 6 | } from "#api"; |
| 7 | 7 | import type { |
| 8 | 8 | AccountId, |
| 9 | − Credential, | |
| 9 | + AuthArtifact, | |
| 10 | 10 | CredentialSlot, |
| 11 | 11 | Source, |
| 12 | 12 | SourceAuth, |
| @@ -21,13 +21,17 @@ import type { · 8 unmodified lines | ||
| 21 | 21 | WorkspaceId, |
| 22 | 22 | } from "#schema"; |
| 23 | 23 | import { |
| 24 | − CredentialIdSchema, | |
| 24 | + AuthArtifactIdSchema, | |
| 25 | 25 | SourceRecipeIdSchema, |
| 26 | 26 | SourceRecipeRevisionIdSchema, |
| 27 | 27 | } from "#schema"; |
| 28 | 28 | import * as Effect from "effect/Effect"; |
| 29 | 29 | |
| 30 | 30 | import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters"; |
| 31 | +import { | |
| 32 | + authArtifactFromSourceAuth, | |
| 33 | + sourceAuthFromAuthArtifact, | |
| 34 | +} from "./auth-artifacts"; | |
| 31 | 35 | |
| 32 | 36 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 33 | 37 | if (value === null || value === undefined) { |
| @@ -57,7 +61,7 @@ const sourceRecipeProviderKeyFromSource = (source: Source): string => { · 23 unmodified lines | ||
| 57 | 61 | }; |
| 58 | 62 | |
| 59 | 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 | 66 | const sourceRecipeSignature = (source: Source): string => |
| 63 | 67 | JSON.stringify({ |
| @@ -89,7 +93,7 @@ const normalizeAuth = ( · 25 unmodified lines | ||
| 89 | 93 | const headerName = trimOrNull(auth.headerName) ?? "Authorization"; |
| 90 | 94 | const prefix = auth.prefix ?? "Bearer "; |
| 91 | 95 | |
| 92 | − if (auth.kind === "bearer") { | |
| 96 | + if (auth.kind === "bearer") { | |
| 93 | 97 | const providerId = trimOrNull(auth.token.providerId); |
| 94 | 98 | const handle = trimOrNull(auth.token.handle); |
| 95 | 99 | if (providerId === null || handle === null) { |
| @@ -107,6 +111,54 @@ const normalizeAuth = ( · 11 unmodified lines | ||
| 107 | 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 | 162 | const accessProviderId = trimOrNull(auth.accessToken.providerId); |
| 111 | 163 | const accessHandle = trimOrNull(auth.accessToken.handle); |
| 112 | 164 | if (accessProviderId === null || accessHandle === null) { |
| @@ -146,86 +198,6 @@ const normalizeImportAuthPolicy = ( · 33 unmodified lines | ||
| 146 | 198 | policy: SourceImportAuthPolicy | undefined, |
| 147 | 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 | 201 | const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> => |
| 230 | 202 | Effect.gen(function* () { |
| 231 | 203 | if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") { |
| @@ -370,12 +342,12 @@ export const splitSourceForStorage = (input: { · 138 unmodified lines | ||
| 370 | 342 | recipeId: SourceRecipeId; |
| 371 | 343 | recipeRevisionId: SourceRecipeRevisionId; |
| 372 | 344 | actorAccountId?: AccountId | null; |
| 373 | − existingRuntimeCredentialId?: Credential["id"] | null; | |
| 374 | − existingImportCredentialId?: Credential["id"] | null; | |
| 345 | + existingRuntimeAuthArtifactId?: AuthArtifact["id"] | null; | |
| 346 | + existingImportAuthArtifactId?: AuthArtifact["id"] | null; | |
| 375 | 347 | }): { |
| 376 | 348 | sourceRecord: StoredSourceRecord; |
| 377 | − runtimeCredential: Credential | null; | |
| 378 | − importCredential: Credential | null; | |
| 349 | + runtimeAuthArtifact: AuthArtifact | null; | |
| 350 | + importAuthArtifact: AuthArtifact | null; | |
| 379 | 351 | } => { |
| 380 | 352 | const sourceRecord: StoredSourceRecord = { |
| 381 | 353 | id: input.source.id, |
| @@ -398,20 +370,22 @@ export const splitSourceForStorage = (input: { · 16 unmodified lines | ||
| 398 | 370 | |
| 399 | 371 | return { |
| 400 | 372 | sourceRecord, |
| 401 | − runtimeCredential: credentialFromAuth({ | |
| 373 | + runtimeAuthArtifact: authArtifactFromSourceAuth({ | |
| 402 | 374 | source: input.source, |
| 403 | 375 | auth: input.source.auth, |
| 404 | 376 | slot: "runtime", |
| 405 | 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" | |
| 409 | − ? credentialFromAuth({ | |
| 381 | + importAuthArtifact: input.source.importAuthPolicy === "separate" | |
| 382 | + ? authArtifactFromSourceAuth({ | |
| 410 | 383 | source: input.source, |
| 411 | 384 | auth: input.source.importAuth, |
| 412 | 385 | slot: "import", |
| 413 | 386 | actorAccountId: input.actorAccountId, |
| 414 | − existingCredentialId: input.existingImportCredentialId, | |
| 387 | + existingAuthArtifactId: input.existingImportAuthArtifactId | |
| 388 | + ?? AuthArtifactIdSchema.make(`auth_art_${crypto.randomUUID()}`), | |
| 415 | 389 | }) |
| 416 | 390 | : null, |
| 417 | 391 | }; |
| @@ -419,8 +393,8 @@ export const splitSourceForStorage = (input: { · 1 unmodified line | ||
| 419 | 393 | |
| 420 | 394 | export const projectSourceFromStorage = (input: { |
| 421 | 395 | sourceRecord: StoredSourceRecord; |
| 422 | − runtimeCredential: Credential | null; | |
| 423 | − importCredential: Credential | null; | |
| 396 | + runtimeAuthArtifact: AuthArtifact | null; | |
| 397 | + importAuthArtifact: AuthArtifact | null; | |
| 424 | 398 | }): Effect.Effect<Source, Error, never> => |
| 425 | 399 | Effect.gen(function* () { |
| 426 | 400 | const adapter = getSourceAdapter(input.sourceRecord.kind); |
| @@ -443,9 +417,9 @@ export const projectSourceFromStorage = (input: { · 16 unmodified lines | ||
| 443 | 417 | importAuthPolicy: input.sourceRecord.importAuthPolicy, |
| 444 | 418 | importAuth: |
| 445 | 419 | input.sourceRecord.importAuthPolicy === "separate" |
| 446 | − ? authFromCredential(input.importCredential) | |
| 420 | + ? sourceAuthFromAuthArtifact(input.importAuthArtifact) | |
| 447 | 421 | : { kind: "none" }, |
| 448 | − auth: authFromCredential(input.runtimeCredential), | |
| 422 | + auth: sourceAuthFromAuthArtifact(input.runtimeAuthArtifact), | |
| 449 | 423 | sourceHash: input.sourceRecord.sourceHash, |
| 450 | 424 | lastError: input.sourceRecord.lastError, |
| 451 | 425 | createdAt: input.sourceRecord.createdAt, |
| @@ -459,23 +433,23 @@ export const projectSourceFromStorage = (input: { · 7 unmodified lines | ||
| 459 | 433 | |
| 460 | 434 | export const projectSourcesFromStorage = (input: { |
| 461 | 435 | sourceRecords: ReadonlyArray<StoredSourceRecord>; |
| 462 | − credentials: ReadonlyArray<Credential>; | |
| 436 | + authArtifacts: ReadonlyArray<AuthArtifact>; | |
| 463 | 437 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { |
| 464 | − const credentialsBySourceId = new Map<string, { | |
| 465 | − runtime: Credential | null; | |
| 466 | − import: Credential | null; | |
| 438 | + const authArtifactsBySourceId = new Map<string, { | |
| 439 | + runtime: AuthArtifact | null; | |
| 440 | + import: AuthArtifact | null; | |
| 467 | 441 | }>(); |
| 468 | 442 | |
| 469 | − for (const credential of input.credentials) { | |
| 470 | − const existing = credentialsBySourceId.get(credential.sourceId) ?? { | |
| 443 | + for (const authArtifact of input.authArtifacts) { | |
| 444 | + const existing = authArtifactsBySourceId.get(authArtifact.sourceId) ?? { | |
| 471 | 445 | runtime: null, |
| 472 | 446 | import: null, |
| 473 | 447 | }; |
| 474 | − const current = credential.slot === "runtime" ? existing.runtime : existing.import; | |
| 475 | − if (current === null || (current.actorAccountId === null && credential.actorAccountId !== null)) { | |
| 476 | − credentialsBySourceId.set(credential.sourceId, { | |
| 448 | + const current = authArtifact.slot === "runtime" ? existing.runtime : existing.import; | |
| 449 | + if (current === null || (current.actorAccountId === null && authArtifact.actorAccountId !== null)) { | |
| 450 | + authArtifactsBySourceId.set(authArtifact.sourceId, { | |
| 477 | 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 | 457 | return Effect.forEach(input.sourceRecords, (sourceRecord) => |
| 484 | 458 | projectSourceFromStorage({ |
| 485 | 459 | sourceRecord, |
| 486 | − runtimeCredential: credentialsBySourceId.get(sourceRecord.id)?.runtime ?? null, | |
| 487 | − importCredential: credentialsBySourceId.get(sourceRecord.id)?.import ?? null, | |
| 460 | + runtimeAuthArtifact: authArtifactsBySourceId.get(sourceRecord.id)?.runtime ?? null, | |
| 461 | + importAuthArtifact: authArtifactsBySourceId.get(sourceRecord.id)?.import ?? null, | |
| 488 | 462 | })); |
| 489 | 463 | }; |
| @@ -148,4 +148,81 @@ describe("source-discovery", () => { · 147 unmodified lines | ||
| 148 | 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 | 7 | createSdkMcpConnector, |
| 8 | 8 | discoverMcpToolsFromConnector, |
| 9 | 9 | } from "@executor/codemode-mcp"; |
| 10 | +import { | |
| 11 | + extractGoogleDiscoveryManifest, | |
| 12 | +} from "@executor/codemode-google-discovery"; | |
| 10 | 13 | import { |
| 11 | 14 | extractOpenApiManifest, |
| 12 | 15 | parseOpenApiDocument, |
| @@ -33,6 +36,10 @@ type OpenApiProbeResult = { · 20 unmodified lines | ||
| 33 | 36 | result: SourceDiscoveryResult; |
| 34 | 37 | }; |
| 35 | 38 | |
| 39 | +type GoogleDiscoveryProbeResult = { | |
| 40 | + result: SourceDiscoveryResult; | |
| 41 | +}; | |
| 42 | + | |
| 36 | 43 | type GraphqlProbeResult = { |
| 37 | 44 | result: SourceDiscoveryResult; |
| 38 | 45 | }; |
| @@ -607,6 +614,89 @@ const deriveOpenApiEndpoint = (input: { · 568 unmodified lines | ||
| 607 | 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 | 700 | const tryDetectOpenApi = (input: { |
| 611 | 701 | normalizedUrl: string; |
| 612 | 702 | headers: Readonly<Record<string, string>>; |
| @@ -851,7 +941,7 @@ const fallbackDiscoveryResult = (normalizedUrl: string): SourceDiscoveryResult = · 238 unmodified lines | ||
| 851 | 941 | transport: null, |
| 852 | 942 | authInference: unknownAuthInference("Could not infer source kind or auth requirements from the provided URL"), |
| 853 | 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 | 953 | const normalizedUrl = normalizeUrl(input.url); |
| 864 | 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 | 964 | const openApi = yield* tryDetectOpenApi({ |
| 867 | 965 | normalizedUrl, |
| 868 | 966 | headers, |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { SqlControlPlaneRows } from "#persistence"; |
| 2 | 2 | import type { |
| 3 | + AccountId, | |
| 3 | 4 | Source, |
| 4 | 5 | } from "#schema"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| @@ -22,6 +23,7 @@ const shouldIndexSource = (source: Source): boolean => · 16 unmodified lines | ||
| 22 | 23 | export const syncSourceMaterialization = (input: { |
| 23 | 24 | rows: SqlControlPlaneRows; |
| 24 | 25 | source: Source; |
| 26 | + actorAccountId?: AccountId | null; | |
| 25 | 27 | resolveSecretMaterial: ResolveSourceSecretMaterial; |
| 26 | 28 | }): Effect.Effect<void, Error, never> => |
| 27 | 29 | Effect.gen(function* () { |
| @@ -35,8 +37,10 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines | ||
| 35 | 37 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 36 | 38 | resolveAuthMaterialForSlot: (slot) => |
| 37 | 39 | resolveSourceAuthMaterial({ |
| 40 | + rows: input.rows, | |
| 38 | 41 | source: input.source, |
| 39 | 42 | slot, |
| 43 | + actorAccountId: input.actorAccountId, | |
| 40 | 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 | 3 | import type { |
| 4 | 4 | Source, |
| @@ -28,7 +28,7 @@ export const normalizeSearchText = ( · 23 unmodified lines | ||
| 28 | 28 | .toLowerCase(); |
| 29 | 29 | |
| 30 | 30 | export const contentHash = (value: string): string => |
| 31 | − createHash("sha256").update(value).digest("hex"); | |
| 31 | + sha256Hex(value); | |
| 32 | 32 | |
| 33 | 33 | export const loadSourceBindingRecord = (input: { |
| 34 | 34 | rows: SqlControlPlaneRows; |
| @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | |
| 3 | 3 | import { |
| 4 | 4 | AccountIdSchema, |
| 5 | + decodeBuiltInAuthArtifactConfig, | |
| 5 | 6 | McpSourceAuthSessionDataJsonSchema, |
| 6 | 7 | OrganizationIdSchema, |
| 7 | 8 | SecretMaterialIdSchema, |
| @@ -186,10 +187,14 @@ describe("source-store", () => { · 178 unmodified lines | ||
| 186 | 187 | ); |
| 187 | 188 | |
| 188 | 189 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true); |
| 189 | − expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(1); | |
| 190 | − expect((yield* persistence.rows.credentials.listByWorkspaceId(workspaceId))[0]?.tokenHandle).toBe( | |
| 191 | − secondTokenId, | |
| 192 | − ); | |
| 190 | + const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId); | |
| 191 | + expect(authArtifacts).toHaveLength(1); | |
| 192 | + const decoded = authArtifacts[0] ? decodeBuiltInAuthArtifactConfig(authArtifacts[0]) : null; | |
| 193 | + expect( | |
| 194 | + decoded !== null && decoded.artifactKind === "static_bearer" | |
| 195 | + ? decoded.config.token.handle | |
| 196 | + : null, | |
| 197 | + ).toBe(secondTokenId); | |
| 193 | 198 | |
| 194 | 199 | const removed = yield* removeSourceById(persistence.rows, { |
| 195 | 200 | workspaceId, |
| @@ -199,7 +204,7 @@ describe("source-store", () => { · 3 unmodified lines | ||
| 199 | 204 | |
| 200 | 205 | expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true); |
| 201 | 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 | 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 | 276 | workspaceId, |
| 272 | 277 | sourceId, |
| 273 | 278 | }); |
| 274 | − expect(credentials).toHaveLength(2); | |
| 275 | − expect(credentials.some((credential) => credential.actorAccountId === null)).toBe(true); | |
| 276 | − expect(credentials.some((credential) => credential.actorAccountId === accountId)).toBe(true); | |
| 277 | − expect(new Set(credentials.map((credential) => credential.id)).size).toBe(2); | |
| 279 | + expect(authArtifacts).toHaveLength(2); | |
| 280 | + expect(authArtifacts.some((artifact) => artifact.actorAccountId === null)).toBe(true); | |
| 281 | + expect(authArtifacts.some((artifact) => artifact.actorAccountId === accountId)).toBe(true); | |
| 282 | + expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2); | |
| 278 | 283 | }), |
| 279 | 284 | ); |
| 280 | 285 | |
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | import type { |
| 2 | 2 | AccountId, |
| 3 | − Credential, | |
| 3 | + AuthArtifact, | |
| 4 | 4 | CredentialSlot, |
| 5 | − SecretRef, | |
| 6 | 5 | Source, |
| 7 | 6 | SourceRecipeId, |
| 8 | 7 | SourceRecipeRevisionId, |
| @@ -22,25 +21,15 @@ import { · 13 unmodified lines | ||
| 22 | 21 | splitSourceForStorage, |
| 23 | 22 | } from "./source-definitions"; |
| 24 | 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 | − { | |
| 28 | − providerId: credential.tokenProviderId, | |
| 29 | − handle: credential.tokenHandle, | |
| 30 | − }, | |
| 31 | − ...(credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== 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; | |
| 27 | +const secretRefKey = (ref: { providerId: string; handle: string }): string => | |
| 28 | + `${ref.providerId}:${ref.handle}`; | |
| 29 | + | |
| 30 | +const cleanupAuthArtifactSecretRefs = (rows: SqlControlPlaneRows, input: { | |
| 31 | + previous: AuthArtifact | null; | |
| 32 | + next: AuthArtifact | null; | |
| 44 | 33 | }) => |
| 45 | 34 | Effect.gen(function* () { |
| 46 | 35 | if (input.previous === null) { |
| @@ -49,9 +38,9 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 2 unmodified lines | ||
| 49 | 38 | |
| 50 | 39 | const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows }); |
| 51 | 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 | 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: { | |
| 66 | − credentials: ReadonlyArray<Credential>; | |
| 54 | +const selectPreferredAuthArtifact = (input: { | |
| 55 | + authArtifacts: ReadonlyArray<AuthArtifact>; | |
| 67 | 56 | actorAccountId?: AccountId | null; |
| 68 | 57 | slot: CredentialSlot; |
| 69 | −}): Credential | null => { | |
| 70 | − const matchingSlot = input.credentials.filter((credential) => credential.slot === input.slot); | |
| 58 | +}): AuthArtifact | null => { | |
| 59 | + const matchingSlot = input.authArtifacts.filter((artifact) => artifact.slot === input.slot); | |
| 71 | 60 | |
| 72 | 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 | 63 | if (exact) { |
| 75 | 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: { | |
| 83 | − credentials: ReadonlyArray<Credential>; | |
| 71 | +const selectExactAuthArtifact = (input: { | |
| 72 | + authArtifacts: ReadonlyArray<AuthArtifact>; | |
| 84 | 73 | actorAccountId?: AccountId | null; |
| 85 | 74 | slot: CredentialSlot; |
| 86 | −}): Credential | null => | |
| 87 | − input.credentials.find( | |
| 88 | − (credential) => | |
| 89 | − credential.slot === input.slot | |
| 90 | − && credential.actorAccountId === (input.actorAccountId ?? null), | |
| 75 | +}): AuthArtifact | null => | |
| 76 | + input.authArtifacts.find( | |
| 77 | + (artifact) => | |
| 78 | + artifact.slot === input.slot | |
| 79 | + && artifact.actorAccountId === (input.actorAccountId ?? null), | |
| 91 | 80 | ) ?? null; |
| 92 | 81 | |
| 93 | 82 | export const loadSourcesInWorkspace = ( |
| @@ -99,27 +88,27 @@ export const loadSourcesInWorkspace = ( · 5 unmodified lines | ||
| 99 | 88 | ) => |
| 100 | 89 | Effect.gen(function* () { |
| 101 | 90 | const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId); |
| 102 | − const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId); | |
| 103 | − const filteredCredentials = sourceRecords.flatMap((sourceRecord) => { | |
| 104 | − const matches = credentials.filter((credential) => credential.sourceId === sourceRecord.id); | |
| 105 | − const preferred = selectPreferredCredential({ | |
| 106 | − credentials: matches, | |
| 91 | + const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId); | |
| 92 | + const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => { | |
| 93 | + const matches = authArtifacts.filter((artifact) => artifact.sourceId === sourceRecord.id); | |
| 94 | + const preferred = selectPreferredAuthArtifact({ | |
| 95 | + authArtifacts: matches, | |
| 107 | 96 | actorAccountId: options.actorAccountId, |
| 108 | 97 | slot: "runtime", |
| 109 | 98 | }); |
| 110 | − const preferredImport = selectPreferredCredential({ | |
| 111 | − credentials: matches, | |
| 99 | + const preferredImport = selectPreferredAuthArtifact({ | |
| 100 | + authArtifacts: matches, | |
| 112 | 101 | actorAccountId: options.actorAccountId, |
| 113 | 102 | slot: "import", |
| 114 | 103 | }); |
| 115 | 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 | 109 | return yield* projectSourcesFromStorage({ |
| 121 | 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 | 133 | workspaceId: input.workspaceId, |
| 145 | 134 | sourceId: input.sourceId, |
| 146 | 135 | }); |
| 147 | − const credential = selectPreferredCredential({ | |
| 148 | − credentials, | |
| 136 | + const authArtifact = selectPreferredAuthArtifact({ | |
| 137 | + authArtifacts, | |
| 149 | 138 | actorAccountId: input.actorAccountId, |
| 150 | 139 | slot: "runtime", |
| 151 | 140 | }); |
| 152 | − const importCredential = selectPreferredCredential({ | |
| 153 | − credentials, | |
| 141 | + const importAuthArtifact = selectPreferredAuthArtifact({ | |
| 142 | + authArtifacts, | |
| 154 | 143 | actorAccountId: input.actorAccountId, |
| 155 | 144 | slot: "import", |
| 156 | 145 | }); |
| 157 | 146 | |
| 158 | 147 | return yield* projectSourceFromStorage({ |
| 159 | 148 | sourceRecord: sourceRecord.value, |
| 160 | − runtimeCredential: credential, | |
| 161 | − importCredential, | |
| 149 | + runtimeAuthArtifact: authArtifact, | |
| 150 | + importAuthArtifact, | |
| 162 | 151 | }); |
| 163 | 152 | }); |
| 164 | 153 | |
| 165 | −const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: { | |
| 154 | +const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: { | |
| 166 | 155 | workspaceId: WorkspaceId; |
| 167 | 156 | sourceId: Source["id"]; |
| 168 | 157 | }) => |
| 169 | 158 | Effect.gen(function* () { |
| 170 | − const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({ | |
| 159 | + const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({ | |
| 171 | 160 | workspaceId: input.workspaceId, |
| 172 | 161 | sourceId: input.sourceId, |
| 173 | 162 | }); |
| 174 | 163 | |
| 175 | − yield* rows.credentials.removeByWorkspaceAndSourceId({ | |
| 164 | + yield* rows.authArtifacts.removeByWorkspaceAndSourceId({ | |
| 176 | 165 | workspaceId: input.workspaceId, |
| 177 | 166 | sourceId: input.sourceId, |
| 178 | 167 | }); |
| 179 | 168 | |
| 180 | 169 | yield* Effect.forEach( |
| 181 | − existingCredentials, | |
| 182 | − (credential) => | |
| 183 | − cleanupCredentialSecretRefs(rows, { | |
| 184 | − previous: credential, | |
| 170 | + existingAuthArtifacts, | |
| 171 | + (artifact) => | |
| 172 | + removeAuthLeaseAndSecrets(rows, { | |
| 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 | 183 | next: null, |
| 186 | 184 | }), |
| 187 | 185 | { discard: true }, |
| 188 | 186 | ); |
| 189 | 187 | |
| 190 | − return existingCredentials.length; | |
| 188 | + return existingAuthArtifacts.length; | |
| 191 | 189 | }); |
| 192 | 190 | |
| 193 | 191 | const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: { |
| @@ -242,7 +240,7 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 48 unmodified lines | ||
| 242 | 240 | workspaceId: input.workspaceId, |
| 243 | 241 | sourceId: input.sourceId, |
| 244 | 242 | }); |
| 245 | − yield* removeCredentialsForSource(rows, input); | |
| 243 | + yield* removeAuthArtifactsForSource(rows, input); | |
| 246 | 244 | const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId); |
| 247 | 245 | if (!removed) { |
| 248 | 246 | return false; |
| @@ -265,17 +263,17 @@ export const persistSource = ( · 16 unmodified lines | ||
| 265 | 263 | ) => |
| 266 | 264 | Effect.gen(function* () { |
| 267 | 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 | 267 | workspaceId: source.workspaceId, |
| 270 | 268 | sourceId: source.id, |
| 271 | 269 | }); |
| 272 | − const existingCredential = selectExactCredential({ | |
| 273 | − credentials: existingCredentials, | |
| 270 | + const existingRuntimeAuthArtifact = selectExactAuthArtifact({ | |
| 271 | + authArtifacts: existingAuthArtifacts, | |
| 274 | 272 | actorAccountId: options.actorAccountId, |
| 275 | 273 | slot: "runtime", |
| 276 | 274 | }); |
| 277 | − const existingImportCredential = selectExactCredential({ | |
| 278 | − credentials: existingCredentials, | |
| 275 | + const existingImportAuthArtifact = selectExactAuthArtifact({ | |
| 276 | + authArtifacts: existingAuthArtifacts, | |
| 279 | 277 | actorAccountId: options.actorAccountId, |
| 280 | 278 | slot: "import", |
| 281 | 279 | }); |
| @@ -309,13 +307,13 @@ export const persistSource = ( · 27 unmodified lines | ||
| 309 | 307 | latestRevisionId: nextRevision.id, |
| 310 | 308 | }); |
| 311 | 309 | |
| 312 | − const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({ | |
| 310 | + const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({ | |
| 313 | 311 | source, |
| 314 | 312 | recipeId: nextRecipe.id, |
| 315 | 313 | recipeRevisionId: nextRevision.id, |
| 316 | 314 | actorAccountId: options.actorAccountId, |
| 317 | − existingRuntimeCredentialId: existingCredential?.id ?? null, | |
| 318 | − existingImportCredentialId: existingImportCredential?.id ?? null, | |
| 315 | + existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null, | |
| 316 | + existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null, | |
| 319 | 317 | }); |
| 320 | 318 | |
| 321 | 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) { | |
| 350 | − yield* rows.credentials.removeByWorkspaceSourceAndActor({ | |
| 347 | + if (runtimeAuthArtifact === null) { | |
| 348 | + if (existingRuntimeAuthArtifact !== null) { | |
| 349 | + yield* removeAuthLeaseAndSecrets(rows, { | |
| 350 | + authArtifactId: existingRuntimeAuthArtifact.id, | |
| 351 | + }); | |
| 352 | + } | |
| 353 | + yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | |
| 351 | 354 | workspaceId: source.workspaceId, |
| 352 | 355 | sourceId: source.id, |
| 353 | 356 | actorAccountId: options.actorAccountId ?? null, |
| 354 | 357 | slot: "runtime", |
| 355 | 358 | }); |
| 356 | 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, { | |
| 361 | − previous: existingCredential ?? null, | |
| 362 | − next: runtimeCredential, | |
| 371 | + yield* cleanupAuthArtifactSecretRefs(rows, { | |
| 372 | + previous: existingRuntimeAuthArtifact ?? null, | |
| 373 | + next: runtimeAuthArtifact, | |
| 363 | 374 | }); |
| 364 | 375 | |
| 365 | − if (importCredential === null) { | |
| 366 | − yield* rows.credentials.removeByWorkspaceSourceAndActor({ | |
| 376 | + if (importAuthArtifact === null) { | |
| 377 | + if (existingImportAuthArtifact !== null) { | |
| 378 | + yield* removeAuthLeaseAndSecrets(rows, { | |
| 379 | + authArtifactId: existingImportAuthArtifact.id, | |
| 380 | + }); | |
| 381 | + } | |
| 382 | + yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({ | |
| 367 | 383 | workspaceId: source.workspaceId, |
| 368 | 384 | sourceId: source.id, |
| 369 | 385 | actorAccountId: options.actorAccountId ?? null, |
| 370 | 386 | slot: "import", |
| 371 | 387 | }); |
| 372 | 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, { | |
| 377 | − previous: existingImportCredential ?? null, | |
| 378 | − next: importCredential, | |
| 400 | + yield* cleanupAuthArtifactSecretRefs(rows, { | |
| 401 | + previous: existingImportAuthArtifact ?? null, | |
| 402 | + next: importAuthArtifact, | |
| 379 | 403 | }); |
| 380 | 404 | |
| 381 | 405 | return source; |
| @@ -72,6 +72,7 @@ const syncArtifactsForSource = (input: { · 71 unmodified lines | ||
| 72 | 72 | syncSourceMaterialization({ |
| 73 | 73 | rows: input.store, |
| 74 | 74 | source: sourceForSync, |
| 75 | + actorAccountId: input.actorAccountId, | |
| 75 | 76 | resolveSecretMaterial, |
| 76 | 77 | }), |
| 77 | 78 | ); |
| @@ -276,7 +277,8 @@ export const removeSource = (input: { · 198 unmodified lines | ||
| 276 | 277 | }) => |
| 277 | 278 | Effect.flatMap(ControlPlaneStore, (store) => |
| 278 | 279 | Effect.gen(function* () { |
| 279 | − const removed = yield* sourceOps.remove.mapStorage( | |
| 280 | + const removed = yield* mapPersistenceError( | |
| 281 | + sourceOps.remove.child("remove"), | |
| 280 | 282 | removeSourceById(store, { |
| 281 | 283 | workspaceId: input.workspaceId, |
| 282 | 284 | sourceId: input.sourceId, |
| @@ -687,7 +687,9 @@ const createWorkspaceToolInvoker = (input: { · 686 unmodified lines | ||
| 687 | 687 | }); |
| 688 | 688 | |
| 689 | 689 | const auth = yield* resolveSourceAuthMaterial({ |
| 690 | + rows: input.rows, | |
| 690 | 691 | source: recipeTool.source, |
| 692 | + actorAccountId: input.accountId, | |
| 691 | 693 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 692 | 694 | context: toSecretResolutionContext(invocation.context), |
| 693 | 695 | }); |
| @@ -9,20 +9,22 @@ export { · 8 unmodified lines | ||
| 9 | 9 | type Role, |
| 10 | 10 | } from "./models/auth/organization-membership"; |
| 11 | 11 | export { |
| 12 | − SecretRefSchema, | |
| 13 | 12 | SourceAuthSchema, |
| 14 | 13 | SourceBindingSchema, |
| 15 | 14 | SourceBindingVersionSchema, |
| 16 | 15 | SourceKindSchema, |
| 17 | 16 | SourceStatusSchema, |
| 18 | 17 | SourceTransportSchema, |
| 19 | − type SecretRef, | |
| 20 | 18 | type SourceAuth, |
| 21 | 19 | type SourceBinding, |
| 22 | 20 | type SourceKind, |
| 23 | 21 | type SourceStatus, |
| 24 | 22 | type SourceTransport, |
| 25 | 23 | } from "./models/source"; |
| 24 | +export { | |
| 25 | + SecretRefSchema, | |
| 26 | + type SecretRef, | |
| 27 | +} from "./models/auth-artifact"; | |
| 26 | 28 | export { |
| 27 | 29 | SourceRecipeAdapterKeySchema, |
| 28 | 30 | SourceRecipeDocumentKindSchema, |
| @@ -56,9 +58,13 @@ export { · 27 unmodified lines | ||
| 56 | 58 | type SourceProbeAuth, |
| 57 | 59 | } from "./models/source-discovery"; |
| 58 | 60 | export { |
| 59 | − CredentialAuthKindSchema, | |
| 60 | − type CredentialAuthKind, | |
| 61 | −} from "./models/credential"; | |
| 61 | + AuthArtifactKindSchema, | |
| 62 | + AuthArtifactSlotSchema, | |
| 63 | + BuiltInAuthArtifactKindSchema, | |
| 64 | + type AuthArtifactKind, | |
| 65 | + type AuthArtifactSlot, | |
| 66 | + type BuiltInAuthArtifactKind, | |
| 67 | +} from "./models/auth-artifact"; | |
| 62 | 68 | export { |
| 63 | 69 | SourceAuthSessionProviderKindSchema, |
| 64 | 70 | SourceAuthSessionStatusSchema, |
| @@ -17,9 +17,13 @@ export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe( · 16 unmodified lines | ||
| 17 | 17 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 18 | 18 | Schema.brand("SourceAuthSessionId"), |
| 19 | 19 | ); |
| 20 | −export const CredentialIdSchema = Schema.String.pipe( | |
| 21 | − Schema.brand("CredentialId"), | |
| 20 | +export const AuthArtifactIdSchema = Schema.String.pipe( | |
| 21 | + Schema.brand("AuthArtifactId"), | |
| 22 | 22 | ); |
| 23 | +export const AuthLeaseIdSchema = Schema.String.pipe( | |
| 24 | + Schema.brand("AuthLeaseId"), | |
| 25 | +); | |
| 26 | +export const CredentialIdSchema = AuthArtifactIdSchema; | |
| 23 | 27 | export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe( |
| 24 | 28 | Schema.brand("WorkspaceSourceOauthClientId"), |
| 25 | 29 | ); |
| @@ -42,6 +46,8 @@ export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; · 16 unmodified lines | ||
| 42 | 46 | export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type; |
| 43 | 47 | export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type; |
| 44 | 48 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 49 | +export type AuthArtifactId = typeof AuthArtifactIdSchema.Type; | |
| 50 | +export type AuthLeaseId = typeof AuthLeaseIdSchema.Type; | |
| 45 | 51 | export type CredentialId = typeof CredentialIdSchema.Type; |
| 46 | 52 | export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type; |
| 47 | 53 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| @@ -6,7 +6,8 @@ export * from "./models/auth"; · 5 unmodified lines | ||
| 6 | 6 | export * from "./models/workspace"; |
| 7 | 7 | export * from "./models/source"; |
| 8 | 8 | export * from "./models/source-recipe"; |
| 9 | −export * from "./models/credential"; | |
| 9 | +export * from "./models/auth-artifact"; | |
| 10 | +export * from "./models/auth-lease"; | |
| 10 | 11 | export * from "./models/source-oauth-client"; |
| 11 | 12 | export * from "./models/secret-material"; |
| 12 | 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"; | |
| 2 | −import { Schema } from "effect"; | |
| 3 | − | |
| 4 | −import { credentialsTable } from "../../persistence/schema"; | |
| 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; | |
| 1 | +export { | |
| 2 | + AuthArtifactSlotSchema as CredentialSlotSchema, | |
| 3 | + type AuthArtifactSlot as CredentialSlot, | |
| 4 | +} from "./auth-artifact"; | |
| @@ -11,12 +11,13 @@ import { · 10 unmodified lines | ||
| 11 | 11 | SourceIdSchema, |
| 12 | 12 | WorkspaceIdSchema, |
| 13 | 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( | |
| 17 | − "mcp_oauth", | |
| 18 | − "oauth2_pkce", | |
| 19 | −); | |
| 20 | +export const SourceAuthSessionProviderKindSchema = Schema.String; | |
| 20 | 21 | |
| 21 | 22 | export const SourceAuthSessionStatusSchema = Schema.Literal( |
| 22 | 23 | "pending", |
| @@ -74,6 +75,30 @@ export const McpSourceAuthSessionDataJsonSchema = Schema.parseJson( · 51 unmodified lines | ||
| 74 | 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 | 102 | const sourceAuthSessionSchemaOverrides = { |
| 78 | 103 | id: SourceAuthSessionIdSchema, |
| 79 | 104 | workspaceId: WorkspaceIdSchema, |
| @@ -97,4 +122,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 17 unmodified lines | ||
| 97 | 122 | export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type; |
| 98 | 123 | export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type; |
| 99 | 124 | export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type; |
| 125 | +export type OAuth2PkceSourceAuthSessionData = | |
| 126 | + typeof OAuth2PkceSourceAuthSessionDataSchema.Type; | |
| 100 | 127 | export type SourceAuthSession = typeof SourceAuthSessionSchema.Type; |
| @@ -5,6 +5,7 @@ import { StringMapSchema, SourceTransportSchema } from "./source"; · 4 unmodified lines | ||
| 5 | 5 | export const SourceDiscoveryKindSchema = Schema.Literal( |
| 6 | 6 | "mcp", |
| 7 | 7 | "openapi", |
| 8 | + "google_discovery", | |
| 8 | 9 | "graphql", |
| 9 | 10 | "unknown", |
| 10 | 11 | ); |
| @@ -9,6 +9,19 @@ import { · 8 unmodified lines | ||
| 9 | 9 | WorkspaceSourceOauthClientIdSchema, |
| 10 | 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 | 25 | export const WorkspaceSourceOauthClientSchema = createSelectSchema( |
| 13 | 26 | workspaceSourceOauthClientsTable, |
| 14 | 27 | { |
| @@ -21,3 +34,7 @@ export const WorkspaceSourceOauthClientSchema = createSelectSchema( · 6 unmodified lines | ||
| 21 | 34 | ); |
| 22 | 35 | |
| 23 | 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 | 11 | SourceRecipeRevisionIdSchema, |
| 12 | 12 | WorkspaceIdSchema, |
| 13 | 13 | } from "../ids"; |
| 14 | +import { SecretRefSchema } from "./auth-artifact"; | |
| 14 | 15 | import { JsonObjectSchema } from "./source-auth-session"; |
| 15 | 16 | |
| 16 | 17 | export const SourceKindSchema = Schema.String; |
| @@ -35,11 +36,6 @@ export const SourceImportAuthPolicySchema = Schema.Literal( · 18 unmodified lines | ||
| 35 | 36 | "separate", |
| 36 | 37 | ); |
| 37 | 38 | |
| 38 | −export const SecretRefSchema = Schema.Struct({ | |
| 39 | − providerId: Schema.String, | |
| 40 | − handle: Schema.String, | |
| 41 | −}); | |
| 42 | − | |
| 43 | 39 | export const SourceAuthSchema = Schema.Union( |
| 44 | 40 | Schema.Struct({ |
| 45 | 41 | kind: Schema.Literal("none"), |
| @@ -57,6 +53,17 @@ export const SourceAuthSchema = Schema.Union( · 11 unmodified lines | ||
| 57 | 53 | accessToken: SecretRefSchema, |
| 58 | 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 | 69 | export const StringMapSchema = Schema.Record({ |
| @@ -170,7 +177,6 @@ export type SourceKind = typeof SourceKindSchema.Type; · 107 unmodified lines | ||
| 170 | 177 | export type SourceStatus = typeof SourceStatusSchema.Type; |
| 171 | 178 | export type SourceTransport = typeof SourceTransportSchema.Type; |
| 172 | 179 | export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type; |
| 173 | −export type SecretRef = typeof SecretRefSchema.Type; | |
| 174 | 180 | export type SourceAuth = typeof SourceAuthSchema.Type; |
| 175 | 181 | export type SourceBinding = typeof SourceBindingSchema.Type; |
| 176 | 182 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; |
| @@ -1,6 +1,10 @@ | ||
| 1 | 1 | import { homedir } from "node:os"; |
| 2 | 2 | import { join } from "node:path"; |
| 3 | 3 | |
| 4 | +import { loadMonorepoRootEnv } from "./env"; | |
| 5 | + | |
| 6 | +loadMonorepoRootEnv(); | |
| 7 | + | |
| 4 | 8 | export const EXECUTOR_HOME_ENV = "EXECUTOR_HOME"; |
| 5 | 9 | export const EXECUTOR_DATA_DIR_ENV = "EXECUTOR_DATA_DIR"; |
| 6 | 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 | + | |