History

google oauth works

Rhys Sullivan committed 8 months ago

Commit 2e328ab1 parent 38327c077 files · +7814 −866Browse files

Unified · Split

77 files
  1. apps/web/src/dev.ts +20 −2
  2. apps/web/src/views/add-source.tsx +174 −25
  3. apps/web/src/views/source-editor.tsx +73 −3
  4. apps/web/src/views/source-templates.ts +179 −2
  5. bun.lock +18 −0
  6. packages/codemode-core/src/http-request-placements.ts +164 −0
  7. packages/codemode-core/src/index.ts +2 −0
  8. packages/codemode-core/src/isomorphic-hash.ts +132 −0
  9. packages/codemode-google-discovery/package.json +28 −0
  10. packages/codemode-google-discovery/src/google-discovery-document.ts +525 −0
  11. packages/codemode-google-discovery/src/google-discovery-tools.test.ts +193 −0
  12. packages/codemode-google-discovery/src/google-discovery-tools.ts +480 −0
  13. packages/codemode-google-discovery/src/google-discovery-types.ts +123 −0
  14. packages/codemode-google-discovery/src/index.ts +29 −0
  15. packages/codemode-google-discovery/tsconfig.json +15 −0
  16. packages/codemode-mcp/src/mcp-connection.ts +0 −2
  17. packages/codemode-openapi/src/openapi-tools.test.ts +125 −1
  18. packages/codemode-openapi/src/openapi-tools.ts +52 −11
  19. packages/control-plane/drizzle/20260312113000_auth_artifacts_and_leases/migration.sql +132 −0
  20. packages/control-plane/drizzle/20260312143000_auth_lease_template_placements/migration.sql +40 −0
  21. packages/control-plane/package.json +1 −0
  22. packages/control-plane/src/api/payload-schemas.test.ts +17 −3
  23. packages/control-plane/src/persistence/control-plane-rows.test.ts +94 −59
  24. packages/control-plane/src/persistence/index.ts +4 −2
  25. packages/control-plane/src/persistence/post-migrations.test.ts +181 −0
  26. packages/control-plane/src/persistence/repos/auth-artifacts-repo.ts +226 −0
  27. packages/control-plane/src/persistence/repos/auth-leases-repo.ts +145 −0
  28. packages/control-plane/src/persistence/repos/credentials-repo.ts +0 −228
  29. packages/control-plane/src/persistence/repos/index.ts +2 −1
  30. packages/control-plane/src/persistence/repos/organizations-repo.ts +13 −11
  31. packages/control-plane/src/persistence/repos/secret-materials-repo.ts +16 −23
  32. packages/control-plane/src/persistence/repos/shared.ts +8 −17
  33. packages/control-plane/src/persistence/repos/workspaces-repo.ts +13 −11
  34. packages/control-plane/src/persistence/schema.ts +43 −17
  35. packages/control-plane/src/runtime/auth-artifacts.ts +403 −0
  36. packages/control-plane/src/runtime/auth-leases.ts +305 −0
  37. packages/control-plane/src/runtime/executor-tools.ts +20 −22
  38. packages/control-plane/src/runtime/graphql-tools.test.ts +154 −0
  39. packages/control-plane/src/runtime/graphql-tools.ts +48 −20
  40. packages/control-plane/src/runtime/oauth-loopback.test.ts +77 −0
  41. packages/control-plane/src/runtime/oauth-loopback.ts +104 −0
  42. packages/control-plane/src/runtime/oauth2-pkce.ts +172 −0
  43. packages/control-plane/src/runtime/operations-shared.ts +11 −4
  44. packages/control-plane/src/runtime/source-adapters/google-discovery.test.ts +119 −0
  45. packages/control-plane/src/runtime/source-adapters/google-discovery.ts +639 −0
  46. packages/control-plane/src/runtime/source-adapters/graphql.ts +34 −10
  47. packages/control-plane/src/runtime/source-adapters/index.ts +6 −0
  48. packages/control-plane/src/runtime/source-adapters/mcp.ts +30 −8
  49. packages/control-plane/src/runtime/source-adapters/openapi.ts +19 −3
  50. packages/control-plane/src/runtime/source-adapters/types.ts +26 −0
  51. packages/control-plane/src/runtime/source-auth-material.test.ts +33 −1
  52. packages/control-plane/src/runtime/source-auth-material.ts +75 −38
  53. packages/control-plane/src/runtime/source-auth-service.test.ts +517 −0
  54. packages/control-plane/src/runtime/source-auth-service.ts +710 −67
  55. packages/control-plane/src/runtime/source-definitions.test.ts +15 −12
  56. packages/control-plane/src/runtime/source-definitions.ts +84 −110
  57. packages/control-plane/src/runtime/source-discovery.test.ts +77 −0
  58. packages/control-plane/src/runtime/source-discovery.ts +99 −1
  59. packages/control-plane/src/runtime/source-materialization.ts +4 −0
  60. packages/control-plane/src/runtime/source-recipe-support.ts +2 −2
  61. packages/control-plane/src/runtime/source-store.test.ts +15 −10
  62. packages/control-plane/src/runtime/source-store.ts +104 −80
  63. packages/control-plane/src/runtime/sources-operations.ts +3 −1
  64. packages/control-plane/src/runtime/workspace-execution-environment.ts +2 −0
  65. packages/control-plane/src/schema/enums.ts +11 −5
  66. packages/control-plane/src/schema/ids.ts +8 −2
  67. packages/control-plane/src/schema/index.ts +2 −1
  68. packages/control-plane/src/schema/models/auth-artifact.ts +306 −0
  69. packages/control-plane/src/schema/models/auth-lease.ts +57 −0
  70. packages/control-plane/src/schema/models/credential.ts +4 −40
  71. packages/control-plane/src/schema/models/source-auth-session.ts +32 −5
  72. packages/control-plane/src/schema/models/source-discovery.ts +1 −0
  73. packages/control-plane/src/schema/models/source-oauth-client.ts +17 −0
  74. packages/control-plane/src/schema/models/source.ts +12 −6
  75. packages/server/src/config.ts +4 −0
  76. packages/server/src/env.test.ts +65 −0
  77. packages/server/src/env.ts +121 −0
apps/web/src/dev.ts+20−2
@@ -5,20 +5,36 @@ · 4 unmodified lines
55 * control-plane handler. Everything else (frontend assets, HMR) is
66 * handled by Vite itself.
77 */
8+import { join } from "node:path";
89 import * as Cause from "effect/Cause";
910 import * as Effect from "effect/Effect";
1011 import * as Exit from "effect/Exit";
1112 import * as Runtime from "effect/Runtime";
1213 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";
1418
1519 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+);
1625
1726 const truncateForLog = (value: string): string =>
1827 value.length > MAX_LOGGED_ERROR_BODY_LENGTH
1928 ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]`
2029 : value;
2130
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+
2238 /**
2339 * Extract a detailed, human-readable description from an error.
2440 *
@@ -45,7 +61,9 @@ const handlerPromise = (async () => { · 20 unmodified lines
4561 const exit = await Effect.runPromiseExit(
4662 Effect.gen(function* () {
4763 const scope = yield* Scope.make();
48− const handler = yield* createLocalExecutorRequestHandler().pipe(
64+ const handler = yield* createLocalExecutorRequestHandler({
65+ localDataDir: resolveWebDevLocalDataDir(),
66+ }).pipe(
4967 Effect.provideService(Scope.Scope, scope),
5068 );
5169 return handler;
apps/web/src/views/add-source.tsx+174−25
@@ -57,9 +57,12 @@ type ProbeAuthState = { · 56 unmodified lines
5757 };
5858
5959 type ConnectFormState = {
60− kind: "mcp" | "openapi" | "graphql";
60+ kind: "mcp" | "openapi" | "graphql" | "google_discovery";
6161 endpoint: string;
6262 specUrl: string;
63+ service: string;
64+ version: string;
65+ discoveryUrl: string;
6366 name: string;
6467 namespace: string;
6568 transport: "" | "auto" | "streamable-http" | "sse";
@@ -83,7 +86,12 @@ type OAuthRequiredInfo = { · 17 unmodified lines
8386 // Constants
8487 // ---------------------------------------------------------------------------
8588
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+];
8795
8896 const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [
8997 "auto",
@@ -119,6 +127,44 @@ const namespaceFromUrl = (url: string): string => { · 29 unmodified lines
119127 }
120128 };
121129
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+
122168 const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {
123169 const trimmed = text.trim();
124170 if (trimmed.length === 0) {
@@ -158,6 +204,9 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 33 unmodified lines
158204 kind: "openapi",
159205 endpoint: discovery?.endpoint ?? "",
160206 specUrl: discovery?.specUrl ?? "",
207+ service: "",
208+ version: "",
209+ discoveryUrl: "",
161210 name: discovery?.name ?? "",
162211 namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),
163212 transport: "",
@@ -197,10 +246,17 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 33 unmodified lines
197246 }
198247 }
199248
249+ const googleDiscoveryDefaults = kind === "google_discovery"
250+ ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint)
251+ : null;
252+
200253 return {
201254 kind,
202255 endpoint: discovery.endpoint,
203256 specUrl: discovery.specUrl ?? "",
257+ service: googleDiscoveryDefaults?.service ?? "",
258+ version: googleDiscoveryDefaults?.version ?? "",
259+ discoveryUrl: googleDiscoveryDefaults?.discoveryUrl ?? "",
204260 name: discovery.name ?? "",
205261 namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint),
206262 transport: kind === "mcp" ? (discovery.transport ?? "auto") : "",
@@ -220,11 +276,25 @@ const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => · 13 unmodified lines
220276 kind: template.kind as ConnectFormState["kind"],
221277 endpoint: template.endpoint,
222278 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 : "",
223282 name: template.name,
224− namespace: namespaceFromUrl(template.endpoint),
283+ namespace: "service" in template
284+ ? googleDiscoveryNamespace(template.service)
285+ : namespaceFromUrl(template.endpoint),
225286 transport: template.kind === "mcp" ? "auto" : "",
287+ authKind: template.kind === "google_discovery" ? "oauth2" : "none",
226288 });
227289
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+
228298 const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => {
229299 if (state.kind === "none") return { kind: "none" };
230300 if (state.kind === "bearer") {
@@ -251,10 +321,9 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 20 unmodified lines
251321 };
252322
253323 const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => {
254− const endpoint = form.endpoint.trim();
255− if (!endpoint) throw new Error("Endpoint is required.");
256−
257324 if (form.kind === "mcp") {
325+ const endpoint = form.endpoint.trim();
326+ if (!endpoint) throw new Error("Endpoint is required.");
258327 return {
259328 kind: "mcp",
260329 endpoint,
@@ -270,6 +339,8 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 9 unmodified lines
270339 const auth = buildHttpAuth(form);
271340
272341 if (form.kind === "openapi") {
342+ const endpoint = form.endpoint.trim();
343+ if (!endpoint) throw new Error("Endpoint is required.");
273344 const specUrl = form.specUrl.trim();
274345 if (!specUrl) throw new Error("OpenAPI sources require a spec URL.");
275346 return {
@@ -282,6 +353,24 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 6 unmodified lines
282353 };
283354 }
284355
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.");
285374 return {
286375 kind: "graphql",
287376 endpoint,
@@ -346,6 +435,16 @@ type SourceOAuthPopupMessage = · 58 unmodified lines
346435 ok: false;
347436 sessionId: string | null;
348437 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;
349448 };
350449
351450 const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {
@@ -369,7 +468,7 @@ const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { · 17 unmodified lines
369468 const startSourceOAuthPopup = async (input: {
370469 authorizationUrl: string;
371470 sessionId: string;
372−}): Promise<CompleteSourceOAuthResult["auth"]> => {
471+}): Promise<void> => {
373472 if (typeof window === "undefined") {
374473 throw new Error("OAuth popup is only available in a browser context");
375474 }
@@ -388,7 +487,7 @@ const startSourceOAuthPopup = async (input: { · 12 unmodified lines
388487
389488 popup.focus();
390489
391− return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => {
490+ return await new Promise<void>((resolve, reject) => {
392491 let settled = false;
393492 let closedPoll = 0;
394493 let resultTimeout = 0;
@@ -416,15 +515,19 @@ const startSourceOAuthPopup = async (input: { · 21 unmodified lines
416515 if (settled) return;
417516 settled = true;
418517 cleanup();
419− resolve(data.auth);
518+ resolve();
420519 };
421520
422521 const onMessage = (event: MessageEvent) => {
423522 if (event.origin !== window.location.origin) return;
424523 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+ }
428531 settleFromPayload(data);
429532 };
430533
@@ -517,6 +620,14 @@ export function AddSourcePage() { · 86 unmodified lines
517620 setConnectForm((current) => ({ ...current, [key]: value }));
518621 };
519622
623+ const setSourceKind = (kind: ConnectFormState["kind"]) => {
624+ setConnectForm((current) => ({
625+ ...current,
626+ kind,
627+ authKind: authKindForSourceKind(current.authKind, kind),
628+ }));
629+ };
630+
520631 const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => {
521632 setProbeAuth((current) => ({ ...current, [key]: value }));
522633 };
@@ -879,10 +990,10 @@ export function AddSourcePage() { · 356 unmodified lines
879990 <div className="space-y-3 border-t border-border pt-4">
880991 <div>
881992 <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>
886997 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
887998 {sourceTemplates.map((template) => (
888999 <button
@@ -930,18 +1041,20 @@ export function AddSourcePage() { · 41 unmodified lines
9301041 <Field label="Kind">
9311042 <SelectInput
9321043 value={connectForm.kind}
933− onChange={(v) => setFormField("kind", v as ConnectFormState["kind"])}
1044+ onChange={(v) => setSourceKind(v as ConnectFormState["kind"])}
9341045 options={kindOptions.map((v) => ({ value: v, label: v }))}
9351046 />
9361047 </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+ )}
9451058 <Field label="Namespace">
9461059 <TextInput
9471060 value={connectForm.namespace}
@@ -959,6 +1072,35 @@ export function AddSourcePage() { · 11 unmodified lines
9591072 />
9601073 </Field>
9611074 )}
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+ )}
9621104 </div>
9631105 </Section>
9641106
@@ -1004,6 +1146,13 @@ export function AddSourcePage() { · 39 unmodified lines
10041146 options={authOptions.map((v) => ({ value: v, label: v }))}
10051147 />
10061148 </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+ )}
10071156 {connectForm.authKind !== "none" && (
10081157 <>
10091158 <Field label="Header name">
apps/web/src/views/source-editor.tsx+73−3
@@ -74,6 +74,8 @@ type SourceFormState = { · 73 unmodified lines
7474 queryParamsText: string;
7575 headersText: string;
7676 specUrl: string;
77+ service: string;
78+ version: string;
7779 defaultHeadersText: string;
7880 authKind: Source["auth"]["kind"];
7981 authHeaderName: string;
@@ -86,7 +88,13 @@ type SourceFormState = { · 6 unmodified lines
8688 oauthRefreshHandle: string;
8789 };
8890
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+];
9098
9199 const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [
92100 "auto",
@@ -112,6 +120,9 @@ const namespaceFromUrl = (url: string): string => { · 19 unmodified lines
112120 }
113121 };
114122
123+const googleDiscoveryNamespace = (service: string): string =>
124+ `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;
125+
115126 const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {
116127 if (typeof window === "undefined") {
117128 return null;
@@ -288,14 +299,20 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 170 unmodified lines
288299 name: template?.name ?? "",
289300 kind: template?.kind ?? "openapi",
290301 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+ : "",
292307 enabled: true,
293308 transport: template?.kind === "mcp" ? "auto" : "",
294309 queryParamsText: "",
295310 headersText: "",
296311 specUrl: template && "specUrl" in template ? template.specUrl : "",
312+ service: template && "service" in template ? template.service : "",
313+ version: template && "version" in template ? template.version : "",
297314 defaultHeadersText: "",
298− authKind: "none",
315+ authKind: template?.kind === "google_discovery" ? "oauth2" : "none",
299316 authHeaderName: "Authorization",
300317 authPrefix: "Bearer ",
301318 bearerProviderId: "",
@@ -316,6 +333,8 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 14 unmodified lines
316333 queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")),
317334 headersText: stringMapToEditor(readBindingStringMap(source, "headers")),
318335 specUrl: readBindingString(source, "specUrl"),
336+ service: readBindingString(source, "service"),
337+ version: readBindingString(source, "version"),
319338 defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),
320339 authKind: source.auth.kind,
321340 authHeaderName: source.auth.kind === "none" ? "Authorization" : source.auth.headerName,
@@ -484,6 +503,27 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 162 unmodified lines
484503 };
485504 }
486505
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+
487527 return {
488528 ...shared,
489529 binding: {},
@@ -784,6 +824,8 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 294 unmodified lines
784824 ? "https://api.github.com"
785825 : formState.kind === "graphql"
786826 ? "https://api.linear.app/graphql"
827+ : formState.kind === "google_discovery"
828+ ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
787829 : "https://mcp.deepwiki.com/mcp"
788830 }
789831 mono
@@ -871,6 +913,34 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 81 unmodified lines
871913 </Section>
872914 )}
873915
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+
874944 <Section title="Authentication">
875945 <div className="grid gap-4 sm:grid-cols-2">
876946 {formState.kind === "mcp" && (
apps/web/src/views/source-templates.ts+179−2
@@ -12,11 +12,45 @@ export type OpenApiSourceTemplate = SourceTemplateBase & { · 11 unmodified lines
1212 specUrl: string;
1313 };
1414
15+export type GoogleDiscoverySourceTemplate = SourceTemplateBase & {
16+ kind: "google_discovery";
17+ service: string;
18+ version: string;
19+ discoveryUrl: string;
20+};
21+
1522 export type NonOpenApiSourceTemplate = SourceTemplateBase & {
16− kind: Exclude<Source["kind"], "openapi" | "internal">;
23+ kind: Exclude<Source["kind"], "openapi" | "google_discovery" | "internal">;
1724 };
1825
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+};
2054
2155 export const sourceTemplates: ReadonlyArray<SourceTemplate> = [
2256 {
@@ -115,4 +149,147 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 92 unmodified lines
115149 kind: "graphql",
116150 endpoint: "https://graphql.anilist.co",
117151 },
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+ }),
118295 ];
bun.lock+18−0
@@ -110,6 +110,21 @@ · 109 unmodified lines
110110 "vitest": "catalog:",
111111 },
112112 },
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+ },
113128 "packages/codemode-mcp": {
114129 "name": "@executor/codemode-mcp",
115130 "dependencies": {
@@ -148,6 +163,7 @@ · 32 unmodified lines
148163 "@effect/platform": "catalog:",
149164 "@electric-sql/pglite": "^0.3.12",
150165 "@executor/codemode-core": "workspace:*",
166+ "@executor/codemode-google-discovery": "workspace:*",
151167 "@executor/codemode-mcp": "workspace:*",
152168 "@executor/codemode-openapi": "workspace:*",
153169 "@executor/runtime-deno-subprocess": "workspace:*",
@@ -472,6 +488,8 @@ · 318 unmodified lines
472488
473489 "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/codemode-core"],
474490
491+ "@executor/codemode-google-discovery": ["@executor/codemode-google-discovery@workspace:packages/codemode-google-discovery"],
492+
475493 "@executor/codemode-mcp": ["@executor/codemode-mcp@workspace:packages/codemode-mcp"],
476494
477495 "@executor/codemode-openapi": ["@executor/codemode-openapi@workspace:packages/codemode-openapi"],
packages/codemode-core/src/http-request-placements.ts+164−0
@@ -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+};
packages/codemode-core/src/index.ts+2−0
@@ -4,3 +4,5 @@ export * from "./schema-types"; · 3 unmodified lines
44 export * from "./tool-map";
55 export * from "./discovery";
66 export * from "./system-tools";
7+export * from "./http-request-placements";
8+export * from "./isomorphic-hash";
packages/codemode-core/src/isomorphic-hash.ts+132−0
@@ -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)));
packages/codemode-google-discovery/package.json+28−0
@@ -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+}
packages/codemode-google-discovery/src/google-discovery-document.ts+525−0
@@ -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];
packages/codemode-google-discovery/src/google-discovery-tools.test.ts+193−0
@@ -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+});
packages/codemode-google-discovery/src/google-discovery-tools.ts+480−0
@@ -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+};
packages/codemode-google-discovery/src/google-discovery-types.ts+123−0
@@ -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;
packages/codemode-google-discovery/src/index.ts+29−0
@@ -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";
packages/codemode-google-discovery/tsconfig.json+15−0
@@ -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+}
packages/codemode-mcp/src/mcp-connection.ts+0−2
@@ -1,5 +1,3 @@
1−import { URL } from "node:url";
2−
31 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
42 import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
53 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
packages/codemode-openapi/src/openapi-tools.test.ts+125−1
@@ -30,6 +30,7 @@ type TestServer = { · 29 unmodified lines
3030 path: string;
3131 query: string;
3232 body: string;
33+ headers: Record<string, string>;
3334 }>;
3435 close: () => Promise<void>;
3536 };
@@ -53,6 +54,7 @@ const makeEffectServerHandler = ( · 17 unmodified lines
5354 path: `/repos/${path.owner}/${path.repo}`,
5455 query: include ? `?include=${encodeURIComponent(include)}` : "",
5556 body: "",
57+ headers: {},
5658 });
5759
5860 return {
@@ -69,6 +71,7 @@ const makeEffectServerHandler = ( · 10 unmodified lines
6971 path: `/repos/${path.owner}/${path.repo}/issues`,
7072 query: "",
7173 body: bodyText,
74+ headers: {},
7275 });
7376
7477 return {
@@ -207,11 +210,21 @@ const makeTestServer = Effect.acquireRelease( · 132 unmodified lines
207210 catch: (error: unknown) =>
208211 error instanceof Error ? error : new Error(String(error)),
209212 }).pipe(Effect.orDie),
210−);
213+ );
211214
212215 const ownerParam = HttpApiSchema.param("owner", Schema.String);
213216 const repoParam = HttpApiSchema.param("repo", Schema.String);
214217
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+
215228 class GeneratedReposApi extends HttpApiGroup.make("repos")
216229 .add(
217230 HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`
@@ -475,6 +488,117 @@ describe("openapi-tools", () => { · 257 unmodified lines
475488 }),
476489 );
477490
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+
478602 it.scoped("falls back to path-template args when extracted path parameters are missing", () =>
479603 Effect.gen(function* () {
480604 const server = yield* makeTestServer;
packages/codemode-openapi/src/openapi-tools.ts+52−11
@@ -8,8 +8,12 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines
88 import * as Layer from "effect/Layer";
99
1010 import {
11+ applyCookiePlacementsToHeaders,
12+ applyHttpQueryPlacementsToUrl,
13+ applyJsonBodyPlacements,
1114 standardSchemaFromJsonSchema,
1215 toTool,
16+ type HttpRequestPlacements,
1317 type ToolMap,
1418 unknownInputSchema,
1519 } from "@executor/codemode-core";
@@ -288,6 +292,7 @@ export type CreateOpenApiToolsFromManifestInput = { · 272 unmodified lines
288292 sourceKey?: string;
289293 defaultHeaders?: Readonly<Record<string, string>>;
290294 credentialHeaders?: Readonly<Record<string, string>>;
295+ credentialPlacements?: HttpRequestPlacements;
291296 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
292297 };
293298
@@ -317,7 +322,7 @@ const buildFetchRequest = (input: { · 23 unmodified lines
317322 args: OpenApiToolArgs;
318323 baseUrl: string;
319324 defaultHeaders: Readonly<Record<string, string>>;
320− credentialHeaders: Readonly<Record<string, string>>;
325+ credentialPlacements: HttpRequestPlacements;
321326 }): {
322327 url: URL;
323328 method: string;
@@ -369,9 +374,11 @@ const buildFetchRequest = (input: { · 45 unmodified lines
369374 }
370375
371376 let body: string | undefined;
377+ const bodyValues = input.credentialPlacements.bodyValues ?? {};
378+ const hasCredentialBodyValues = Object.keys(bodyValues).length > 0;
372379
373380 if (input.payload.requestBody !== null) {
374− if (!hasRequestBody(input.args)) {
381+ if (!hasRequestBody(input.args) && !hasCredentialBodyValues) {
375382 if (input.payload.requestBody.required) {
376383 throw new OpenApiToolInvocationError({
377384 operation: "validate_args",
@@ -380,7 +387,13 @@ const buildFetchRequest = (input: { · 2 unmodified lines
380387 });
381388 }
382389 } 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+ );
384397
385398 const preferredContentType = input.payload.requestBody.contentTypes[0];
386399 if (preferredContentType) {
@@ -391,14 +404,23 @@ const buildFetchRequest = (input: { · 4 unmodified lines
391404 }
392405 }
393406
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;
396418 }
397419
398420 return {
399− url,
421+ url: urlWithAuth,
400422 method: input.payload.method.toUpperCase(),
401− headers,
423+ headers: headersWithAuthCookies,
402424 body,
403425 };
404426 };
@@ -413,15 +435,32 @@ export type CreateOpenApiToolFromDefinitionInput = { · 8 unmodified lines
413435 baseUrl: string;
414436 defaultHeaders?: Readonly<Record<string, string>>;
415437 credentialHeaders?: Readonly<Record<string, string>>;
438+ credentialPlacements?: HttpRequestPlacements;
416439 refHintTable?: Readonly<OpenApiRefHintTable>;
417440 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
418441 };
419442
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+
420456 export const createOpenApiToolFromDefinition = (
421457 input: CreateOpenApiToolFromDefinitionInput,
422458 ) => {
423459 const defaultHeaders = input.defaultHeaders ?? {};
424− const credentialHeaders = input.credentialHeaders ?? {};
460+ const credentialPlacements = normalizeCredentialPlacements({
461+ credentialHeaders: input.credentialHeaders,
462+ credentialPlacements: input.credentialPlacements,
463+ });
425464 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
426465 const presentation = buildOpenApiToolPresentation({
427466 definition: input.definition,
@@ -441,7 +480,7 @@ export const createOpenApiToolFromDefinition = ( · 13 unmodified lines
441480 args: decodedArgs,
442481 baseUrl: input.baseUrl,
443482 defaultHeaders,
444− credentialHeaders,
483+ credentialPlacements,
445484 });
446485
447486 return Effect.runPromise(
@@ -503,7 +542,6 @@ export const createOpenApiToolsFromManifest = ( · 55 unmodified lines
503542 const baseUrl = normalizeHttpUrl(input.baseUrl);
504543 const sourceKey = input.sourceKey ?? "openapi.generated";
505544 const defaultHeaders = input.defaultHeaders ?? {};
506− const credentialHeaders = input.credentialHeaders ?? {};
507545 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
508546
509547 const definitions = compileOpenApiToolDefinitions(input.manifest);
@@ -517,7 +555,8 @@ export const createOpenApiToolsFromManifest = ( · 7 unmodified lines
517555 sourceKey,
518556 baseUrl,
519557 defaultHeaders,
520− credentialHeaders,
558+ credentialHeaders: input.credentialHeaders,
559+ credentialPlacements: input.credentialPlacements,
521560 refHintTable: input.manifest.refHintTable,
522561 httpClientLayer,
523562 });
@@ -534,6 +573,7 @@ export const createOpenApiToolsFromSpec = (input: { · 10 unmodified lines
534573 sourceKey?: string;
535574 defaultHeaders?: Readonly<Record<string, string>>;
536575 credentialHeaders?: Readonly<Record<string, string>>;
576+ credentialPlacements?: HttpRequestPlacements;
537577 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
538578 }): Effect.Effect<
539579 { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap },
@@ -551,6 +591,7 @@ export const createOpenApiToolsFromSpec = (input: { · 11 unmodified lines
551591 sourceKey: input.sourceKey,
552592 defaultHeaders: input.defaultHeaders,
553593 credentialHeaders: input.credentialHeaders,
594+ credentialPlacements: input.credentialPlacements,
554595 httpClientLayer: input.httpClientLayer,
555596 }),
556597 }),
packages/control-plane/drizzle/20260312113000_auth_artifacts_and_leases/migration.sql+132−0
@@ -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
packages/control-plane/drizzle/20260312143000_auth_lease_template_placements/migration.sql+40−0
@@ -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
packages/control-plane/package.json+1−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
2626 "@effect/platform": "catalog:",
2727 "@electric-sql/pglite": "^0.3.12",
2828 "@executor/codemode-core": "workspace:*",
29+ "@executor/codemode-google-discovery": "workspace:*",
2930 "@executor/codemode-mcp": "workspace:*",
3031 "@executor/runtime-ses": "workspace:*",
3132 "@executor/codemode-openapi": "workspace:*",
packages/control-plane/src/api/payload-schemas.test.ts+17−3
@@ -64,9 +64,23 @@ describe("control-plane payload schemas", () => { · 63 unmodified lines
6464 specUrl: " https://example.com/openapi.json ",
6565 }),
6666 ).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",
7084 });
7185
7286 expect(
packages/control-plane/src/persistence/control-plane-rows.test.ts+94−59
@@ -8,6 +8,9 @@ import { · 7 unmodified lines
88 OrganizationMemberIdSchema,
99 PolicyIdSchema,
1010 SecretMaterialIdSchema,
11+ StaticBearerAuthArtifactConfigJsonSchema,
12+ StaticOAuth2AuthArtifactConfigJsonSchema,
13+ decodeBuiltInAuthArtifactConfig,
1114 SourceAuthSessionIdSchema,
1215 SourceIdSchema,
1316 SourceRecipeIdSchema,
@@ -39,6 +42,12 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 25 unmodified lines
3942 );
4043
4144 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
45+const encodeStaticBearerArtifactConfig = Schema.encodeSync(
46+ StaticBearerAuthArtifactConfigJsonSchema,
47+);
48+const encodeStaticOAuth2ArtifactConfig = Schema.encodeSync(
49+ StaticOAuth2AuthArtifactConfigJsonSchema,
50+);
4251
4352 const openApiBindingConfigJson = (specUrl: string): string =>
4453 JSON.stringify({
@@ -148,7 +157,7 @@ const seedWorkspaceSourceState = (input: { · 103 unmodified lines
148157 });
149158 });
150159
151−const seedWorkspaceCredentialState = (input: {
160+const seedWorkspaceAuthArtifactState = (input: {
152161 persistence: SqlControlPlanePersistence;
153162 accountId: ReturnType<typeof AccountIdSchema.make>;
154163 organizationId: ReturnType<typeof OrganizationIdSchema.make>;
@@ -160,7 +169,7 @@ const seedWorkspaceCredentialState = (input: { · 5 unmodified lines
160169 }, unknown, never> =>
161170 Effect.gen(function* () {
162171 const now = Date.now();
163− const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`);
172+ const authArtifactId = CredentialIdSchema.make(`cred_${input.workspaceId}`);
164173 const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`);
165174 const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`);
166175
@@ -181,19 +190,26 @@ const seedWorkspaceCredentialState = (input: { · 14 unmodified lines
181190 createdAt: now,
182191 updatedAt: now,
183192 });
184− yield* input.persistence.rows.credentials.upsert({
185− id: credentialId,
193+ yield* input.persistence.rows.authArtifacts.upsert({
194+ id: authArtifactId,
186195 workspaceId: input.workspaceId,
187196 sourceId: input.sourceId,
188197 actorAccountId: input.accountId,
189198 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,
197213 createdAt: now,
198214 updatedAt: now,
199215 });
@@ -463,20 +479,23 @@ describe("control-plane-persistence-drizzle", () => { · 263 unmodified lines
463479 });
464480
465481 yield* Effect.tryPromise(async () => {
466− await persistence.db.insert(drizzleSchema.credentialsTable).values([
482+ await persistence.db.insert(drizzleSchema.authArtifactsTable).values([
467483 {
468484 id: nullCredentialA,
469485 workspaceId,
470486 sourceId,
471487 actorAccountId: null,
472488 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,
480499 createdAt: now,
481500 updatedAt: now,
482501 },
@@ -486,79 +505,95 @@ describe("control-plane-persistence-drizzle", () => { · 3 unmodified lines
486505 sourceId,
487506 actorAccountId: null,
488507 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,
496518 createdAt: now + 1,
497519 updatedAt: now + 1,
498520 },
499521 ]);
500522 }).pipe(Effect.orDie);
501523
502− yield* persistence.rows.credentials.upsert({
524+ yield* persistence.rows.authArtifacts.upsert({
503525 id: CredentialIdSchema.make("cred_null_credentials_replacement"),
504526 workspaceId,
505527 sourceId,
506528 actorAccountId: null,
507529 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,
515544 createdAt: now + 2,
516545 updatedAt: now + 2,
517546 });
518547
519− yield* persistence.rows.credentials.upsert({
548+ yield* persistence.rows.authArtifacts.upsert({
520549 id: actorCredentialId,
521550 workspaceId,
522551 sourceId,
523552 actorAccountId: accountId,
524553 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,
532564 createdAt: now + 3,
533565 updatedAt: now + 3,
534566 });
535567
536− const allCredentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({
568+ const allAuthArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({
537569 workspaceId,
538570 sourceId,
539571 });
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(
548583 [actorCredentialId, nullCredentialA].sort(),
549584 );
550585
551− const forActor = yield* persistence.rows.credentials.listByWorkspaceSourceAndActor({
586+ const forActor = yield* persistence.rows.authArtifacts.listByWorkspaceSourceAndActor({
552587 workspaceId,
553588 sourceId,
554589 actorAccountId: accountId,
555590 });
556591 expect(forActor).toHaveLength(2);
557− expect(new Set(forActor.map((credential) => credential.id))).toEqual(
592+ expect(new Set(forActor.map((artifact) => artifact.id))).toEqual(
558593 new Set([actorCredentialId, nullCredentialA]),
559594 );
560595
561− const nullActorOnly = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({
596+ const nullActorOnly = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({
562597 workspaceId,
563598 sourceId,
564599 actorAccountId: null,
@@ -569,7 +604,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines
569604 expect(nullActorOnly.value.id).toBe(nullCredentialA);
570605 }
571606
572− const missingActor = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({
607+ const missingActor = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({
573608 workspaceId,
574609 sourceId,
575610 actorAccountId: AccountIdSchema.make("acc_missing_credentials"),
@@ -588,7 +623,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
588623 const sourceId = SourceIdSchema.make("src_ws_cleanup");
589624 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
590625
591− const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({
626+ const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({
592627 persistence,
593628 accountId,
594629 organizationId,
@@ -670,7 +705,7 @@ describe("control-plane-persistence-drizzle", () => { · 75 unmodified lines
670705 const removed = yield* persistence.rows.workspaces.removeById(workspaceId);
671706 expect(removed).toBe(true);
672707 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);
674709 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
675710 expect(
676711 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId),
@@ -892,7 +927,7 @@ describe("control-plane-persistence-drizzle", () => { · 215 unmodified lines
892927 const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup");
893928 const sourceId = SourceIdSchema.make("src_org_cleanup");
894929
895− const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({
930+ const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({
896931 persistence,
897932 accountId,
898933 organizationId,
@@ -903,7 +938,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines
903938 const removed = yield* persistence.rows.organizations.removeTreeById(organizationId);
904939 expect(removed).toBe(true);
905940 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);
907942 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
908943 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
909944 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
packages/control-plane/src/persistence/index.ts+4−2
@@ -6,7 +6,8 @@ import * as Layer from "effect/Layer"; · 5 unmodified lines
66 import { createDrizzleClient, type DrizzleClient } from "./client";
77 import {
88 createAccountsRepo,
9− createCredentialsRepo,
9+ createAuthArtifactsRepo,
10+ createAuthLeasesRepo,
1011 createCodeMigrationsRepo,
1112 createExecutionInteractionsRepo,
1213 createExecutionsRepo,
@@ -64,7 +65,8 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 51 unmodified lines
6465 sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables),
6566 sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),
6667 codeMigrations: createCodeMigrationsRepo(client, tables),
67− credentials: createCredentialsRepo(client, tables),
68+ authArtifacts: createAuthArtifactsRepo(client, tables),
69+ authLeases: createAuthLeasesRepo(client, tables),
6870 sourceOauthClients: createSourceOauthClientsRepo(client, tables),
6971 secretMaterials: createSecretMaterialsRepo(client, tables),
7072 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),
packages/control-plane/src/persistence/post-migrations.test.ts+181−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
99 McpSourceAuthSessionDataJsonSchema,
1010 OrganizationIdSchema,
1111 SourceAuthSessionIdSchema,
12+ decodeAuthLeasePlacementTemplates,
1213 SourceIdSchema,
1314 SourceRecipeIdSchema,
1415 SourceRecipeRevisionIdSchema,
@@ -753,4 +754,184 @@ describe("code-migrations", () => { · 738 unmodified lines
753754 rmSync(legacyMigrationsDir, { recursive: true, force: true });
754755 }
755756 });
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+ });
756937 });
packages/control-plane/src/persistence/repos/auth-artifacts-repo.ts+226−0
@@ -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+});
packages/control-plane/src/persistence/repos/auth-leases-repo.ts+145−0
@@ -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+});
packages/control-plane/src/persistence/repos/credentials-repo.ts+0−228
@@ -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−});
packages/control-plane/src/persistence/repos/index.ts+2−1
@@ -9,7 +9,8 @@ export { createSourceRecipeDocumentsRepo } from "./source-recipe-documents-repo" · 8 unmodified lines
99 export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo";
1010 export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";
1111 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";
1314 export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
1415 export { createSecretMaterialsRepo } from "./secret-materials-repo";
1516 export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";
packages/control-plane/src/persistence/repos/organizations-repo.ts+13−11
@@ -13,7 +13,7 @@ import type { DrizzleTables } from "../schema"; · 12 unmodified lines
1313 import {
1414 cleanupOrphanedSourceRecipes,
1515 firstOption,
16− postgresSecretHandlesFromCredentials,
16+ postgresSecretHandlesFromAuthArtifacts,
1717 withoutCreatedAt,
1818 } from "./shared";
1919
@@ -144,20 +144,18 @@ export const createOrganizationsRepo = ( · 124 unmodified lines
144144 })
145145 .from(tables.sourcesTable)
146146 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
147− const credentials = await tx
147+ const authArtifacts = await tx
148148 .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,
153151 })
154− .from(tables.credentialsTable)
155− .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
152+ .from(tables.authArtifactsTable)
153+ .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds));
156154
157155 const executionIds = executionRows.map((execution) => execution.id);
158156 const recipeIds = sourceRows.map((source) => source.recipeId);
159157 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
160− const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);
158+ const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts);
161159
162160 if (executionIds.length > 0) {
163161 await tx
@@ -171,6 +169,10 @@ export const createOrganizationsRepo = ( · 7 unmodified lines
171169 .delete(tables.executionsTable)
172170 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
173171
172+ await tx
173+ .delete(tables.authLeasesTable)
174+ .where(inArray(tables.authLeasesTable.workspaceId, workspaceIds));
175+
174176 await tx
175177 .delete(tables.sourceAuthSessionsTable)
176178 .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds));
@@ -180,8 +182,8 @@ export const createOrganizationsRepo = ( · 3 unmodified lines
180182 .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds));
181183
182184 await tx
183− .delete(tables.credentialsTable)
184− .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
185+ .delete(tables.authArtifactsTable)
186+ .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds));
185187
186188 await tx
187189 .delete(tables.sourcesTable)
packages/control-plane/src/persistence/repos/secret-materials-repo.ts+16−23
@@ -1,10 +1,11 @@
11 import {
2+ authArtifactSecretRefs,
23 type SecretMaterial,
34 SecretMaterialSchema,
45 } from "#schema";
56 import * as Option from "effect/Option";
67 import { Schema } from "effect";
7−import { eq, desc, or, and } from "drizzle-orm";
8+import { eq, desc, and } from "drizzle-orm";
89
910 import type { DrizzleClient } from "../client";
1011 import type { DrizzleTables } from "../schema";
@@ -92,34 +93,24 @@ export const createSecretMaterialsRepo = ( · 81 unmodified lines
9293 }),
9394
9495 /**
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.
9697 * Returns a map of secretId -> Array<{ sourceId, sourceName }>.
9798 */
9899 listLinkedSources: () =>
99100 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.
102101 const rows = await db
103102 .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,
109106 sourceName: tables.sourcesTable.name,
110107 })
111− .from(tables.credentialsTable)
108+ .from(tables.authArtifactsTable)
112109 .innerJoin(
113110 tables.sourcesTable,
114111 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),
123114 ),
124115 );
125116
@@ -138,11 +129,13 @@ export const createSecretMaterialsRepo = ( · 12 unmodified lines
138129 }
139130 };
140131
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+ }
146139 }
147140 }
148141
packages/control-plane/src/persistence/repos/shared.ts+8−17
@@ -1,3 +1,4 @@
1+import { authArtifactSecretRefs, type AuthArtifact } from "#schema";
12 import { inArray } from "drizzle-orm";
23 import * as Option from "effect/Option";
34
@@ -16,26 +17,16 @@ export const withoutCreatedAt = <A extends { createdAt: unknown }>( · 12 unmodified lines
1617
1718 const POSTGRES_SECRET_PROVIDER_ID = "postgres";
1819
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">>,
2622 ): ReadonlyArray<string> => {
2723 const handles = new Set<string>();
2824
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+ }
3930 }
4031 }
4132
packages/control-plane/src/persistence/repos/workspaces-repo.ts+13−11
@@ -8,7 +8,7 @@ import type { DrizzleTables } from "../schema"; · 7 unmodified lines
88 import {
99 cleanupOrphanedSourceRecipes,
1010 firstOption,
11− postgresSecretHandlesFromCredentials,
11+ postgresSecretHandlesFromAuthArtifacts,
1212 } from "./shared";
1313
1414 const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
@@ -83,16 +83,14 @@ export const createWorkspacesRepo = ( · 68 unmodified lines
8383 const executionIds = executionRows.map((execution) => execution.id);
8484 const recipeIds = sourceRows.map((source) => source.recipeId);
8585 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
86− const credentials = await tx
86+ const authArtifacts = await tx
8787 .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,
9290 })
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);
9694
9795 if (executionIds.length > 0) {
9896 await tx
@@ -104,6 +102,10 @@ export const createWorkspacesRepo = ( · 5 unmodified lines
104102 .delete(tables.executionsTable)
105103 .where(eq(tables.executionsTable.workspaceId, workspaceId));
106104
105+ await tx
106+ .delete(tables.authLeasesTable)
107+ .where(eq(tables.authLeasesTable.workspaceId, workspaceId));
108+
107109 await tx
108110 .delete(tables.sourceAuthSessionsTable)
109111 .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId));
@@ -113,8 +115,8 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
113115 .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId));
114116
115117 await tx
116− .delete(tables.credentialsTable)
117− .where(eq(tables.credentialsTable.workspaceId, workspaceId));
118+ .delete(tables.authArtifactsTable)
119+ .where(eq(tables.authArtifactsTable.workspaceId, workspaceId));
118120
119121 await tx
120122 .delete(tables.sourcesTable)
packages/control-plane/src/persistence/schema.ts+43−17
@@ -22,7 +22,8 @@ export const tableNames = { · 21 unmodified lines
2222 sourceRecipeSchemaBundles: "source_recipe_schema_bundles",
2323 sourceRecipeOperations: "source_recipe_operations",
2424 codeMigrations: "control_plane_code_migrations",
25− credentials: "workspace_source_credentials",
25+ authArtifacts: "workspace_source_auth_artifacts",
26+ authLeases: "workspace_source_auth_leases",
2627 workspaceSourceOauthClients: "workspace_source_oauth_clients",
2728 secretMaterials: "secret_materials",
2829 sourceAuthSessions: "source_auth_sessions",
@@ -351,48 +352,72 @@ export const codeMigrationsTable = pgTable( · 322 unmodified lines
351352 },
352353 );
353354
354−export const credentialsTable = pgTable(
355− tableNames.credentials,
355+export const authArtifactsTable = pgTable(
356+ tableNames.authArtifacts,
356357 {
357358 id: text("id").notNull().primaryKey(),
358359 workspaceId: text("workspace_id").notNull(),
359360 sourceId: text("source_id").notNull(),
360361 actorAccountId: text("actor_account_id"),
361362 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"),
369366 createdAt: bigint("created_at", { mode: "number" }).notNull(),
370367 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
371368 },
372369 (table) => [
373− index("credentials_workspace_idx").on(
370+ index("auth_artifacts_workspace_idx").on(
374371 table.workspaceId,
375372 table.updatedAt,
376373 table.id,
377374 ),
378− uniqueIndex("credentials_workspace_source_actor_idx").on(
375+ uniqueIndex("auth_artifacts_workspace_source_actor_idx").on(
379376 table.workspaceId,
380377 table.sourceId,
381378 table.actorAccountId,
382379 table.slot,
383380 ),
384− index("credentials_workspace_source_idx").on(
381+ index("auth_artifacts_workspace_source_idx").on(
385382 table.workspaceId,
386383 table.sourceId,
387384 table.updatedAt,
388385 table.id,
389386 ),
390387 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,
393418 ),
394419 check(
395− "credentials_slot_check",
420+ "auth_leases_slot_check",
396421 sql`${table.slot} in ('runtime', 'import')`,
397422 ),
398423 ],
@@ -638,7 +663,8 @@ export const drizzleSchema = { · 239 unmodified lines
638663 sourceRecipeSchemaBundlesTable,
639664 sourceRecipeOperationsTable,
640665 codeMigrationsTable,
641− credentialsTable,
666+ authArtifactsTable,
667+ authLeasesTable,
642668 workspaceSourceOauthClientsTable,
643669 secretMaterialsTable,
644670 sourceAuthSessionsTable,
packages/control-plane/src/runtime/auth-artifacts.ts+403−0
@@ -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+ });
packages/control-plane/src/runtime/auth-leases.ts+305−0
@@ -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+ });
packages/control-plane/src/runtime/executor-tools.ts+20−22
@@ -106,11 +106,6 @@ const trimOrNull = (value: string | null | undefined): string | null => { · 105 unmodified lines
106106 return trimmed.length > 0 ? trimmed : null;
107107 };
108108
109−const resolveOpenApiSourceLabel = (input: {
110− name?: string | null;
111− endpoint: string;
112−}): string => trimOrNull(input.name) ?? input.endpoint;
113−
114109 const resolveLocalCredentialUrl = (input: {
115110 baseUrl: string;
116111 workspaceId: WorkspaceId;
@@ -127,9 +122,12 @@ const promptForSourceCredentialSelection = (input: { · 10 unmodified lines
127122 args: {
128123 workspaceId: WorkspaceId;
129124 sourceId: Source["id"];
130− kind: "openapi" | "graphql";
131− endpoint: string;
125+ kind: "openapi" | "graphql" | "google_discovery";
126+ endpoint?: string;
132127 specUrl?: string;
128+ service?: string;
129+ version?: string;
130+ discoveryUrl?: string | null;
133131 name?: string | null;
134132 namespace?: string | null;
135133 };
@@ -233,6 +231,14 @@ export const createExecutorToolMap = (input: { · 97 unmodified lines
233231 endpoint: string;
234232 name?: string | null;
235233 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;
236242 },
237243 context,
238244 ): Promise<Source> => {
@@ -241,27 +247,19 @@ export const createExecutorToolMap = (input: { · 2 unmodified lines
241247 `executor.sources.add:${crypto.randomUUID()}`,
242248 );
243249 const preparedArgs: ExecutorAddSourceInput =
244− args.kind === "openapi"
250+ args.kind === undefined || args.kind === "mcp"
245251 ? {
246− ...args,
252+ kind: args.kind,
253+ endpoint: args.endpoint,
254+ name: args.name ?? null,
255+ namespace: args.namespace ?? null,
247256 workspaceId: input.workspaceId,
248257 actorAccountId: input.accountId,
249258 executionId,
250259 interactionId,
251260 }
252− : args.kind === "graphql"
253− ? {
254− ...args,
255− workspaceId: input.workspaceId,
256− actorAccountId: input.accountId,
257− executionId,
258− interactionId,
259− }
260261 : {
261− kind: args.kind,
262− endpoint: args.endpoint,
263− name: args.name ?? null,
264− namespace: args.namespace ?? null,
262+ ...args,
265263 workspaceId: input.workspaceId,
266264 actorAccountId: input.accountId,
267265 executionId,
@@ -293,7 +291,7 @@ export const createExecutorToolMap = (input: { · 25 unmodified lines
293291 let pendingResult = result;
294292 let pendingArgs = preparedArgs as Extract<
295293 ExecutorAddSourceInput,
296− { kind: "openapi" | "graphql" }
294+ { kind: "openapi" | "graphql" | "google_discovery" }
297295 >;
298296
299297 while (pendingResult.kind === "credential_required") {
packages/control-plane/src/runtime/graphql-tools.test.ts+154−0
@@ -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+});
packages/control-plane/src/runtime/graphql-tools.ts+48−20
@@ -1,8 +1,11 @@
1−import { createHash } from "node:crypto";
2−
31 import {
2+ applyCookiePlacementsToHeaders,
3+ applyHttpQueryPlacementsToUrl,
4+ applyJsonBodyPlacements,
5+ sha256Hex,
46 standardSchemaFromJsonSchema,
57 toTool,
8+ type HttpRequestPlacements,
69 type ToolDescriptor,
710 type ToolMap,
811 type ToolMetadata,
@@ -137,6 +140,7 @@ type GraphqlHttpInvocation = { · 128 unmodified lines
137140 path: string;
138141 defaultHeaders?: Readonly<Record<string, string>>;
139142 credentialHeaders?: Readonly<Record<string, string>>;
143+ credentialPlacements?: HttpRequestPlacements;
140144 query: string;
141145 variables?: Record<string, unknown>;
142146 operationName?: string;
@@ -998,7 +1002,7 @@ const resolveGraphqlFieldToolIds = ( · 855 unmodified lines
9981002 );
9991003 applyDuplicates(
10001004 (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)}`,
10021006 );
10031007
10041008 return staged
@@ -1071,7 +1075,7 @@ const fieldToolDraftsFromRootType = (input: { · 66 unmodified lines
10711075 };
10721076
10731077 export const createGraphqlSourceHash = (documentText: string): string =>
1074− createHash("sha256").update(documentText).digest("hex");
1078+ sha256Hex(documentText);
10751079
10761080 export const extractGraphqlManifest = (
10771081 sourceName: string,
@@ -1272,6 +1276,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 194 unmodified lines
12721276 schemaRefTable?: Readonly<Record<string, unknown>>;
12731277 defaultHeaders?: Readonly<Record<string, string>>;
12741278 credentialHeaders?: Readonly<Record<string, string>>;
1279+ credentialPlacements?: HttpRequestPlacements;
12751280 }) => {
12761281 const decodedProviderData = decodeGraphqlToolProviderDataJson(
12771282 input.providerDataJson,
@@ -1340,6 +1345,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 62 unmodified lines
13401345 path: input.path,
13411346 defaultHeaders: input.defaultHeaders,
13421347 credentialHeaders: input.credentialHeaders,
1348+ credentialPlacements: input.credentialPlacements,
13431349 args,
13441350 }),
13451351 );
@@ -1351,6 +1357,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 5 unmodified lines
13511357 path: input.path,
13521358 defaultHeaders: input.defaultHeaders,
13531359 credentialHeaders: input.credentialHeaders,
1360+ credentialPlacements: input.credentialPlacements,
13541361 args,
13551362 }),
13561363 );
@@ -1396,25 +1403,40 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 39 unmodified lines
13961403
13971404 const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) =>
13981405 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+
13991434 const response = yield* Effect.tryPromise({
14001435 try: () =>
1401− fetch(input.endpoint, {
1436+ fetch(endpoint, {
14021437 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),
14181440 }),
14191441 catch: (cause) =>
14201442 graphqlToolError(`GraphQL request failed for ${input.path}`, cause),
@@ -1446,6 +1468,7 @@ const invokeRawGraphqlTool = (input: { · 25 unmodified lines
14461468 path: string;
14471469 defaultHeaders?: Readonly<Record<string, string>>;
14481470 credentialHeaders?: Readonly<Record<string, string>>;
1471+ credentialPlacements?: HttpRequestPlacements;
14491472 args: unknown;
14501473 }) =>
14511474 Effect.gen(function* () {
@@ -1462,6 +1485,7 @@ const invokeRawGraphqlTool = (input: { · 10 unmodified lines
14621485 path: input.path,
14631486 defaultHeaders: input.defaultHeaders,
14641487 credentialHeaders: input.credentialHeaders,
1488+ credentialPlacements: input.credentialPlacements,
14651489 query,
14661490 variables:
14671491 record.variables !== undefined ? asRecord(record.variables) : undefined,
@@ -1486,6 +1510,7 @@ const invokeGraphqlFieldTool = (input: { · 18 unmodified lines
14861510 path: string;
14871511 defaultHeaders?: Readonly<Record<string, string>>;
14881512 credentialHeaders?: Readonly<Record<string, string>>;
1513+ credentialPlacements?: HttpRequestPlacements;
14891514 args: unknown;
14901515 }) =>
14911516 Effect.gen(function* () {
@@ -1501,6 +1526,7 @@ const invokeGraphqlFieldTool = (input: { · 9 unmodified lines
15011526 path: input.path,
15021527 defaultHeaders: input.defaultHeaders,
15031528 credentialHeaders: input.credentialHeaders,
1529+ credentialPlacements: input.credentialPlacements,
15041530 query: input.entry.operationDocument,
15051531 variables,
15061532 operationName: input.entry.operationName,
@@ -1524,6 +1550,7 @@ export const createGraphqlToolsFromManifest = (input: { · 17 unmodified lines
15241550 sourceKey: string;
15251551 defaultHeaders?: Readonly<Record<string, string>>;
15261552 credentialHeaders?: Readonly<Record<string, string>>;
1553+ credentialPlacements?: HttpRequestPlacements;
15271554 }): ToolMap => {
15281555 const endpoint = normalizeHttpUrl(input.endpoint);
15291556 const definitions = compileGraphqlToolDefinitions(input.manifest);
@@ -1552,6 +1579,7 @@ export const createGraphqlToolsFromManifest = (input: { · 22 unmodified lines
15521579 schemaRefTable: input.manifest.schemaRefTable,
15531580 defaultHeaders: input.defaultHeaders,
15541581 credentialHeaders: input.credentialHeaders,
1582+ credentialPlacements: input.credentialPlacements,
15551583 }),
15561584 ] as const;
15571585 }),
packages/control-plane/src/runtime/oauth-loopback.test.ts+77−0
@@ -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+});
packages/control-plane/src/runtime/oauth-loopback.ts+104−0
@@ -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+ });
packages/control-plane/src/runtime/oauth2-pkce.ts+172−0
@@ -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+ });
packages/control-plane/src/runtime/operations-shared.ts+11−4
@@ -23,14 +23,21 @@ const isUniqueViolation = (error: ControlPlanePersistenceError): boolean => · 22 unmodified lines
2323
2424 export const mapPersistenceError = <A>(
2525 operation: OperationErrorsLike,
26− effect: Effect.Effect<A, ControlPlanePersistenceError>,
26+ effect: Effect.Effect<A, ControlPlanePersistenceError | Error>,
2727 ): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> =>
2828 effect.pipe(
2929 Effect.mapError((error) => {
3030 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+ );
3441 }),
3542 );
3643
packages/control-plane/src/runtime/source-adapters/google-discovery.test.ts+119−0
@@ -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+});
packages/control-plane/src/runtime/source-adapters/google-discovery.ts+639−0
@@ -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+};
packages/control-plane/src/runtime/source-adapters/graphql.ts+34−10
@@ -1,4 +1,7 @@
11 import {
2+ applyCookiePlacementsToHeaders,
3+ applyHttpQueryPlacementsToUrl,
4+ applyJsonBodyPlacements,
25 makeToolInvokerFromTools,
36 typeSignatureFromSchemaJson,
47 } from "@executor/codemode-core";
@@ -135,20 +138,38 @@ const graphqlBindingConfigFromSource = ( · 130 unmodified lines
135138 const fetchGraphqlIntrospectionDocumentWithHeaders = (input: {
136139 url: string;
137140 headers?: Readonly<Record<string, string>>;
141+ queryParams?: Readonly<Record<string, string>>;
142+ cookies?: Readonly<Record<string, string>>;
143+ bodyValues?: Readonly<Record<string, string>>;
138144 }): Effect.Effect<string, Error, never> =>
139145 Effect.tryPromise({
140146 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+ {
142153 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+ ),
146171 },
147− body: JSON.stringify({
148− query: GRAPHQL_INTROSPECTION_QUERY,
149− operationName: "IntrospectionQuery",
150− }),
151− });
172+ );
152173 const text = await response.text();
153174
154175 let parsed: unknown;
@@ -470,6 +491,9 @@ export const graphqlSourceAdapter: SourceAdapter = { · 315 unmodified lines
470491 ...(bindingConfig.defaultHeaders ?? {}),
471492 ...auth.headers,
472493 },
494+ queryParams: auth.queryParams,
495+ cookies: auth.cookies,
496+ bodyValues: auth.bodyValues,
473497 },
474498 ).pipe(
475499 Effect.mapError(
@@ -564,7 +588,7 @@ export const graphqlSourceAdapter: SourceAdapter = { · 88 unmodified lines
564588 providerDataJson: operation.providerDataJson,
565589 schemaRefTable: schemaRefTable.right,
566590 defaultHeaders: bindingConfig.defaultHeaders ?? {},
567− credentialHeaders: auth.headers,
591+ credentialPlacements: auth,
568592 });
569593
570594 return yield* makeToolInvokerFromTools({
packages/control-plane/src/runtime/source-adapters/index.ts+6−0
@@ -2,6 +2,7 @@ import type { Source, StoredSourceRecipeOperationRecord } from "#schema"; · 1 unmodified line
22 import * as Schema from "effect/Schema";
33
44 import { graphqlSourceAdapter } from "./graphql";
5+import { googleDiscoverySourceAdapter } from "./google-discovery";
56 import { internalSourceAdapter } from "./internal";
67 import { mcpSourceAdapter } from "./mcp";
78 import { openApiSourceAdapter } from "./openapi";
@@ -10,6 +11,7 @@ import type { SourceAdapter } from "./types"; · 2 unmodified lines
1011 export const builtInSourceAdapters = [
1112 openApiSourceAdapter,
1213 graphqlSourceAdapter,
14+ googleDiscoverySourceAdapter,
1315 mcpSourceAdapter,
1416 internalSourceAdapter,
1517 ] as const satisfies readonly SourceAdapter[];
@@ -18,12 +20,14 @@ export const connectableSourceAdapters = [ · 2 unmodified lines
1820 mcpSourceAdapter,
1921 openApiSourceAdapter,
2022 graphqlSourceAdapter,
23+ googleDiscoverySourceAdapter,
2124 ] as const;
2225
2326 export const ConnectSourcePayloadSchema = Schema.Union(
2427 mcpSourceAdapter.connectPayloadSchema!,
2528 openApiSourceAdapter.connectPayloadSchema!,
2629 graphqlSourceAdapter.connectPayloadSchema!,
30+ googleDiscoverySourceAdapter.connectPayloadSchema!,
2731 );
2832
2933 export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type;
@@ -32,12 +36,14 @@ export const executorAddableSourceAdapters = [ · 2 unmodified lines
3236 mcpSourceAdapter,
3337 openApiSourceAdapter,
3438 graphqlSourceAdapter,
39+ googleDiscoverySourceAdapter,
3540 ] as const;
3641
3742 export const ExecutorAddSourceInputSchema = Schema.Union(
3843 mcpSourceAdapter.executorAddInputSchema!,
3944 openApiSourceAdapter.executorAddInputSchema!,
4045 graphqlSourceAdapter.executorAddInputSchema!,
46+ googleDiscoverySourceAdapter.executorAddInputSchema!,
4147 );
4248
4349 export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type;
packages/control-plane/src/runtime/source-adapters/mcp.ts+30−8
@@ -1,4 +1,5 @@
11 import {
2+ applyCookiePlacementsToHeaders,
23 makeToolInvokerFromTools,
34 } from "@executor/codemode-core";
45 import {
@@ -41,6 +42,19 @@ import { · 36 unmodified lines
4142 SourceConnectCommonFieldsSchema,
4243 } from "./shared";
4344
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+
4458 const McpConnectPayloadSchema = Schema.extend(
4559 SourceConnectCommonFieldsSchema,
4660 Schema.extend(
@@ -339,11 +353,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 292 unmodified lines
339353 createSdkMcpConnector({
340354 endpoint: source.endpoint,
341355 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,
346359 },
360+ headers: headersWithAuthCookies({
361+ headers: bindingConfig.headers ?? {},
362+ authHeaders: auth.headers,
363+ authCookies: auth.cookies,
364+ }),
347365 }),
348366 catch: (cause) =>
349367 cause instanceof Error ? cause : new Error(String(cause)),
@@ -394,11 +412,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 44 unmodified lines
394412 connect: createSdkMcpConnector({
395413 endpoint: source.endpoint,
396414 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,
401418 },
419+ headers: headersWithAuthCookies({
420+ headers: bindingConfig.headers ?? {},
421+ authHeaders: auth.headers,
422+ authCookies: auth.cookies,
423+ }),
402424 }),
403425 namespace: source.namespace ?? namespaceFromSourceName(source.name),
404426 sourceKey: source.id,
packages/control-plane/src/runtime/source-adapters/openapi.ts+19−3
@@ -5,6 +5,8 @@ import { · 4 unmodified lines
55 } from "@effect/platform";
66 import type { SqlControlPlaneRows } from "#persistence";
77 import {
8+ applyCookiePlacementsToHeaders,
9+ applyHttpQueryPlacementsToUrl,
810 makeToolInvokerFromTools,
911 } from "@executor/codemode-core";
1012 import {
@@ -152,11 +154,23 @@ const openApiBindingConfigFromSource = ( · 141 unmodified lines
152154 const fetchOpenApiDocumentWithHeaders = (input: {
153155 url: string;
154156 headers?: Readonly<Record<string, string>>;
157+ queryParams?: Readonly<Record<string, string>>;
158+ cookies?: Readonly<Record<string, string>>;
155159 }): Effect.Effect<string, Error, never> =>
156160 Effect.gen(function* () {
157161 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+ ),
160174 );
161175 const response = yield* client.execute(request).pipe(
162176 Effect.mapError((cause) =>
@@ -522,6 +536,8 @@ export const openApiSourceAdapter: SourceAdapter = { · 359 unmodified lines
522536 ...(bindingConfig.defaultHeaders ?? {}),
523537 ...auth.headers,
524538 },
539+ queryParams: auth.queryParams,
540+ cookies: auth.cookies,
525541 }).pipe(
526542 Effect.mapError(
527543 (cause) =>
@@ -609,7 +625,7 @@ export const openApiSourceAdapter: SourceAdapter = { · 81 unmodified lines
609625 sourceKey: source.id,
610626 baseUrl: source.endpoint,
611627 defaultHeaders: bindingConfig.defaultHeaders ?? {},
612− credentialHeaders: auth.headers,
628+ credentialPlacements: auth,
613629 refHintTable: refHintTable.right,
614630 });
615631
packages/control-plane/src/runtime/source-adapters/types.ts+26−0
@@ -7,6 +7,7 @@ import type { SqlControlPlaneRows } from "#persistence"; · 6 unmodified lines
77 import type {
88 AccountId,
99 CredentialSlot,
10+ OAuth2ClientAuthenticationMethod,
1011 Source,
1112 SourceBinding,
1213 SourceImportAuthPolicy,
@@ -68,6 +69,24 @@ export type SourceAdapterPersistedOperationMetadata = { · 55 unmodified lines
6869 approvalLabel: string | null;
6970 };
7071
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+
7190 export type SourceAdapterRepairRevisionInput = {
7291 rows: SqlControlPlaneRows;
7392 sourceRecord: StoredSourceRecord;
@@ -137,6 +156,13 @@ export type SourceAdapter = { · 63 unmodified lines
137156 materializeSource: (
138157 input: SourceAdapterSyncInput,
139158 ) => 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>;
140166 invokePersistedTool: (
141167 input: SourceAdapterInvokePersistedToolInput,
142168 ) => Effect.Effect<unknown, Error, never>;
packages/control-plane/src/runtime/source-auth-material.test.ts+33−1
@@ -43,7 +43,15 @@ describe("source-auth-material", () => { · 42 unmodified lines
4343 resolveSecretMaterial: () => Effect.die("should not be called"),
4444 }));
4545
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+ });
4755 });
4856
4957 it("resolves bearer auth headers from the configured token ref", async () => {
@@ -69,9 +77,21 @@ describe("source-auth-material", () => { · 19 unmodified lines
6977
7078 expect(calls).toEqual(["postgres:sec_bearer"]);
7179 expect(auth).toEqual({
80+ placements: [
81+ {
82+ location: "header",
83+ name: "X-Api-Key",
84+ value: "Token resolved-bearer-token",
85+ },
86+ ],
7287 headers: {
7388 "X-Api-Key": "Token resolved-bearer-token",
7489 },
90+ queryParams: {},
91+ cookies: {},
92+ bodyValues: {},
93+ expiresAt: null,
94+ refreshAfter: null,
7595 });
7696 });
7797
@@ -107,9 +127,21 @@ describe("source-auth-material", () => { · 29 unmodified lines
107127
108128 expect(calls).toEqual(["postgres:sec_access"]);
109129 expect(auth).toEqual({
130+ placements: [
131+ {
132+ location: "header",
133+ name: "Authorization",
134+ value: "Bearer resolved-access-token",
135+ },
136+ ],
110137 headers: {
111138 Authorization: "Bearer resolved-access-token",
112139 },
140+ queryParams: {},
141+ cookies: {},
142+ bodyValues: {},
143+ expiresAt: null,
144+ refreshAfter: null,
113145 });
114146 });
115147 });
packages/control-plane/src/runtime/source-auth-material.ts+75−38
@@ -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";
28 import * as Effect from "effect/Effect";
9+import * as Option from "effect/Option";
310
11+import {
12+ authArtifactFromSourceAuth,
13+ resolveAuthArtifactMaterial,
14+ type ResolvedSourceAuthMaterial,
15+} from "./auth-artifacts";
16+import { resolveAuthArtifactMaterialWithLeases } from "./auth-leases";
417 import type {
518 ResolveSecretMaterial,
619 SecretMaterialResolveContext,
720 } from "./secret-material-providers";
821
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−
4022 const authForSlot = (input: {
4123 source: Source;
4224 slot: CredentialSlot;
@@ -59,14 +41,69 @@ const authForSlot = (input: { · 16 unmodified lines
5941 export const resolveSourceAuthMaterial = (input: {
6042 source: Source;
6143 slot?: CredentialSlot;
44+ actorAccountId?: AccountId | null;
45+ rows?: SqlControlPlaneRows;
6246 resolveSecretMaterial: ResolveSecretMaterial;
6347 context?: SecretMaterialResolveContext;
6448 }): 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({
6778 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+ });
72107 });
108+
109+export type { ResolvedSourceAuthMaterial } from "./auth-artifacts";
packages/control-plane/src/runtime/source-auth-service.test.ts+517−0
@@ -1,14 +1,33 @@
1+import { createServer } from "node:http";
2+
13 import { describe, expect, it } from "vitest";
24 import * as Schema from "effect/Schema";
5+import * as Effect from "effect/Effect";
6+import * as Option from "effect/Option";
37
48 import {
9+ AccountIdSchema,
510 McpSourceAuthSessionDataJsonSchema,
11+ OAuth2PkceSourceAuthSessionDataJsonSchema,
12+ SecretMaterialIdSchema,
613 type Source,
14+ type SourceAuthSession,
15+ WorkspaceIdSchema,
16+ decodeAuthLeasePlacementTemplates,
17+ decodeBuiltInAuthArtifactConfig,
718 } from "#schema";
19+import {
20+ createSqlControlPlanePersistence,
21+ type SqlControlPlanePersistence,
22+} from "#persistence";
823
924 import {
25+ createRuntimeSourceAuthService,
1026 createTerminalSourceAuthSessionPatch,
1127 } from "./source-auth-service";
28+import { createLiveExecutionManager } from "./live-execution";
29+import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
30+import { resolveSourceAuthMaterial } from "./source-auth-material";
1231
1332 const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({
1433 id: "src_test" as Source["id"],
@@ -33,6 +52,117 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 18 unmodified lines
3352 updatedAt: 1,
3453 });
3554
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+
36166 describe("source-auth-service", () => {
37167 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
38168
@@ -89,4 +219,391 @@ describe("source-auth-service", () => { · 50 unmodified lines
89219 updatedAt: 456,
90220 });
91221 });
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);
92609 });
packages/control-plane/src/runtime/source-auth-service.ts+710−67
@@ -13,6 +13,8 @@ import { · 12 unmodified lines
1313 ExecutionIdSchema,
1414 McpSourceAuthSessionDataJsonSchema,
1515 type McpSourceAuthSessionData,
16+ OAuth2PkceSourceAuthSessionDataJsonSchema,
17+ type OAuth2PkceSourceAuthSessionData,
1618 type SecretMaterialPurpose,
1719 Source,
1820 type SourceImportAuthPolicy,
@@ -23,6 +25,9 @@ import { · 4 unmodified lines
2325 SourceSchema,
2426 type SecretRef,
2527 type StringMap,
28+ WorkspaceSourceOauthClientIdSchema,
29+ WorkspaceSourceOauthClientMetadataJsonSchema,
30+ type WorkspaceSourceOauthClientRedirectMode,
2631 type WorkspaceId,
2732 } from "#schema";
2833 import * as Context from "effect/Context";
@@ -46,6 +51,7 @@ import { · 17 unmodified lines
4651 updateSourceFromPayload,
4752 } from "./source-definitions";
4853 import {
54+ getSourceAdapterForSource,
4955 hasSourceAdapterFamily,
5056 sourceBindingStateFromSource,
5157 } from "./source-adapters";
@@ -56,10 +62,17 @@ import { · 4 unmodified lines
5662 type ResolveSecretMaterial,
5763 type StoreSecretMaterial,
5864 } from "./secret-material-providers";
65+import { upsertOauth2AuthorizedUserLeaseFromTokenResponse } from "./auth-leases";
5966 import {
6067 persistMcpRecipeMaterializationFromManifest,
6168 syncSourceMaterialization,
6269 } from "./source-materialization";
70+import {
71+ buildOAuth2AuthorizationUrl,
72+ createPkceCodeVerifier,
73+ exchangeOAuth2AuthorizationCode,
74+} from "./oauth2-pkce";
75+import { startOauthLoopbackRedirectServer } from "./oauth-loopback";
6376 import {
6477 loadSourceById,
6578 loadSourcesInWorkspace,
@@ -113,6 +126,22 @@ const normalizeEndpoint = (endpoint: string): string => { · 47 unmodified lines
113126 return url.toString();
114127 };
115128
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+
116145 const SourceOAuthSessionStatePayloadSchema = Schema.Struct({
117146 kind: Schema.Literal("source_oauth"),
118147 nonce: Schema.String,
@@ -228,6 +257,42 @@ const mergeMcpSourceAuthSessionData = (input: { · 109 unmodified lines
228257 });
229258 };
230259
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+
231296
232297 const completeLiveInteraction = (input: {
233298 liveExecutionManager: LiveExecutionManager;
@@ -361,6 +426,22 @@ export type ExecutorAddSourceInput = · 127 unmodified lines
361426 importAuthPolicy?: SourceImportAuthPolicy | null;
362427 importAuth?: ExecutorHttpSourceAuthInput | null;
363428 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;
364445 };
365446
366447 export type ConnectMcpSourceInput = {
@@ -525,7 +606,7 @@ const materializeExecutorHttpAuth = (input: { · 158 unmodified lines
525606 });
526607
527608 const materializeExecutorHttpImportAuth = (input: {
528− sourceKind: "openapi" | "graphql";
609+ sourceKind: "openapi" | "graphql" | "google_discovery";
529610 existing?: Source;
530611 importAuthPolicy?: SourceImportAuthPolicy | null;
531612 importAuth?: ExecutorHttpSourceAuthInput | null;
@@ -535,7 +616,10 @@ const materializeExecutorHttpImportAuth = (input: { · 3 unmodified lines
535616 importAuth: Source["importAuth"];
536617 }, Error, never> =>
537618 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"
539623 ? "reuse_runtime"
540624 : "none";
541625 const importAuthPolicy = input.importAuthPolicy ?? input.existing?.importAuthPolicy ?? adapterDefault;
@@ -579,6 +663,234 @@ const shouldPromptForExecutorHttpRuntimeCredentialSetup = (input: { · 37 unmodified lines
579663 && input.auth.kind === "none"
580664 && (input.existing?.auth.kind ?? "none") === "none";
581665
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+
582894 const connectMcpSourceInternal = (input: {
583895 rows: SqlControlPlaneRows;
584896 getLocalServerBaseUrl?: () => string | undefined;
@@ -692,6 +1004,7 @@ const connectMcpSourceInternal = (input: { · 107 unmodified lines
6921004 yield* syncSourceMaterialization({
6931005 rows: input.rows,
6941006 source: persistedDraft,
1007+ actorAccountId: input.actorAccountId,
6951008 resolveSecretMaterial: input.resolveSecretMaterial,
6961009 });
6971010
@@ -812,6 +1125,8 @@ const addExecutorHttpSource = (input: { · 114 unmodified lines
8121125 sourceInput: Extract<ExecutorAddSourceInput, { kind: "openapi" | "graphql" }>;
8131126 storeSecretMaterial: StoreSecretMaterial;
8141127 resolveSecretMaterial: ResolveSecretMaterial;
1128+ getLocalServerBaseUrl?: () => string | undefined;
1129+ baseUrl?: string | null;
8151130 }): Effect.Effect<ExecutorSourceAddResult, Error, never> =>
8161131 Effect.gen(function* () {
8171132 const normalizedEndpoint = normalizeEndpoint(input.sourceInput.endpoint);
@@ -926,6 +1241,24 @@ const addExecutorHttpSource = (input: { · 108 unmodified lines
9261241 explicitAuthProvided: input.sourceInput.auth !== undefined,
9271242 auth: persistedDraft.auth,
9281243 })) {
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+
9291262 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
9301263 actorAccountId: input.sourceInput.actorAccountId,
9311264 status: "auth_required",
@@ -946,6 +1279,218 @@ const addExecutorHttpSource = (input: { · 14 unmodified lines
9461279 ...persistedDraft,
9471280 status: "connected",
9481281 },
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,
9491494 resolveSecretMaterial: input.resolveSecretMaterial,
9501495 }),
9511496 );
@@ -1058,8 +1603,17 @@ export const createRuntimeSourceAuthService = (input: { · 106 unmodified lines
10581603 actorAccountId,
10591604 }),
10601605
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")
10631617 ? addExecutorHttpSource({
10641618 rows: input.rows,
10651619 sourceInput: sourceInput as Extract<
@@ -1068,6 +1622,8 @@ export const createRuntimeSourceAuthService = (input: { · 2 unmodified lines
10681622 >,
10691623 storeSecretMaterial,
10701624 resolveSecretMaterial,
1625+ getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1626+ baseUrl: options?.baseUrl,
10711627 })
10721628 : connectMcpSourceInternal({
10731629 rows: input.rows,
@@ -1297,7 +1853,6 @@ export const createRuntimeSourceAuthService = (input: { · 223 unmodified lines
12971853 }
12981854
12991855 const session = sessionOption.value;
1300− const sessionData = decodeMcpSourceAuthSessionData(session);
13011856 if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) {
13021857 return yield* Effect.fail(
13031858 new Error(
@@ -1330,6 +1885,10 @@ export const createRuntimeSourceAuthService = (input: { · 26 unmodified lines
13301885 );
13311886 }
13321887
1888+ const sessionData = session.providerKind === "oauth2_pkce"
1889+ ? decodeOauth2PkceSourceAuthSessionData(session)
1890+ : decodeMcpSourceAuthSessionData(session);
1891+
13331892 if (trimOrNull(error) !== null) {
13341893 const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed";
13351894 const failedAt = Date.now();
@@ -1351,6 +1910,7 @@ export const createRuntimeSourceAuthService = (input: { · 15 unmodified lines
13511910 yield* syncSourceMaterialization({
13521911 rows: input.rows,
13531912 source: failedSource,
1913+ actorAccountId: session.actorAccountId,
13541914 resolveSecretMaterial,
13551915 });
13561916 yield* completeLiveInteraction({
@@ -1374,54 +1934,157 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines
13741934 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));
13751935 }
13761936
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+ }
13901963
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+ }
14072001
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+ }
14212083 const indexed = yield* Effect.either(
14222084 syncSourceMaterialization({
14232085 rows: input.rows,
14242086 source: connectedSource,
2087+ actorAccountId: session.actorAccountId,
14252088 resolveSecretMaterial,
14262089 }),
14272090 );
@@ -1437,26 +2100,6 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines
14372100 onRight: () => Effect.succeed(undefined),
14382101 });
14392102
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−
14602103 yield* completeLiveInteraction({
14612104 liveExecutionManager: input.liveExecutionManager,
14622105 session,
packages/control-plane/src/runtime/source-definitions.test.ts+15−12
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
33
44 import {
55 CredentialIdSchema,
6+ decodeBuiltInAuthArtifactConfig,
67 SourceIdSchema,
78 WorkspaceIdSchema,
89 type Source,
@@ -361,14 +362,16 @@ describe("source-definitions", () => { · 352 unmodified lines
361362 const recipeRevisionId = stableSourceRecipeRevisionId(source);
362363 const existingCredentialId = CredentialIdSchema.make("cred_existing");
363364
364− const { sourceRecord, runtimeCredential } = splitSourceForStorage({
365+ const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({
365366 source,
366367 recipeId,
367368 recipeRevisionId,
368− existingRuntimeCredentialId: existingCredentialId,
369+ existingRuntimeAuthArtifactId: existingCredentialId,
369370 });
370371
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");
372375 expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({
373376 adapterKey: "mcp",
374377 version: 1,
@@ -377,8 +380,8 @@ describe("source-definitions", () => { · 2 unmodified lines
377380
378381 const projected = await Effect.runPromise(projectSourceFromStorage({
379382 sourceRecord,
380− runtimeCredential: runtimeCredential ?? null,
381− importCredential: null,
383+ runtimeAuthArtifact: runtimeAuthArtifact ?? null,
384+ importAuthArtifact: null,
382385 }));
383386
384387 expect(projected).toEqual(source);
@@ -421,15 +424,15 @@ describe("source-definitions", () => { · 36 unmodified lines
421424 });
422425
423426 for (const source of [withRefresh, withoutRefresh]) {
424− const { sourceRecord, runtimeCredential } = splitSourceForStorage({
427+ const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({
425428 source,
426429 recipeId: stableSourceRecipeId(source),
427430 recipeRevisionId: stableSourceRecipeRevisionId(source),
428431 });
429432 const projected = await Effect.runPromise(projectSourceFromStorage({
430433 sourceRecord,
431− runtimeCredential: runtimeCredential ?? null,
432− importCredential: null,
434+ runtimeAuthArtifact: runtimeAuthArtifact ?? null,
435+ importAuthArtifact: null,
433436 }));
434437
435438 expect(projected).toEqual(source);
@@ -440,18 +443,18 @@ describe("source-definitions", () => { · 4 unmodified lines
440443 const source = makeSource({
441444 auth: { kind: "none" },
442445 });
443− const { sourceRecord, runtimeCredential } = splitSourceForStorage({
446+ const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({
444447 source,
445448 recipeId: stableSourceRecipeId(source),
446449 recipeRevisionId: stableSourceRecipeRevisionId(source),
447450 });
448451
449− expect(runtimeCredential).toBeNull();
452+ expect(runtimeAuthArtifact).toBeNull();
450453
451454 const projected = await Effect.runPromise(projectSourceFromStorage({
452455 sourceRecord,
453− runtimeCredential: null,
454− importCredential: null,
456+ runtimeAuthArtifact: null,
457+ importAuthArtifact: null,
455458 }));
456459
457460 expect(projected.auth).toEqual({ kind: "none" });
packages/control-plane/src/runtime/source-definitions.ts+84−110
@@ -1,4 +1,4 @@
1−import { createHash } from "node:crypto";
1+import { sha256Hex } from "@executor/codemode-core";
22
33 import type {
44 CreateSourcePayload,
@@ -6,7 +6,7 @@ import type { · 1 unmodified line
66 } from "#api";
77 import type {
88 AccountId,
9− Credential,
9+ AuthArtifact,
1010 CredentialSlot,
1111 Source,
1212 SourceAuth,
@@ -21,13 +21,17 @@ import type { · 8 unmodified lines
2121 WorkspaceId,
2222 } from "#schema";
2323 import {
24− CredentialIdSchema,
24+ AuthArtifactIdSchema,
2525 SourceRecipeIdSchema,
2626 SourceRecipeRevisionIdSchema,
2727 } from "#schema";
2828 import * as Effect from "effect/Effect";
2929
3030 import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters";
31+import {
32+ authArtifactFromSourceAuth,
33+ sourceAuthFromAuthArtifact,
34+} from "./auth-artifacts";
3135
3236 const trimOrNull = (value: string | null | undefined): string | null => {
3337 if (value === null || value === undefined) {
@@ -57,7 +61,7 @@ const sourceRecipeProviderKeyFromSource = (source: Source): string => { · 23 unmodified lines
5761 };
5862
5963 const stableHash = (value: string): string =>
60− createHash("sha256").update(value).digest("hex").slice(0, 24);
64+ sha256Hex(value).slice(0, 24);
6165
6266 const sourceRecipeSignature = (source: Source): string =>
6367 JSON.stringify({
@@ -89,7 +93,7 @@ const normalizeAuth = ( · 25 unmodified lines
8993 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
9094 const prefix = auth.prefix ?? "Bearer ";
9195
92− if (auth.kind === "bearer") {
96+ if (auth.kind === "bearer") {
9397 const providerId = trimOrNull(auth.token.providerId);
9498 const handle = trimOrNull(auth.token.handle);
9599 if (providerId === null || handle === null) {
@@ -107,6 +111,54 @@ const normalizeAuth = ( · 11 unmodified lines
107111 } satisfies SourceAuth;
108112 }
109113
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+
110162 const accessProviderId = trimOrNull(auth.accessToken.providerId);
111163 const accessHandle = trimOrNull(auth.accessToken.handle);
112164 if (accessProviderId === null || accessHandle === null) {
@@ -146,86 +198,6 @@ const normalizeImportAuthPolicy = ( · 33 unmodified lines
146198 policy: SourceImportAuthPolicy | undefined,
147199 ): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy;
148200
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−
229201 const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> =>
230202 Effect.gen(function* () {
231203 if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") {
@@ -370,12 +342,12 @@ export const splitSourceForStorage = (input: { · 138 unmodified lines
370342 recipeId: SourceRecipeId;
371343 recipeRevisionId: SourceRecipeRevisionId;
372344 actorAccountId?: AccountId | null;
373− existingRuntimeCredentialId?: Credential["id"] | null;
374− existingImportCredentialId?: Credential["id"] | null;
345+ existingRuntimeAuthArtifactId?: AuthArtifact["id"] | null;
346+ existingImportAuthArtifactId?: AuthArtifact["id"] | null;
375347 }): {
376348 sourceRecord: StoredSourceRecord;
377− runtimeCredential: Credential | null;
378− importCredential: Credential | null;
349+ runtimeAuthArtifact: AuthArtifact | null;
350+ importAuthArtifact: AuthArtifact | null;
379351 } => {
380352 const sourceRecord: StoredSourceRecord = {
381353 id: input.source.id,
@@ -398,20 +370,22 @@ export const splitSourceForStorage = (input: { · 16 unmodified lines
398370
399371 return {
400372 sourceRecord,
401− runtimeCredential: credentialFromAuth({
373+ runtimeAuthArtifact: authArtifactFromSourceAuth({
402374 source: input.source,
403375 auth: input.source.auth,
404376 slot: "runtime",
405377 actorAccountId: input.actorAccountId,
406− existingCredentialId: input.existingRuntimeCredentialId,
378+ existingAuthArtifactId: input.existingRuntimeAuthArtifactId
379+ ?? AuthArtifactIdSchema.make(`auth_art_${crypto.randomUUID()}`),
407380 }),
408− importCredential: input.source.importAuthPolicy === "separate"
409− ? credentialFromAuth({
381+ importAuthArtifact: input.source.importAuthPolicy === "separate"
382+ ? authArtifactFromSourceAuth({
410383 source: input.source,
411384 auth: input.source.importAuth,
412385 slot: "import",
413386 actorAccountId: input.actorAccountId,
414− existingCredentialId: input.existingImportCredentialId,
387+ existingAuthArtifactId: input.existingImportAuthArtifactId
388+ ?? AuthArtifactIdSchema.make(`auth_art_${crypto.randomUUID()}`),
415389 })
416390 : null,
417391 };
@@ -419,8 +393,8 @@ export const splitSourceForStorage = (input: { · 1 unmodified line
419393
420394 export const projectSourceFromStorage = (input: {
421395 sourceRecord: StoredSourceRecord;
422− runtimeCredential: Credential | null;
423− importCredential: Credential | null;
396+ runtimeAuthArtifact: AuthArtifact | null;
397+ importAuthArtifact: AuthArtifact | null;
424398 }): Effect.Effect<Source, Error, never> =>
425399 Effect.gen(function* () {
426400 const adapter = getSourceAdapter(input.sourceRecord.kind);
@@ -443,9 +417,9 @@ export const projectSourceFromStorage = (input: { · 16 unmodified lines
443417 importAuthPolicy: input.sourceRecord.importAuthPolicy,
444418 importAuth:
445419 input.sourceRecord.importAuthPolicy === "separate"
446− ? authFromCredential(input.importCredential)
420+ ? sourceAuthFromAuthArtifact(input.importAuthArtifact)
447421 : { kind: "none" },
448− auth: authFromCredential(input.runtimeCredential),
422+ auth: sourceAuthFromAuthArtifact(input.runtimeAuthArtifact),
449423 sourceHash: input.sourceRecord.sourceHash,
450424 lastError: input.sourceRecord.lastError,
451425 createdAt: input.sourceRecord.createdAt,
@@ -459,23 +433,23 @@ export const projectSourceFromStorage = (input: { · 7 unmodified lines
459433
460434 export const projectSourcesFromStorage = (input: {
461435 sourceRecords: ReadonlyArray<StoredSourceRecord>;
462− credentials: ReadonlyArray<Credential>;
436+ authArtifacts: ReadonlyArray<AuthArtifact>;
463437 }): 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;
467441 }>();
468442
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) ?? {
471445 runtime: null,
472446 import: null,
473447 };
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, {
477451 ...existing,
478− [credential.slot]: credential,
452+ [authArtifact.slot]: authArtifact,
479453 });
480454 }
481455 }
@@ -483,7 +457,7 @@ export const projectSourcesFromStorage = (input: { · 1 unmodified line
483457 return Effect.forEach(input.sourceRecords, (sourceRecord) =>
484458 projectSourceFromStorage({
485459 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,
488462 }));
489463 };
packages/control-plane/src/runtime/source-discovery.test.ts+77−0
@@ -148,4 +148,81 @@ describe("source-discovery", () => { · 147 unmodified lines
148148 await server.close();
149149 }
150150 });
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+ });
151228 });
packages/control-plane/src/runtime/source-discovery.ts+99−1
@@ -7,6 +7,9 @@ import { · 6 unmodified lines
77 createSdkMcpConnector,
88 discoverMcpToolsFromConnector,
99 } from "@executor/codemode-mcp";
10+import {
11+ extractGoogleDiscoveryManifest,
12+} from "@executor/codemode-google-discovery";
1013 import {
1114 extractOpenApiManifest,
1215 parseOpenApiDocument,
@@ -33,6 +36,10 @@ type OpenApiProbeResult = { · 20 unmodified lines
3336 result: SourceDiscoveryResult;
3437 };
3538
39+type GoogleDiscoveryProbeResult = {
40+ result: SourceDiscoveryResult;
41+};
42+
3643 type GraphqlProbeResult = {
3744 result: SourceDiscoveryResult;
3845 };
@@ -607,6 +614,89 @@ const deriveOpenApiEndpoint = (input: { · 568 unmodified lines
607614 return new URL(input.normalizedUrl).origin;
608615 };
609616
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+
610700 const tryDetectOpenApi = (input: {
611701 normalizedUrl: string;
612702 headers: Readonly<Record<string, string>>;
@@ -851,7 +941,7 @@ const fallbackDiscoveryResult = (normalizedUrl: string): SourceDiscoveryResult = · 238 unmodified lines
851941 transport: null,
852942 authInference: unknownAuthInference("Could not infer source kind or auth requirements from the provided URL"),
853943 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."],
855945 };
856946 };
857947
@@ -863,6 +953,14 @@ export const discoverSource = (input: { · 5 unmodified lines
863953 const normalizedUrl = normalizeUrl(input.url);
864954 const headers = probeHeadersFromAuth(input.probeAuth);
865955
956+ const googleDiscovery = yield* tryDetectGoogleDiscovery({
957+ normalizedUrl,
958+ headers,
959+ });
960+ if (googleDiscovery) {
961+ return googleDiscovery.result;
962+ }
963+
866964 const openApi = yield* tryDetectOpenApi({
867965 normalizedUrl,
868966 headers,
packages/control-plane/src/runtime/source-materialization.ts+4−0
@@ -1,5 +1,6 @@
11 import type { SqlControlPlaneRows } from "#persistence";
22 import type {
3+ AccountId,
34 Source,
45 } from "#schema";
56 import * as Effect from "effect/Effect";
@@ -22,6 +23,7 @@ const shouldIndexSource = (source: Source): boolean => · 16 unmodified lines
2223 export const syncSourceMaterialization = (input: {
2324 rows: SqlControlPlaneRows;
2425 source: Source;
26+ actorAccountId?: AccountId | null;
2527 resolveSecretMaterial: ResolveSourceSecretMaterial;
2628 }): Effect.Effect<void, Error, never> =>
2729 Effect.gen(function* () {
@@ -35,8 +37,10 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines
3537 resolveSecretMaterial: input.resolveSecretMaterial,
3638 resolveAuthMaterialForSlot: (slot) =>
3739 resolveSourceAuthMaterial({
40+ rows: input.rows,
3841 source: input.source,
3942 slot,
43+ actorAccountId: input.actorAccountId,
4044 resolveSecretMaterial: input.resolveSecretMaterial,
4145 }),
4246 });
packages/control-plane/src/runtime/source-recipe-support.ts+2−2
@@ -1,4 +1,4 @@
1−import { createHash } from "node:crypto";
1+import { sha256Hex } from "@executor/codemode-core";
22
33 import type {
44 Source,
@@ -28,7 +28,7 @@ export const normalizeSearchText = ( · 23 unmodified lines
2828 .toLowerCase();
2929
3030 export const contentHash = (value: string): string =>
31− createHash("sha256").update(value).digest("hex");
31+ sha256Hex(value);
3232
3333 export const loadSourceBindingRecord = (input: {
3434 rows: SqlControlPlaneRows;
packages/control-plane/src/runtime/source-store.test.ts+15−10
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
22
33 import {
44 AccountIdSchema,
5+ decodeBuiltInAuthArtifactConfig,
56 McpSourceAuthSessionDataJsonSchema,
67 OrganizationIdSchema,
78 SecretMaterialIdSchema,
@@ -186,10 +187,14 @@ describe("source-store", () => { · 178 unmodified lines
186187 );
187188
188189 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);
193198
194199 const removed = yield* removeSourceById(persistence.rows, {
195200 workspaceId,
@@ -199,7 +204,7 @@ describe("source-store", () => { · 3 unmodified lines
199204
200205 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);
201206 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);
203208 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
204209 }),
205210 );
@@ -267,14 +272,14 @@ describe("source-store", () => { · 61 unmodified lines
267272 },
268273 );
269274
270− const credentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({
275+ const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({
271276 workspaceId,
272277 sourceId,
273278 });
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);
278283 }),
279284 );
280285
packages/control-plane/src/runtime/source-store.ts+104−80
@@ -1,8 +1,7 @@
11 import type {
22 AccountId,
3− Credential,
3+ AuthArtifact,
44 CredentialSlot,
5− SecretRef,
65 Source,
76 SourceRecipeId,
87 SourceRecipeRevisionId,
@@ -22,25 +21,15 @@ import { · 13 unmodified lines
2221 splitSourceForStorage,
2322 } from "./source-definitions";
2423 import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
24+import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
25+import { removeAuthLeaseAndSecrets } from "./auth-leases";
2526
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;
4433 }) =>
4534 Effect.gen(function* () {
4635 if (input.previous === null) {
@@ -49,9 +38,9 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 2 unmodified lines
4938
5039 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows });
5140 const nextRefKeys = new Set(
52− (input.next === null ? [] : credentialSecretRefs(input.next)).map(secretRefKey),
41+ (input.next === null ? [] : authArtifactSecretMaterialRefs(input.next)).map(secretRefKey),
5342 );
54− const refsToDelete = credentialSecretRefs(input.previous).filter(
43+ const refsToDelete = authArtifactSecretMaterialRefs(input.previous).filter(
5544 (ref) => !nextRefKeys.has(secretRefKey(ref)),
5645 );
5746
@@ -62,32 +51,32 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 4 unmodified lines
6251 );
6352 });
6453
65−const selectPreferredCredential = (input: {
66− credentials: ReadonlyArray<Credential>;
54+const selectPreferredAuthArtifact = (input: {
55+ authArtifacts: ReadonlyArray<AuthArtifact>;
6756 actorAccountId?: AccountId | null;
6857 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);
7160
7261 if (input.actorAccountId !== undefined) {
73− const exact = matchingSlot.find((credential) => credential.actorAccountId === input.actorAccountId);
62+ const exact = matchingSlot.find((artifact) => artifact.actorAccountId === input.actorAccountId);
7463 if (exact) {
7564 return exact;
7665 }
7766 }
7867
79− return matchingSlot.find((credential) => credential.actorAccountId === null) ?? null;
68+ return matchingSlot.find((artifact) => artifact.actorAccountId === null) ?? null;
8069 };
8170
82−const selectExactCredential = (input: {
83− credentials: ReadonlyArray<Credential>;
71+const selectExactAuthArtifact = (input: {
72+ authArtifacts: ReadonlyArray<AuthArtifact>;
8473 actorAccountId?: AccountId | null;
8574 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),
9180 ) ?? null;
9281
9382 export const loadSourcesInWorkspace = (
@@ -99,27 +88,27 @@ export const loadSourcesInWorkspace = ( · 5 unmodified lines
9988 ) =>
10089 Effect.gen(function* () {
10190 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,
10796 actorAccountId: options.actorAccountId,
10897 slot: "runtime",
10998 });
110− const preferredImport = selectPreferredCredential({
111− credentials: matches,
99+ const preferredImport = selectPreferredAuthArtifact({
100+ authArtifacts: matches,
112101 actorAccountId: options.actorAccountId,
113102 slot: "import",
114103 });
115104 return [preferred, preferredImport].filter(
116− (credential): credential is Credential => credential !== null,
105+ (artifact): artifact is AuthArtifact => artifact !== null,
117106 );
118107 });
119108
120109 return yield* projectSourcesFromStorage({
121110 sourceRecords,
122− credentials: filteredCredentials,
111+ authArtifacts: filteredAuthArtifacts,
123112 });
124113 });
125114
@@ -140,54 +129,63 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 14 unmodified lines
140129 );
141130 }
142131
143− const credentials = yield* rows.credentials.listByWorkspaceAndSourceId({
132+ const authArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
144133 workspaceId: input.workspaceId,
145134 sourceId: input.sourceId,
146135 });
147− const credential = selectPreferredCredential({
148− credentials,
136+ const authArtifact = selectPreferredAuthArtifact({
137+ authArtifacts,
149138 actorAccountId: input.actorAccountId,
150139 slot: "runtime",
151140 });
152− const importCredential = selectPreferredCredential({
153− credentials,
141+ const importAuthArtifact = selectPreferredAuthArtifact({
142+ authArtifacts,
154143 actorAccountId: input.actorAccountId,
155144 slot: "import",
156145 });
157146
158147 return yield* projectSourceFromStorage({
159148 sourceRecord: sourceRecord.value,
160− runtimeCredential: credential,
161− importCredential,
149+ runtimeAuthArtifact: authArtifact,
150+ importAuthArtifact,
162151 });
163152 });
164153
165−const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: {
154+const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: {
166155 workspaceId: WorkspaceId;
167156 sourceId: Source["id"];
168157 }) =>
169158 Effect.gen(function* () {
170− const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({
159+ const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
171160 workspaceId: input.workspaceId,
172161 sourceId: input.sourceId,
173162 });
174163
175− yield* rows.credentials.removeByWorkspaceAndSourceId({
164+ yield* rows.authArtifacts.removeByWorkspaceAndSourceId({
176165 workspaceId: input.workspaceId,
177166 sourceId: input.sourceId,
178167 });
179168
180169 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,
185183 next: null,
186184 }),
187185 { discard: true },
188186 );
189187
190− return existingCredentials.length;
188+ return existingAuthArtifacts.length;
191189 });
192190
193191 const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: {
@@ -242,7 +240,7 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 48 unmodified lines
242240 workspaceId: input.workspaceId,
243241 sourceId: input.sourceId,
244242 });
245− yield* removeCredentialsForSource(rows, input);
243+ yield* removeAuthArtifactsForSource(rows, input);
246244 const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);
247245 if (!removed) {
248246 return false;
@@ -265,17 +263,17 @@ export const persistSource = ( · 16 unmodified lines
265263 ) =>
266264 Effect.gen(function* () {
267265 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
268− const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({
266+ const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
269267 workspaceId: source.workspaceId,
270268 sourceId: source.id,
271269 });
272− const existingCredential = selectExactCredential({
273− credentials: existingCredentials,
270+ const existingRuntimeAuthArtifact = selectExactAuthArtifact({
271+ authArtifacts: existingAuthArtifacts,
274272 actorAccountId: options.actorAccountId,
275273 slot: "runtime",
276274 });
277− const existingImportCredential = selectExactCredential({
278− credentials: existingCredentials,
275+ const existingImportAuthArtifact = selectExactAuthArtifact({
276+ authArtifacts: existingAuthArtifacts,
279277 actorAccountId: options.actorAccountId,
280278 slot: "import",
281279 });
@@ -309,13 +307,13 @@ export const persistSource = ( · 27 unmodified lines
309307 latestRevisionId: nextRevision.id,
310308 });
311309
312− const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({
310+ const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({
313311 source,
314312 recipeId: nextRecipe.id,
315313 recipeRevisionId: nextRevision.id,
316314 actorAccountId: options.actorAccountId,
317− existingRuntimeCredentialId: existingCredential?.id ?? null,
318− existingImportCredentialId: existingImportCredential?.id ?? null,
315+ existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null,
316+ existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null,
319317 });
320318
321319 if (Option.isNone(existing)) {
@@ -346,36 +344,62 @@ export const persistSource = ( · 24 unmodified lines
346344 });
347345 }
348346
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({
351354 workspaceId: source.workspaceId,
352355 sourceId: source.id,
353356 actorAccountId: options.actorAccountId ?? null,
354357 slot: "runtime",
355358 });
356359 } 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+ }
358369 }
359370
360− yield* cleanupCredentialSecretRefs(rows, {
361− previous: existingCredential ?? null,
362− next: runtimeCredential,
371+ yield* cleanupAuthArtifactSecretRefs(rows, {
372+ previous: existingRuntimeAuthArtifact ?? null,
373+ next: runtimeAuthArtifact,
363374 });
364375
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({
367383 workspaceId: source.workspaceId,
368384 sourceId: source.id,
369385 actorAccountId: options.actorAccountId ?? null,
370386 slot: "import",
371387 });
372388 } 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+ }
374398 }
375399
376− yield* cleanupCredentialSecretRefs(rows, {
377− previous: existingImportCredential ?? null,
378− next: importCredential,
400+ yield* cleanupAuthArtifactSecretRefs(rows, {
401+ previous: existingImportAuthArtifact ?? null,
402+ next: importAuthArtifact,
379403 });
380404
381405 return source;
packages/control-plane/src/runtime/sources-operations.ts+3−1
@@ -72,6 +72,7 @@ const syncArtifactsForSource = (input: { · 71 unmodified lines
7272 syncSourceMaterialization({
7373 rows: input.store,
7474 source: sourceForSync,
75+ actorAccountId: input.actorAccountId,
7576 resolveSecretMaterial,
7677 }),
7778 );
@@ -276,7 +277,8 @@ export const removeSource = (input: { · 198 unmodified lines
276277 }) =>
277278 Effect.flatMap(ControlPlaneStore, (store) =>
278279 Effect.gen(function* () {
279− const removed = yield* sourceOps.remove.mapStorage(
280+ const removed = yield* mapPersistenceError(
281+ sourceOps.remove.child("remove"),
280282 removeSourceById(store, {
281283 workspaceId: input.workspaceId,
282284 sourceId: input.sourceId,
packages/control-plane/src/runtime/workspace-execution-environment.ts+2−0
@@ -687,7 +687,9 @@ const createWorkspaceToolInvoker = (input: { · 686 unmodified lines
687687 });
688688
689689 const auth = yield* resolveSourceAuthMaterial({
690+ rows: input.rows,
690691 source: recipeTool.source,
692+ actorAccountId: input.accountId,
691693 resolveSecretMaterial: input.resolveSecretMaterial,
692694 context: toSecretResolutionContext(invocation.context),
693695 });
packages/control-plane/src/schema/enums.ts+11−5
@@ -9,20 +9,22 @@ export { · 8 unmodified lines
99 type Role,
1010 } from "./models/auth/organization-membership";
1111 export {
12− SecretRefSchema,
1312 SourceAuthSchema,
1413 SourceBindingSchema,
1514 SourceBindingVersionSchema,
1615 SourceKindSchema,
1716 SourceStatusSchema,
1817 SourceTransportSchema,
19− type SecretRef,
2018 type SourceAuth,
2119 type SourceBinding,
2220 type SourceKind,
2321 type SourceStatus,
2422 type SourceTransport,
2523 } from "./models/source";
24+export {
25+ SecretRefSchema,
26+ type SecretRef,
27+} from "./models/auth-artifact";
2628 export {
2729 SourceRecipeAdapterKeySchema,
2830 SourceRecipeDocumentKindSchema,
@@ -56,9 +58,13 @@ export { · 27 unmodified lines
5658 type SourceProbeAuth,
5759 } from "./models/source-discovery";
5860 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";
6268 export {
6369 SourceAuthSessionProviderKindSchema,
6470 SourceAuthSessionStatusSchema,
packages/control-plane/src/schema/ids.ts+8−2
@@ -17,9 +17,13 @@ export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe( · 16 unmodified lines
1717 export const SourceAuthSessionIdSchema = Schema.String.pipe(
1818 Schema.brand("SourceAuthSessionId"),
1919 );
20−export const CredentialIdSchema = Schema.String.pipe(
21− Schema.brand("CredentialId"),
20+export const AuthArtifactIdSchema = Schema.String.pipe(
21+ Schema.brand("AuthArtifactId"),
2222 );
23+export const AuthLeaseIdSchema = Schema.String.pipe(
24+ Schema.brand("AuthLeaseId"),
25+);
26+export const CredentialIdSchema = AuthArtifactIdSchema;
2327 export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(
2428 Schema.brand("WorkspaceSourceOauthClientId"),
2529 );
@@ -42,6 +46,8 @@ export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; · 16 unmodified lines
4246 export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;
4347 export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type;
4448 export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
49+export type AuthArtifactId = typeof AuthArtifactIdSchema.Type;
50+export type AuthLeaseId = typeof AuthLeaseIdSchema.Type;
4551 export type CredentialId = typeof CredentialIdSchema.Type;
4652 export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
4753 export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
packages/control-plane/src/schema/index.ts+2−1
@@ -6,7 +6,8 @@ export * from "./models/auth"; · 5 unmodified lines
66 export * from "./models/workspace";
77 export * from "./models/source";
88 export * from "./models/source-recipe";
9−export * from "./models/credential";
9+export * from "./models/auth-artifact";
10+export * from "./models/auth-lease";
1011 export * from "./models/source-oauth-client";
1112 export * from "./models/secret-material";
1213 export * from "./models/source-auth-session";
packages/control-plane/src/schema/models/auth-artifact.ts+306−0
@@ -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+};
packages/control-plane/src/schema/models/auth-lease.ts+57−0
@@ -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+ );
packages/control-plane/src/schema/models/credential.ts+4−40
@@ -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";
packages/control-plane/src/schema/models/source-auth-session.ts+32−5
@@ -11,12 +11,13 @@ import { · 10 unmodified lines
1111 SourceIdSchema,
1212 WorkspaceIdSchema,
1313 } from "../ids";
14−import { CredentialSlotSchema } from "./credential";
14+import {
15+ CredentialSlotSchema,
16+ OAuth2ClientAuthenticationMethodSchema,
17+ SecretRefSchema,
18+} from "./auth-artifact";
1519
16−export const SourceAuthSessionProviderKindSchema = Schema.Literal(
17− "mcp_oauth",
18− "oauth2_pkce",
19−);
20+export const SourceAuthSessionProviderKindSchema = Schema.String;
2021
2122 export const SourceAuthSessionStatusSchema = Schema.Literal(
2223 "pending",
@@ -74,6 +75,30 @@ export const McpSourceAuthSessionDataJsonSchema = Schema.parseJson( · 51 unmodified lines
7475 McpSourceAuthSessionDataSchema,
7576 );
7677
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+
77102 const sourceAuthSessionSchemaOverrides = {
78103 id: SourceAuthSessionIdSchema,
79104 workspaceId: WorkspaceIdSchema,
@@ -97,4 +122,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 17 unmodified lines
97122 export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type;
98123 export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type;
99124 export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type;
125+export type OAuth2PkceSourceAuthSessionData =
126+ typeof OAuth2PkceSourceAuthSessionDataSchema.Type;
100127 export type SourceAuthSession = typeof SourceAuthSessionSchema.Type;
packages/control-plane/src/schema/models/source-discovery.ts+1−0
@@ -5,6 +5,7 @@ import { StringMapSchema, SourceTransportSchema } from "./source"; · 4 unmodified lines
55 export const SourceDiscoveryKindSchema = Schema.Literal(
66 "mcp",
77 "openapi",
8+ "google_discovery",
89 "graphql",
910 "unknown",
1011 );
packages/control-plane/src/schema/models/source-oauth-client.ts+17−0
@@ -9,6 +9,19 @@ import { · 8 unmodified lines
99 WorkspaceSourceOauthClientIdSchema,
1010 } from "../ids";
1111
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+
1225 export const WorkspaceSourceOauthClientSchema = createSelectSchema(
1326 workspaceSourceOauthClientsTable,
1427 {
@@ -21,3 +34,7 @@ export const WorkspaceSourceOauthClientSchema = createSelectSchema( · 6 unmodified lines
2134 );
2235
2336 export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type;
37+export type WorkspaceSourceOauthClientRedirectMode =
38+ typeof WorkspaceSourceOauthClientRedirectModeSchema.Type;
39+export type WorkspaceSourceOauthClientMetadata =
40+ typeof WorkspaceSourceOauthClientMetadataSchema.Type;
packages/control-plane/src/schema/models/source.ts+12−6
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
1111 SourceRecipeRevisionIdSchema,
1212 WorkspaceIdSchema,
1313 } from "../ids";
14+import { SecretRefSchema } from "./auth-artifact";
1415 import { JsonObjectSchema } from "./source-auth-session";
1516
1617 export const SourceKindSchema = Schema.String;
@@ -35,11 +36,6 @@ export const SourceImportAuthPolicySchema = Schema.Literal( · 18 unmodified lines
3536 "separate",
3637 );
3738
38−export const SecretRefSchema = Schema.Struct({
39− providerId: Schema.String,
40− handle: Schema.String,
41−});
42−
4339 export const SourceAuthSchema = Schema.Union(
4440 Schema.Struct({
4541 kind: Schema.Literal("none"),
@@ -57,6 +53,17 @@ export const SourceAuthSchema = Schema.Union( · 11 unmodified lines
5753 accessToken: SecretRefSchema,
5854 refreshToken: Schema.NullOr(SecretRefSchema),
5955 }),
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+ }),
6067 );
6168
6269 export const StringMapSchema = Schema.Record({
@@ -170,7 +177,6 @@ export type SourceKind = typeof SourceKindSchema.Type; · 107 unmodified lines
170177 export type SourceStatus = typeof SourceStatusSchema.Type;
171178 export type SourceTransport = typeof SourceTransportSchema.Type;
172179 export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;
173−export type SecretRef = typeof SecretRefSchema.Type;
174180 export type SourceAuth = typeof SourceAuthSchema.Type;
175181 export type SourceBinding = typeof SourceBindingSchema.Type;
176182 export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;
packages/server/src/config.ts+4−0
@@ -1,6 +1,10 @@
11 import { homedir } from "node:os";
22 import { join } from "node:path";
33
4+import { loadMonorepoRootEnv } from "./env";
5+
6+loadMonorepoRootEnv();
7+
48 export const EXECUTOR_HOME_ENV = "EXECUTOR_HOME";
59 export const EXECUTOR_DATA_DIR_ENV = "EXECUTOR_DATA_DIR";
610 export const EXECUTOR_LOCAL_DATA_DIR_ENV = "EXECUTOR_LOCAL_DATA_DIR";
packages/server/src/env.test.ts+65−0
@@ -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+});
packages/server/src/env.ts+121−0
@@ -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+