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
5 * control-plane handler. Everything else (frontend assets, HMR) is5 * control-plane handler. Everything else (frontend assets, HMR) is
6 * handled by Vite itself.6 * handled by Vite itself.
7 */7 */
8import { join } from "node:path";
8import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
9import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";11import * as Exit from "effect/Exit";
11import * as Runtime from "effect/Runtime";12import * as Runtime from "effect/Runtime";
12import * as Scope from "effect/Scope";13import * as Scope from "effect/Scope";
13import { createLocalExecutorRequestHandler } from "@executor/server";14import {
15 createLocalExecutorRequestHandler,
16 DEFAULT_EXECUTOR_DATA_DIR,
17} from "@executor/server";
14 18
15const MAX_LOGGED_ERROR_BODY_LENGTH = 4_000;19const MAX_LOGGED_ERROR_BODY_LENGTH = 4_000;
20const EXECUTOR_WEB_DEV_LOCAL_DATA_DIR_ENV = "EXECUTOR_WEB_DEV_LOCAL_DATA_DIR";
21const DEFAULT_WEB_DEV_LOCAL_DATA_DIR = join(
22 DEFAULT_EXECUTOR_DATA_DIR,
23 "control-plane-web-dev",
24);
16 25
17const truncateForLog = (value: string): string =>26const truncateForLog = (value: string): string =>
18 value.length > MAX_LOGGED_ERROR_BODY_LENGTH27 value.length > MAX_LOGGED_ERROR_BODY_LENGTH
19 ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]`28 ? `${value.slice(0, MAX_LOGGED_ERROR_BODY_LENGTH)}... [truncated]`
20 : value;29 : value;
21 30
31const resolveWebDevLocalDataDir = (): string => {
32 const configured = process.env[EXECUTOR_WEB_DEV_LOCAL_DATA_DIR_ENV]?.trim();
33 return configured && configured.length > 0
34 ? configured
35 : DEFAULT_WEB_DEV_LOCAL_DATA_DIR;
36};
37
22/**38/**
23 * Extract a detailed, human-readable description from an error.39 * Extract a detailed, human-readable description from an error.
24 *40 *
@@ -45,7 +61,9 @@ const handlerPromise = (async () => { · 20 unmodified lines
45 const exit = await Effect.runPromiseExit(61 const exit = await Effect.runPromiseExit(
46 Effect.gen(function* () {62 Effect.gen(function* () {
47 const scope = yield* Scope.make();63 const scope = yield* Scope.make();
48 const handler = yield* createLocalExecutorRequestHandler().pipe(64 const handler = yield* createLocalExecutorRequestHandler({
65 localDataDir: resolveWebDevLocalDataDir(),
66 }).pipe(
49 Effect.provideService(Scope.Scope, scope),67 Effect.provideService(Scope.Scope, scope),
50 );68 );
51 return handler;69 return handler;
apps/web/src/views/add-source.tsx+174−25
@@ -57,9 +57,12 @@ type ProbeAuthState = { · 56 unmodified lines
57};57};
58 58
59type ConnectFormState = {59type ConnectFormState = {
60 kind: "mcp" | "openapi" | "graphql";60 kind: "mcp" | "openapi" | "graphql" | "google_discovery";
61 endpoint: string;61 endpoint: string;
62 specUrl: string;62 specUrl: string;
63 service: string;
64 version: string;
65 discoveryUrl: string;
63 name: string;66 name: string;
64 namespace: string;67 namespace: string;
65 transport: "" | "auto" | "streamable-http" | "sse";68 transport: "" | "auto" | "streamable-http" | "sse";
@@ -83,7 +86,12 @@ type OAuthRequiredInfo = { · 17 unmodified lines
83// Constants86// Constants
84// ---------------------------------------------------------------------------87// ---------------------------------------------------------------------------
85 88
86const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = ["mcp", "openapi", "graphql"];89const kindOptions: ReadonlyArray<ConnectFormState["kind"]> = [
90 "mcp",
91 "openapi",
92 "graphql",
93 "google_discovery",
94];
87 95
88const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [96const transportOptions: ReadonlyArray<"auto" | "streamable-http" | "sse"> = [
89 "auto",97 "auto",
@@ -119,6 +127,44 @@ const namespaceFromUrl = (url: string): string => { · 29 unmodified lines
119 }127 }
120};128};
121 129
130const googleDiscoveryNamespace = (service: string): string =>
131 `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;
132
133const 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
122const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {168const parseJsonStringMap = (label: string, text: string): Record<string, string> | null => {
123 const trimmed = text.trim();169 const trimmed = text.trim();
124 if (trimmed.length === 0) {170 if (trimmed.length === 0) {
@@ -158,6 +204,9 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 33 unmodified lines
158 kind: "openapi",204 kind: "openapi",
159 endpoint: discovery?.endpoint ?? "",205 endpoint: discovery?.endpoint ?? "",
160 specUrl: discovery?.specUrl ?? "",206 specUrl: discovery?.specUrl ?? "",
207 service: "",
208 version: "",
209 discoveryUrl: "",
161 name: discovery?.name ?? "",210 name: discovery?.name ?? "",
162 namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),211 namespace: discovery?.namespace || namespaceFromUrl(discovery?.endpoint ?? ""),
163 transport: "",212 transport: "",
@@ -197,10 +246,17 @@ const defaultConnectForm = (discovery?: SourceDiscoveryResult): ConnectFormState · 33 unmodified lines
197 }246 }
198 }247 }
199 248
249 const googleDiscoveryDefaults = kind === "google_discovery"
250 ? googleDiscoveryDefaultsFromUrl(discovery.specUrl ?? discovery.endpoint)
251 : null;
252
200 return {253 return {
201 kind,254 kind,
202 endpoint: discovery.endpoint,255 endpoint: discovery.endpoint,
203 specUrl: discovery.specUrl ?? "",256 specUrl: discovery.specUrl ?? "",
257 service: googleDiscoveryDefaults?.service ?? "",
258 version: googleDiscoveryDefaults?.version ?? "",
259 discoveryUrl: googleDiscoveryDefaults?.discoveryUrl ?? "",
204 name: discovery.name ?? "",260 name: discovery.name ?? "",
205 namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint),261 namespace: discovery.namespace || namespaceFromUrl(discovery.endpoint),
206 transport: kind === "mcp" ? (discovery.transport ?? "auto") : "",262 transport: kind === "mcp" ? (discovery.transport ?? "auto") : "",
@@ -220,11 +276,25 @@ const connectFormFromTemplate = (template: SourceTemplate): ConnectFormState => · 13 unmodified lines
220 kind: template.kind as ConnectFormState["kind"],276 kind: template.kind as ConnectFormState["kind"],
221 endpoint: template.endpoint,277 endpoint: template.endpoint,
222 specUrl: "specUrl" in template ? template.specUrl : "",278 specUrl: "specUrl" in template ? template.specUrl : "",
279 service: "service" in template ? template.service : "",
280 version: "version" in template ? template.version : "",
281 discoveryUrl: "discoveryUrl" in template ? template.discoveryUrl : "",
223 name: template.name,282 name: template.name,
224 namespace: namespaceFromUrl(template.endpoint),283 namespace: "service" in template
284 ? googleDiscoveryNamespace(template.service)
285 : namespaceFromUrl(template.endpoint),
225 transport: template.kind === "mcp" ? "auto" : "",286 transport: template.kind === "mcp" ? "auto" : "",
287 authKind: template.kind === "google_discovery" ? "oauth2" : "none",
226});288});
227 289
290const authKindForSourceKind = (
291 currentAuthKind: ConnectFormState["authKind"],
292 nextKind: ConnectFormState["kind"],
293): ConnectFormState["authKind"] =>
294 nextKind === "google_discovery" && currentAuthKind === "none"
295 ? "oauth2"
296 : currentAuthKind;
297
228const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => {298const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth"] => {
229 if (state.kind === "none") return { kind: "none" };299 if (state.kind === "none") return { kind: "none" };
230 if (state.kind === "bearer") {300 if (state.kind === "bearer") {
@@ -251,10 +321,9 @@ const buildProbeAuth = (state: ProbeAuthState): DiscoverSourcePayload["probeAuth · 20 unmodified lines
251};321};
252 322
253const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => {323const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => {
254 const endpoint = form.endpoint.trim();
255 if (!endpoint) throw new Error("Endpoint is required.");
256
257 if (form.kind === "mcp") {324 if (form.kind === "mcp") {
325 const endpoint = form.endpoint.trim();
326 if (!endpoint) throw new Error("Endpoint is required.");
258 return {327 return {
259 kind: "mcp",328 kind: "mcp",
260 endpoint,329 endpoint,
@@ -270,6 +339,8 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 9 unmodified lines
270 const auth = buildHttpAuth(form);339 const auth = buildHttpAuth(form);
271 340
272 if (form.kind === "openapi") {341 if (form.kind === "openapi") {
342 const endpoint = form.endpoint.trim();
343 if (!endpoint) throw new Error("Endpoint is required.");
273 const specUrl = form.specUrl.trim();344 const specUrl = form.specUrl.trim();
274 if (!specUrl) throw new Error("OpenAPI sources require a spec URL.");345 if (!specUrl) throw new Error("OpenAPI sources require a spec URL.");
275 return {346 return {
@@ -282,6 +353,24 @@ const buildConnectPayload = (form: ConnectFormState): ConnectSourcePayload => { · 6 unmodified lines
282 };353 };
283 }354 }
284 355
356 if (form.kind === "google_discovery") {
357 const service = form.service.trim();
358 const version = form.version.trim();
359 if (!service) throw new Error("Google Discovery sources require a service name.");
360 if (!version) throw new Error("Google Discovery sources require a version.");
361 return {
362 kind: "google_discovery",
363 service,
364 version,
365 discoveryUrl: trimToNull(form.discoveryUrl),
366 name: trimToNull(form.name),
367 namespace: trimToNull(form.namespace),
368 auth,
369 };
370 }
371
372 const endpoint = form.endpoint.trim();
373 if (!endpoint) throw new Error("Endpoint is required.");
285 return {374 return {
286 kind: "graphql",375 kind: "graphql",
287 endpoint,376 endpoint,
@@ -346,6 +435,16 @@ type SourceOAuthPopupMessage = · 58 unmodified lines
346 ok: false;435 ok: false;
347 sessionId: string | null;436 sessionId: string | null;
348 error: string;437 error: string;
438 }
439 | {
440 type: "executor:source-oauth-result";
441 ok: true;
442 sourceId: string;
443 }
444 | {
445 type: "executor:source-oauth-result";
446 ok: false;
447 error: string;
349 };448 };
350 449
351const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {450const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {
@@ -369,7 +468,7 @@ const clearStoredSourceOAuthPopupResult = (sessionId: string): void => { · 17 unmodified lines
369const startSourceOAuthPopup = async (input: {468const startSourceOAuthPopup = async (input: {
370 authorizationUrl: string;469 authorizationUrl: string;
371 sessionId: string;470 sessionId: string;
372}): Promise<CompleteSourceOAuthResult["auth"]> => {471}): Promise<void> => {
373 if (typeof window === "undefined") {472 if (typeof window === "undefined") {
374 throw new Error("OAuth popup is only available in a browser context");473 throw new Error("OAuth popup is only available in a browser context");
375 }474 }
@@ -388,7 +487,7 @@ const startSourceOAuthPopup = async (input: { · 12 unmodified lines
388 487
389 popup.focus();488 popup.focus();
390 489
391 return await new Promise<CompleteSourceOAuthResult["auth"]>((resolve, reject) => {490 return await new Promise<void>((resolve, reject) => {
392 let settled = false;491 let settled = false;
393 let closedPoll = 0;492 let closedPoll = 0;
394 let resultTimeout = 0;493 let resultTimeout = 0;
@@ -416,15 +515,19 @@ const startSourceOAuthPopup = async (input: { · 21 unmodified lines
416 if (settled) return;515 if (settled) return;
417 settled = true;516 settled = true;
418 cleanup();517 cleanup();
419 resolve(data.auth);518 resolve();
420 };519 };
421 520
422 const onMessage = (event: MessageEvent) => {521 const onMessage = (event: MessageEvent) => {
423 if (event.origin !== window.location.origin) return;522 if (event.origin !== window.location.origin) return;
424 const data = event.data as SourceOAuthPopupMessage | undefined;523 const data = event.data as SourceOAuthPopupMessage | undefined;
425 if (!data || data.type !== "executor:oauth-result") return;524 if (!data) return;
426 if (data.ok && data.sessionId !== input.sessionId) return;525 if (data.type === "executor:oauth-result") {
427 if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) return;526 if (data.ok && data.sessionId !== input.sessionId) return;
527 if (!data.ok && data.sessionId !== null && data.sessionId !== input.sessionId) return;
528 } else if (data.type !== "executor:source-oauth-result") {
529 return;
530 }
428 settleFromPayload(data);531 settleFromPayload(data);
429 };532 };
430 533
@@ -517,6 +620,14 @@ export function AddSourcePage() { · 86 unmodified lines
517 setConnectForm((current) => ({ ...current, [key]: value }));620 setConnectForm((current) => ({ ...current, [key]: value }));
518 };621 };
519 622
623 const setSourceKind = (kind: ConnectFormState["kind"]) => {
624 setConnectForm((current) => ({
625 ...current,
626 kind,
627 authKind: authKindForSourceKind(current.authKind, kind),
628 }));
629 };
630
520 const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => {631 const setProbeField = <K extends keyof ProbeAuthState>(key: K, value: ProbeAuthState[K]) => {
521 setProbeAuth((current) => ({ ...current, [key]: value }));632 setProbeAuth((current) => ({ ...current, [key]: value }));
522 };633 };
@@ -879,10 +990,10 @@ export function AddSourcePage() { · 356 unmodified lines
879 <div className="space-y-3 border-t border-border pt-4">990 <div className="space-y-3 border-t border-border pt-4">
880 <div>991 <div>
881 <p className="text-[12px] font-medium text-foreground">Start from a known source</p>992 <p className="text-[12px] font-medium text-foreground">Start from a known source</p>
882 <p className="text-[11px] text-muted-foreground">993 <p className="text-[11px] text-muted-foreground">
883 Load a real MCP, OpenAPI, or GraphQL template without running discovery first.994 Load a real MCP, OpenAPI, GraphQL, or Google Discovery template without running discovery first.
884 </p>995 </p>
885 </div>996 </div>
886 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">997 <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
887 {sourceTemplates.map((template) => (998 {sourceTemplates.map((template) => (
888 <button999 <button
@@ -930,18 +1041,20 @@ export function AddSourcePage() { · 41 unmodified lines
930 <Field label="Kind">1041 <Field label="Kind">
931 <SelectInput1042 <SelectInput
932 value={connectForm.kind}1043 value={connectForm.kind}
933 onChange={(v) => setFormField("kind", v as ConnectFormState["kind"])}1044 onChange={(v) => setSourceKind(v as ConnectFormState["kind"])}
934 options={kindOptions.map((v) => ({ value: v, label: v }))}1045 options={kindOptions.map((v) => ({ value: v, label: v }))}
935 />1046 />
936 </Field>1047 </Field>
937 <Field label="Endpoint" className="sm:col-span-2">1048 {connectForm.kind !== "google_discovery" && (
938 <TextInput1049 <Field label="Endpoint" className="sm:col-span-2">
939 value={connectForm.endpoint}1050 <TextInput
940 onChange={(v) => setFormField("endpoint", v)}1051 value={connectForm.endpoint}
941 placeholder="https://api.example.com"1052 onChange={(v) => setFormField("endpoint", v)}
942 mono1053 placeholder="https://api.example.com"
943 />1054 mono
944 </Field>1055 />
1056 </Field>
1057 )}
945 <Field label="Namespace">1058 <Field label="Namespace">
946 <TextInput1059 <TextInput
947 value={connectForm.namespace}1060 value={connectForm.namespace}
@@ -959,6 +1072,35 @@ export function AddSourcePage() { · 11 unmodified lines
959 />1072 />
960 </Field>1073 </Field>
961 )}1074 )}
1075 {connectForm.kind === "google_discovery" && (
1076 <>
1077 <Field label="Service">
1078 <TextInput
1079 value={connectForm.service}
1080 onChange={(v) => setFormField("service", v)}
1081 placeholder="sheets"
1082 />
1083 </Field>
1084 <Field label="Version">
1085 <TextInput
1086 value={connectForm.version}
1087 onChange={(v) => setFormField("version", v)}
1088 placeholder="v4"
1089 />
1090 </Field>
1091 <Field label="Discovery URL (optional)" className="sm:col-span-2">
1092 <TextInput
1093 value={connectForm.discoveryUrl}
1094 onChange={(v) => setFormField("discoveryUrl", v)}
1095 placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
1096 mono
1097 />
1098 </Field>
1099 <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">
1100 Common Google API versions: Gmail `v1`, Sheets `v4`, Drive `v3`, Calendar `v3`, Docs `v1`.
1101 </div>
1102 </>
1103 )}
962 </div>1104 </div>
963 </Section>1105 </Section>
964 1106
@@ -1004,6 +1146,13 @@ export function AddSourcePage() { · 39 unmodified lines
1004 options={authOptions.map((v) => ({ value: v, label: v }))}1146 options={authOptions.map((v) => ({ value: v, label: v }))}
1005 />1147 />
1006 </Field>1148 </Field>
1149 {connectForm.kind === "google_discovery" && (
1150 <div className="sm:col-span-2 rounded-lg border border-border bg-card/60 px-3 py-2 text-[12px] text-muted-foreground">
1151 {connectForm.authKind === "oauth2"
1152 ? "OAuth starts after you click Connect. Executor will open a Google sign-in popup and store the resulting refreshable credentials for this source."
1153 : "Choosing auth mode 'none' skips the Google OAuth popup. Use 'oauth2' if you want executor to start the sign-in flow for you."}
1154 </div>
1155 )}
1007 {connectForm.authKind !== "none" && (1156 {connectForm.authKind !== "none" && (
1008 <>1157 <>
1009 <Field label="Header name">1158 <Field label="Header name">
apps/web/src/views/source-editor.tsx+73−3
@@ -74,6 +74,8 @@ type SourceFormState = { · 73 unmodified lines
74 queryParamsText: string;74 queryParamsText: string;
75 headersText: string;75 headersText: string;
76 specUrl: string;76 specUrl: string;
77 service: string;
78 version: string;
77 defaultHeadersText: string;79 defaultHeadersText: string;
78 authKind: Source["auth"]["kind"];80 authKind: Source["auth"]["kind"];
79 authHeaderName: string;81 authHeaderName: string;
@@ -86,7 +88,13 @@ type SourceFormState = { · 6 unmodified lines
86 oauthRefreshHandle: string;88 oauthRefreshHandle: string;
87};89};
88 90
89const kindOptions: ReadonlyArray<Source["kind"]> = ["mcp", "openapi", "graphql", "internal"];91const kindOptions: ReadonlyArray<Source["kind"]> = [
92 "mcp",
93 "openapi",
94 "graphql",
95 "google_discovery",
96 "internal",
97];
90 98
91const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [99const transportOptions: ReadonlyArray<Exclude<TransportValue, "">> = [
92 "auto",100 "auto",
@@ -112,6 +120,9 @@ const namespaceFromUrl = (url: string): string => { · 19 unmodified lines
112 }120 }
113};121};
114 122
123const googleDiscoveryNamespace = (service: string): string =>
124 `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;
125
115const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {126const readStoredSourceOAuthPopupResult = (sessionId: string): SourceOAuthPopupMessage | null => {
116 if (typeof window === "undefined") {127 if (typeof window === "undefined") {
117 return null;128 return null;
@@ -288,14 +299,20 @@ const defaultFormState = (template?: SourceTemplate): SourceFormState => ({ · 170 unmodified lines
288 name: template?.name ?? "",299 name: template?.name ?? "",
289 kind: template?.kind ?? "openapi",300 kind: template?.kind ?? "openapi",
290 endpoint: template?.endpoint ?? "",301 endpoint: template?.endpoint ?? "",
291 namespace: template ? namespaceFromUrl(template.endpoint) : "",302 namespace: template
303 ? "service" in template
304 ? googleDiscoveryNamespace(template.service)
305 : namespaceFromUrl(template.endpoint)
306 : "",
292 enabled: true,307 enabled: true,
293 transport: template?.kind === "mcp" ? "auto" : "",308 transport: template?.kind === "mcp" ? "auto" : "",
294 queryParamsText: "",309 queryParamsText: "",
295 headersText: "",310 headersText: "",
296 specUrl: template && "specUrl" in template ? template.specUrl : "",311 specUrl: template && "specUrl" in template ? template.specUrl : "",
312 service: template && "service" in template ? template.service : "",
313 version: template && "version" in template ? template.version : "",
297 defaultHeadersText: "",314 defaultHeadersText: "",
298 authKind: "none",315 authKind: template?.kind === "google_discovery" ? "oauth2" : "none",
299 authHeaderName: "Authorization",316 authHeaderName: "Authorization",
300 authPrefix: "Bearer ",317 authPrefix: "Bearer ",
301 bearerProviderId: "",318 bearerProviderId: "",
@@ -316,6 +333,8 @@ const formStateFromSource = (source: Source): SourceFormState => ({ · 14 unmodified lines
316 queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")),333 queryParamsText: stringMapToEditor(readBindingStringMap(source, "queryParams")),
317 headersText: stringMapToEditor(readBindingStringMap(source, "headers")),334 headersText: stringMapToEditor(readBindingStringMap(source, "headers")),
318 specUrl: readBindingString(source, "specUrl"),335 specUrl: readBindingString(source, "specUrl"),
336 service: readBindingString(source, "service"),
337 version: readBindingString(source, "version"),
319 defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),338 defaultHeadersText: stringMapToEditor(readBindingStringMap(source, "defaultHeaders")),
320 authKind: source.auth.kind,339 authKind: source.auth.kind,
321 authHeaderName: source.auth.kind === "none" ? "Authorization" : source.auth.headerName,340 authHeaderName: source.auth.kind === "none" ? "Authorization" : source.auth.headerName,
@@ -484,6 +503,27 @@ const buildSourcePayload = (state: SourceFormState): CreateSourcePayload => { · 162 unmodified lines
484 };503 };
485 }504 }
486 505
506 if (state.kind === "google_discovery") {
507 const service = state.service.trim();
508 const version = state.version.trim();
509 if (!service) {
510 throw new Error("Google Discovery sources require a service.");
511 }
512 if (!version) {
513 throw new Error("Google Discovery sources require a version.");
514 }
515
516 return {
517 ...shared,
518 binding: {
519 service,
520 version,
521 discoveryUrl: endpoint,
522 defaultHeaders: parseJsonStringMap("Default headers", state.defaultHeadersText),
523 },
524 };
525 }
526
487 return {527 return {
488 ...shared,528 ...shared,
489 binding: {},529 binding: {},
@@ -784,6 +824,8 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 294 unmodified lines
784 ? "https://api.github.com"824 ? "https://api.github.com"
785 : formState.kind === "graphql"825 : formState.kind === "graphql"
786 ? "https://api.linear.app/graphql"826 ? "https://api.linear.app/graphql"
827 : formState.kind === "google_discovery"
828 ? "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
787 : "https://mcp.deepwiki.com/mcp"829 : "https://mcp.deepwiki.com/mcp"
788 }830 }
789 mono831 mono
@@ -871,6 +913,34 @@ function SourceEditor(props: { mode: "create" | "edit"; source?: Source }) { · 81 unmodified lines
871 </Section>913 </Section>
872 )}914 )}
873 915
916 {formState.kind === "google_discovery" && (
917 <Section title="Google Discovery">
918 <div className="grid gap-4 sm:grid-cols-2">
919 <Field label="Service">
920 <TextInput
921 value={formState.service}
922 onChange={(value) => setField("service", value)}
923 placeholder="sheets"
924 />
925 </Field>
926 <Field label="Version">
927 <TextInput
928 value={formState.version}
929 onChange={(value) => setField("version", value)}
930 placeholder="v4"
931 />
932 </Field>
933 <Field label="Default headers (JSON)" className="sm:col-span-2">
934 <CodeEditor
935 value={formState.defaultHeadersText}
936 onChange={(value) => setField("defaultHeadersText", value)}
937 placeholder={'{\n "x-goog-user-project": "my-project"\n}'}
938 />
939 </Field>
940 </div>
941 </Section>
942 )}
943
874 <Section title="Authentication">944 <Section title="Authentication">
875 <div className="grid gap-4 sm:grid-cols-2">945 <div className="grid gap-4 sm:grid-cols-2">
876 {formState.kind === "mcp" && (946 {formState.kind === "mcp" && (
apps/web/src/views/source-templates.ts+179−2
@@ -12,11 +12,45 @@ export type OpenApiSourceTemplate = SourceTemplateBase & { · 11 unmodified lines
12 specUrl: string;12 specUrl: string;
13};13};
14 14
15export type GoogleDiscoverySourceTemplate = SourceTemplateBase & {
16 kind: "google_discovery";
17 service: string;
18 version: string;
19 discoveryUrl: string;
20};
21
15export type NonOpenApiSourceTemplate = SourceTemplateBase & {22export type NonOpenApiSourceTemplate = SourceTemplateBase & {
16 kind: Exclude<Source["kind"], "openapi" | "internal">;23 kind: Exclude<Source["kind"], "openapi" | "google_discovery" | "internal">;
17};24};
18 25
19export type SourceTemplate = OpenApiSourceTemplate | NonOpenApiSourceTemplate;26export type SourceTemplate =
27 | OpenApiSourceTemplate
28 | GoogleDiscoverySourceTemplate
29 | NonOpenApiSourceTemplate;
30
31const googleDiscoveryUrl = (service: string, version: string): string =>
32 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
33
34const googleDiscoveryTemplate = (input: {
35 id: string;
36 name: string;
37 summary: string;
38 service: string;
39 version: string;
40 discoveryUrl?: string;
41}): GoogleDiscoverySourceTemplate => {
42 const discoveryUrl = input.discoveryUrl ?? googleDiscoveryUrl(input.service, input.version);
43 return {
44 id: input.id,
45 name: input.name,
46 summary: input.summary,
47 kind: "google_discovery",
48 endpoint: discoveryUrl,
49 service: input.service,
50 version: input.version,
51 discoveryUrl,
52 };
53};
20 54
21export const sourceTemplates: ReadonlyArray<SourceTemplate> = [55export const sourceTemplates: ReadonlyArray<SourceTemplate> = [
22 {56 {
@@ -115,4 +149,147 @@ export const sourceTemplates: ReadonlyArray<SourceTemplate> = [ · 92 unmodified lines
115 kind: "graphql",149 kind: "graphql",
116 endpoint: "https://graphql.anilist.co",150 endpoint: "https://graphql.anilist.co",
117 },151 },
152 googleDiscoveryTemplate({
153 id: "google-calendar",
154 name: "Google Calendar",
155 summary: "Calendars, events, ACLs, and scheduling workflows.",
156 service: "calendar",
157 version: "v3",
158 discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3",
159 }),
160 googleDiscoveryTemplate({
161 id: "google-drive",
162 name: "Google Drive",
163 summary: "Files, folders, permissions, comments, and shared drives.",
164 service: "drive",
165 version: "v3",
166 }),
167 googleDiscoveryTemplate({
168 id: "google-gmail",
169 name: "Gmail",
170 summary: "Messages, threads, labels, drafts, and mailbox automation.",
171 service: "gmail",
172 version: "v1",
173 discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1",
174 }),
175 googleDiscoveryTemplate({
176 id: "google-docs",
177 name: "Google Docs",
178 summary: "Documents, structural edits, text ranges, and formatting.",
179 service: "docs",
180 version: "v1",
181 discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1",
182 }),
183 googleDiscoveryTemplate({
184 id: "google-sheets",
185 name: "Google Sheets",
186 summary: "Spreadsheets, values, ranges, formatting, and batch updates.",
187 service: "sheets",
188 version: "v4",
189 discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4",
190 }),
191 googleDiscoveryTemplate({
192 id: "google-slides",
193 name: "Google Slides",
194 summary: "Presentations, slides, page elements, and deck updates.",
195 service: "slides",
196 version: "v1",
197 discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1",
198 }),
199 googleDiscoveryTemplate({
200 id: "google-forms",
201 name: "Google Forms",
202 summary: "Forms, questions, responses, quizzes, and form metadata.",
203 service: "forms",
204 version: "v1",
205 discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1",
206 }),
207 googleDiscoveryTemplate({
208 id: "google-people",
209 name: "Google People",
210 summary: "Contacts, profiles, directory people, and contact groups.",
211 service: "people",
212 version: "v1",
213 discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1",
214 }),
215 googleDiscoveryTemplate({
216 id: "google-tasks",
217 name: "Google Tasks",
218 summary: "Task lists, task items, notes, and due dates.",
219 service: "tasks",
220 version: "v1",
221 discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1",
222 }),
223 googleDiscoveryTemplate({
224 id: "google-chat",
225 name: "Google Chat",
226 summary: "Spaces, messages, members, reactions, and chat workflows.",
227 service: "chat",
228 version: "v1",
229 discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1",
230 }),
231 googleDiscoveryTemplate({
232 id: "google-keep",
233 name: "Google Keep",
234 summary: "Notes, lists, attachments, and collaborative annotations.",
235 service: "keep",
236 version: "v1",
237 discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1",
238 }),
239 googleDiscoveryTemplate({
240 id: "google-classroom",
241 name: "Google Classroom",
242 summary: "Courses, rosters, coursework, submissions, and grading data.",
243 service: "classroom",
244 version: "v1",
245 discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1",
246 }),
247 googleDiscoveryTemplate({
248 id: "google-admin-directory",
249 name: "Google Admin Directory",
250 summary: "Users, groups, org units, roles, and domain directory resources.",
251 service: "admin",
252 version: "directory_v1",
253 discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1",
254 }),
255 googleDiscoveryTemplate({
256 id: "google-admin-reports",
257 name: "Google Admin Reports",
258 summary: "Audit events, usage reports, and admin activity logs.",
259 service: "admin",
260 version: "reports_v1",
261 discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1",
262 }),
263 googleDiscoveryTemplate({
264 id: "google-apps-script",
265 name: "Google Apps Script",
266 summary: "Projects, deployments, script execution, and Apps Script metadata.",
267 service: "script",
268 version: "v1",
269 discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1",
270 }),
271 googleDiscoveryTemplate({
272 id: "google-bigquery",
273 name: "Google BigQuery",
274 summary: "Datasets, tables, jobs, routines, and analytical query workflows.",
275 service: "bigquery",
276 version: "v2",
277 discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2",
278 }),
279 googleDiscoveryTemplate({
280 id: "google-cloud-resource-manager",
281 name: "Google Cloud Resource Manager",
282 summary: "Projects, folders, organizations, and IAM-oriented resource hierarchy.",
283 service: "cloudresourcemanager",
284 version: "v3",
285 discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3",
286 }),
287 googleDiscoveryTemplate({
288 id: "google-youtube-data",
289 name: "YouTube Data",
290 summary: "Channels, playlists, videos, comments, captions, and uploads.",
291 service: "youtube",
292 version: "v3",
293 discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3",
294 }),
118];295];
bun.lock+18−0
@@ -110,6 +110,21 @@ · 109 unmodified lines
110 "vitest": "catalog:",110 "vitest": "catalog:",
111 },111 },
112 },112 },
113 "packages/codemode-google-discovery": {
114 "name": "@executor/codemode-google-discovery",
115 "dependencies": {
116 "@effect/platform": "catalog:",
117 "@executor/codemode-core": "workspace:*",
118 "effect": "catalog:",
119 },
120 "devDependencies": {
121 "@effect/platform-node": "^0.104.1",
122 "@effect/vitest": "catalog:",
123 "@types/node": "catalog:",
124 "bun-types": "catalog:",
125 "vitest": "catalog:",
126 },
127 },
113 "packages/codemode-mcp": {128 "packages/codemode-mcp": {
114 "name": "@executor/codemode-mcp",129 "name": "@executor/codemode-mcp",
115 "dependencies": {130 "dependencies": {
@@ -148,6 +163,7 @@ · 32 unmodified lines
148 "@effect/platform": "catalog:",163 "@effect/platform": "catalog:",
149 "@electric-sql/pglite": "^0.3.12",164 "@electric-sql/pglite": "^0.3.12",
150 "@executor/codemode-core": "workspace:*",165 "@executor/codemode-core": "workspace:*",
166 "@executor/codemode-google-discovery": "workspace:*",
151 "@executor/codemode-mcp": "workspace:*",167 "@executor/codemode-mcp": "workspace:*",
152 "@executor/codemode-openapi": "workspace:*",168 "@executor/codemode-openapi": "workspace:*",
153 "@executor/runtime-deno-subprocess": "workspace:*",169 "@executor/runtime-deno-subprocess": "workspace:*",
@@ -472,6 +488,8 @@ · 318 unmodified lines
472 488
473 "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/codemode-core"],489 "@executor/codemode-core": ["@executor/codemode-core@workspace:packages/codemode-core"],
474 490
491 "@executor/codemode-google-discovery": ["@executor/codemode-google-discovery@workspace:packages/codemode-google-discovery"],
492
475 "@executor/codemode-mcp": ["@executor/codemode-mcp@workspace:packages/codemode-mcp"],493 "@executor/codemode-mcp": ["@executor/codemode-mcp@workspace:packages/codemode-mcp"],
476 494
477 "@executor/codemode-openapi": ["@executor/codemode-openapi@workspace:packages/codemode-openapi"],495 "@executor/codemode-openapi": ["@executor/codemode-openapi@workspace:packages/codemode-openapi"],
packages/codemode-core/src/http-request-placements.ts+164−0
@@ -0,0 +1,164 @@
1export 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
8const isRecord = (value: unknown): value is Record<string, unknown> =>
9 typeof value === "object" && value !== null && !Array.isArray(value);
10
11const decodeJsonPointerSegment = (value: string): string =>
12 value.replaceAll("~1", "/").replaceAll("~0", "~");
13
14const 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
31const 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
45const 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
70const 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
93export 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
106export 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
130export 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
4export * from "./tool-map";4export * from "./tool-map";
5export * from "./discovery";5export * from "./discovery";
6export * from "./system-tools";6export * from "./system-tools";
7export * from "./http-request-placements";
8export * 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
11const 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
22const rotr = (x: number, n: number): number => (x >>> n) | (x << (32 - n));
23
24const 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
58const 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
82const 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
119const 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 */
131export 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 @@
1import { sha256Hex } from "@executor/codemode-core";
2import * as Effect from "effect/Effect";
3
4import type {
5 GoogleDiscoveryHttpMethod,
6 GoogleDiscoveryManifestMethod,
7 GoogleDiscoveryMethodParameter,
8 GoogleDiscoverySchemaRefTable,
9 GoogleDiscoveryToolManifest,
10} from "./google-discovery-types";
11
12type JsonObject = Record<string, unknown>;
13
14type 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
26type 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
40type 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
54type DiscoveryResource = {
55 methods?: unknown;
56 resources?: unknown;
57};
58
59type 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
74const asRecord = (value: unknown): JsonObject =>
75 typeof value === "object" && value !== null && !Array.isArray(value)
76 ? value as JsonObject
77 : {};
78
79const asString = (value: unknown): string | null =>
80 typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
81
82const asBoolean = (value: unknown): boolean | null =>
83 typeof value === "boolean" ? value : null;
84
85const 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
93const contentHash = (value: string): string =>
94 sha256Hex(value);
95
96const escapeJsonPointerSegment = (value: string): string =>
97 value.replaceAll("~", "~0").replaceAll("/", "~1");
98
99const schemaRefKey = (schemaId: string): string =>
100 `#/$defs/google/${escapeJsonPointerSegment(schemaId)}`;
101
102const 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
118const 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
213const 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
232const 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
277const 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
297const 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
322const 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
341const 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
401const 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
434export 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
523export 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 @@
1import { createServer } from "node:http";
2
3import { describe, expect, it } from "@effect/vitest";
4import { makeToolInvokerFromTools } from "@executor/codemode-core";
5
6import * as Effect from "effect/Effect";
7
8import {
9 compileGoogleDiscoveryToolDefinitions,
10 createGoogleDiscoveryToolFromDefinition,
11 extractGoogleDiscoveryManifest,
12} from "./index";
13
14const 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
25const 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
94describe("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 @@
1import {
2 FetchHttpClient,
3 HttpClient,
4 type HttpClientResponse,
5 HttpClientRequest,
6} from "@effect/platform";
7import {
8 applyCookiePlacementsToHeaders,
9 applyHttpQueryPlacementsToUrl,
10 applyJsonBodyPlacements,
11 standardSchemaFromJsonSchema,
12 toTool,
13 type HttpRequestPlacements,
14 type ToolMetadata,
15} from "@executor/codemode-core";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Schema from "effect/Schema";
19
20import type {
21 GoogleDiscoveryManifestMethod,
22 GoogleDiscoverySchemaRefTable,
23 GoogleDiscoveryToolDefinition,
24} from "./google-discovery-types";
25import {
26 GoogleDiscoveryToolProviderDataSchema,
27} from "./google-discovery-types";
28
29const decodeGoogleDiscoveryToolProviderDataJson = Schema.decodeUnknownEither(
30 Schema.parseJson(GoogleDiscoveryToolProviderDataSchema),
31);
32
33export 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
66const decodeGoogleDiscoverySchemaRefTableJson = Schema.decodeUnknownEither(
67 Schema.parseJson(
68 Schema.Record({
69 key: Schema.String,
70 value: Schema.Unknown,
71 }),
72 ),
73);
74
75const asRecord = (value: unknown): Record<string, unknown> =>
76 typeof value === "object" && value !== null && !Array.isArray(value)
77 ? value as Record<string, unknown>
78 : {};
79
80const 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
93const 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
117const 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
166const 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
190const 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
213const 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
235const 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
247const 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
349export 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
365export 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
478export {
479 decodeGoogleDiscoverySchemaRefTableJson,
480};
packages/codemode-google-discovery/src/google-discovery-types.ts+123−0
@@ -0,0 +1,123 @@
1import { Schema } from "effect";
2
3export const GOOGLE_DISCOVERY_HTTP_METHODS = [
4 "get",
5 "put",
6 "post",
7 "delete",
8 "patch",
9 "head",
10 "options",
11] as const;
12
13export const GoogleDiscoveryHttpMethodSchema = Schema.Literal(
14 ...GOOGLE_DISCOVERY_HTTP_METHODS,
15);
16
17export const GoogleDiscoveryParameterLocationSchema = Schema.Literal(
18 "path",
19 "query",
20 "header",
21);
22
23export 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
34export 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
48export 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
60export 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
81export const GoogleDiscoverySchemaRefTableSchema = Schema.Record({
82 key: Schema.String,
83 value: Schema.String,
84});
85
86export 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
107export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethodSchema.Type;
108export type GoogleDiscoveryParameterLocation =
109 typeof GoogleDiscoveryParameterLocationSchema.Type;
110export type GoogleDiscoveryMethodParameter =
111 typeof GoogleDiscoveryMethodParameterSchema.Type;
112export type GoogleDiscoveryInvocationPayload =
113 typeof GoogleDiscoveryInvocationPayloadSchema.Type;
114export type GoogleDiscoveryToolProviderData =
115 typeof GoogleDiscoveryToolProviderDataSchema.Type;
116export type GoogleDiscoveryManifestMethod =
117 typeof GoogleDiscoveryManifestMethodSchema.Type;
118export type GoogleDiscoverySchemaRefTable =
119 typeof GoogleDiscoverySchemaRefTableSchema.Type;
120export type GoogleDiscoveryToolManifest =
121 typeof GoogleDiscoveryToolManifestSchema.Type;
122
123export type GoogleDiscoveryToolDefinition = GoogleDiscoveryManifestMethod;
packages/codemode-google-discovery/src/index.ts+29−0
@@ -0,0 +1,29 @@
1export {
2 extractGoogleDiscoveryManifest,
3 compileGoogleDiscoveryToolDefinitions,
4} from "./google-discovery-document";
5export {
6 createGoogleDiscoveryToolFromDefinition,
7 decodeGoogleDiscoverySchemaRefTableJson,
8 googleDiscoveryProviderDataJsonFromDefinition,
9 type CreateGoogleDiscoveryToolFromDefinitionInput,
10} from "./google-discovery-tools";
11export {
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 @@
1import { URL } from "node:url";
2
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";1import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";2import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
5import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";3import { 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
30 path: string;30 path: string;
31 query: string;31 query: string;
32 body: string;32 body: string;
33 headers: Record<string, string>;
33 }>;34 }>;
34 close: () => Promise<void>;35 close: () => Promise<void>;
35};36};
@@ -53,6 +54,7 @@ const makeEffectServerHandler = ( · 17 unmodified lines
53 path: `/repos/${path.owner}/${path.repo}`,54 path: `/repos/${path.owner}/${path.repo}`,
54 query: include ? `?include=${encodeURIComponent(include)}` : "",55 query: include ? `?include=${encodeURIComponent(include)}` : "",
55 body: "",56 body: "",
57 headers: {},
56 });58 });
57 59
58 return {60 return {
@@ -69,6 +71,7 @@ const makeEffectServerHandler = ( · 10 unmodified lines
69 path: `/repos/${path.owner}/${path.repo}/issues`,71 path: `/repos/${path.owner}/${path.repo}/issues`,
70 query: "",72 query: "",
71 body: bodyText,73 body: bodyText,
74 headers: {},
72 });75 });
73 76
74 return {77 return {
@@ -207,11 +210,21 @@ const makeTestServer = Effect.acquireRelease( · 132 unmodified lines
207 catch: (error: unknown) =>210 catch: (error: unknown) =>
208 error instanceof Error ? error : new Error(String(error)),211 error instanceof Error ? error : new Error(String(error)),
209 }).pipe(Effect.orDie),212 }).pipe(Effect.orDie),
210);213 );
211 214
212const ownerParam = HttpApiSchema.param("owner", Schema.String);215const ownerParam = HttpApiSchema.param("owner", Schema.String);
213const repoParam = HttpApiSchema.param("repo", Schema.String);216const repoParam = HttpApiSchema.param("repo", Schema.String);
214 217
218const 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
215class GeneratedReposApi extends HttpApiGroup.make("repos")228class GeneratedReposApi extends HttpApiGroup.make("repos")
216 .add(229 .add(
217 HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`230 HttpApiEndpoint.get("getRepo")`/repos/${ownerParam}/${repoParam}`
@@ -475,6 +488,117 @@ describe("openapi-tools", () => { · 257 unmodified lines
475 }),488 }),
476 );489 );
477 490
491 it.scoped("applies credential placements for query params, cookies, and body values", () =>
492 Effect.gen(function* () {
493 const requests: TestServer["requests"] = [];
494 const server = yield* Effect.acquireRelease(
495 Effect.promise<TestServer>(
496 () =>
497 new Promise<TestServer>((resolve, reject) => {
498 const httpServer = createServer((request, response) => {
499 const url = new URL(
500 request.url ?? "/",
501 `http://${request.headers.host ?? "127.0.0.1"}`,
502 );
503 let body = "";
504
505 request.setEncoding("utf8");
506 request.on("data", (chunk) => {
507 body += chunk;
508 });
509 request.on("end", () => {
510 requests.push({
511 method: request.method ?? "GET",
512 path: url.pathname,
513 query: url.search,
514 body,
515 headers: requestHeadersFromNode(request),
516 });
517 response.statusCode = 200;
518 response.setHeader("content-type", "application/json");
519 response.end(JSON.stringify({ ok: true }));
520 });
521 });
522
523 httpServer.once("error", reject);
524 httpServer.listen(0, "127.0.0.1", () => {
525 const address = httpServer.address();
526 if (!address || typeof address === "string") {
527 reject(new Error("failed to resolve test server address"));
528 return;
529 }
530
531 resolve({
532 baseUrl: `http://127.0.0.1:${address.port}`,
533 requests,
534 close: async () => {
535 await new Promise<void>((closeResolve, closeReject) => {
536 httpServer.close((error: Error | undefined) => {
537 if (error) {
538 closeReject(error);
539 return;
540 }
541 closeResolve();
542 });
543 });
544 },
545 });
546 });
547 }),
548 ),
549 (resource) =>
550 Effect.tryPromise({
551 try: () => resource.close(),
552 catch: (error: unknown) =>
553 error instanceof Error ? error : new Error(String(error)),
554 }).pipe(Effect.orDie),
555 );
556 const extracted = yield* createOpenApiToolsFromSpec({
557 sourceName: "demo",
558 openApiSpec: generatedOpenApiSpec,
559 baseUrl: server.baseUrl,
560 namespace: "source.demo",
561 credentialPlacements: {
562 headers: {
563 authorization: "Bearer top-secret",
564 },
565 queryParams: {
566 api_key: "secret-key",
567 },
568 cookies: {
569 session: "abc123",
570 },
571 bodyValues: {
572 "auth.token": "body-secret",
573 },
574 },
575 });
576
577 const getRepo = resolveToolExecutor(extracted.tools, "source.demo.repos.getRepo");
578 const createIssue = resolveToolExecutor(extracted.tools, "source.demo.repos.createIssue");
579
580 yield* Effect.promise(() => getRepo({ owner: "octocat", repo: "hello-world" }));
581 yield* Effect.promise(() =>
582 createIssue({
583 owner: "octocat",
584 repo: "hello-world",
585 body: { title: "Bug report" },
586 }),
587 );
588
589 expect(server.requests).toHaveLength(2);
590 expect(server.requests[0]?.query).toContain("api_key=secret-key");
591 expect(server.requests[0]?.headers.authorization).toBe("Bearer top-secret");
592 expect(server.requests[0]?.headers.cookie).toContain("session=abc123");
593 expect(JSON.parse(server.requests[1]?.body ?? "{}")).toEqual({
594 title: "Bug report",
595 auth: {
596 token: "body-secret",
597 },
598 });
599 }),
600 );
601
478 it.scoped("falls back to path-template args when extracted path parameters are missing", () =>602 it.scoped("falls back to path-template args when extracted path parameters are missing", () =>
479 Effect.gen(function* () {603 Effect.gen(function* () {
480 const server = yield* makeTestServer;604 const server = yield* makeTestServer;
packages/codemode-openapi/src/openapi-tools.ts+52−11
@@ -8,8 +8,12 @@ import * as Effect from "effect/Effect"; · 7 unmodified lines
8import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
9 9
10import {10import {
11 applyCookiePlacementsToHeaders,
12 applyHttpQueryPlacementsToUrl,
13 applyJsonBodyPlacements,
11 standardSchemaFromJsonSchema,14 standardSchemaFromJsonSchema,
12 toTool,15 toTool,
16 type HttpRequestPlacements,
13 type ToolMap,17 type ToolMap,
14 unknownInputSchema,18 unknownInputSchema,
15} from "@executor/codemode-core";19} from "@executor/codemode-core";
@@ -288,6 +292,7 @@ export type CreateOpenApiToolsFromManifestInput = { · 272 unmodified lines
288 sourceKey?: string;292 sourceKey?: string;
289 defaultHeaders?: Readonly<Record<string, string>>;293 defaultHeaders?: Readonly<Record<string, string>>;
290 credentialHeaders?: Readonly<Record<string, string>>;294 credentialHeaders?: Readonly<Record<string, string>>;
295 credentialPlacements?: HttpRequestPlacements;
291 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;296 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
292};297};
293 298
@@ -317,7 +322,7 @@ const buildFetchRequest = (input: { · 23 unmodified lines
317 args: OpenApiToolArgs;322 args: OpenApiToolArgs;
318 baseUrl: string;323 baseUrl: string;
319 defaultHeaders: Readonly<Record<string, string>>;324 defaultHeaders: Readonly<Record<string, string>>;
320 credentialHeaders: Readonly<Record<string, string>>;325 credentialPlacements: HttpRequestPlacements;
321}): {326}): {
322 url: URL;327 url: URL;
323 method: string;328 method: string;
@@ -369,9 +374,11 @@ const buildFetchRequest = (input: { · 45 unmodified lines
369 }374 }
370 375
371 let body: string | undefined;376 let body: string | undefined;
377 const bodyValues = input.credentialPlacements.bodyValues ?? {};
378 const hasCredentialBodyValues = Object.keys(bodyValues).length > 0;
372 379
373 if (input.payload.requestBody !== null) {380 if (input.payload.requestBody !== null) {
374 if (!hasRequestBody(input.args)) {381 if (!hasRequestBody(input.args) && !hasCredentialBodyValues) {
375 if (input.payload.requestBody.required) {382 if (input.payload.requestBody.required) {
376 throw new OpenApiToolInvocationError({383 throw new OpenApiToolInvocationError({
377 operation: "validate_args",384 operation: "validate_args",
@@ -380,7 +387,13 @@ const buildFetchRequest = (input: { · 2 unmodified lines
380 });387 });
381 }388 }
382 } else {389 } else {
383 body = JSON.stringify(input.args.body);390 body = JSON.stringify(
391 applyJsonBodyPlacements({
392 body: hasRequestBody(input.args) ? input.args.body : {},
393 bodyValues,
394 label: `${input.payload.method.toUpperCase()} ${input.payload.pathTemplate}`,
395 }),
396 );
384 397
385 const preferredContentType = input.payload.requestBody.contentTypes[0];398 const preferredContentType = input.payload.requestBody.contentTypes[0];
386 if (preferredContentType) {399 if (preferredContentType) {
@@ -391,14 +404,23 @@ const buildFetchRequest = (input: { · 4 unmodified lines
391 }404 }
392 }405 }
393 406
394 for (const [key, value] of Object.entries(input.credentialHeaders)) {407 const urlWithAuth = applyHttpQueryPlacementsToUrl({
395 headers[key] = value;408 url,
409 queryParams: input.credentialPlacements.queryParams,
410 });
411 const headersWithAuthCookies = applyCookiePlacementsToHeaders({
412 headers,
413 cookies: input.credentialPlacements.cookies,
414 });
415
416 for (const [key, value] of Object.entries(input.credentialPlacements.headers ?? {})) {
417 headersWithAuthCookies[key] = value;
396 }418 }
397 419
398 return {420 return {
399 url,421 url: urlWithAuth,
400 method: input.payload.method.toUpperCase(),422 method: input.payload.method.toUpperCase(),
401 headers,423 headers: headersWithAuthCookies,
402 body,424 body,
403 };425 };
404};426};
@@ -413,15 +435,32 @@ export type CreateOpenApiToolFromDefinitionInput = { · 8 unmodified lines
413 baseUrl: string;435 baseUrl: string;
414 defaultHeaders?: Readonly<Record<string, string>>;436 defaultHeaders?: Readonly<Record<string, string>>;
415 credentialHeaders?: Readonly<Record<string, string>>;437 credentialHeaders?: Readonly<Record<string, string>>;
438 credentialPlacements?: HttpRequestPlacements;
416 refHintTable?: Readonly<OpenApiRefHintTable>;439 refHintTable?: Readonly<OpenApiRefHintTable>;
417 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;440 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
418};441};
419 442
443const 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
420export const createOpenApiToolFromDefinition = (456export const createOpenApiToolFromDefinition = (
421 input: CreateOpenApiToolFromDefinitionInput,457 input: CreateOpenApiToolFromDefinitionInput,
422) => {458) => {
423 const defaultHeaders = input.defaultHeaders ?? {};459 const defaultHeaders = input.defaultHeaders ?? {};
424 const credentialHeaders = input.credentialHeaders ?? {};460 const credentialPlacements = normalizeCredentialPlacements({
461 credentialHeaders: input.credentialHeaders,
462 credentialPlacements: input.credentialPlacements,
463 });
425 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;464 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
426 const presentation = buildOpenApiToolPresentation({465 const presentation = buildOpenApiToolPresentation({
427 definition: input.definition,466 definition: input.definition,
@@ -441,7 +480,7 @@ export const createOpenApiToolFromDefinition = ( · 13 unmodified lines
441 args: decodedArgs,480 args: decodedArgs,
442 baseUrl: input.baseUrl,481 baseUrl: input.baseUrl,
443 defaultHeaders,482 defaultHeaders,
444 credentialHeaders,483 credentialPlacements,
445 });484 });
446 485
447 return Effect.runPromise(486 return Effect.runPromise(
@@ -503,7 +542,6 @@ export const createOpenApiToolsFromManifest = ( · 55 unmodified lines
503 const baseUrl = normalizeHttpUrl(input.baseUrl);542 const baseUrl = normalizeHttpUrl(input.baseUrl);
504 const sourceKey = input.sourceKey ?? "openapi.generated";543 const sourceKey = input.sourceKey ?? "openapi.generated";
505 const defaultHeaders = input.defaultHeaders ?? {};544 const defaultHeaders = input.defaultHeaders ?? {};
506 const credentialHeaders = input.credentialHeaders ?? {};
507 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;545 const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
508 546
509 const definitions = compileOpenApiToolDefinitions(input.manifest);547 const definitions = compileOpenApiToolDefinitions(input.manifest);
@@ -517,7 +555,8 @@ export const createOpenApiToolsFromManifest = ( · 7 unmodified lines
517 sourceKey,555 sourceKey,
518 baseUrl,556 baseUrl,
519 defaultHeaders,557 defaultHeaders,
520 credentialHeaders,558 credentialHeaders: input.credentialHeaders,
559 credentialPlacements: input.credentialPlacements,
521 refHintTable: input.manifest.refHintTable,560 refHintTable: input.manifest.refHintTable,
522 httpClientLayer,561 httpClientLayer,
523 });562 });
@@ -534,6 +573,7 @@ export const createOpenApiToolsFromSpec = (input: { · 10 unmodified lines
534 sourceKey?: string;573 sourceKey?: string;
535 defaultHeaders?: Readonly<Record<string, string>>;574 defaultHeaders?: Readonly<Record<string, string>>;
536 credentialHeaders?: Readonly<Record<string, string>>;575 credentialHeaders?: Readonly<Record<string, string>>;
576 credentialPlacements?: HttpRequestPlacements;
537 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;577 httpClientLayer?: Layer.Layer<HttpClient.HttpClient, never, never>;
538}): Effect.Effect<578}): Effect.Effect<
539 { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap },579 { manifest: OpenApiToolManifest; definitions: Array<OpenApiToolDefinition>; tools: ToolMap },
@@ -551,6 +591,7 @@ export const createOpenApiToolsFromSpec = (input: { · 11 unmodified lines
551 sourceKey: input.sourceKey,591 sourceKey: input.sourceKey,
552 defaultHeaders: input.defaultHeaders,592 defaultHeaders: input.defaultHeaders,
553 credentialHeaders: input.credentialHeaders,593 credentialHeaders: input.credentialHeaders,
594 credentialPlacements: input.credentialPlacements,
554 httpClientLayer: input.httpClientLayer,595 httpClientLayer: input.httpClientLayer,
555 }),596 }),
556 }),597 }),
packages/control-plane/drizzle/20260312113000_auth_artifacts_and_leases/migration.sql+132−0
@@ -0,0 +1,132 @@
1ALTER TABLE "workspace_source_credentials"
2RENAME TO "workspace_source_auth_artifacts";--> statement-breakpoint
3
4ALTER TABLE "workspace_source_auth_artifacts"
5ADD COLUMN "artifact_kind" text;--> statement-breakpoint
6
7ALTER TABLE "workspace_source_auth_artifacts"
8ADD COLUMN "config_json" text;--> statement-breakpoint
9
10ALTER TABLE "workspace_source_auth_artifacts"
11ADD COLUMN "grant_set_json" text;--> statement-breakpoint
12
13UPDATE "workspace_source_auth_artifacts"
14SET
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
48WHERE "artifact_kind" IS NULL OR "config_json" IS NULL;--> statement-breakpoint
49
50ALTER TABLE "workspace_source_auth_artifacts"
51ALTER COLUMN "artifact_kind" SET NOT NULL;--> statement-breakpoint
52
53ALTER TABLE "workspace_source_auth_artifacts"
54ALTER COLUMN "config_json" SET NOT NULL;--> statement-breakpoint
55
56DROP INDEX IF EXISTS "credentials_workspace_idx";--> statement-breakpoint
57DROP INDEX IF EXISTS "credentials_workspace_source_actor_idx";--> statement-breakpoint
58DROP INDEX IF EXISTS "credentials_workspace_source_idx";--> statement-breakpoint
59
60ALTER TABLE "workspace_source_auth_artifacts"
61DROP CONSTRAINT IF EXISTS "credentials_auth_kind_check";--> statement-breakpoint
62
63ALTER TABLE "workspace_source_auth_artifacts"
64DROP CONSTRAINT IF EXISTS "credentials_slot_check";--> statement-breakpoint
65
66ALTER TABLE "workspace_source_auth_artifacts"
67DROP COLUMN "auth_kind";--> statement-breakpoint
68
69ALTER TABLE "workspace_source_auth_artifacts"
70DROP COLUMN "auth_header_name";--> statement-breakpoint
71
72ALTER TABLE "workspace_source_auth_artifacts"
73DROP COLUMN "auth_prefix";--> statement-breakpoint
74
75ALTER TABLE "workspace_source_auth_artifacts"
76DROP COLUMN "token_provider_id";--> statement-breakpoint
77
78ALTER TABLE "workspace_source_auth_artifacts"
79DROP COLUMN "token_handle";--> statement-breakpoint
80
81ALTER TABLE "workspace_source_auth_artifacts"
82DROP COLUMN "refresh_token_provider_id";--> statement-breakpoint
83
84ALTER TABLE "workspace_source_auth_artifacts"
85DROP COLUMN "refresh_token_handle";--> statement-breakpoint
86
87ALTER TABLE "workspace_source_auth_artifacts"
88ADD CONSTRAINT "auth_artifacts_slot_check"
89CHECK ("slot" in ('runtime', 'import'));--> statement-breakpoint
90
91CREATE INDEX "auth_artifacts_workspace_idx"
92ON "workspace_source_auth_artifacts" ("workspace_id","updated_at","id");--> statement-breakpoint
93
94CREATE UNIQUE INDEX "auth_artifacts_workspace_source_actor_idx"
95ON "workspace_source_auth_artifacts" ("workspace_id","source_id","actor_account_id","slot");--> statement-breakpoint
96
97CREATE INDEX "auth_artifacts_workspace_source_idx"
98ON "workspace_source_auth_artifacts" ("workspace_id","source_id","updated_at","id");--> statement-breakpoint
99
100CREATE 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
114ALTER TABLE "workspace_source_auth_leases"
115ADD CONSTRAINT "auth_leases_slot_check"
116CHECK ("slot" in ('runtime', 'import'));--> statement-breakpoint
117
118CREATE UNIQUE INDEX "auth_leases_auth_artifact_idx"
119ON "workspace_source_auth_leases" ("auth_artifact_id");--> statement-breakpoint
120
121CREATE INDEX "auth_leases_workspace_source_idx"
122ON "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
131ALTER TABLE "source_auth_sessions"
132DROP 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 @@
1ALTER TABLE "workspace_source_auth_leases"
2ADD COLUMN "placements_template_json" text;--> statement-breakpoint
3
4UPDATE "workspace_source_auth_leases"
5SET "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
36ALTER TABLE "workspace_source_auth_leases"
37ALTER COLUMN "placements_template_json" SET NOT NULL;--> statement-breakpoint
38
39ALTER TABLE "workspace_source_auth_leases"
40DROP COLUMN "placements_json";--> statement-breakpoint
packages/control-plane/package.json+1−0
@@ -26,6 +26,7 @@ · 25 unmodified lines
26 "@effect/platform": "catalog:",26 "@effect/platform": "catalog:",
27 "@electric-sql/pglite": "^0.3.12",27 "@electric-sql/pglite": "^0.3.12",
28 "@executor/codemode-core": "workspace:*",28 "@executor/codemode-core": "workspace:*",
29 "@executor/codemode-google-discovery": "workspace:*",
29 "@executor/codemode-mcp": "workspace:*",30 "@executor/codemode-mcp": "workspace:*",
30 "@executor/runtime-ses": "workspace:*",31 "@executor/runtime-ses": "workspace:*",
31 "@executor/codemode-openapi": "workspace:*",32 "@executor/codemode-openapi": "workspace:*",
packages/control-plane/src/api/payload-schemas.test.ts+17−3
@@ -64,9 +64,23 @@ describe("control-plane payload schemas", () => { · 63 unmodified lines
64 specUrl: " https://example.com/openapi.json ",64 specUrl: " https://example.com/openapi.json ",
65 }),65 }),
66 ).toEqual({66 ).toEqual({
67 kind: "openapi",67 kind: "openapi",
68 endpoint: "https://api.github.com",68 endpoint: "https://api.github.com",
69 specUrl: "https://example.com/openapi.json",69 specUrl: "https://example.com/openapi.json",
70 });
71
72 expect(
73 Schema.decodeUnknownSync(ConnectSourcePayloadSchema)({
74 kind: "google_discovery",
75 service: " sheets ",
76 version: " v4 ",
77 discoveryUrl: " https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest ",
78 }),
79 ).toEqual({
80 kind: "google_discovery",
81 service: "sheets",
82 version: "v4",
83 discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
70 });84 });
71 85
72 expect(86 expect(
packages/control-plane/src/persistence/control-plane-rows.test.ts+94−59
@@ -8,6 +8,9 @@ import { · 7 unmodified lines
8 OrganizationMemberIdSchema,8 OrganizationMemberIdSchema,
9 PolicyIdSchema,9 PolicyIdSchema,
10 SecretMaterialIdSchema,10 SecretMaterialIdSchema,
11 StaticBearerAuthArtifactConfigJsonSchema,
12 StaticOAuth2AuthArtifactConfigJsonSchema,
13 decodeBuiltInAuthArtifactConfig,
11 SourceAuthSessionIdSchema,14 SourceAuthSessionIdSchema,
12 SourceIdSchema,15 SourceIdSchema,
13 SourceRecipeIdSchema,16 SourceRecipeIdSchema,
@@ -39,6 +42,12 @@ const makePersistence: Effect.Effect<SqlControlPlanePersistence, unknown, Scope. · 25 unmodified lines
39 );42 );
40 43
41const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);44const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
45const encodeStaticBearerArtifactConfig = Schema.encodeSync(
46 StaticBearerAuthArtifactConfigJsonSchema,
47);
48const encodeStaticOAuth2ArtifactConfig = Schema.encodeSync(
49 StaticOAuth2AuthArtifactConfigJsonSchema,
50);
42 51
43const openApiBindingConfigJson = (specUrl: string): string =>52const openApiBindingConfigJson = (specUrl: string): string =>
44 JSON.stringify({53 JSON.stringify({
@@ -148,7 +157,7 @@ const seedWorkspaceSourceState = (input: { · 103 unmodified lines
148 });157 });
149 });158 });
150 159
151const seedWorkspaceCredentialState = (input: {160const seedWorkspaceAuthArtifactState = (input: {
152 persistence: SqlControlPlanePersistence;161 persistence: SqlControlPlanePersistence;
153 accountId: ReturnType<typeof AccountIdSchema.make>;162 accountId: ReturnType<typeof AccountIdSchema.make>;
154 organizationId: ReturnType<typeof OrganizationIdSchema.make>;163 organizationId: ReturnType<typeof OrganizationIdSchema.make>;
@@ -160,7 +169,7 @@ const seedWorkspaceCredentialState = (input: { · 5 unmodified lines
160}, unknown, never> =>169}, unknown, never> =>
161 Effect.gen(function* () {170 Effect.gen(function* () {
162 const now = Date.now();171 const now = Date.now();
163 const credentialId = CredentialIdSchema.make(`cred_${input.workspaceId}`);172 const authArtifactId = CredentialIdSchema.make(`cred_${input.workspaceId}`);
164 const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`);173 const tokenId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_token`);
165 const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`);174 const refreshId = SecretMaterialIdSchema.make(`sec_${input.workspaceId}_refresh`);
166 175
@@ -181,19 +190,26 @@ const seedWorkspaceCredentialState = (input: { · 14 unmodified lines
181 createdAt: now,190 createdAt: now,
182 updatedAt: now,191 updatedAt: now,
183 });192 });
184 yield* input.persistence.rows.credentials.upsert({193 yield* input.persistence.rows.authArtifacts.upsert({
185 id: credentialId,194 id: authArtifactId,
186 workspaceId: input.workspaceId,195 workspaceId: input.workspaceId,
187 sourceId: input.sourceId,196 sourceId: input.sourceId,
188 actorAccountId: input.accountId,197 actorAccountId: input.accountId,
189 slot: "runtime",198 slot: "runtime",
190 authKind: "oauth2",199 artifactKind: "static_oauth2",
191 authHeaderName: "Authorization",200 configJson: encodeStaticOAuth2ArtifactConfig({
192 authPrefix: "Bearer ",201 headerName: "Authorization",
193 tokenProviderId: "postgres",202 prefix: "Bearer ",
194 tokenHandle: tokenId,203 accessToken: {
195 refreshTokenProviderId: "postgres",204 providerId: "postgres",
196 refreshTokenHandle: refreshId,205 handle: tokenId,
206 },
207 refreshToken: {
208 providerId: "postgres",
209 handle: refreshId,
210 },
211 }),
212 grantSetJson: null,
197 createdAt: now,213 createdAt: now,
198 updatedAt: now,214 updatedAt: now,
199 });215 });
@@ -463,20 +479,23 @@ describe("control-plane-persistence-drizzle", () => { · 263 unmodified lines
463 });479 });
464 480
465 yield* Effect.tryPromise(async () => {481 yield* Effect.tryPromise(async () => {
466 await persistence.db.insert(drizzleSchema.credentialsTable).values([482 await persistence.db.insert(drizzleSchema.authArtifactsTable).values([
467 {483 {
468 id: nullCredentialA,484 id: nullCredentialA,
469 workspaceId,485 workspaceId,
470 sourceId,486 sourceId,
471 actorAccountId: null,487 actorAccountId: null,
472 slot: "runtime",488 slot: "runtime",
473 authKind: "bearer",489 artifactKind: "static_bearer",
474 authHeaderName: "Authorization",490 configJson: encodeStaticBearerArtifactConfig({
475 authPrefix: "Bearer ",491 headerName: "Authorization",
476 tokenProviderId: "postgres",492 prefix: "Bearer ",
477 tokenHandle: "sec_null_a",493 token: {
478 refreshTokenProviderId: null,494 providerId: "postgres",
479 refreshTokenHandle: null,495 handle: "sec_null_a",
496 },
497 }),
498 grantSetJson: null,
480 createdAt: now,499 createdAt: now,
481 updatedAt: now,500 updatedAt: now,
482 },501 },
@@ -486,79 +505,95 @@ describe("control-plane-persistence-drizzle", () => { · 3 unmodified lines
486 sourceId,505 sourceId,
487 actorAccountId: null,506 actorAccountId: null,
488 slot: "runtime",507 slot: "runtime",
489 authKind: "bearer",508 artifactKind: "static_bearer",
490 authHeaderName: "Authorization",509 configJson: encodeStaticBearerArtifactConfig({
491 authPrefix: "Bearer ",510 headerName: "Authorization",
492 tokenProviderId: "postgres",511 prefix: "Bearer ",
493 tokenHandle: "sec_null_b",512 token: {
494 refreshTokenProviderId: null,513 providerId: "postgres",
495 refreshTokenHandle: null,514 handle: "sec_null_b",
515 },
516 }),
517 grantSetJson: null,
496 createdAt: now + 1,518 createdAt: now + 1,
497 updatedAt: now + 1,519 updatedAt: now + 1,
498 },520 },
499 ]);521 ]);
500 }).pipe(Effect.orDie);522 }).pipe(Effect.orDie);
501 523
502 yield* persistence.rows.credentials.upsert({524 yield* persistence.rows.authArtifacts.upsert({
503 id: CredentialIdSchema.make("cred_null_credentials_replacement"),525 id: CredentialIdSchema.make("cred_null_credentials_replacement"),
504 workspaceId,526 workspaceId,
505 sourceId,527 sourceId,
506 actorAccountId: null,528 actorAccountId: null,
507 slot: "runtime",529 slot: "runtime",
508 authKind: "oauth2",530 artifactKind: "static_oauth2",
509 authHeaderName: "X-Auth",531 configJson: encodeStaticOAuth2ArtifactConfig({
510 authPrefix: "Token ",532 headerName: "X-Auth",
511 tokenProviderId: "postgres",533 prefix: "Token ",
512 tokenHandle: "sec_null_replacement",534 accessToken: {
513 refreshTokenProviderId: "postgres",535 providerId: "postgres",
514 refreshTokenHandle: "sec_null_refresh",536 handle: "sec_null_replacement",
537 },
538 refreshToken: {
539 providerId: "postgres",
540 handle: "sec_null_refresh",
541 },
542 }),
543 grantSetJson: null,
515 createdAt: now + 2,544 createdAt: now + 2,
516 updatedAt: now + 2,545 updatedAt: now + 2,
517 });546 });
518 547
519 yield* persistence.rows.credentials.upsert({548 yield* persistence.rows.authArtifacts.upsert({
520 id: actorCredentialId,549 id: actorCredentialId,
521 workspaceId,550 workspaceId,
522 sourceId,551 sourceId,
523 actorAccountId: accountId,552 actorAccountId: accountId,
524 slot: "runtime",553 slot: "runtime",
525 authKind: "bearer",554 artifactKind: "static_bearer",
526 authHeaderName: "Authorization",555 configJson: encodeStaticBearerArtifactConfig({
527 authPrefix: "Bearer ",556 headerName: "Authorization",
528 tokenProviderId: "postgres",557 prefix: "Bearer ",
529 tokenHandle: "sec_actor",558 token: {
530 refreshTokenProviderId: null,559 providerId: "postgres",
531 refreshTokenHandle: null,560 handle: "sec_actor",
561 },
562 }),
563 grantSetJson: null,
532 createdAt: now + 3,564 createdAt: now + 3,
533 updatedAt: now + 3,565 updatedAt: now + 3,
534 });566 });
535 567
536 const allCredentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({568 const allAuthArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({
537 workspaceId,569 workspaceId,
538 sourceId,570 sourceId,
539 });571 });
540 expect(allCredentials).toHaveLength(2);572 expect(allAuthArtifacts).toHaveLength(2);
541 const nullActorCredentials = allCredentials.filter((credential) => credential.actorAccountId === null);573 const nullActorArtifacts = allAuthArtifacts.filter((artifact) => artifact.actorAccountId === null);
542 expect(nullActorCredentials).toHaveLength(1);574 expect(nullActorArtifacts).toHaveLength(1);
543 expect(nullActorCredentials[0]?.id).toBe(nullCredentialA);575 expect(nullActorArtifacts[0]?.id).toBe(nullCredentialA);
544 expect(nullActorCredentials[0]?.authKind).toBe("oauth2");576 const nullActorConfig = decodeBuiltInAuthArtifactConfig(nullActorArtifacts[0]!);
545 expect(nullActorCredentials[0]?.authHeaderName).toBe("X-Auth");577 expect(nullActorConfig?.artifactKind).toBe("static_oauth2");
546 expect(nullActorCredentials[0]?.tokenHandle).toBe("sec_null_replacement");578 if (nullActorConfig !== null && nullActorConfig.artifactKind === "static_oauth2") {
547 expect(allCredentials.map((credential) => credential.id).sort()).toEqual(579 expect(nullActorConfig.config.headerName).toBe("X-Auth");
580 expect(nullActorConfig.config.accessToken.handle).toBe("sec_null_replacement");
581 }
582 expect(allAuthArtifacts.map((artifact) => artifact.id).sort()).toEqual(
548 [actorCredentialId, nullCredentialA].sort(),583 [actorCredentialId, nullCredentialA].sort(),
549 );584 );
550 585
551 const forActor = yield* persistence.rows.credentials.listByWorkspaceSourceAndActor({586 const forActor = yield* persistence.rows.authArtifacts.listByWorkspaceSourceAndActor({
552 workspaceId,587 workspaceId,
553 sourceId,588 sourceId,
554 actorAccountId: accountId,589 actorAccountId: accountId,
555 });590 });
556 expect(forActor).toHaveLength(2);591 expect(forActor).toHaveLength(2);
557 expect(new Set(forActor.map((credential) => credential.id))).toEqual(592 expect(new Set(forActor.map((artifact) => artifact.id))).toEqual(
558 new Set([actorCredentialId, nullCredentialA]),593 new Set([actorCredentialId, nullCredentialA]),
559 );594 );
560 595
561 const nullActorOnly = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({596 const nullActorOnly = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({
562 workspaceId,597 workspaceId,
563 sourceId,598 sourceId,
564 actorAccountId: null,599 actorAccountId: null,
@@ -569,7 +604,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines
569 expect(nullActorOnly.value.id).toBe(nullCredentialA);604 expect(nullActorOnly.value.id).toBe(nullCredentialA);
570 }605 }
571 606
572 const missingActor = yield* persistence.rows.credentials.getByWorkspaceSourceAndActor({607 const missingActor = yield* persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({
573 workspaceId,608 workspaceId,
574 sourceId,609 sourceId,
575 actorAccountId: AccountIdSchema.make("acc_missing_credentials"),610 actorAccountId: AccountIdSchema.make("acc_missing_credentials"),
@@ -588,7 +623,7 @@ describe("control-plane-persistence-drizzle", () => { · 12 unmodified lines
588 const sourceId = SourceIdSchema.make("src_ws_cleanup");623 const sourceId = SourceIdSchema.make("src_ws_cleanup");
589 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);624 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(`src_recipe_rev_${sourceId}`);
590 625
591 const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({626 const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({
592 persistence,627 persistence,
593 accountId,628 accountId,
594 organizationId,629 organizationId,
@@ -670,7 +705,7 @@ describe("control-plane-persistence-drizzle", () => { · 75 unmodified lines
670 const removed = yield* persistence.rows.workspaces.removeById(workspaceId);705 const removed = yield* persistence.rows.workspaces.removeById(workspaceId);
671 expect(removed).toBe(true);706 expect(removed).toBe(true);
672 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);707 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
673 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);708 expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0);
674 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);709 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
675 expect(710 expect(
676 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId),711 yield* persistence.rows.sourceRecipeDocuments.listByRevisionId(recipeRevisionId),
@@ -892,7 +927,7 @@ describe("control-plane-persistence-drizzle", () => { · 215 unmodified lines
892 const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup");927 const workspaceId = WorkspaceIdSchema.make("ws_org_cleanup");
893 const sourceId = SourceIdSchema.make("src_org_cleanup");928 const sourceId = SourceIdSchema.make("src_org_cleanup");
894 929
895 const { tokenId, refreshId } = yield* seedWorkspaceCredentialState({930 const { tokenId, refreshId } = yield* seedWorkspaceAuthArtifactState({
896 persistence,931 persistence,
897 accountId,932 accountId,
898 organizationId,933 organizationId,
@@ -903,7 +938,7 @@ describe("control-plane-persistence-drizzle", () => { · 4 unmodified lines
903 const removed = yield* persistence.rows.organizations.removeTreeById(organizationId);938 const removed = yield* persistence.rows.organizations.removeTreeById(organizationId);
904 expect(removed).toBe(true);939 expect(removed).toBe(true);
905 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);940 expect(Option.isNone(yield* persistence.rows.workspaces.getById(workspaceId))).toBe(true);
906 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);941 expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0);
907 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);942 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
908 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);943 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(tokenId))).toBe(true);
909 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);944 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(refreshId))).toBe(true);
packages/control-plane/src/persistence/index.ts+4−2
@@ -6,7 +6,8 @@ import * as Layer from "effect/Layer"; · 5 unmodified lines
6import { createDrizzleClient, type DrizzleClient } from "./client";6import { createDrizzleClient, type DrizzleClient } from "./client";
7import {7import {
8 createAccountsRepo,8 createAccountsRepo,
9 createCredentialsRepo,9 createAuthArtifactsRepo,
10 createAuthLeasesRepo,
10 createCodeMigrationsRepo,11 createCodeMigrationsRepo,
11 createExecutionInteractionsRepo,12 createExecutionInteractionsRepo,
12 createExecutionsRepo,13 createExecutionsRepo,
@@ -64,7 +65,8 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 51 unmodified lines
64 sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables),65 sourceRecipeSchemaBundles: createSourceRecipeSchemaBundlesRepo(client, tables),
65 sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),66 sourceRecipeOperations: createSourceRecipeOperationsRepo(client, tables),
66 codeMigrations: createCodeMigrationsRepo(client, tables),67 codeMigrations: createCodeMigrationsRepo(client, tables),
67 credentials: createCredentialsRepo(client, tables),68 authArtifacts: createAuthArtifactsRepo(client, tables),
69 authLeases: createAuthLeasesRepo(client, tables),
68 sourceOauthClients: createSourceOauthClientsRepo(client, tables),70 sourceOauthClients: createSourceOauthClientsRepo(client, tables),
69 secretMaterials: createSecretMaterialsRepo(client, tables),71 secretMaterials: createSecretMaterialsRepo(client, tables),
70 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),72 sourceAuthSessions: createSourceAuthSessionsRepo(client, tables),
packages/control-plane/src/persistence/post-migrations.test.ts+181−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 McpSourceAuthSessionDataJsonSchema,9 McpSourceAuthSessionDataJsonSchema,
10 OrganizationIdSchema,10 OrganizationIdSchema,
11 SourceAuthSessionIdSchema,11 SourceAuthSessionIdSchema,
12 decodeAuthLeasePlacementTemplates,
12 SourceIdSchema,13 SourceIdSchema,
13 SourceRecipeIdSchema,14 SourceRecipeIdSchema,
14 SourceRecipeRevisionIdSchema,15 SourceRecipeRevisionIdSchema,
@@ -753,4 +754,184 @@ describe("code-migrations", () => { · 738 unmodified lines
753 rmSync(legacyMigrationsDir, { recursive: true, force: true });754 rmSync(legacyMigrationsDir, { recursive: true, force: true });
754 }755 }
755 });756 });
757
758 it("upgrades auth leases into template-based placement storage", async () => {
759 const localDataDir = mkdtempSync(path.join(tmpdir(), "executor-cp-db-"));
760 const legacyMigrationsDir = copyMigrationsBefore(
761 "20260312143000_auth_lease_template_placements",
762 );
763 const workspaceId = WorkspaceIdSchema.make("ws_legacy_auth_lease");
764 const accountId = AccountIdSchema.make("acc_legacy_auth_lease");
765 const organizationId = OrganizationIdSchema.make("org_legacy_auth_lease");
766 const sourceId = SourceIdSchema.make("src_legacy_auth_lease");
767 const recipeId = SourceRecipeIdSchema.make("src_recipe_legacy_auth_lease");
768 const recipeRevisionId = SourceRecipeRevisionIdSchema.make(
769 "src_recipe_rev_legacy_auth_lease",
770 );
771 const authArtifactId = "auth_art_legacy_auth_lease";
772 const now = Date.now();
773 let legacyPersistence: SqlControlPlanePersistence | null = null;
774 let upgradedPersistence: SqlControlPlanePersistence | null = null;
775
776 try {
777 legacyPersistence = await Effect.runPromise(
778 createSqlControlPlanePersistence({
779 localDataDir,
780 migrationsFolder: legacyMigrationsDir,
781 runCodeMigrations: false,
782 }),
783 );
784
785 await Effect.runPromise(legacyPersistence.rows.organizations.insert({
786 id: organizationId,
787 slug: "org-legacy-auth-lease",
788 name: "Legacy Org",
789 status: "active",
790 createdByAccountId: accountId,
791 createdAt: now,
792 updatedAt: now,
793 }));
794 await Effect.runPromise(legacyPersistence.rows.workspaces.insert({
795 id: workspaceId,
796 organizationId,
797 name: "Legacy Workspace",
798 createdByAccountId: accountId,
799 createdAt: now,
800 updatedAt: now,
801 }));
802 await Effect.runPromise(legacyPersistence.rows.sourceRecipes.upsert({
803 id: recipeId,
804 kind: "http_api",
805 adapterKey: "openapi",
806 providerKey: "generic_http",
807 name: "Legacy OpenAPI",
808 summary: null,
809 visibility: "workspace",
810 latestRevisionId: recipeRevisionId,
811 createdAt: now,
812 updatedAt: now,
813 }));
814 await Effect.runPromise(legacyPersistence.rows.sourceRecipeRevisions.upsert(baseRevisionRecord({
815 id: recipeRevisionId,
816 recipeId,
817 revisionNumber: 1,
818 sourceConfigJson: JSON.stringify({
819 kind: "openapi",
820 endpoint: "https://api.example.com",
821 specUrl: "https://api.example.com/openapi.json",
822 }),
823 createdAt: now,
824 updatedAt: now,
825 })));
826 await Effect.runPromise(legacyPersistence.rows.sources.insert({
827 id: sourceId,
828 workspaceId,
829 recipeId,
830 recipeRevisionId,
831 name: "Legacy OpenAPI",
832 kind: "openapi",
833 endpoint: "https://api.example.com",
834 status: "connected",
835 enabled: true,
836 namespace: "legacy.openapi",
837 importAuthPolicy: "reuse_runtime",
838 bindingConfigJson: openApiBindingConfigJson("https://api.example.com/openapi.json"),
839 sourceHash: null,
840 lastError: null,
841 createdAt: now,
842 updatedAt: now,
843 }));
844 await Effect.runPromise(legacyPersistence.rows.authArtifacts.upsert({
845 id: authArtifactId as any,
846 workspaceId,
847 sourceId,
848 actorAccountId: accountId,
849 slot: "runtime",
850 artifactKind: "static_bearer",
851 configJson: JSON.stringify({
852 headerName: "Authorization",
853 prefix: "Bearer ",
854 token: {
855 providerId: "postgres",
856 handle: "sec_legacy_auth_lease_token",
857 },
858 }),
859 grantSetJson: null,
860 createdAt: now,
861 updatedAt: now,
862 }));
863
864 await legacyPersistence.db.execute(sql`
865 INSERT INTO "workspace_source_auth_leases" (
866 "id",
867 "auth_artifact_id",
868 "workspace_id",
869 "source_id",
870 "actor_account_id",
871 "slot",
872 "placements_json",
873 "expires_at",
874 "refresh_after",
875 "created_at",
876 "updated_at"
877 ) VALUES (
878 'auth_lease_legacy_auth_lease',
879 ${authArtifactId},
880 ${workspaceId},
881 ${sourceId},
882 ${accountId},
883 'runtime',
884 '[{"location":"header","name":"Authorization","value":"Bearer abc"},{"location":"query","name":"api_key","value":"q123"},{"location":"cookie","name":"sid","value":"cookie123"},{"location":"body","path":"auth.token","value":"body123"}]',
885 NULL,
886 NULL,
887 ${now},
888 ${now}
889 );
890 `);
891
892 await legacyPersistence.close();
893 legacyPersistence = null;
894
895 upgradedPersistence = await Effect.runPromise(
896 createSqlControlPlanePersistence({
897 localDataDir,
898 }),
899 );
900
901 const leaseOption = await Effect.runPromise(
902 upgradedPersistence.rows.authLeases.getByAuthArtifactId(authArtifactId as any),
903 );
904 expect(Option.isSome(leaseOption)).toBe(true);
905 const lease = Option.getOrNull(leaseOption);
906 expect(lease).not.toBeNull();
907 expect(lease?.placementsTemplateJson).not.toBeNull();
908 expect(decodeAuthLeasePlacementTemplates(lease!)).toEqual([
909 {
910 location: "header",
911 name: "Authorization",
912 parts: [{ kind: "literal", value: "Bearer abc" }],
913 },
914 {
915 location: "query",
916 name: "api_key",
917 parts: [{ kind: "literal", value: "q123" }],
918 },
919 {
920 location: "cookie",
921 name: "sid",
922 parts: [{ kind: "literal", value: "cookie123" }],
923 },
924 {
925 location: "body",
926 path: "auth.token",
927 parts: [{ kind: "literal", value: "body123" }],
928 },
929 ]);
930 } finally {
931 await legacyPersistence?.close().catch(() => undefined);
932 await upgradedPersistence?.close().catch(() => undefined);
933 rmSync(localDataDir, { recursive: true, force: true });
934 rmSync(legacyMigrationsDir, { recursive: true, force: true });
935 }
936 });
756});937});
packages/control-plane/src/persistence/repos/auth-artifacts-repo.ts+226−0
@@ -0,0 +1,226 @@
1import {
2 AuthArtifactSchema,
3 type AuthArtifact,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { and, asc, eq, inArray, isNull, or } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption } from "./shared";
12
13const decodeAuthArtifact = Schema.decodeUnknownSync(AuthArtifactSchema);
14
15const 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
27export 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 @@
1import {
2 AuthLeaseSchema,
3 type AuthLease,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { and, asc, eq, isNull } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";
12
13const decodeAuthLease = Schema.decodeUnknownSync(AuthLeaseSchema);
14
15export 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 @@
1import {
2 CredentialSchema,
3 type Credential,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { and, asc, eq, inArray, isNull, or } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption } from "./shared";
12
13const decodeCredential = Schema.decodeUnknownSync(CredentialSchema);
14
15const 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
27export 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
9export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo";9export { createSourceRecipeSchemaBundlesRepo } from "./source-recipe-schema-bundles-repo";
10export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";10export { createSourceRecipeOperationsRepo } from "./source-recipe-operations-repo";
11export { createCodeMigrationsRepo } from "./code-migrations-repo";11export { createCodeMigrationsRepo } from "./code-migrations-repo";
12export { createCredentialsRepo } from "./credentials-repo";12export { createAuthArtifactsRepo } from "./auth-artifacts-repo";
13export { createAuthLeasesRepo } from "./auth-leases-repo";
13export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";14export { createSourceOauthClientsRepo } from "./source-oauth-clients-repo";
14export { createSecretMaterialsRepo } from "./secret-materials-repo";15export { createSecretMaterialsRepo } from "./secret-materials-repo";
15export { createSourceAuthSessionsRepo } from "./source-auth-sessions-repo";16export { 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
13import {13import {
14 cleanupOrphanedSourceRecipes,14 cleanupOrphanedSourceRecipes,
15 firstOption,15 firstOption,
16 postgresSecretHandlesFromCredentials,16 postgresSecretHandlesFromAuthArtifacts,
17 withoutCreatedAt,17 withoutCreatedAt,
18} from "./shared";18} from "./shared";
19 19
@@ -144,20 +144,18 @@ export const createOrganizationsRepo = ( · 124 unmodified lines
144 })144 })
145 .from(tables.sourcesTable)145 .from(tables.sourcesTable)
146 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));146 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
147 const credentials = await tx147 const authArtifacts = await tx
148 .select({148 .select({
149 tokenProviderId: tables.credentialsTable.tokenProviderId,149 artifactKind: tables.authArtifactsTable.artifactKind,
150 tokenHandle: tables.credentialsTable.tokenHandle,150 configJson: tables.authArtifactsTable.configJson,
151 refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId,
152 refreshTokenHandle: tables.credentialsTable.refreshTokenHandle,
153 })151 })
154 .from(tables.credentialsTable)152 .from(tables.authArtifactsTable)
155 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));153 .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds));
156 154
157 const executionIds = executionRows.map((execution) => execution.id);155 const executionIds = executionRows.map((execution) => execution.id);
158 const recipeIds = sourceRows.map((source) => source.recipeId);156 const recipeIds = sourceRows.map((source) => source.recipeId);
159 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);157 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
160 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);158 const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts);
161 159
162 if (executionIds.length > 0) {160 if (executionIds.length > 0) {
163 await tx161 await tx
@@ -171,6 +169,10 @@ export const createOrganizationsRepo = ( · 7 unmodified lines
171 .delete(tables.executionsTable)169 .delete(tables.executionsTable)
172 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));170 .where(inArray(tables.executionsTable.workspaceId, workspaceIds));
173 171
172 await tx
173 .delete(tables.authLeasesTable)
174 .where(inArray(tables.authLeasesTable.workspaceId, workspaceIds));
175
174 await tx176 await tx
175 .delete(tables.sourceAuthSessionsTable)177 .delete(tables.sourceAuthSessionsTable)
176 .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds));178 .where(inArray(tables.sourceAuthSessionsTable.workspaceId, workspaceIds));
@@ -180,8 +182,8 @@ export const createOrganizationsRepo = ( · 3 unmodified lines
180 .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds));182 .where(inArray(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceIds));
181 183
182 await tx184 await tx
183 .delete(tables.credentialsTable)185 .delete(tables.authArtifactsTable)
184 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));186 .where(inArray(tables.authArtifactsTable.workspaceId, workspaceIds));
185 187
186 await tx188 await tx
187 .delete(tables.sourcesTable)189 .delete(tables.sourcesTable)
packages/control-plane/src/persistence/repos/secret-materials-repo.ts+16−23
@@ -1,10 +1,11 @@
1import {1import {
2 authArtifactSecretRefs,
2 type SecretMaterial,3 type SecretMaterial,
3 SecretMaterialSchema,4 SecretMaterialSchema,
4} from "#schema";5} from "#schema";
5import * as Option from "effect/Option";6import * as Option from "effect/Option";
6import { Schema } from "effect";7import { Schema } from "effect";
7import { eq, desc, or, and } from "drizzle-orm";8import { eq, desc, and } from "drizzle-orm";
8 9
9import type { DrizzleClient } from "../client";10import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";11import type { DrizzleTables } from "../schema";
@@ -92,34 +93,24 @@ export const createSecretMaterialsRepo = ( · 81 unmodified lines
92 }),93 }),
93 94
94 /**95 /**
95 * For each secret, find the sources that reference it via credentials.96 * For each secret, find the sources that reference it via auth artifacts.
96 * Returns a map of secretId -> Array<{ sourceId, sourceName }>.97 * Returns a map of secretId -> Array<{ sourceId, sourceName }>.
97 */98 */
98 listLinkedSources: () =>99 listLinkedSources: () =>
99 client.use("rows.secret_materials.list_linked_sources", async (db) => {100 client.use("rows.secret_materials.list_linked_sources", async (db) => {
100 // Find all credentials that reference any postgres-stored secret
101 // and join through bindings to sources.
102 const rows = await db101 const rows = await db
103 .select({102 .select({
104 tokenHandle: tables.credentialsTable.tokenHandle,103 artifactKind: tables.authArtifactsTable.artifactKind,
105 tokenProviderId: tables.credentialsTable.tokenProviderId,104 configJson: tables.authArtifactsTable.configJson,
106 refreshTokenHandle: tables.credentialsTable.refreshTokenHandle,105 sourceId: tables.authArtifactsTable.sourceId,
107 refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId,
108 sourceId: tables.credentialsTable.sourceId,
109 sourceName: tables.sourcesTable.name,106 sourceName: tables.sourcesTable.name,
110 })107 })
111 .from(tables.credentialsTable)108 .from(tables.authArtifactsTable)
112 .innerJoin(109 .innerJoin(
113 tables.sourcesTable,110 tables.sourcesTable,
114 and(111 and(
115 eq(tables.credentialsTable.workspaceId, tables.sourcesTable.workspaceId),112 eq(tables.authArtifactsTable.workspaceId, tables.sourcesTable.workspaceId),
116 eq(tables.credentialsTable.sourceId, tables.sourcesTable.sourceId),113 eq(tables.authArtifactsTable.sourceId, tables.sourcesTable.sourceId),
117 ),
118 )
119 .where(
120 or(
121 eq(tables.credentialsTable.tokenProviderId, "postgres"),
122 eq(tables.credentialsTable.refreshTokenProviderId, "postgres"),
123 ),114 ),
124 );115 );
125 116
@@ -138,11 +129,13 @@ export const createSecretMaterialsRepo = ( · 12 unmodified lines
138 }129 }
139 };130 };
140 131
141 if (row.tokenProviderId === "postgres") {132 for (const ref of authArtifactSecretRefs({
142 addLink(row.tokenHandle);133 artifactKind: row.artifactKind,
143 }134 configJson: row.configJson,
144 if (row.refreshTokenProviderId === "postgres" && row.refreshTokenHandle) {135 })) {
145 addLink(row.refreshTokenHandle);136 if (ref.providerId === "postgres") {
137 addLink(ref.handle);
138 }
146 }139 }
147 }140 }
148 141
packages/control-plane/src/persistence/repos/shared.ts+8−17
@@ -1,3 +1,4 @@
1import { authArtifactSecretRefs, type AuthArtifact } from "#schema";
1import { inArray } from "drizzle-orm";2import { inArray } from "drizzle-orm";
2import * as Option from "effect/Option";3import * as Option from "effect/Option";
3 4
@@ -16,26 +17,16 @@ export const withoutCreatedAt = <A extends { createdAt: unknown }>( · 12 unmodified lines
16 17
17const POSTGRES_SECRET_PROVIDER_ID = "postgres";18const POSTGRES_SECRET_PROVIDER_ID = "postgres";
18 19
19export const postgresSecretHandlesFromCredentials = (20export const postgresSecretHandlesFromAuthArtifacts = (
20 credentials: ReadonlyArray<{21 artifacts: ReadonlyArray<Pick<AuthArtifact, "artifactKind" | "configJson">>,
21 tokenProviderId: string;
22 tokenHandle: string;
23 refreshTokenProviderId: string | null;
24 refreshTokenHandle: string | null;
25 }>,
26): ReadonlyArray<string> => {22): ReadonlyArray<string> => {
27 const handles = new Set<string>();23 const handles = new Set<string>();
28 24
29 for (const credential of credentials) {25 for (const artifact of artifacts) {
30 if (credential.tokenProviderId === POSTGRES_SECRET_PROVIDER_ID) {26 for (const ref of authArtifactSecretRefs(artifact)) {
31 handles.add(credential.tokenHandle);27 if (ref.providerId === POSTGRES_SECRET_PROVIDER_ID) {
32 }28 handles.add(ref.handle);
33 29 }
34 if (
35 credential.refreshTokenProviderId === POSTGRES_SECRET_PROVIDER_ID
36 && credential.refreshTokenHandle !== null
37 ) {
38 handles.add(credential.refreshTokenHandle);
39 }30 }
40 }31 }
41 32
packages/control-plane/src/persistence/repos/workspaces-repo.ts+13−11
@@ -8,7 +8,7 @@ import type { DrizzleTables } from "../schema"; · 7 unmodified lines
8import {8import {
9 cleanupOrphanedSourceRecipes,9 cleanupOrphanedSourceRecipes,
10 firstOption,10 firstOption,
11 postgresSecretHandlesFromCredentials,11 postgresSecretHandlesFromAuthArtifacts,
12} from "./shared";12} from "./shared";
13 13
14const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);14const decodeWorkspace = Schema.decodeUnknownSync(WorkspaceSchema);
@@ -83,16 +83,14 @@ export const createWorkspacesRepo = ( · 68 unmodified lines
83 const executionIds = executionRows.map((execution) => execution.id);83 const executionIds = executionRows.map((execution) => execution.id);
84 const recipeIds = sourceRows.map((source) => source.recipeId);84 const recipeIds = sourceRows.map((source) => source.recipeId);
85 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);85 const recipeRevisionIds = sourceRows.map((source) => source.recipeRevisionId);
86 const credentials = await tx86 const authArtifacts = await tx
87 .select({87 .select({
88 tokenProviderId: tables.credentialsTable.tokenProviderId,88 artifactKind: tables.authArtifactsTable.artifactKind,
89 tokenHandle: tables.credentialsTable.tokenHandle,89 configJson: tables.authArtifactsTable.configJson,
90 refreshTokenProviderId: tables.credentialsTable.refreshTokenProviderId,
91 refreshTokenHandle: tables.credentialsTable.refreshTokenHandle,
92 })90 })
93 .from(tables.credentialsTable)91 .from(tables.authArtifactsTable)
94 .where(eq(tables.credentialsTable.workspaceId, workspaceId));92 .where(eq(tables.authArtifactsTable.workspaceId, workspaceId));
95 const postgresSecretHandles = postgresSecretHandlesFromCredentials(credentials);93 const postgresSecretHandles = postgresSecretHandlesFromAuthArtifacts(authArtifacts);
96 94
97 if (executionIds.length > 0) {95 if (executionIds.length > 0) {
98 await tx96 await tx
@@ -104,6 +102,10 @@ export const createWorkspacesRepo = ( · 5 unmodified lines
104 .delete(tables.executionsTable)102 .delete(tables.executionsTable)
105 .where(eq(tables.executionsTable.workspaceId, workspaceId));103 .where(eq(tables.executionsTable.workspaceId, workspaceId));
106 104
105 await tx
106 .delete(tables.authLeasesTable)
107 .where(eq(tables.authLeasesTable.workspaceId, workspaceId));
108
107 await tx109 await tx
108 .delete(tables.sourceAuthSessionsTable)110 .delete(tables.sourceAuthSessionsTable)
109 .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId));111 .where(eq(tables.sourceAuthSessionsTable.workspaceId, workspaceId));
@@ -113,8 +115,8 @@ export const createWorkspacesRepo = ( · 3 unmodified lines
113 .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId));115 .where(eq(tables.workspaceSourceOauthClientsTable.workspaceId, workspaceId));
114 116
115 await tx117 await tx
116 .delete(tables.credentialsTable)118 .delete(tables.authArtifactsTable)
117 .where(eq(tables.credentialsTable.workspaceId, workspaceId));119 .where(eq(tables.authArtifactsTable.workspaceId, workspaceId));
118 120
119 await tx121 await tx
120 .delete(tables.sourcesTable)122 .delete(tables.sourcesTable)
packages/control-plane/src/persistence/schema.ts+43−17
@@ -22,7 +22,8 @@ export const tableNames = { · 21 unmodified lines
22 sourceRecipeSchemaBundles: "source_recipe_schema_bundles",22 sourceRecipeSchemaBundles: "source_recipe_schema_bundles",
23 sourceRecipeOperations: "source_recipe_operations",23 sourceRecipeOperations: "source_recipe_operations",
24 codeMigrations: "control_plane_code_migrations",24 codeMigrations: "control_plane_code_migrations",
25 credentials: "workspace_source_credentials",25 authArtifacts: "workspace_source_auth_artifacts",
26 authLeases: "workspace_source_auth_leases",
26 workspaceSourceOauthClients: "workspace_source_oauth_clients",27 workspaceSourceOauthClients: "workspace_source_oauth_clients",
27 secretMaterials: "secret_materials",28 secretMaterials: "secret_materials",
28 sourceAuthSessions: "source_auth_sessions",29 sourceAuthSessions: "source_auth_sessions",
@@ -351,48 +352,72 @@ export const codeMigrationsTable = pgTable( · 322 unmodified lines
351 },352 },
352);353);
353 354
354export const credentialsTable = pgTable(355export const authArtifactsTable = pgTable(
355 tableNames.credentials,356 tableNames.authArtifacts,
356 {357 {
357 id: text("id").notNull().primaryKey(),358 id: text("id").notNull().primaryKey(),
358 workspaceId: text("workspace_id").notNull(),359 workspaceId: text("workspace_id").notNull(),
359 sourceId: text("source_id").notNull(),360 sourceId: text("source_id").notNull(),
360 actorAccountId: text("actor_account_id"),361 actorAccountId: text("actor_account_id"),
361 slot: text("slot").notNull(),362 slot: text("slot").notNull(),
362 authKind: text("auth_kind").notNull(),363 artifactKind: text("artifact_kind").notNull(),
363 authHeaderName: text("auth_header_name").notNull(),364 configJson: text("config_json").notNull(),
364 authPrefix: text("auth_prefix").notNull(),365 grantSetJson: text("grant_set_json"),
365 tokenProviderId: text("token_provider_id").notNull(),
366 tokenHandle: text("token_handle").notNull(),
367 refreshTokenProviderId: text("refresh_token_provider_id"),
368 refreshTokenHandle: text("refresh_token_handle"),
369 createdAt: bigint("created_at", { mode: "number" }).notNull(),366 createdAt: bigint("created_at", { mode: "number" }).notNull(),
370 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),367 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
371 },368 },
372 (table) => [369 (table) => [
373 index("credentials_workspace_idx").on(370 index("auth_artifacts_workspace_idx").on(
374 table.workspaceId,371 table.workspaceId,
375 table.updatedAt,372 table.updatedAt,
376 table.id,373 table.id,
377 ),374 ),
378 uniqueIndex("credentials_workspace_source_actor_idx").on(375 uniqueIndex("auth_artifacts_workspace_source_actor_idx").on(
379 table.workspaceId,376 table.workspaceId,
380 table.sourceId,377 table.sourceId,
381 table.actorAccountId,378 table.actorAccountId,
382 table.slot,379 table.slot,
383 ),380 ),
384 index("credentials_workspace_source_idx").on(381 index("auth_artifacts_workspace_source_idx").on(
385 table.workspaceId,382 table.workspaceId,
386 table.sourceId,383 table.sourceId,
387 table.updatedAt,384 table.updatedAt,
388 table.id,385 table.id,
389 ),386 ),
390 check(387 check(
391 "credentials_auth_kind_check",388 "auth_artifacts_slot_check",
392 sql`${table.authKind} in ('bearer', 'oauth2')`,389 sql`${table.slot} in ('runtime', 'import')`,
390 ),
391 ],
392);
393
394export const authLeasesTable = pgTable(
395 tableNames.authLeases,
396 {
397 id: text("id").notNull().primaryKey(),
398 authArtifactId: text("auth_artifact_id").notNull(),
399 workspaceId: text("workspace_id").notNull(),
400 sourceId: text("source_id").notNull(),
401 actorAccountId: text("actor_account_id"),
402 slot: text("slot").notNull(),
403 placementsTemplateJson: text("placements_template_json").notNull(),
404 expiresAt: bigint("expires_at", { mode: "number" }),
405 refreshAfter: bigint("refresh_after", { mode: "number" }),
406 createdAt: bigint("created_at", { mode: "number" }).notNull(),
407 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
408 },
409 (table) => [
410 uniqueIndex("auth_leases_auth_artifact_idx").on(table.authArtifactId),
411 index("auth_leases_workspace_source_idx").on(
412 table.workspaceId,
413 table.sourceId,
414 table.actorAccountId,
415 table.slot,
416 table.updatedAt,
417 table.id,
393 ),418 ),
394 check(419 check(
395 "credentials_slot_check",420 "auth_leases_slot_check",
396 sql`${table.slot} in ('runtime', 'import')`,421 sql`${table.slot} in ('runtime', 'import')`,
397 ),422 ),
398 ],423 ],
@@ -638,7 +663,8 @@ export const drizzleSchema = { · 239 unmodified lines
638 sourceRecipeSchemaBundlesTable,663 sourceRecipeSchemaBundlesTable,
639 sourceRecipeOperationsTable,664 sourceRecipeOperationsTable,
640 codeMigrationsTable,665 codeMigrationsTable,
641 credentialsTable,666 authArtifactsTable,
667 authLeasesTable,
642 workspaceSourceOauthClientsTable,668 workspaceSourceOauthClientsTable,
643 secretMaterialsTable,669 secretMaterialsTable,
644 sourceAuthSessionsTable,670 sourceAuthSessionsTable,
packages/control-plane/src/runtime/auth-artifacts.ts+403−0
@@ -0,0 +1,403 @@
1import {
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";
24import * as Effect from "effect/Effect";
25import * as Schema from "effect/Schema";
26
27import type {
28 ResolveSecretMaterial,
29 SecretMaterialResolveContext,
30} from "./secret-material-providers";
31
32export 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
42const emptyResolvedAuthMaterial: ResolvedSourceAuthMaterial = {
43 placements: [],
44 headers: {},
45 queryParams: {},
46 cookies: {},
47 bodyValues: {},
48 expiresAt: null,
49 refreshAfter: null,
50};
51
52const encodeStaticBearerAuthArtifactConfig = Schema.encodeSync(
53 StaticBearerAuthArtifactConfigJsonSchema,
54);
55
56const encodeStaticOAuth2AuthArtifactConfig = Schema.encodeSync(
57 StaticOAuth2AuthArtifactConfigJsonSchema,
58);
59
60const encodeStaticPlacementsAuthArtifactConfig = Schema.encodeSync(
61 StaticPlacementsAuthArtifactConfigJsonSchema,
62);
63const encodeRefreshableOAuth2AuthorizedUserAuthArtifactConfig = Schema.encodeSync(
64 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
65);
66
67const decodeRequestPlacementTemplates = Schema.decodeUnknownEither(
68 RequestPlacementTemplatesJsonSchema,
69);
70const encodeAuthGrantSet = Schema.encodeSync(AuthGrantSetJsonSchema);
71
72const 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
94const 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
120const 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
136export 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
214export 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
257export const authArtifactGrantSet = (artifact: Pick<AuthArtifact, "grantSetJson">): ReadonlyArray<string> | null =>
258 decodeAuthGrantSet(artifact.grantSetJson);
259
260export const authArtifactSecretMaterialRefs = (
261 artifact: Pick<AuthArtifact, "artifactKind" | "configJson">,
262): ReadonlyArray<SecretRef> => authArtifactSecretRefs(artifact);
263
264export 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
285export const encodeAuthGrantSetJson = (grantSet: ReadonlyArray<string> | null): string | null =>
286 grantSet === null ? null : encodeAuthGrantSet([...grantSet]);
287
288export 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 @@
1import type { SqlControlPlaneRows } from "#persistence";
2import type {
3 AuthArtifact,
4 AuthLease,
5 RequestPlacementTemplate,
6 SecretRef,
7} from "#schema";
8import {
9 AuthLeaseIdSchema,
10 RefreshableOAuth2AuthorizedUserAuthArtifactKind,
11 authLeaseSecretRefs,
12 decodeBuiltInAuthArtifactConfig,
13} from "#schema";
14import * as Effect from "effect/Effect";
15import * as Option from "effect/Option";
16
17import { resolveAuthArtifactMaterial, type ResolvedSourceAuthMaterial } from "./auth-artifacts";
18import {
19 createDefaultSecretMaterialDeleter,
20 createDefaultSecretMaterialStorer,
21 type ResolveSecretMaterial,
22 type SecretMaterialResolveContext,
23} from "./secret-material-providers";
24import { refreshOAuth2AccessToken } from "./oauth2-pkce";
25import type { OAuth2TokenResponse } from "./oauth2-pkce";
26
27const LEASE_REFRESH_SKEW_MS = 60_000;
28
29const encodeLeasePlacementTemplatesJson = (templates: ReadonlyArray<RequestPlacementTemplate>): string =>
30 JSON.stringify(templates);
31
32const secretRefKey = (ref: SecretRef): string =>
33 `${ref.providerId}:${ref.handle}`;
34
35const 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
62const 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
78const 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
171export 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
187export 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
258export 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
106 return trimmed.length > 0 ? trimmed : null;106 return trimmed.length > 0 ? trimmed : null;
107};107};
108 108
109const resolveOpenApiSourceLabel = (input: {
110 name?: string | null;
111 endpoint: string;
112}): string => trimOrNull(input.name) ?? input.endpoint;
113
114const resolveLocalCredentialUrl = (input: {109const resolveLocalCredentialUrl = (input: {
115 baseUrl: string;110 baseUrl: string;
116 workspaceId: WorkspaceId;111 workspaceId: WorkspaceId;
@@ -127,9 +122,12 @@ const promptForSourceCredentialSelection = (input: { · 10 unmodified lines
127 args: {122 args: {
128 workspaceId: WorkspaceId;123 workspaceId: WorkspaceId;
129 sourceId: Source["id"];124 sourceId: Source["id"];
130 kind: "openapi" | "graphql";125 kind: "openapi" | "graphql" | "google_discovery";
131 endpoint: string;126 endpoint?: string;
132 specUrl?: string;127 specUrl?: string;
128 service?: string;
129 version?: string;
130 discoveryUrl?: string | null;
133 name?: string | null;131 name?: string | null;
134 namespace?: string | null;132 namespace?: string | null;
135 };133 };
@@ -233,6 +231,14 @@ export const createExecutorToolMap = (input: { · 97 unmodified lines
233 endpoint: string;231 endpoint: string;
234 name?: string | null;232 name?: string | null;
235 namespace?: string | null;233 namespace?: string | null;
234 }
235 | {
236 kind: "google_discovery";
237 service: string;
238 version: string;
239 discoveryUrl?: string | null;
240 name?: string | null;
241 namespace?: string | null;
236 },242 },
237 context,243 context,
238 ): Promise<Source> => {244 ): Promise<Source> => {
@@ -241,27 +247,19 @@ export const createExecutorToolMap = (input: { · 2 unmodified lines
241 `executor.sources.add:${crypto.randomUUID()}`,247 `executor.sources.add:${crypto.randomUUID()}`,
242 );248 );
243 const preparedArgs: ExecutorAddSourceInput =249 const preparedArgs: ExecutorAddSourceInput =
244 args.kind === "openapi"250 args.kind === undefined || args.kind === "mcp"
245 ? {251 ? {
246 ...args,252 kind: args.kind,
253 endpoint: args.endpoint,
254 name: args.name ?? null,
255 namespace: args.namespace ?? null,
247 workspaceId: input.workspaceId,256 workspaceId: input.workspaceId,
248 actorAccountId: input.accountId,257 actorAccountId: input.accountId,
249 executionId,258 executionId,
250 interactionId,259 interactionId,
251 }260 }
252 : args.kind === "graphql"
253 ? {
254 ...args,
255 workspaceId: input.workspaceId,
256 actorAccountId: input.accountId,
257 executionId,
258 interactionId,
259 }
260 : {261 : {
261 kind: args.kind,262 ...args,
262 endpoint: args.endpoint,
263 name: args.name ?? null,
264 namespace: args.namespace ?? null,
265 workspaceId: input.workspaceId,263 workspaceId: input.workspaceId,
266 actorAccountId: input.accountId,264 actorAccountId: input.accountId,
267 executionId,265 executionId,
@@ -293,7 +291,7 @@ export const createExecutorToolMap = (input: { · 25 unmodified lines
293 let pendingResult = result;291 let pendingResult = result;
294 let pendingArgs = preparedArgs as Extract<292 let pendingArgs = preparedArgs as Extract<
295 ExecutorAddSourceInput,293 ExecutorAddSourceInput,
296 { kind: "openapi" | "graphql" }294 { kind: "openapi" | "graphql" | "google_discovery" }
297 >;295 >;
298 296
299 while (pendingResult.kind === "credential_required") {297 while (pendingResult.kind === "credential_required") {
packages/control-plane/src/runtime/graphql-tools.test.ts+154−0
@@ -0,0 +1,154 @@
1import { createServer, type IncomingMessage } from "node:http";
2
3import { afterEach, describe, expect, it } from "vitest";
4
5import { createGraphqlToolFromPersistedOperation } from "./graphql-tools";
6
7type CapturedRequest = {
8 path: string;
9 query: string;
10 headers: Record<string, string>;
11 body: string;
12};
13
14const 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
24const 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
77describe("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 @@
1import { createHash } from "node:crypto";
2
3import {1import {
2 applyCookiePlacementsToHeaders,
3 applyHttpQueryPlacementsToUrl,
4 applyJsonBodyPlacements,
5 sha256Hex,
4 standardSchemaFromJsonSchema,6 standardSchemaFromJsonSchema,
5 toTool,7 toTool,
8 type HttpRequestPlacements,
6 type ToolDescriptor,9 type ToolDescriptor,
7 type ToolMap,10 type ToolMap,
8 type ToolMetadata,11 type ToolMetadata,
@@ -137,6 +140,7 @@ type GraphqlHttpInvocation = { · 128 unmodified lines
137 path: string;140 path: string;
138 defaultHeaders?: Readonly<Record<string, string>>;141 defaultHeaders?: Readonly<Record<string, string>>;
139 credentialHeaders?: Readonly<Record<string, string>>;142 credentialHeaders?: Readonly<Record<string, string>>;
143 credentialPlacements?: HttpRequestPlacements;
140 query: string;144 query: string;
141 variables?: Record<string, unknown>;145 variables?: Record<string, unknown>;
142 operationName?: string;146 operationName?: string;
@@ -998,7 +1002,7 @@ const resolveGraphqlFieldToolIds = ( · 855 unmodified lines
998 );1002 );
999 applyDuplicates(1003 applyDuplicates(
1000 (draft) =>1004 (draft) =>
1001 `${draft.leaf}${toPascalCase(draft.operationType)}${createHash("sha1").update(`${draft.group}:${draft.fieldName}`).digest("hex").slice(0, 6)}`,1005 `${draft.leaf}${toPascalCase(draft.operationType)}${sha256Hex(`${draft.group}:${draft.fieldName}`).slice(0, 6)}`,
1002 );1006 );
1003 1007
1004 return staged1008 return staged
@@ -1071,7 +1075,7 @@ const fieldToolDraftsFromRootType = (input: { · 66 unmodified lines
1071};1075};
1072 1076
1073export const createGraphqlSourceHash = (documentText: string): string =>1077export const createGraphqlSourceHash = (documentText: string): string =>
1074 createHash("sha256").update(documentText).digest("hex");1078 sha256Hex(documentText);
1075 1079
1076export const extractGraphqlManifest = (1080export const extractGraphqlManifest = (
1077 sourceName: string,1081 sourceName: string,
@@ -1272,6 +1276,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 194 unmodified lines
1272 schemaRefTable?: Readonly<Record<string, unknown>>;1276 schemaRefTable?: Readonly<Record<string, unknown>>;
1273 defaultHeaders?: Readonly<Record<string, string>>;1277 defaultHeaders?: Readonly<Record<string, string>>;
1274 credentialHeaders?: Readonly<Record<string, string>>;1278 credentialHeaders?: Readonly<Record<string, string>>;
1279 credentialPlacements?: HttpRequestPlacements;
1275}) => {1280}) => {
1276 const decodedProviderData = decodeGraphqlToolProviderDataJson(1281 const decodedProviderData = decodeGraphqlToolProviderDataJson(
1277 input.providerDataJson,1282 input.providerDataJson,
@@ -1340,6 +1345,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 62 unmodified lines
1340 path: input.path,1345 path: input.path,
1341 defaultHeaders: input.defaultHeaders,1346 defaultHeaders: input.defaultHeaders,
1342 credentialHeaders: input.credentialHeaders,1347 credentialHeaders: input.credentialHeaders,
1348 credentialPlacements: input.credentialPlacements,
1343 args,1349 args,
1344 }),1350 }),
1345 );1351 );
@@ -1351,6 +1357,7 @@ export const createGraphqlToolFromPersistedOperation = (input: { · 5 unmodified lines
1351 path: input.path,1357 path: input.path,
1352 defaultHeaders: input.defaultHeaders,1358 defaultHeaders: input.defaultHeaders,
1353 credentialHeaders: input.credentialHeaders,1359 credentialHeaders: input.credentialHeaders,
1360 credentialPlacements: input.credentialPlacements,
1354 args,1361 args,
1355 }),1362 }),
1356 );1363 );
@@ -1396,25 +1403,40 @@ export const graphqlToolDescriptorFromDefinition = (input: { · 39 unmodified lines
1396 1403
1397const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) =>1404const invokeGraphqlHttpRequest = (input: GraphqlHttpInvocation) =>
1398 Effect.gen(function* () {1405 Effect.gen(function* () {
1406 const endpoint = applyHttpQueryPlacementsToUrl({
1407 url: input.endpoint,
1408 queryParams: input.credentialPlacements?.queryParams,
1409 }).toString();
1410 const requestBody = applyJsonBodyPlacements({
1411 body: {
1412 query: input.query,
1413 ...(input.variables !== undefined
1414 ? { variables: input.variables }
1415 : {}),
1416 ...(input.operationName
1417 ? { operationName: input.operationName }
1418 : {}),
1419 },
1420 bodyValues: input.credentialPlacements?.bodyValues,
1421 label: `GraphQL ${input.path}`,
1422 });
1423 const headers = applyCookiePlacementsToHeaders({
1424 headers: {
1425 "content-type": "application/json",
1426 ...(input.defaultHeaders ?? {}),
1427 ...(input.requestHeaders ?? {}),
1428 ...(input.credentialHeaders ?? {}),
1429 ...(input.credentialPlacements?.headers ?? {}),
1430 },
1431 cookies: input.credentialPlacements?.cookies,
1432 });
1433
1399 const response = yield* Effect.tryPromise({1434 const response = yield* Effect.tryPromise({
1400 try: () =>1435 try: () =>
1401 fetch(input.endpoint, {1436 fetch(endpoint, {
1402 method: "POST",1437 method: "POST",
1403 headers: new Headers({1438 headers: new Headers(headers),
1404 "content-type": "application/json",1439 body: JSON.stringify(requestBody),
1405 ...(input.defaultHeaders ?? {}),
1406 ...(input.requestHeaders ?? {}),
1407 ...(input.credentialHeaders ?? {}),
1408 }),
1409 body: JSON.stringify({
1410 query: input.query,
1411 ...(input.variables !== undefined
1412 ? { variables: input.variables }
1413 : {}),
1414 ...(input.operationName
1415 ? { operationName: input.operationName }
1416 : {}),
1417 }),
1418 }),1440 }),
1419 catch: (cause) =>1441 catch: (cause) =>
1420 graphqlToolError(`GraphQL request failed for ${input.path}`, cause),1442 graphqlToolError(`GraphQL request failed for ${input.path}`, cause),
@@ -1446,6 +1468,7 @@ const invokeRawGraphqlTool = (input: { · 25 unmodified lines
1446 path: string;1468 path: string;
1447 defaultHeaders?: Readonly<Record<string, string>>;1469 defaultHeaders?: Readonly<Record<string, string>>;
1448 credentialHeaders?: Readonly<Record<string, string>>;1470 credentialHeaders?: Readonly<Record<string, string>>;
1471 credentialPlacements?: HttpRequestPlacements;
1449 args: unknown;1472 args: unknown;
1450}) =>1473}) =>
1451 Effect.gen(function* () {1474 Effect.gen(function* () {
@@ -1462,6 +1485,7 @@ const invokeRawGraphqlTool = (input: { · 10 unmodified lines
1462 path: input.path,1485 path: input.path,
1463 defaultHeaders: input.defaultHeaders,1486 defaultHeaders: input.defaultHeaders,
1464 credentialHeaders: input.credentialHeaders,1487 credentialHeaders: input.credentialHeaders,
1488 credentialPlacements: input.credentialPlacements,
1465 query,1489 query,
1466 variables:1490 variables:
1467 record.variables !== undefined ? asRecord(record.variables) : undefined,1491 record.variables !== undefined ? asRecord(record.variables) : undefined,
@@ -1486,6 +1510,7 @@ const invokeGraphqlFieldTool = (input: { · 18 unmodified lines
1486 path: string;1510 path: string;
1487 defaultHeaders?: Readonly<Record<string, string>>;1511 defaultHeaders?: Readonly<Record<string, string>>;
1488 credentialHeaders?: Readonly<Record<string, string>>;1512 credentialHeaders?: Readonly<Record<string, string>>;
1513 credentialPlacements?: HttpRequestPlacements;
1489 args: unknown;1514 args: unknown;
1490}) =>1515}) =>
1491 Effect.gen(function* () {1516 Effect.gen(function* () {
@@ -1501,6 +1526,7 @@ const invokeGraphqlFieldTool = (input: { · 9 unmodified lines
1501 path: input.path,1526 path: input.path,
1502 defaultHeaders: input.defaultHeaders,1527 defaultHeaders: input.defaultHeaders,
1503 credentialHeaders: input.credentialHeaders,1528 credentialHeaders: input.credentialHeaders,
1529 credentialPlacements: input.credentialPlacements,
1504 query: input.entry.operationDocument,1530 query: input.entry.operationDocument,
1505 variables,1531 variables,
1506 operationName: input.entry.operationName,1532 operationName: input.entry.operationName,
@@ -1524,6 +1550,7 @@ export const createGraphqlToolsFromManifest = (input: { · 17 unmodified lines
1524 sourceKey: string;1550 sourceKey: string;
1525 defaultHeaders?: Readonly<Record<string, string>>;1551 defaultHeaders?: Readonly<Record<string, string>>;
1526 credentialHeaders?: Readonly<Record<string, string>>;1552 credentialHeaders?: Readonly<Record<string, string>>;
1553 credentialPlacements?: HttpRequestPlacements;
1527}): ToolMap => {1554}): ToolMap => {
1528 const endpoint = normalizeHttpUrl(input.endpoint);1555 const endpoint = normalizeHttpUrl(input.endpoint);
1529 const definitions = compileGraphqlToolDefinitions(input.manifest);1556 const definitions = compileGraphqlToolDefinitions(input.manifest);
@@ -1552,6 +1579,7 @@ export const createGraphqlToolsFromManifest = (input: { · 22 unmodified lines
1552 schemaRefTable: input.manifest.schemaRefTable,1579 schemaRefTable: input.manifest.schemaRefTable,
1553 defaultHeaders: input.defaultHeaders,1580 defaultHeaders: input.defaultHeaders,
1554 credentialHeaders: input.credentialHeaders,1581 credentialHeaders: input.credentialHeaders,
1582 credentialPlacements: input.credentialPlacements,
1555 }),1583 }),
1556 ] as const;1584 ] as const;
1557 }),1585 }),
packages/control-plane/src/runtime/oauth-loopback.test.ts+77−0
@@ -0,0 +1,77 @@
1import { createServer } from "node:http";
2
3import { describe, expect, it } from "vitest";
4import * as Effect from "effect/Effect";
5
6import { startOauthLoopbackRedirectServer } from "./oauth-loopback";
7
8const 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
52describe("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 @@
1import { createServer } from "node:http";
2
3import * as Effect from "effect/Effect";
4
5const DEFAULT_LOOPBACK_TIMEOUT_MS = 10 * 60_000;
6
7export type OauthLoopbackRedirectServer = {
8 redirectUri: string;
9 close: Effect.Effect<void, Error, never>;
10};
11
12const 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
25export 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 @@
1import { createHash, randomBytes } from "node:crypto";
2
3import * as Effect from "effect/Effect";
4
5export type OAuth2TokenResponse = {
6 access_token: string;
7 token_type?: string;
8 refresh_token?: string;
9 expires_in?: number;
10 scope?: string;
11};
12
13export type OAuth2ClientAuthenticationMethod =
14 | "none"
15 | "client_secret_post";
16
17const encodeBase64Url = (input: Buffer): string =>
18 input.toString("base64")
19 .replaceAll("+", "-")
20 .replaceAll("/", "_")
21 .replaceAll("=", "");
22
23export const createPkceCodeVerifier = (): string =>
24 encodeBase64Url(randomBytes(48));
25
26export const createPkceCodeChallenge = (verifier: string): string =>
27 encodeBase64Url(createHash("sha256").update(verifier).digest());
28
29export 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
54const 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
100const 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
122export 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
150export 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
23 23
24export const mapPersistenceError = <A>(24export const mapPersistenceError = <A>(
25 operation: OperationErrorsLike,25 operation: OperationErrorsLike,
26 effect: Effect.Effect<A, ControlPlanePersistenceError>,26 effect: Effect.Effect<A, ControlPlanePersistenceError | Error>,
27): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> =>27): Effect.Effect<A, ControlPlaneBadRequestError | ControlPlaneStorageError> =>
28 effect.pipe(28 effect.pipe(
29 Effect.mapError((error) => {29 Effect.mapError((error) => {
30 const errors = asOperationErrors(operation);30 const errors = asOperationErrors(operation);
31 return isUniqueViolation(error)31 if (error instanceof ControlPlanePersistenceError) {
32 ? errors.badRequest("Unique constraint violation", error.details ?? "duplicate key")32 return isUniqueViolation(error)
33 : errors.storage(error);33 ? errors.badRequest("Unique constraint violation", error.details ?? "duplicate key")
34 : errors.storage(error);
35 }
36
37 return errors.unknownStorage(
38 error,
39 error.message,
40 );
34 }),41 }),
35 );42 );
36 43
packages/control-plane/src/runtime/source-adapters/google-discovery.test.ts+119−0
@@ -0,0 +1,119 @@
1import { createServer } from "node:http";
2
3import { describe, expect, it } from "@effect/vitest";
4import { SourceIdSchema } from "#schema";
5
6import * as Effect from "effect/Effect";
7
8import { googleDiscoverySourceAdapter } from "./google-discovery";
9import { createSourceFromPayload } from "../source-definitions";
10
11const 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
22const 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
58describe("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 @@
1import {
2 FetchHttpClient,
3 HttpClient,
4 HttpClientRequest,
5} from "@effect/platform";
6import {
7 makeToolInvokerFromTools,
8 typeSignatureFromSchemaJson,
9} from "@executor/codemode-core";
10import {
11 compileGoogleDiscoveryToolDefinitions,
12 createGoogleDiscoveryToolFromDefinition,
13 decodeGoogleDiscoverySchemaRefTableJson,
14 extractGoogleDiscoveryManifest,
15 googleDiscoveryProviderDataJsonFromDefinition,
16 GoogleDiscoveryToolProviderDataSchema,
17 type GoogleDiscoveryToolManifest,
18} from "@executor/codemode-google-discovery";
19import type {
20 Source,
21 SourceRecipeRevisionId,
22 StoredSourceRecipeDocumentRecord,
23 StoredSourceRecipeOperationRecord,
24 StoredSourceRecipeSchemaBundleRecord,
25} from "#schema";
26import {
27 SourceRecipeSchemaBundleIdSchema,
28 StringMapSchema,
29} from "#schema";
30import * as Either from "effect/Either";
31import * as Effect from "effect/Effect";
32import * as Schema from "effect/Schema";
33
34import {
35 contentHash,
36 normalizeSearchText,
37} from "../source-recipe-support";
38import type {
39 SourceAdapter,
40 SourceAdapterMaterialization,
41} from "./types";
42import {
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
56const 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
74const GoogleDiscoveryExecutorAddInputSchema = GoogleDiscoveryConnectPayloadSchema;
75
76const 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
86const 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
94type GoogleDiscoveryBindingConfig = typeof GoogleDiscoveryBindingConfigSchema.Type;
95
96const GOOGLE_DISCOVERY_BINDING_CONFIG_VERSION = 1;
97const GOOGLE_DISCOVERY_MATERIALIZATION_REVISION_ID =
98 "src_recipe_rev_materialization" as SourceRecipeRevisionId;
99
100const decodeGoogleDiscoveryProviderDataJson = Schema.decodeUnknownEither(
101 Schema.parseJson(GoogleDiscoveryToolProviderDataSchema),
102);
103
104const 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
113const defaultGoogleDiscoveryUrl = (service: string, version: string): string =>
114 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
115
116const 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
158const 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
205const 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
239const 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
286const 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
328const 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
362const trimEnv = (value: string | undefined): string | null => {
363 const trimmed = value?.trim();
364 return trimmed && trimmed.length > 0 ? trimmed : null;
365};
366
367export 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 @@
1import {1import {
2 applyCookiePlacementsToHeaders,
3 applyHttpQueryPlacementsToUrl,
4 applyJsonBodyPlacements,
2 makeToolInvokerFromTools,5 makeToolInvokerFromTools,
3 typeSignatureFromSchemaJson,6 typeSignatureFromSchemaJson,
4} from "@executor/codemode-core";7} from "@executor/codemode-core";
@@ -135,20 +138,38 @@ const graphqlBindingConfigFromSource = ( · 130 unmodified lines
135const fetchGraphqlIntrospectionDocumentWithHeaders = (input: {138const fetchGraphqlIntrospectionDocumentWithHeaders = (input: {
136 url: string;139 url: string;
137 headers?: Readonly<Record<string, string>>;140 headers?: Readonly<Record<string, string>>;
141 queryParams?: Readonly<Record<string, string>>;
142 cookies?: Readonly<Record<string, string>>;
143 bodyValues?: Readonly<Record<string, string>>;
138}): Effect.Effect<string, Error, never> =>144}): Effect.Effect<string, Error, never> =>
139 Effect.tryPromise({145 Effect.tryPromise({
140 try: async () => {146 try: async () => {
141 const response = await fetch(input.url, {147 const response = await fetch(
148 applyHttpQueryPlacementsToUrl({
149 url: input.url,
150 queryParams: input.queryParams,
151 }).toString(),
152 {
142 method: "POST",153 method: "POST",
143 headers: {154 headers: applyCookiePlacementsToHeaders({
144 "content-type": "application/json",155 headers: {
145 ...(input.headers ?? {}),156 "content-type": "application/json",
157 ...(input.headers ?? {}),
158 },
159 cookies: input.cookies,
160 }),
161 body: JSON.stringify(
162 applyJsonBodyPlacements({
163 body: {
164 query: GRAPHQL_INTROSPECTION_QUERY,
165 operationName: "IntrospectionQuery",
166 },
167 bodyValues: input.bodyValues,
168 label: `GraphQL introspection ${input.url}`,
169 }),
170 ),
146 },171 },
147 body: JSON.stringify({172 );
148 query: GRAPHQL_INTROSPECTION_QUERY,
149 operationName: "IntrospectionQuery",
150 }),
151 });
152 const text = await response.text();173 const text = await response.text();
153 174
154 let parsed: unknown;175 let parsed: unknown;
@@ -470,6 +491,9 @@ export const graphqlSourceAdapter: SourceAdapter = { · 315 unmodified lines
470 ...(bindingConfig.defaultHeaders ?? {}),491 ...(bindingConfig.defaultHeaders ?? {}),
471 ...auth.headers,492 ...auth.headers,
472 },493 },
494 queryParams: auth.queryParams,
495 cookies: auth.cookies,
496 bodyValues: auth.bodyValues,
473 },497 },
474 ).pipe(498 ).pipe(
475 Effect.mapError(499 Effect.mapError(
@@ -564,7 +588,7 @@ export const graphqlSourceAdapter: SourceAdapter = { · 88 unmodified lines
564 providerDataJson: operation.providerDataJson,588 providerDataJson: operation.providerDataJson,
565 schemaRefTable: schemaRefTable.right,589 schemaRefTable: schemaRefTable.right,
566 defaultHeaders: bindingConfig.defaultHeaders ?? {},590 defaultHeaders: bindingConfig.defaultHeaders ?? {},
567 credentialHeaders: auth.headers,591 credentialPlacements: auth,
568 });592 });
569 593
570 return yield* makeToolInvokerFromTools({594 return yield* makeToolInvokerFromTools({
packages/control-plane/src/runtime/source-adapters/index.ts+6−0
@@ -2,6 +2,7 @@ import type { Source, StoredSourceRecipeOperationRecord } from "#schema"; · 1 unmodified line
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3 3
4import { graphqlSourceAdapter } from "./graphql";4import { graphqlSourceAdapter } from "./graphql";
5import { googleDiscoverySourceAdapter } from "./google-discovery";
5import { internalSourceAdapter } from "./internal";6import { internalSourceAdapter } from "./internal";
6import { mcpSourceAdapter } from "./mcp";7import { mcpSourceAdapter } from "./mcp";
7import { openApiSourceAdapter } from "./openapi";8import { openApiSourceAdapter } from "./openapi";
@@ -10,6 +11,7 @@ import type { SourceAdapter } from "./types"; · 2 unmodified lines
10export const builtInSourceAdapters = [11export const builtInSourceAdapters = [
11 openApiSourceAdapter,12 openApiSourceAdapter,
12 graphqlSourceAdapter,13 graphqlSourceAdapter,
14 googleDiscoverySourceAdapter,
13 mcpSourceAdapter,15 mcpSourceAdapter,
14 internalSourceAdapter,16 internalSourceAdapter,
15] as const satisfies readonly SourceAdapter[];17] as const satisfies readonly SourceAdapter[];
@@ -18,12 +20,14 @@ export const connectableSourceAdapters = [ · 2 unmodified lines
18 mcpSourceAdapter,20 mcpSourceAdapter,
19 openApiSourceAdapter,21 openApiSourceAdapter,
20 graphqlSourceAdapter,22 graphqlSourceAdapter,
23 googleDiscoverySourceAdapter,
21] as const;24] as const;
22 25
23export const ConnectSourcePayloadSchema = Schema.Union(26export const ConnectSourcePayloadSchema = Schema.Union(
24 mcpSourceAdapter.connectPayloadSchema!,27 mcpSourceAdapter.connectPayloadSchema!,
25 openApiSourceAdapter.connectPayloadSchema!,28 openApiSourceAdapter.connectPayloadSchema!,
26 graphqlSourceAdapter.connectPayloadSchema!,29 graphqlSourceAdapter.connectPayloadSchema!,
30 googleDiscoverySourceAdapter.connectPayloadSchema!,
27);31);
28 32
29export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type;33export type ConnectSourcePayload = typeof ConnectSourcePayloadSchema.Type;
@@ -32,12 +36,14 @@ export const executorAddableSourceAdapters = [ · 2 unmodified lines
32 mcpSourceAdapter,36 mcpSourceAdapter,
33 openApiSourceAdapter,37 openApiSourceAdapter,
34 graphqlSourceAdapter,38 graphqlSourceAdapter,
39 googleDiscoverySourceAdapter,
35] as const;40] as const;
36 41
37export const ExecutorAddSourceInputSchema = Schema.Union(42export const ExecutorAddSourceInputSchema = Schema.Union(
38 mcpSourceAdapter.executorAddInputSchema!,43 mcpSourceAdapter.executorAddInputSchema!,
39 openApiSourceAdapter.executorAddInputSchema!,44 openApiSourceAdapter.executorAddInputSchema!,
40 graphqlSourceAdapter.executorAddInputSchema!,45 graphqlSourceAdapter.executorAddInputSchema!,
46 googleDiscoverySourceAdapter.executorAddInputSchema!,
41);47);
42 48
43export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type;49export type ExecutorAddSourceInput = typeof ExecutorAddSourceInputSchema.Type;
packages/control-plane/src/runtime/source-adapters/mcp.ts+30−8
@@ -1,4 +1,5 @@
1import {1import {
2 applyCookiePlacementsToHeaders,
2 makeToolInvokerFromTools,3 makeToolInvokerFromTools,
3} from "@executor/codemode-core";4} from "@executor/codemode-core";
4import {5import {
@@ -41,6 +42,19 @@ import { · 36 unmodified lines
41 SourceConnectCommonFieldsSchema,42 SourceConnectCommonFieldsSchema,
42} from "./shared";43} from "./shared";
43 44
45const 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
44const McpConnectPayloadSchema = Schema.extend(58const McpConnectPayloadSchema = Schema.extend(
45 SourceConnectCommonFieldsSchema,59 SourceConnectCommonFieldsSchema,
46 Schema.extend(60 Schema.extend(
@@ -339,11 +353,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 292 unmodified lines
339 createSdkMcpConnector({353 createSdkMcpConnector({
340 endpoint: source.endpoint,354 endpoint: source.endpoint,
341 transport: bindingConfig.transport ?? undefined,355 transport: bindingConfig.transport ?? undefined,
342 queryParams: bindingConfig.queryParams ?? undefined,356 queryParams: {
343 headers: {357 ...(bindingConfig.queryParams ?? {}),
344 ...(bindingConfig.headers ?? {}),358 ...auth.queryParams,
345 ...auth.headers,
346 },359 },
360 headers: headersWithAuthCookies({
361 headers: bindingConfig.headers ?? {},
362 authHeaders: auth.headers,
363 authCookies: auth.cookies,
364 }),
347 }),365 }),
348 catch: (cause) =>366 catch: (cause) =>
349 cause instanceof Error ? cause : new Error(String(cause)),367 cause instanceof Error ? cause : new Error(String(cause)),
@@ -394,11 +412,15 @@ export const mcpSourceAdapter: SourceAdapter = { · 44 unmodified lines
394 connect: createSdkMcpConnector({412 connect: createSdkMcpConnector({
395 endpoint: source.endpoint,413 endpoint: source.endpoint,
396 transport: bindingConfig.transport ?? undefined,414 transport: bindingConfig.transport ?? undefined,
397 queryParams: bindingConfig.queryParams ?? undefined,415 queryParams: {
398 headers: {416 ...(bindingConfig.queryParams ?? {}),
399 ...(bindingConfig.headers ?? {}),417 ...auth.queryParams,
400 ...auth.headers,
401 },418 },
419 headers: headersWithAuthCookies({
420 headers: bindingConfig.headers ?? {},
421 authHeaders: auth.headers,
422 authCookies: auth.cookies,
423 }),
402 }),424 }),
403 namespace: source.namespace ?? namespaceFromSourceName(source.name),425 namespace: source.namespace ?? namespaceFromSourceName(source.name),
404 sourceKey: source.id,426 sourceKey: source.id,
packages/control-plane/src/runtime/source-adapters/openapi.ts+19−3
@@ -5,6 +5,8 @@ import { · 4 unmodified lines
5} from "@effect/platform";5} from "@effect/platform";
6import type { SqlControlPlaneRows } from "#persistence";6import type { SqlControlPlaneRows } from "#persistence";
7import {7import {
8 applyCookiePlacementsToHeaders,
9 applyHttpQueryPlacementsToUrl,
8 makeToolInvokerFromTools,10 makeToolInvokerFromTools,
9} from "@executor/codemode-core";11} from "@executor/codemode-core";
10import {12import {
@@ -152,11 +154,23 @@ const openApiBindingConfigFromSource = ( · 141 unmodified lines
152const fetchOpenApiDocumentWithHeaders = (input: {154const fetchOpenApiDocumentWithHeaders = (input: {
153 url: string;155 url: string;
154 headers?: Readonly<Record<string, string>>;156 headers?: Readonly<Record<string, string>>;
157 queryParams?: Readonly<Record<string, string>>;
158 cookies?: Readonly<Record<string, string>>;
155}): Effect.Effect<string, Error, never> =>159}): Effect.Effect<string, Error, never> =>
156 Effect.gen(function* () {160 Effect.gen(function* () {
157 const client = yield* HttpClient.HttpClient;161 const client = yield* HttpClient.HttpClient;
158 const request = HttpClientRequest.get(input.url).pipe(162 const request = HttpClientRequest.get(
159 HttpClientRequest.setHeaders(input.headers ?? {}),163 applyHttpQueryPlacementsToUrl({
164 url: input.url,
165 queryParams: input.queryParams,
166 }).toString(),
167 ).pipe(
168 HttpClientRequest.setHeaders(
169 applyCookiePlacementsToHeaders({
170 headers: input.headers ?? {},
171 cookies: input.cookies,
172 }),
173 ),
160 );174 );
161 const response = yield* client.execute(request).pipe(175 const response = yield* client.execute(request).pipe(
162 Effect.mapError((cause) =>176 Effect.mapError((cause) =>
@@ -522,6 +536,8 @@ export const openApiSourceAdapter: SourceAdapter = { · 359 unmodified lines
522 ...(bindingConfig.defaultHeaders ?? {}),536 ...(bindingConfig.defaultHeaders ?? {}),
523 ...auth.headers,537 ...auth.headers,
524 },538 },
539 queryParams: auth.queryParams,
540 cookies: auth.cookies,
525 }).pipe(541 }).pipe(
526 Effect.mapError(542 Effect.mapError(
527 (cause) =>543 (cause) =>
@@ -609,7 +625,7 @@ export const openApiSourceAdapter: SourceAdapter = { · 81 unmodified lines
609 sourceKey: source.id,625 sourceKey: source.id,
610 baseUrl: source.endpoint,626 baseUrl: source.endpoint,
611 defaultHeaders: bindingConfig.defaultHeaders ?? {},627 defaultHeaders: bindingConfig.defaultHeaders ?? {},
612 credentialHeaders: auth.headers,628 credentialPlacements: auth,
613 refHintTable: refHintTable.right,629 refHintTable: refHintTable.right,
614 });630 });
615 631
packages/control-plane/src/runtime/source-adapters/types.ts+26−0
@@ -7,6 +7,7 @@ import type { SqlControlPlaneRows } from "#persistence"; · 6 unmodified lines
7import type {7import type {
8 AccountId,8 AccountId,
9 CredentialSlot,9 CredentialSlot,
10 OAuth2ClientAuthenticationMethod,
10 Source,11 Source,
11 SourceBinding,12 SourceBinding,
12 SourceImportAuthPolicy,13 SourceImportAuthPolicy,
@@ -68,6 +69,24 @@ export type SourceAdapterPersistedOperationMetadata = { · 55 unmodified lines
68 approvalLabel: string | null;69 approvalLabel: string | null;
69};70};
70 71
72export 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
83export type SourceAdapterDefaultOauthClient = {
84 providerKey: string;
85 clientId: string;
86 clientSecret: string | null;
87 redirectMode?: "app_callback" | "loopback";
88};
89
71export type SourceAdapterRepairRevisionInput = {90export type SourceAdapterRepairRevisionInput = {
72 rows: SqlControlPlaneRows;91 rows: SqlControlPlaneRows;
73 sourceRecord: StoredSourceRecord;92 sourceRecord: StoredSourceRecord;
@@ -137,6 +156,13 @@ export type SourceAdapter = { · 63 unmodified lines
137 materializeSource: (156 materializeSource: (
138 input: SourceAdapterSyncInput,157 input: SourceAdapterSyncInput,
139 ) => Effect.Effect<SourceAdapterMaterialization, Error, never>;158 ) => Effect.Effect<SourceAdapterMaterialization, Error, never>;
159 getOauth2SetupConfig?: (input: {
160 source: Source;
161 slot: CredentialSlot;
162 }) => Effect.Effect<SourceAdapterOauth2SetupConfig | null, Error, never>;
163 getDefaultOauthClient?: (
164 source: Source,
165 ) => Effect.Effect<SourceAdapterDefaultOauthClient | null, Error, never>;
140 invokePersistedTool: (166 invokePersistedTool: (
141 input: SourceAdapterInvokePersistedToolInput,167 input: SourceAdapterInvokePersistedToolInput,
142 ) => Effect.Effect<unknown, Error, never>;168 ) => Effect.Effect<unknown, Error, never>;
packages/control-plane/src/runtime/source-auth-material.test.ts+33−1
@@ -43,7 +43,15 @@ describe("source-auth-material", () => { · 42 unmodified lines
43 resolveSecretMaterial: () => Effect.die("should not be called"),43 resolveSecretMaterial: () => Effect.die("should not be called"),
44 }));44 }));
45 45
46 expect(auth).toEqual({ headers: {} });46 expect(auth).toEqual({
47 placements: [],
48 headers: {},
49 queryParams: {},
50 cookies: {},
51 bodyValues: {},
52 expiresAt: null,
53 refreshAfter: null,
54 });
47 });55 });
48 56
49 it("resolves bearer auth headers from the configured token ref", async () => {57 it("resolves bearer auth headers from the configured token ref", async () => {
@@ -69,9 +77,21 @@ describe("source-auth-material", () => { · 19 unmodified lines
69 77
70 expect(calls).toEqual(["postgres:sec_bearer"]);78 expect(calls).toEqual(["postgres:sec_bearer"]);
71 expect(auth).toEqual({79 expect(auth).toEqual({
80 placements: [
81 {
82 location: "header",
83 name: "X-Api-Key",
84 value: "Token resolved-bearer-token",
85 },
86 ],
72 headers: {87 headers: {
73 "X-Api-Key": "Token resolved-bearer-token",88 "X-Api-Key": "Token resolved-bearer-token",
74 },89 },
90 queryParams: {},
91 cookies: {},
92 bodyValues: {},
93 expiresAt: null,
94 refreshAfter: null,
75 });95 });
76 });96 });
77 97
@@ -107,9 +127,21 @@ describe("source-auth-material", () => { · 29 unmodified lines
107 127
108 expect(calls).toEqual(["postgres:sec_access"]);128 expect(calls).toEqual(["postgres:sec_access"]);
109 expect(auth).toEqual({129 expect(auth).toEqual({
130 placements: [
131 {
132 location: "header",
133 name: "Authorization",
134 value: "Bearer resolved-access-token",
135 },
136 ],
110 headers: {137 headers: {
111 Authorization: "Bearer resolved-access-token",138 Authorization: "Bearer resolved-access-token",
112 },139 },
140 queryParams: {},
141 cookies: {},
142 bodyValues: {},
143 expiresAt: null,
144 refreshAfter: null,
113 });145 });
114 });146 });
115 });147 });
packages/control-plane/src/runtime/source-auth-material.ts+75−38
@@ -1,42 +1,24 @@
1import type { CredentialSlot, Source, SourceAuth } from "#schema";1import type { SqlControlPlaneRows } from "#persistence";
2import type {
3 AccountId,
4 CredentialSlot,
5 Source,
6 SourceAuth,
7} from "#schema";
2import * as Effect from "effect/Effect";8import * as Effect from "effect/Effect";
9import * as Option from "effect/Option";
3 10
11import {
12 authArtifactFromSourceAuth,
13 resolveAuthArtifactMaterial,
14 type ResolvedSourceAuthMaterial,
15} from "./auth-artifacts";
16import { resolveAuthArtifactMaterialWithLeases } from "./auth-leases";
4import type {17import type {
5 ResolveSecretMaterial,18 ResolveSecretMaterial,
6 SecretMaterialResolveContext,19 SecretMaterialResolveContext,
7} from "./secret-material-providers";20} from "./secret-material-providers";
8 21
9export type ResolvedSourceAuthMaterial = {
10 headers: Readonly<Record<string, string>>;
11};
12
13export 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
40const authForSlot = (input: {22const authForSlot = (input: {
41 source: Source;23 source: Source;
42 slot: CredentialSlot;24 slot: CredentialSlot;
@@ -59,14 +41,69 @@ const authForSlot = (input: { · 16 unmodified lines
59export const resolveSourceAuthMaterial = (input: {41export const resolveSourceAuthMaterial = (input: {
60 source: Source;42 source: Source;
61 slot?: CredentialSlot;43 slot?: CredentialSlot;
44 actorAccountId?: AccountId | null;
45 rows?: SqlControlPlaneRows;
62 resolveSecretMaterial: ResolveSecretMaterial;46 resolveSecretMaterial: ResolveSecretMaterial;
63 context?: SecretMaterialResolveContext;47 context?: SecretMaterialResolveContext;
64}): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>48}): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>
65 resolveAuthMaterial({49 Effect.gen(function* () {
66 auth: authForSlot({50 const slot = input.slot ?? "runtime";
51
52 if (input.rows !== undefined) {
53 const candidateSlots =
54 slot === "import" && input.source.importAuthPolicy === "reuse_runtime"
55 ? ["import", "runtime"] satisfies ReadonlyArray<CredentialSlot>
56 : [slot] satisfies ReadonlyArray<CredentialSlot>;
57
58 for (const candidateSlot of candidateSlots) {
59 const artifactOption = yield* input.rows.authArtifacts.getByWorkspaceSourceAndActor({
60 workspaceId: input.source.workspaceId,
61 sourceId: input.source.id,
62 actorAccountId: input.actorAccountId ?? null,
63 slot: candidateSlot,
64 });
65
66 if (Option.isSome(artifactOption)) {
67 return yield* resolveAuthArtifactMaterialWithLeases({
68 rows: input.rows,
69 artifact: artifactOption.value,
70 resolveSecretMaterial: input.resolveSecretMaterial,
71 context: input.context,
72 });
73 }
74 }
75 }
76
77 const artifact = authArtifactFromSourceAuth({
67 source: input.source,78 source: input.source,
68 slot: input.slot ?? "runtime",79 auth: authForSlot({
69 }),80 source: input.source,
70 resolveSecretMaterial: input.resolveSecretMaterial,81 slot,
71 context: input.context,82 }),
83 slot,
84 actorAccountId: input.actorAccountId ?? null,
85 });
86
87 if (input.rows !== undefined) {
88 return yield* resolveAuthArtifactMaterialWithLeases({
89 rows: input.rows,
90 artifact,
91 resolveSecretMaterial: input.resolveSecretMaterial,
92 context: input.context,
93 });
94 }
95
96 if (artifact?.artifactKind === "oauth2_authorized_user") {
97 return yield* Effect.fail(
98 new Error("Dynamic auth artifacts require persistence-backed lease resolution"),
99 );
100 }
101
102 return yield* resolveAuthArtifactMaterial({
103 artifact,
104 resolveSecretMaterial: input.resolveSecretMaterial,
105 context: input.context,
106 });
72 });107 });
108
109export type { ResolvedSourceAuthMaterial } from "./auth-artifacts";
packages/control-plane/src/runtime/source-auth-service.test.ts+517−0
@@ -1,14 +1,33 @@
1import { createServer } from "node:http";
2
1import { describe, expect, it } from "vitest";3import { describe, expect, it } from "vitest";
2import * as Schema from "effect/Schema";4import * as Schema from "effect/Schema";
5import * as Effect from "effect/Effect";
6import * as Option from "effect/Option";
3 7
4import {8import {
9 AccountIdSchema,
5 McpSourceAuthSessionDataJsonSchema,10 McpSourceAuthSessionDataJsonSchema,
11 OAuth2PkceSourceAuthSessionDataJsonSchema,
12 SecretMaterialIdSchema,
6 type Source,13 type Source,
14 type SourceAuthSession,
15 WorkspaceIdSchema,
16 decodeAuthLeasePlacementTemplates,
17 decodeBuiltInAuthArtifactConfig,
7} from "#schema";18} from "#schema";
19import {
20 createSqlControlPlanePersistence,
21 type SqlControlPlanePersistence,
22} from "#persistence";
8 23
9import {24import {
25 createRuntimeSourceAuthService,
10 createTerminalSourceAuthSessionPatch,26 createTerminalSourceAuthSessionPatch,
11} from "./source-auth-service";27} from "./source-auth-service";
28import { createLiveExecutionManager } from "./live-execution";
29import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
30import { resolveSourceAuthMaterial } from "./source-auth-material";
12 31
13const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({32const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({
14 id: "src_test" as Source["id"],33 id: "src_test" as Source["id"],
@@ -33,6 +52,117 @@ const makeExistingOpenApiSource = (auth: Source["auth"]): Source => ({ · 18 unmodified lines
33 updatedAt: 1,52 updatedAt: 1,
34});53});
35 54
55const 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
66const 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
159const makePersistence = async (): Promise<SqlControlPlanePersistence> =>
160 Effect.runPromise(
161 createSqlControlPlanePersistence({
162 localDataDir: ":memory:",
163 }),
164 );
165
36describe("source-auth-service", () => {166describe("source-auth-service", () => {
37 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);167 const encodeSessionDataJson = Schema.encodeSync(McpSourceAuthSessionDataJsonSchema);
38 168
@@ -89,4 +219,391 @@ describe("source-auth-service", () => { · 50 unmodified lines
89 updatedAt: 456,219 updatedAt: 456,
90 });220 });
91 });221 });
222
223 it("completes Google Discovery desktop OAuth into a refreshable auth artifact and lease", async () => {
224 const discoveryDocument = await fetchLiveDiscoveryDocument();
225 const previousClientId = process.env.GOOGLE_CLIENT_ID;
226 const previousClientSecret = process.env.GOOGLE_CLIENT_SECRET;
227 process.env.GOOGLE_CLIENT_ID = "google-test-client";
228 process.env.GOOGLE_CLIENT_SECRET = "google-test-secret";
229
230 const persistence = await makePersistence();
231 try {
232 const now = Date.now();
233 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth");
234 const accountId = AccountIdSchema.make("acc_google_oauth");
235
236 await Effect.runPromise(
237 persistence.rows.organizations.insert({
238 id: "org_google_oauth" as any,
239 slug: "google-oauth",
240 name: "Google OAuth",
241 status: "active",
242 createdByAccountId: accountId,
243 createdAt: now,
244 updatedAt: now,
245 }),
246 );
247 await Effect.runPromise(
248 persistence.rows.workspaces.insert({
249 id: workspaceId,
250 organizationId: "org_google_oauth" as any,
251 name: "Workspace",
252 createdByAccountId: accountId,
253 createdAt: now,
254 updatedAt: now,
255 }),
256 );
257
258 const service = createRuntimeSourceAuthService({
259 rows: persistence.rows,
260 liveExecutionManager: createLiveExecutionManager(),
261 getLocalServerBaseUrl: () => "http://localhost:8788",
262 });
263
264 await withGoogleAuthTestServer({
265 discoveryDocument,
266 handler: async ({ discoveryUrl, tokenUrl, tokenRequests }) => {
267 const originalFetch = globalThis.fetch;
268 globalThis.fetch = (async (input, init) => {
269 const url = typeof input === "string"
270 ? input
271 : input instanceof URL
272 ? input.toString()
273 : input.url;
274 if (url === "https://oauth2.googleapis.com/token") {
275 return originalFetch(tokenUrl, init);
276 }
277
278 return originalFetch(input as any, init);
279 }) as typeof fetch;
280
281 try {
282 const addResult = await Effect.runPromise(
283 service.addExecutorSource({
284 kind: "google_discovery",
285 workspaceId,
286 actorAccountId: accountId,
287 executionId: null,
288 interactionId: null,
289 service: "sheets",
290 version: "v4",
291 discoveryUrl,
292 scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"],
293 }),
294 );
295
296 expect(addResult.kind).toBe("oauth_required");
297 if (addResult.kind !== "oauth_required") {
298 return;
299 }
300
301 const sessionOption = await Effect.runPromise(
302 persistence.rows.sourceAuthSessions.getById(addResult.sessionId),
303 );
304 expect(Option.isSome(sessionOption)).toBe(true);
305 const session = Option.getOrNull(sessionOption) as SourceAuthSession;
306 expect(session.providerKind).toBe("oauth2_pkce");
307 const sessionData = Schema.decodeUnknownSync(
308 OAuth2PkceSourceAuthSessionDataJsonSchema,
309 )(session.sessionDataJson);
310 expect(sessionData.redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
311 expect(sessionData.tokenEndpoint).toBe("https://oauth2.googleapis.com/token");
312 expect(sessionData.authorizationUrl).toContain(
313 encodeURIComponent(sessionData.redirectUri),
314 );
315
316 const loopbackResponse = await fetch(
317 `${sessionData.redirectUri}/?state=${encodeURIComponent(session.state)}&code=loopback-code`,
318 {
319 redirect: "manual",
320 },
321 );
322 expect(loopbackResponse.status).toBe(302);
323 const completionLocation = loopbackResponse.headers.get("location");
324 expect(completionLocation).toBeTruthy();
325 if (!completionLocation) {
326 return;
327 }
328 const completionUrl = new URL(completionLocation);
329 expect(completionUrl.origin).toBe("http://localhost:8788");
330 expect(completionUrl.searchParams.get("state")).toBe(session.state);
331 expect(completionUrl.searchParams.get("code")).toBe("loopback-code");
332
333 const connectedSource = await Effect.runPromise(
334 service.completeSourceCredentialSetup({
335 workspaceId,
336 sourceId: addResult.source.id,
337 actorAccountId: accountId,
338 state: session.state,
339 code: "authorization-code",
340 }),
341 );
342
343 expect(connectedSource.status).toBe("connected");
344 expect(connectedSource.auth.kind).toBe("oauth2_authorized_user");
345 if (connectedSource.auth.kind !== "oauth2_authorized_user") {
346 return;
347 }
348 expect(connectedSource.auth.clientId).toBe("google-test-client");
349 expect(connectedSource.auth.tokenEndpoint).toBe("https://oauth2.googleapis.com/token");
350 expect(connectedSource.auth.grantSet).toEqual([
351 "https://www.googleapis.com/auth/spreadsheets.readonly",
352 ]);
353
354 expect(tokenRequests).toHaveLength(1);
355 expect(tokenRequests[0]?.get("grant_type")).toBe("authorization_code");
356 expect(tokenRequests[0]?.get("client_id")).toBe("google-test-client");
357 expect(tokenRequests[0]?.get("client_secret")).toBe("google-test-secret");
358 expect(tokenRequests[0]?.get("code")).toBe("authorization-code");
359 expect(tokenRequests[0]?.get("redirect_uri")).toBe(sessionData.redirectUri);
360 expect(tokenRequests[0]?.get("code_verifier")).toBe(sessionData.codeVerifier);
361
362 const artifactOption = await Effect.runPromise(
363 persistence.rows.authArtifacts.getByWorkspaceSourceAndActor({
364 workspaceId,
365 sourceId: connectedSource.id,
366 actorAccountId: accountId,
367 slot: "runtime",
368 }),
369 );
370 expect(Option.isSome(artifactOption)).toBe(true);
371 const artifact = Option.getOrNull(artifactOption)!;
372 const decodedArtifact = decodeBuiltInAuthArtifactConfig(artifact);
373 expect(decodedArtifact?.artifactKind).toBe("oauth2_authorized_user");
374
375 const leaseOption = await Effect.runPromise(
376 persistence.rows.authLeases.getByAuthArtifactId(artifact.id),
377 );
378 expect(Option.isSome(leaseOption)).toBe(true);
379 const initialLease = Option.getOrNull(leaseOption)!;
380 const initialTemplates = decodeAuthLeasePlacementTemplates(initialLease);
381 expect(initialTemplates).toEqual([
382 {
383 location: "header",
384 name: "Authorization",
385 parts: [
386 {
387 kind: "literal",
388 value: "Bearer ",
389 },
390 {
391 kind: "secret_ref",
392 ref: {
393 providerId: "postgres",
394 handle: expect.any(String),
395 },
396 },
397 ],
398 },
399 ]);
400
401 const resolver = createDefaultSecretMaterialResolver({
402 rows: persistence.rows,
403 });
404 const resolvedInitialAuth = await Effect.runPromise(
405 resolveSourceAuthMaterial({
406 source: connectedSource,
407 slot: "runtime",
408 actorAccountId: accountId,
409 rows: persistence.rows,
410 resolveSecretMaterial: resolver,
411 }),
412 );
413 expect(resolvedInitialAuth.headers.Authorization).toBe(
414 "Bearer access-token-initial",
415 );
416
417 await Effect.runPromise(
418 persistence.rows.authLeases.upsert({
419 ...initialLease,
420 refreshAfter: Date.now() - 1,
421 expiresAt: Date.now() + 10_000,
422 updatedAt: Date.now(),
423 }),
424 );
425
426 const previousAccessTokenHandle = initialTemplates?.[0]?.parts[1];
427 const refreshedAuth = await Effect.runPromise(
428 resolveSourceAuthMaterial({
429 source: connectedSource,
430 slot: "runtime",
431 actorAccountId: accountId,
432 rows: persistence.rows,
433 resolveSecretMaterial: resolver,
434 }),
435 );
436 expect(refreshedAuth.headers.Authorization).toBe(
437 "Bearer access-token-refreshed",
438 );
439 expect(tokenRequests).toHaveLength(2);
440 expect(tokenRequests[1]?.get("grant_type")).toBe("refresh_token");
441 expect(tokenRequests[1]?.get("refresh_token")).toBe("refresh-token-live");
442
443 if (previousAccessTokenHandle?.kind === "secret_ref") {
444 const oldSecret = await Effect.runPromise(
445 persistence.rows.secretMaterials.getById(
446 SecretMaterialIdSchema.make(previousAccessTokenHandle.ref.handle),
447 ),
448 );
449 expect(Option.isNone(oldSecret)).toBe(true);
450 }
451
452 const recipeOperations = await Effect.runPromise(
453 persistence.rows.sources.getByWorkspaceAndId(
454 workspaceId,
455 connectedSource.id,
456 ),
457 );
458 expect(Option.isSome(recipeOperations)).toBe(true);
459 const storedSource = Option.getOrNull(recipeOperations)!;
460 const materializedOperations = await Effect.runPromise(
461 persistence.rows.sourceRecipeOperations.listByRevisionId(
462 storedSource.recipeRevisionId,
463 ),
464 );
465 expect(materializedOperations.length).toBeGreaterThan(0);
466 expect(
467 materializedOperations.some(
468 (operation) => operation.toolId === "spreadsheets.values.get",
469 ),
470 ).toBe(true);
471 } finally {
472 globalThis.fetch = originalFetch;
473 }
474 },
475 });
476 } finally {
477 await persistence.close();
478 if (previousClientId === undefined) {
479 delete process.env.GOOGLE_CLIENT_ID;
480 } else {
481 process.env.GOOGLE_CLIENT_ID = previousClientId;
482 }
483 if (previousClientSecret === undefined) {
484 delete process.env.GOOGLE_CLIENT_SECRET;
485 } else {
486 process.env.GOOGLE_CLIENT_SECRET = previousClientSecret;
487 }
488 }
489 }, 60_000);
490
491 it("uses the request origin callback for Google Discovery OAuth started from the web app", async () => {
492 const discoveryDocument = await fetchLiveDiscoveryDocument();
493 const previousClientId = process.env.GOOGLE_CLIENT_ID;
494 const previousClientSecret = process.env.GOOGLE_CLIENT_SECRET;
495 process.env.GOOGLE_CLIENT_ID = "google-test-client";
496 process.env.GOOGLE_CLIENT_SECRET = "google-test-secret";
497
498 const persistence = await makePersistence();
499 try {
500 const now = Date.now();
501 const workspaceId = WorkspaceIdSchema.make("ws_google_oauth_web");
502 const accountId = AccountIdSchema.make("acc_google_oauth_web");
503
504 await Effect.runPromise(
505 persistence.rows.organizations.insert({
506 id: "org_google_oauth_web" as any,
507 slug: "google-oauth-web",
508 name: "Google OAuth Web",
509 status: "active",
510 createdByAccountId: accountId,
511 createdAt: now,
512 updatedAt: now,
513 }),
514 );
515 await Effect.runPromise(
516 persistence.rows.workspaces.insert({
517 id: workspaceId,
518 organizationId: "org_google_oauth_web" as any,
519 name: "Workspace",
520 createdByAccountId: accountId,
521 createdAt: now,
522 updatedAt: now,
523 }),
524 );
525
526 const service = createRuntimeSourceAuthService({
527 rows: persistence.rows,
528 liveExecutionManager: createLiveExecutionManager(),
529 getLocalServerBaseUrl: () => "http://127.0.0.1:8788",
530 });
531
532 await withGoogleAuthTestServer({
533 discoveryDocument,
534 handler: async ({ discoveryUrl, tokenUrl }) => {
535 const originalFetch = globalThis.fetch;
536 globalThis.fetch = (async (input, init) => {
537 const url = typeof input === "string"
538 ? input
539 : input instanceof URL
540 ? input.toString()
541 : input.url;
542 if (url === "https://oauth2.googleapis.com/token") {
543 return originalFetch(tokenUrl, init);
544 }
545
546 return originalFetch(input as any, init);
547 }) as typeof fetch;
548
549 try {
550 const addResult = await Effect.runPromise(
551 service.addExecutorSource(
552 {
553 kind: "google_discovery",
554 workspaceId,
555 actorAccountId: accountId,
556 executionId: null,
557 interactionId: null,
558 service: "sheets",
559 version: "v4",
560 discoveryUrl,
561 scopes: ["https://www.googleapis.com/auth/spreadsheets.readonly"],
562 },
563 {
564 baseUrl: "https://app.executor.dev",
565 },
566 ),
567 );
568
569 expect(addResult.kind).toBe("oauth_required");
570 if (addResult.kind !== "oauth_required") {
571 return;
572 }
573
574 const sessionOption = await Effect.runPromise(
575 persistence.rows.sourceAuthSessions.getById(addResult.sessionId),
576 );
577 expect(Option.isSome(sessionOption)).toBe(true);
578 const session = Option.getOrNull(sessionOption) as SourceAuthSession;
579 const sessionData = Schema.decodeUnknownSync(
580 OAuth2PkceSourceAuthSessionDataJsonSchema,
581 )(session.sessionDataJson);
582
583 expect(sessionData.redirectUri).toBe(
584 `https://app.executor.dev/v1/workspaces/${encodeURIComponent(workspaceId)}/sources/${encodeURIComponent(addResult.source.id)}/credentials/oauth/complete`,
585 );
586 expect(sessionData.authorizationUrl).toContain(
587 encodeURIComponent(sessionData.redirectUri),
588 );
589 expect(sessionData.redirectUri).not.toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
590 } finally {
591 globalThis.fetch = originalFetch;
592 }
593 },
594 });
595 } finally {
596 await persistence.close();
597 if (previousClientId === undefined) {
598 delete process.env.GOOGLE_CLIENT_ID;
599 } else {
600 process.env.GOOGLE_CLIENT_ID = previousClientId;
601 }
602 if (previousClientSecret === undefined) {
603 delete process.env.GOOGLE_CLIENT_SECRET;
604 } else {
605 process.env.GOOGLE_CLIENT_SECRET = previousClientSecret;
606 }
607 }
608 }, 60_000);
92});609});
packages/control-plane/src/runtime/source-auth-service.ts+710−67
@@ -13,6 +13,8 @@ import { · 12 unmodified lines
13 ExecutionIdSchema,13 ExecutionIdSchema,
14 McpSourceAuthSessionDataJsonSchema,14 McpSourceAuthSessionDataJsonSchema,
15 type McpSourceAuthSessionData,15 type McpSourceAuthSessionData,
16 OAuth2PkceSourceAuthSessionDataJsonSchema,
17 type OAuth2PkceSourceAuthSessionData,
16 type SecretMaterialPurpose,18 type SecretMaterialPurpose,
17 Source,19 Source,
18 type SourceImportAuthPolicy,20 type SourceImportAuthPolicy,
@@ -23,6 +25,9 @@ import { · 4 unmodified lines
23 SourceSchema,25 SourceSchema,
24 type SecretRef,26 type SecretRef,
25 type StringMap,27 type StringMap,
28 WorkspaceSourceOauthClientIdSchema,
29 WorkspaceSourceOauthClientMetadataJsonSchema,
30 type WorkspaceSourceOauthClientRedirectMode,
26 type WorkspaceId,31 type WorkspaceId,
27} from "#schema";32} from "#schema";
28import * as Context from "effect/Context";33import * as Context from "effect/Context";
@@ -46,6 +51,7 @@ import { · 17 unmodified lines
46 updateSourceFromPayload,51 updateSourceFromPayload,
47} from "./source-definitions";52} from "./source-definitions";
48import {53import {
54 getSourceAdapterForSource,
49 hasSourceAdapterFamily,55 hasSourceAdapterFamily,
50 sourceBindingStateFromSource,56 sourceBindingStateFromSource,
51} from "./source-adapters";57} from "./source-adapters";
@@ -56,10 +62,17 @@ import { · 4 unmodified lines
56 type ResolveSecretMaterial,62 type ResolveSecretMaterial,
57 type StoreSecretMaterial,63 type StoreSecretMaterial,
58} from "./secret-material-providers";64} from "./secret-material-providers";
65import { upsertOauth2AuthorizedUserLeaseFromTokenResponse } from "./auth-leases";
59import {66import {
60 persistMcpRecipeMaterializationFromManifest,67 persistMcpRecipeMaterializationFromManifest,
61 syncSourceMaterialization,68 syncSourceMaterialization,
62} from "./source-materialization";69} from "./source-materialization";
70import {
71 buildOAuth2AuthorizationUrl,
72 createPkceCodeVerifier,
73 exchangeOAuth2AuthorizationCode,
74} from "./oauth2-pkce";
75import { startOauthLoopbackRedirectServer } from "./oauth-loopback";
63import {76import {
64 loadSourceById,77 loadSourceById,
65 loadSourcesInWorkspace,78 loadSourcesInWorkspace,
@@ -113,6 +126,22 @@ const normalizeEndpoint = (endpoint: string): string => { · 47 unmodified lines
113 return url.toString();126 return url.toString();
114};127};
115 128
129const defaultGoogleDiscoveryUrl = (service: string, version: string): string =>
130 `https://www.googleapis.com/discovery/v1/apis/${encodeURIComponent(service)}/${encodeURIComponent(version)}/rest`;
131
132const 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
142const defaultGoogleDiscoveryNamespace = (service: string): string =>
143 `google.${service.trim().toLowerCase().replace(/[^a-z0-9]+/g, ".").replace(/^\.+|\.+$/g, "")}`;
144
116const SourceOAuthSessionStatePayloadSchema = Schema.Struct({145const SourceOAuthSessionStatePayloadSchema = Schema.Struct({
117 kind: Schema.Literal("source_oauth"),146 kind: Schema.Literal("source_oauth"),
118 nonce: Schema.String,147 nonce: Schema.String,
@@ -228,6 +257,42 @@ const mergeMcpSourceAuthSessionData = (input: { · 109 unmodified lines
228 });257 });
229};258};
230 259
260const encodeOauth2PkceSourceAuthSessionData = (
261 sessionData: OAuth2PkceSourceAuthSessionData,
262): string => Schema.encodeSync(OAuth2PkceSourceAuthSessionDataJsonSchema)(sessionData);
263
264const decodeOauth2PkceSourceAuthSessionDataJson = Schema.decodeUnknownEither(
265 OAuth2PkceSourceAuthSessionDataJsonSchema,
266);
267
268const 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
285const mergeOauth2PkceSourceAuthSessionData = (input: {
286 session: Pick<SourceAuthSession, "id" | "providerKind" | "sessionDataJson">;
287 patch: Partial<OAuth2PkceSourceAuthSessionData>;
288}): string => {
289 const existing = decodeOauth2PkceSourceAuthSessionData(input.session);
290 return encodeOauth2PkceSourceAuthSessionData({
291 ...existing,
292 ...input.patch,
293 });
294};
295
231 296
232const completeLiveInteraction = (input: {297const completeLiveInteraction = (input: {
233 liveExecutionManager: LiveExecutionManager;298 liveExecutionManager: LiveExecutionManager;
@@ -361,6 +426,22 @@ export type ExecutorAddSourceInput = · 127 unmodified lines
361 importAuthPolicy?: SourceImportAuthPolicy | null;426 importAuthPolicy?: SourceImportAuthPolicy | null;
362 importAuth?: ExecutorHttpSourceAuthInput | null;427 importAuth?: ExecutorHttpSourceAuthInput | null;
363 auth?: ExecutorHttpSourceAuthInput | null;428 auth?: ExecutorHttpSourceAuthInput | null;
429 }
430 | {
431 kind: "google_discovery";
432 workspaceId: WorkspaceId;
433 actorAccountId?: AccountId | null;
434 executionId: SourceAuthSession["executionId"];
435 interactionId: SourceAuthSession["interactionId"];
436 service: string;
437 version: string;
438 discoveryUrl?: string | null;
439 scopes?: ReadonlyArray<string> | null;
440 name?: string | null;
441 namespace?: string | null;
442 importAuthPolicy?: SourceImportAuthPolicy | null;
443 importAuth?: ExecutorHttpSourceAuthInput | null;
444 auth?: ExecutorHttpSourceAuthInput | null;
364 };445 };
365 446
366export type ConnectMcpSourceInput = {447export type ConnectMcpSourceInput = {
@@ -525,7 +606,7 @@ const materializeExecutorHttpAuth = (input: { · 158 unmodified lines
525 });606 });
526 607
527const materializeExecutorHttpImportAuth = (input: {608const materializeExecutorHttpImportAuth = (input: {
528 sourceKind: "openapi" | "graphql";609 sourceKind: "openapi" | "graphql" | "google_discovery";
529 existing?: Source;610 existing?: Source;
530 importAuthPolicy?: SourceImportAuthPolicy | null;611 importAuthPolicy?: SourceImportAuthPolicy | null;
531 importAuth?: ExecutorHttpSourceAuthInput | null;612 importAuth?: ExecutorHttpSourceAuthInput | null;
@@ -535,7 +616,10 @@ const materializeExecutorHttpImportAuth = (input: { · 3 unmodified lines
535 importAuth: Source["importAuth"];616 importAuth: Source["importAuth"];
536}, Error, never> =>617}, Error, never> =>
537 Effect.gen(function* () {618 Effect.gen(function* () {
538 const adapterDefault = input.sourceKind === "openapi" || input.sourceKind === "graphql"619 const adapterDefault =
620 input.sourceKind === "openapi"
621 || input.sourceKind === "graphql"
622 || input.sourceKind === "google_discovery"
539 ? "reuse_runtime"623 ? "reuse_runtime"
540 : "none";624 : "none";
541 const importAuthPolicy = input.importAuthPolicy ?? input.existing?.importAuthPolicy ?? adapterDefault;625 const importAuthPolicy = input.importAuthPolicy ?? input.existing?.importAuthPolicy ?? adapterDefault;
@@ -579,6 +663,234 @@ const shouldPromptForExecutorHttpRuntimeCredentialSetup = (input: { · 37 unmodified lines
579 && input.auth.kind === "none"663 && input.auth.kind === "none"
580 && (input.existing?.auth.kind ?? "none") === "none";664 && (input.existing?.auth.kind ?? "none") === "none";
581 665
666type ResolvedSourceOauthClient = {
667 providerKey: string;
668 clientId: string;
669 clientSecret: SecretRef | null;
670 redirectMode: WorkspaceSourceOauthClientRedirectMode;
671};
672
673const decodeWorkspaceSourceOauthClientMetadataOption = Schema.decodeUnknownOption(
674 WorkspaceSourceOauthClientMetadataJsonSchema,
675);
676
677const encodeWorkspaceSourceOauthClientMetadataJson = Schema.encodeSync(
678 WorkspaceSourceOauthClientMetadataJsonSchema,
679);
680
681const 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
698const 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
709const 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
780const 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
582const connectMcpSourceInternal = (input: {894const connectMcpSourceInternal = (input: {
583 rows: SqlControlPlaneRows;895 rows: SqlControlPlaneRows;
584 getLocalServerBaseUrl?: () => string | undefined;896 getLocalServerBaseUrl?: () => string | undefined;
@@ -692,6 +1004,7 @@ const connectMcpSourceInternal = (input: { · 107 unmodified lines
692 yield* syncSourceMaterialization({1004 yield* syncSourceMaterialization({
693 rows: input.rows,1005 rows: input.rows,
694 source: persistedDraft,1006 source: persistedDraft,
1007 actorAccountId: input.actorAccountId,
695 resolveSecretMaterial: input.resolveSecretMaterial,1008 resolveSecretMaterial: input.resolveSecretMaterial,
696 });1009 });
697 1010
@@ -812,6 +1125,8 @@ const addExecutorHttpSource = (input: { · 114 unmodified lines
812 sourceInput: Extract<ExecutorAddSourceInput, { kind: "openapi" | "graphql" }>;1125 sourceInput: Extract<ExecutorAddSourceInput, { kind: "openapi" | "graphql" }>;
813 storeSecretMaterial: StoreSecretMaterial;1126 storeSecretMaterial: StoreSecretMaterial;
814 resolveSecretMaterial: ResolveSecretMaterial;1127 resolveSecretMaterial: ResolveSecretMaterial;
1128 getLocalServerBaseUrl?: () => string | undefined;
1129 baseUrl?: string | null;
815}): Effect.Effect<ExecutorSourceAddResult, Error, never> =>1130}): Effect.Effect<ExecutorSourceAddResult, Error, never> =>
816 Effect.gen(function* () {1131 Effect.gen(function* () {
817 const normalizedEndpoint = normalizeEndpoint(input.sourceInput.endpoint);1132 const normalizedEndpoint = normalizeEndpoint(input.sourceInput.endpoint);
@@ -926,6 +1241,24 @@ const addExecutorHttpSource = (input: { · 108 unmodified lines
926 explicitAuthProvided: input.sourceInput.auth !== undefined,1241 explicitAuthProvided: input.sourceInput.auth !== undefined,
927 auth: persistedDraft.auth,1242 auth: persistedDraft.auth,
928 })) {1243 })) {
1244 const requestBaseUrl = trimOrNull(input.baseUrl);
1245 const baseUrl = requestBaseUrl ?? input.getLocalServerBaseUrl?.() ?? null;
1246 if (baseUrl) {
1247 const oauthRequired = yield* startOauth2PkceSourceCredentialSetup({
1248 rows: input.rows,
1249 source: persistedDraft,
1250 actorAccountId: input.sourceInput.actorAccountId,
1251 executionId: input.sourceInput.executionId,
1252 interactionId: input.sourceInput.interactionId,
1253 baseUrl,
1254 redirectModeOverride: requestBaseUrl ? "app_callback" : undefined,
1255 storeSecretMaterial: input.storeSecretMaterial,
1256 });
1257 if (oauthRequired) {
1258 return oauthRequired;
1259 }
1260 }
1261
929 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {1262 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
930 actorAccountId: input.sourceInput.actorAccountId,1263 actorAccountId: input.sourceInput.actorAccountId,
931 status: "auth_required",1264 status: "auth_required",
@@ -946,6 +1279,218 @@ const addExecutorHttpSource = (input: { · 14 unmodified lines
946 ...persistedDraft,1279 ...persistedDraft,
947 status: "connected",1280 status: "connected",
948 },1281 },
1282 actorAccountId: input.sourceInput.actorAccountId,
1283 resolveSecretMaterial: input.resolveSecretMaterial,
1284 }),
1285 );
1286
1287 return yield* Either.match(synced, {
1288 onLeft: (error) =>
1289 isSourceCredentialRequiredError(error)
1290 ? updateSourceStatus(input.rows, persistedDraft, {
1291 actorAccountId: input.sourceInput.actorAccountId,
1292 status: "auth_required",
1293 lastError: null,
1294 }).pipe(
1295 Effect.map((source) =>
1296 ({
1297 kind: "credential_required",
1298 source,
1299 credentialSlot: error.slot,
1300 } satisfies ExecutorSourceAddResult)
1301 ),
1302 )
1303 : updateSourceStatus(input.rows, persistedDraft, {
1304 actorAccountId: input.sourceInput.actorAccountId,
1305 status: "error",
1306 lastError: error.message,
1307 }).pipe(
1308 Effect.zipRight(Effect.fail(error)),
1309 ),
1310 onRight: () =>
1311 updateSourceStatus(input.rows, persistedDraft, {
1312 actorAccountId: input.sourceInput.actorAccountId,
1313 status: "connected",
1314 lastError: null,
1315 }).pipe(
1316 Effect.map((source) =>
1317 ({
1318 kind: "connected",
1319 source,
1320 } satisfies ExecutorSourceAddResult)
1321 ),
1322 ),
1323 });
1324 });
1325
1326const addExecutorGoogleDiscoverySource = (input: {
1327 rows: SqlControlPlaneRows;
1328 sourceInput: Extract<ExecutorAddSourceInput, { kind: "google_discovery" }>;
1329 storeSecretMaterial: StoreSecretMaterial;
1330 resolveSecretMaterial: ResolveSecretMaterial;
1331 getLocalServerBaseUrl?: () => string | undefined;
1332 baseUrl?: string | null;
1333}): Effect.Effect<ExecutorSourceAddResult, Error, never> =>
1334 Effect.gen(function* () {
1335 const normalizedService = input.sourceInput.service.trim();
1336 const normalizedVersion = input.sourceInput.version.trim();
1337 const normalizedDiscoveryUrl = normalizeEndpoint(
1338 trimOrNull(input.sourceInput.discoveryUrl)
1339 ?? defaultGoogleDiscoveryUrl(normalizedService, normalizedVersion),
1340 );
1341 const existingSources = yield* loadSourcesInWorkspace(
1342 input.rows,
1343 input.sourceInput.workspaceId,
1344 {
1345 actorAccountId: input.sourceInput.actorAccountId,
1346 },
1347 );
1348 const existing = existingSources.find((source) => {
1349 if (source.kind !== "google_discovery") {
1350 return false;
1351 }
1352
1353 const binding = source.binding;
1354 if (typeof binding.service !== "string" || typeof binding.version !== "string") {
1355 return false;
1356 }
1357
1358 const existingDiscoveryUrl =
1359 typeof binding.discoveryUrl === "string" && binding.discoveryUrl.trim().length > 0
1360 ? normalizeEndpoint(binding.discoveryUrl)
1361 : defaultGoogleDiscoveryUrl(binding.service.trim(), binding.version.trim());
1362
1363 return binding.service.trim() === normalizedService
1364 && binding.version.trim() === normalizedVersion
1365 && existingDiscoveryUrl === normalizedDiscoveryUrl;
1366 });
1367
1368 const chosenName =
1369 trimOrNull(input.sourceInput.name)
1370 ?? existing?.name
1371 ?? defaultGoogleDiscoverySourceName(normalizedService, normalizedVersion);
1372 const chosenNamespace =
1373 trimOrNull(input.sourceInput.namespace)
1374 ?? existing?.namespace
1375 ?? defaultGoogleDiscoveryNamespace(normalizedService);
1376 const chosenScopes = (input.sourceInput.scopes ?? (Array.isArray(existing?.binding.scopes)
1377 ? existing?.binding.scopes as ReadonlyArray<string>
1378 : []))
1379 .map((scope) => scope.trim())
1380 .filter((scope) => scope.length > 0);
1381 const existingBinding = existing
1382 ? yield* sourceBindingStateFromSource(existing)
1383 : null;
1384 const now = Date.now();
1385
1386 const auth = yield* materializeExecutorHttpAuth({
1387 existing,
1388 auth: input.sourceInput.auth,
1389 storeSecretMaterial: input.storeSecretMaterial,
1390 });
1391 const importAuth = yield* materializeExecutorHttpImportAuth({
1392 sourceKind: input.sourceInput.kind,
1393 existing,
1394 importAuthPolicy: input.sourceInput.importAuthPolicy ?? null,
1395 importAuth: input.sourceInput.importAuth ?? null,
1396 storeSecretMaterial: input.storeSecretMaterial,
1397 });
1398
1399 const draftSource = existing
1400 ? yield* updateSourceFromPayload({
1401 source: existing,
1402 payload: {
1403 name: chosenName,
1404 endpoint: normalizedDiscoveryUrl,
1405 namespace: chosenNamespace,
1406 status: "probing",
1407 enabled: true,
1408 binding: {
1409 service: normalizedService,
1410 version: normalizedVersion,
1411 discoveryUrl: normalizedDiscoveryUrl,
1412 defaultHeaders: existingBinding?.defaultHeaders ?? null,
1413 scopes: [...chosenScopes],
1414 },
1415 importAuthPolicy: importAuth.importAuthPolicy,
1416 importAuth: importAuth.importAuth,
1417 auth,
1418 lastError: null,
1419 },
1420 now,
1421 })
1422 : yield* createSourceFromPayload({
1423 workspaceId: input.sourceInput.workspaceId,
1424 sourceId: SourceIdSchema.make(`src_${crypto.randomUUID()}`),
1425 payload: {
1426 name: chosenName,
1427 kind: "google_discovery",
1428 endpoint: normalizedDiscoveryUrl,
1429 namespace: chosenNamespace,
1430 status: "probing",
1431 enabled: true,
1432 binding: {
1433 service: normalizedService,
1434 version: normalizedVersion,
1435 discoveryUrl: normalizedDiscoveryUrl,
1436 defaultHeaders: existingBinding?.defaultHeaders ?? null,
1437 scopes: [...chosenScopes],
1438 },
1439 importAuthPolicy: importAuth.importAuthPolicy,
1440 importAuth: importAuth.importAuth,
1441 auth,
1442 },
1443 now,
1444 });
1445
1446 const persistedDraft = yield* persistSource(input.rows, draftSource, {
1447 actorAccountId: input.sourceInput.actorAccountId,
1448 });
1449
1450 if (shouldPromptForExecutorHttpRuntimeCredentialSetup({
1451 existing,
1452 explicitAuthProvided: input.sourceInput.auth !== undefined,
1453 auth: persistedDraft.auth,
1454 })) {
1455 const requestBaseUrl = trimOrNull(input.baseUrl);
1456 const baseUrl = requestBaseUrl ?? input.getLocalServerBaseUrl?.() ?? null;
1457 if (baseUrl) {
1458 const oauthRequired = yield* startOauth2PkceSourceCredentialSetup({
1459 rows: input.rows,
1460 source: persistedDraft,
1461 actorAccountId: input.sourceInput.actorAccountId,
1462 executionId: input.sourceInput.executionId,
1463 interactionId: input.sourceInput.interactionId,
1464 baseUrl,
1465 redirectModeOverride: requestBaseUrl ? "app_callback" : undefined,
1466 storeSecretMaterial: input.storeSecretMaterial,
1467 });
1468 if (oauthRequired) {
1469 return oauthRequired;
1470 }
1471 }
1472
1473 const authRequiredSource = yield* updateSourceStatus(input.rows, persistedDraft, {
1474 actorAccountId: input.sourceInput.actorAccountId,
1475 status: "auth_required",
1476 lastError: null,
1477 });
1478
1479 return {
1480 kind: "credential_required",
1481 source: authRequiredSource,
1482 credentialSlot: "runtime",
1483 } satisfies ExecutorSourceAddResult;
1484 }
1485
1486 const synced = yield* Effect.either(
1487 syncSourceMaterialization({
1488 rows: input.rows,
1489 source: {
1490 ...persistedDraft,
1491 status: "connected",
1492 },
1493 actorAccountId: input.sourceInput.actorAccountId,
949 resolveSecretMaterial: input.resolveSecretMaterial,1494 resolveSecretMaterial: input.resolveSecretMaterial,
950 }),1495 }),
951 );1496 );
@@ -1058,8 +1603,17 @@ export const createRuntimeSourceAuthService = (input: { · 106 unmodified lines
1058 actorAccountId,1603 actorAccountId,
1059 }),1604 }),
1060 1605
1061 addExecutorSource: (sourceInput, options) =>1606 addExecutorSource: (sourceInput, options = undefined) =>
1062 hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")1607 sourceInput.kind === "google_discovery"
1608 ? addExecutorGoogleDiscoverySource({
1609 rows: input.rows,
1610 sourceInput,
1611 storeSecretMaterial,
1612 resolveSecretMaterial,
1613 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1614 baseUrl: options?.baseUrl,
1615 })
1616 : hasSourceAdapterFamily(sourceInput.kind ?? "mcp", "http_api")
1063 ? addExecutorHttpSource({1617 ? addExecutorHttpSource({
1064 rows: input.rows,1618 rows: input.rows,
1065 sourceInput: sourceInput as Extract<1619 sourceInput: sourceInput as Extract<
@@ -1068,6 +1622,8 @@ export const createRuntimeSourceAuthService = (input: { · 2 unmodified lines
1068 >,1622 >,
1069 storeSecretMaterial,1623 storeSecretMaterial,
1070 resolveSecretMaterial,1624 resolveSecretMaterial,
1625 getLocalServerBaseUrl: input.getLocalServerBaseUrl,
1626 baseUrl: options?.baseUrl,
1071 })1627 })
1072 : connectMcpSourceInternal({1628 : connectMcpSourceInternal({
1073 rows: input.rows,1629 rows: input.rows,
@@ -1297,7 +1853,6 @@ export const createRuntimeSourceAuthService = (input: { · 223 unmodified lines
1297 }1853 }
1298 1854
1299 const session = sessionOption.value;1855 const session = sessionOption.value;
1300 const sessionData = decodeMcpSourceAuthSessionData(session);
1301 if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) {1856 if (session.workspaceId !== workspaceId || session.sourceId !== sourceId) {
1302 return yield* Effect.fail(1857 return yield* Effect.fail(
1303 new Error(1858 new Error(
@@ -1330,6 +1885,10 @@ export const createRuntimeSourceAuthService = (input: { · 26 unmodified lines
1330 );1885 );
1331 }1886 }
1332 1887
1888 const sessionData = session.providerKind === "oauth2_pkce"
1889 ? decodeOauth2PkceSourceAuthSessionData(session)
1890 : decodeMcpSourceAuthSessionData(session);
1891
1333 if (trimOrNull(error) !== null) {1892 if (trimOrNull(error) !== null) {
1334 const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed";1893 const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed";
1335 const failedAt = Date.now();1894 const failedAt = Date.now();
@@ -1351,6 +1910,7 @@ export const createRuntimeSourceAuthService = (input: { · 15 unmodified lines
1351 yield* syncSourceMaterialization({1910 yield* syncSourceMaterialization({
1352 rows: input.rows,1911 rows: input.rows,
1353 source: failedSource,1912 source: failedSource,
1913 actorAccountId: session.actorAccountId,
1354 resolveSecretMaterial,1914 resolveSecretMaterial,
1355 });1915 });
1356 yield* completeLiveInteraction({1916 yield* completeLiveInteraction({
@@ -1374,54 +1934,157 @@ export const createRuntimeSourceAuthService = (input: { · 17 unmodified lines
1374 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));1934 return yield* Effect.fail(new Error("OAuth session is missing the PKCE code verifier"));
1375 }1935 }
1376 1936
1377 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({1937 const now = Date.now();
1378 session: {1938 let connectedSource: Source;
1379 endpoint: sessionData.endpoint,1939 if (session.providerKind === "oauth2_pkce") {
1380 redirectUrl: sessionData.redirectUri,1940 const oauthSessionData = decodeOauth2PkceSourceAuthSessionData(session);
1381 codeVerifier: sessionData.codeVerifier,1941 let clientSecret: string | null = null;
1382 resourceMetadataUrl: sessionData.resourceMetadataUrl,1942 if (oauthSessionData.clientSecret) {
1383 authorizationServerUrl: sessionData.authorizationServerUrl,1943 clientSecret = yield* resolveSecretMaterial({
1384 resourceMetadata: sessionData.resourceMetadata,1944 ref: oauthSessionData.clientSecret,
1385 authorizationServerMetadata: sessionData.authorizationServerMetadata,1945 });
1386 clientInformation: sessionData.clientInformation,1946 }
1387 },1947
1388 code: authorizationCode,1948 const exchanged = yield* exchangeOAuth2AuthorizationCode({
1389 });1949 tokenEndpoint: oauthSessionData.tokenEndpoint,
1950 clientId: oauthSessionData.clientId,
1951 clientAuthentication: oauthSessionData.clientAuthentication,
1952 clientSecret,
1953 redirectUri: oauthSessionData.redirectUri,
1954 codeVerifier: oauthSessionData.codeVerifier ?? "",
1955 code: authorizationCode,
1956 });
1957 const refreshToken = trimOrNull(exchanged.refresh_token);
1958 if (refreshToken === null) {
1959 return yield* Effect.fail(
1960 new Error("OAuth authorization did not return a refresh token"),
1961 );
1962 }
1390 1963
1391 const oauthSecretName = resolveSourceOAuthSecretName({1964 const refreshTokenRef = yield* storeSecretMaterial({
1392 displayName: source.name,1965 purpose: "oauth_refresh_token",
1393 endpoint: source.endpoint,1966 value: refreshToken,
1394 });1967 name: `${source.name} Refresh`,
1395 const accessTokenRef = yield* storeSecretMaterial({1968 });
1396 purpose: "oauth_access_token",1969 const grantedScopes = trimOrNull(exchanged.scope)
1397 value: exchanged.tokens.access_token,1970 ? exchanged.scope!.split(/\s+/).filter((scope) => scope.length > 0)
1398 name: oauthSecretName,1971 : [...oauthSessionData.scopes];
1399 });1972 connectedSource = yield* updateSourceStatus(input.rows, source, {
1400 const refreshTokenRef = exchanged.tokens.refresh_token1973 actorAccountId: session.actorAccountId,
1401 ? yield* storeSecretMaterial({1974 status: "connected",
1402 purpose: "oauth_refresh_token",1975 lastError: null,
1403 value: exchanged.tokens.refresh_token,1976 auth: {
1404 name: `${oauthSecretName} Refresh`,1977 kind: "oauth2_authorized_user",
1405 })1978 headerName: oauthSessionData.headerName,
1406 : null;1979 prefix: oauthSessionData.prefix,
1980 tokenEndpoint: oauthSessionData.tokenEndpoint,
1981 clientId: oauthSessionData.clientId,
1982 clientAuthentication: oauthSessionData.clientAuthentication,
1983 clientSecret: oauthSessionData.clientSecret,
1984 refreshToken: refreshTokenRef,
1985 grantSet: grantedScopes,
1986 },
1987 });
1988 const authArtifact = yield* input.rows.authArtifacts.getByWorkspaceSourceAndActor({
1989 workspaceId: connectedSource.workspaceId,
1990 sourceId: connectedSource.id,
1991 actorAccountId: session.actorAccountId ?? null,
1992 slot: "runtime",
1993 });
1994 if (Option.isSome(authArtifact)) {
1995 yield* upsertOauth2AuthorizedUserLeaseFromTokenResponse({
1996 rows: input.rows,
1997 artifact: authArtifact.value,
1998 tokenResponse: exchanged,
1999 });
2000 }
1407 2001
1408 const now = Date.now();2002 yield* input.rows.sourceAuthSessions.update(
1409 const connectedSource = yield* updateSourceStatus(input.rows, source, {2003 session.id,
1410 actorAccountId: session.actorAccountId,2004 createTerminalSourceAuthSessionPatch({
1411 status: "connected",2005 sessionDataJson: mergeOauth2PkceSourceAuthSessionData({
1412 lastError: null,2006 session,
1413 auth: {2007 patch: {
1414 kind: "oauth2",2008 codeVerifier: null,
1415 headerName: "Authorization",2009 authorizationUrl: null,
1416 prefix: "Bearer ",2010 },
1417 accessToken: accessTokenRef,2011 }),
1418 refreshToken: refreshTokenRef,2012 status: "completed",
1419 },2013 now,
1420 });2014 errorText: null,
2015 }),
2016 );
2017 } else {
2018 const mcpSessionData = decodeMcpSourceAuthSessionData(session);
2019 const exchanged = yield* exchangeMcpOAuthAuthorizationCode({
2020 session: {
2021 endpoint: mcpSessionData.endpoint,
2022 redirectUrl: mcpSessionData.redirectUri,
2023 codeVerifier: mcpSessionData.codeVerifier ?? "",
2024 resourceMetadataUrl: mcpSessionData.resourceMetadataUrl,
2025 authorizationServerUrl: mcpSessionData.authorizationServerUrl,
2026 resourceMetadata: mcpSessionData.resourceMetadata,
2027 authorizationServerMetadata: mcpSessionData.authorizationServerMetadata,
2028 clientInformation: mcpSessionData.clientInformation,
2029 },
2030 code: authorizationCode,
2031 });
2032
2033 const oauthSecretName = resolveSourceOAuthSecretName({
2034 displayName: source.name,
2035 endpoint: source.endpoint,
2036 });
2037 const accessTokenRef = yield* storeSecretMaterial({
2038 purpose: "oauth_access_token",
2039 value: exchanged.tokens.access_token,
2040 name: oauthSecretName,
2041 });
2042 const refreshTokenRef = exchanged.tokens.refresh_token
2043 ? yield* storeSecretMaterial({
2044 purpose: "oauth_refresh_token",
2045 value: exchanged.tokens.refresh_token,
2046 name: `${oauthSecretName} Refresh`,
2047 })
2048 : null;
2049
2050 connectedSource = yield* updateSourceStatus(input.rows, source, {
2051 actorAccountId: session.actorAccountId,
2052 status: "connected",
2053 lastError: null,
2054 auth: {
2055 kind: "oauth2",
2056 headerName: "Authorization",
2057 prefix: "Bearer ",
2058 accessToken: accessTokenRef,
2059 refreshToken: refreshTokenRef,
2060 },
2061 });
2062
2063 yield* input.rows.sourceAuthSessions.update(
2064 session.id,
2065 createTerminalSourceAuthSessionPatch({
2066 sessionDataJson: mergeMcpSourceAuthSessionData({
2067 session,
2068 patch: {
2069 codeVerifier: null,
2070 authorizationUrl: null,
2071 resourceMetadataUrl: exchanged.resourceMetadataUrl,
2072 authorizationServerUrl: exchanged.authorizationServerUrl,
2073 resourceMetadata: exchanged.resourceMetadata,
2074 authorizationServerMetadata: exchanged.authorizationServerMetadata,
2075 },
2076 }),
2077 status: "completed",
2078 now,
2079 errorText: null,
2080 }),
2081 );
2082 }
1421 const indexed = yield* Effect.either(2083 const indexed = yield* Effect.either(
1422 syncSourceMaterialization({2084 syncSourceMaterialization({
1423 rows: input.rows,2085 rows: input.rows,
1424 source: connectedSource,2086 source: connectedSource,
2087 actorAccountId: session.actorAccountId,
1425 resolveSecretMaterial,2088 resolveSecretMaterial,
1426 }),2089 }),
1427 );2090 );
@@ -1437,26 +2100,6 @@ export const createRuntimeSourceAuthService = (input: { · 9 unmodified lines
1437 onRight: () => Effect.succeed(undefined),2100 onRight: () => Effect.succeed(undefined),
1438 });2101 });
1439 2102
1440 yield* input.rows.sourceAuthSessions.update(
1441 session.id,
1442 createTerminalSourceAuthSessionPatch({
1443 sessionDataJson: mergeMcpSourceAuthSessionData({
1444 session,
1445 patch: {
1446 codeVerifier: null,
1447 authorizationUrl: null,
1448 resourceMetadataUrl: exchanged.resourceMetadataUrl,
1449 authorizationServerUrl: exchanged.authorizationServerUrl,
1450 resourceMetadata: exchanged.resourceMetadata,
1451 authorizationServerMetadata: exchanged.authorizationServerMetadata,
1452 },
1453 }),
1454 status: "completed",
1455 now,
1456 errorText: null,
1457 }),
1458 );
1459
1460 yield* completeLiveInteraction({2103 yield* completeLiveInteraction({
1461 liveExecutionManager: input.liveExecutionManager,2104 liveExecutionManager: input.liveExecutionManager,
1462 session,2105 session,
packages/control-plane/src/runtime/source-definitions.test.ts+15−12
@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; · 2 unmodified lines
3 3
4import {4import {
5 CredentialIdSchema,5 CredentialIdSchema,
6 decodeBuiltInAuthArtifactConfig,
6 SourceIdSchema,7 SourceIdSchema,
7 WorkspaceIdSchema,8 WorkspaceIdSchema,
8 type Source,9 type Source,
@@ -361,14 +362,16 @@ describe("source-definitions", () => { · 352 unmodified lines
361 const recipeRevisionId = stableSourceRecipeRevisionId(source);362 const recipeRevisionId = stableSourceRecipeRevisionId(source);
362 const existingCredentialId = CredentialIdSchema.make("cred_existing");363 const existingCredentialId = CredentialIdSchema.make("cred_existing");
363 364
364 const { sourceRecord, runtimeCredential } = splitSourceForStorage({365 const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({
365 source,366 source,
366 recipeId,367 recipeId,
367 recipeRevisionId,368 recipeRevisionId,
368 existingRuntimeCredentialId: existingCredentialId,369 existingRuntimeAuthArtifactId: existingCredentialId,
369 });370 });
370 371
371 expect(runtimeCredential?.id).toBe(existingCredentialId);372 expect(runtimeAuthArtifact?.id).toBe(existingCredentialId);
373 const decoded = runtimeAuthArtifact ? decodeBuiltInAuthArtifactConfig(runtimeAuthArtifact) : null;
374 expect(decoded?.artifactKind).toBe("static_bearer");
372 expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({375 expect(JSON.parse(sourceRecord.bindingConfigJson ?? "{}")).toEqual({
373 adapterKey: "mcp",376 adapterKey: "mcp",
374 version: 1,377 version: 1,
@@ -377,8 +380,8 @@ describe("source-definitions", () => { · 2 unmodified lines
377 380
378 const projected = await Effect.runPromise(projectSourceFromStorage({381 const projected = await Effect.runPromise(projectSourceFromStorage({
379 sourceRecord,382 sourceRecord,
380 runtimeCredential: runtimeCredential ?? null,383 runtimeAuthArtifact: runtimeAuthArtifact ?? null,
381 importCredential: null,384 importAuthArtifact: null,
382 }));385 }));
383 386
384 expect(projected).toEqual(source);387 expect(projected).toEqual(source);
@@ -421,15 +424,15 @@ describe("source-definitions", () => { · 36 unmodified lines
421 });424 });
422 425
423 for (const source of [withRefresh, withoutRefresh]) {426 for (const source of [withRefresh, withoutRefresh]) {
424 const { sourceRecord, runtimeCredential } = splitSourceForStorage({427 const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({
425 source,428 source,
426 recipeId: stableSourceRecipeId(source),429 recipeId: stableSourceRecipeId(source),
427 recipeRevisionId: stableSourceRecipeRevisionId(source),430 recipeRevisionId: stableSourceRecipeRevisionId(source),
428 });431 });
429 const projected = await Effect.runPromise(projectSourceFromStorage({432 const projected = await Effect.runPromise(projectSourceFromStorage({
430 sourceRecord,433 sourceRecord,
431 runtimeCredential: runtimeCredential ?? null,434 runtimeAuthArtifact: runtimeAuthArtifact ?? null,
432 importCredential: null,435 importAuthArtifact: null,
433 }));436 }));
434 437
435 expect(projected).toEqual(source);438 expect(projected).toEqual(source);
@@ -440,18 +443,18 @@ describe("source-definitions", () => { · 4 unmodified lines
440 const source = makeSource({443 const source = makeSource({
441 auth: { kind: "none" },444 auth: { kind: "none" },
442 });445 });
443 const { sourceRecord, runtimeCredential } = splitSourceForStorage({446 const { sourceRecord, runtimeAuthArtifact } = splitSourceForStorage({
444 source,447 source,
445 recipeId: stableSourceRecipeId(source),448 recipeId: stableSourceRecipeId(source),
446 recipeRevisionId: stableSourceRecipeRevisionId(source),449 recipeRevisionId: stableSourceRecipeRevisionId(source),
447 });450 });
448 451
449 expect(runtimeCredential).toBeNull();452 expect(runtimeAuthArtifact).toBeNull();
450 453
451 const projected = await Effect.runPromise(projectSourceFromStorage({454 const projected = await Effect.runPromise(projectSourceFromStorage({
452 sourceRecord,455 sourceRecord,
453 runtimeCredential: null,456 runtimeAuthArtifact: null,
454 importCredential: null,457 importAuthArtifact: null,
455 }));458 }));
456 459
457 expect(projected.auth).toEqual({ kind: "none" });460 expect(projected.auth).toEqual({ kind: "none" });
packages/control-plane/src/runtime/source-definitions.ts+84−110
@@ -1,4 +1,4 @@
1import { createHash } from "node:crypto";1import { sha256Hex } from "@executor/codemode-core";
2 2
3import type {3import type {
4 CreateSourcePayload,4 CreateSourcePayload,
@@ -6,7 +6,7 @@ import type { · 1 unmodified line
6} from "#api";6} from "#api";
7import type {7import type {
8 AccountId,8 AccountId,
9 Credential,9 AuthArtifact,
10 CredentialSlot,10 CredentialSlot,
11 Source,11 Source,
12 SourceAuth,12 SourceAuth,
@@ -21,13 +21,17 @@ import type { · 8 unmodified lines
21 WorkspaceId,21 WorkspaceId,
22} from "#schema";22} from "#schema";
23import {23import {
24 CredentialIdSchema,24 AuthArtifactIdSchema,
25 SourceRecipeIdSchema,25 SourceRecipeIdSchema,
26 SourceRecipeRevisionIdSchema,26 SourceRecipeRevisionIdSchema,
27} from "#schema";27} from "#schema";
28import * as Effect from "effect/Effect";28import * as Effect from "effect/Effect";
29 29
30import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters";30import { getSourceAdapter, getSourceAdapterForSource } from "./source-adapters";
31import {
32 authArtifactFromSourceAuth,
33 sourceAuthFromAuthArtifact,
34} from "./auth-artifacts";
31 35
32const trimOrNull = (value: string | null | undefined): string | null => {36const trimOrNull = (value: string | null | undefined): string | null => {
33 if (value === null || value === undefined) {37 if (value === null || value === undefined) {
@@ -57,7 +61,7 @@ const sourceRecipeProviderKeyFromSource = (source: Source): string => { · 23 unmodified lines
57};61};
58 62
59const stableHash = (value: string): string =>63const stableHash = (value: string): string =>
60 createHash("sha256").update(value).digest("hex").slice(0, 24);64 sha256Hex(value).slice(0, 24);
61 65
62const sourceRecipeSignature = (source: Source): string =>66const sourceRecipeSignature = (source: Source): string =>
63 JSON.stringify({67 JSON.stringify({
@@ -89,7 +93,7 @@ const normalizeAuth = ( · 25 unmodified lines
89 const headerName = trimOrNull(auth.headerName) ?? "Authorization";93 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
90 const prefix = auth.prefix ?? "Bearer ";94 const prefix = auth.prefix ?? "Bearer ";
91 95
92 if (auth.kind === "bearer") {96 if (auth.kind === "bearer") {
93 const providerId = trimOrNull(auth.token.providerId);97 const providerId = trimOrNull(auth.token.providerId);
94 const handle = trimOrNull(auth.token.handle);98 const handle = trimOrNull(auth.token.handle);
95 if (providerId === null || handle === null) {99 if (providerId === null || handle === null) {
@@ -107,6 +111,54 @@ const normalizeAuth = ( · 11 unmodified lines
107 } satisfies SourceAuth;111 } satisfies SourceAuth;
108 }112 }
109 113
114 if (auth.kind === "oauth2_authorized_user") {
115 const refreshProviderId = trimOrNull(auth.refreshToken.providerId);
116 const refreshHandle = trimOrNull(auth.refreshToken.handle);
117 if (refreshProviderId === null || refreshHandle === null) {
118 return yield* Effect.fail(
119 new Error("OAuth2 authorized-user auth requires a refresh token secret ref"),
120 );
121 }
122
123 let clientSecret: { providerId: string; handle: string } | null = null;
124 if (auth.clientSecret !== null) {
125 const clientSecretProviderId = trimOrNull(auth.clientSecret.providerId);
126 const clientSecretHandle = trimOrNull(auth.clientSecret.handle);
127 if (clientSecretProviderId === null || clientSecretHandle === null) {
128 return yield* Effect.fail(
129 new Error("OAuth2 authorized-user client secret ref must include providerId and handle"),
130 );
131 }
132 clientSecret = {
133 providerId: clientSecretProviderId,
134 handle: clientSecretHandle,
135 };
136 }
137
138 const tokenEndpoint = trimOrNull(auth.tokenEndpoint);
139 const clientId = trimOrNull(auth.clientId);
140 if (tokenEndpoint === null || clientId === null) {
141 return yield* Effect.fail(
142 new Error("OAuth2 authorized-user auth requires tokenEndpoint and clientId"),
143 );
144 }
145
146 return {
147 kind: "oauth2_authorized_user",
148 headerName,
149 prefix,
150 tokenEndpoint,
151 clientId,
152 clientAuthentication: auth.clientAuthentication,
153 clientSecret,
154 refreshToken: {
155 providerId: refreshProviderId,
156 handle: refreshHandle,
157 },
158 grantSet: auth.grantSet ?? null,
159 } satisfies SourceAuth;
160 }
161
110 const accessProviderId = trimOrNull(auth.accessToken.providerId);162 const accessProviderId = trimOrNull(auth.accessToken.providerId);
111 const accessHandle = trimOrNull(auth.accessToken.handle);163 const accessHandle = trimOrNull(auth.accessToken.handle);
112 if (accessProviderId === null || accessHandle === null) {164 if (accessProviderId === null || accessHandle === null) {
@@ -146,86 +198,6 @@ const normalizeImportAuthPolicy = ( · 33 unmodified lines
146 policy: SourceImportAuthPolicy | undefined,198 policy: SourceImportAuthPolicy | undefined,
147): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy;199): SourceImportAuthPolicy => policy ?? getSourceAdapter(sourceKind).defaultImportAuthPolicy;
148 200
149const 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
193const 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
229const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> =>201const validateSourceImportAuth = (source: Source): Effect.Effect<Source, Error, never> =>
230 Effect.gen(function* () {202 Effect.gen(function* () {
231 if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") {203 if (source.importAuthPolicy !== "separate" && source.importAuth.kind !== "none") {
@@ -370,12 +342,12 @@ export const splitSourceForStorage = (input: { · 138 unmodified lines
370 recipeId: SourceRecipeId;342 recipeId: SourceRecipeId;
371 recipeRevisionId: SourceRecipeRevisionId;343 recipeRevisionId: SourceRecipeRevisionId;
372 actorAccountId?: AccountId | null;344 actorAccountId?: AccountId | null;
373 existingRuntimeCredentialId?: Credential["id"] | null;345 existingRuntimeAuthArtifactId?: AuthArtifact["id"] | null;
374 existingImportCredentialId?: Credential["id"] | null;346 existingImportAuthArtifactId?: AuthArtifact["id"] | null;
375}): {347}): {
376 sourceRecord: StoredSourceRecord;348 sourceRecord: StoredSourceRecord;
377 runtimeCredential: Credential | null;349 runtimeAuthArtifact: AuthArtifact | null;
378 importCredential: Credential | null;350 importAuthArtifact: AuthArtifact | null;
379} => {351} => {
380 const sourceRecord: StoredSourceRecord = {352 const sourceRecord: StoredSourceRecord = {
381 id: input.source.id,353 id: input.source.id,
@@ -398,20 +370,22 @@ export const splitSourceForStorage = (input: { · 16 unmodified lines
398 370
399 return {371 return {
400 sourceRecord,372 sourceRecord,
401 runtimeCredential: credentialFromAuth({373 runtimeAuthArtifact: authArtifactFromSourceAuth({
402 source: input.source,374 source: input.source,
403 auth: input.source.auth,375 auth: input.source.auth,
404 slot: "runtime",376 slot: "runtime",
405 actorAccountId: input.actorAccountId,377 actorAccountId: input.actorAccountId,
406 existingCredentialId: input.existingRuntimeCredentialId,378 existingAuthArtifactId: input.existingRuntimeAuthArtifactId
379 ?? AuthArtifactIdSchema.make(`auth_art_${crypto.randomUUID()}`),
407 }),380 }),
408 importCredential: input.source.importAuthPolicy === "separate"381 importAuthArtifact: input.source.importAuthPolicy === "separate"
409 ? credentialFromAuth({382 ? authArtifactFromSourceAuth({
410 source: input.source,383 source: input.source,
411 auth: input.source.importAuth,384 auth: input.source.importAuth,
412 slot: "import",385 slot: "import",
413 actorAccountId: input.actorAccountId,386 actorAccountId: input.actorAccountId,
414 existingCredentialId: input.existingImportCredentialId,387 existingAuthArtifactId: input.existingImportAuthArtifactId
388 ?? AuthArtifactIdSchema.make(`auth_art_${crypto.randomUUID()}`),
415 })389 })
416 : null,390 : null,
417 };391 };
@@ -419,8 +393,8 @@ export const splitSourceForStorage = (input: { · 1 unmodified line
419 393
420export const projectSourceFromStorage = (input: {394export const projectSourceFromStorage = (input: {
421 sourceRecord: StoredSourceRecord;395 sourceRecord: StoredSourceRecord;
422 runtimeCredential: Credential | null;396 runtimeAuthArtifact: AuthArtifact | null;
423 importCredential: Credential | null;397 importAuthArtifact: AuthArtifact | null;
424}): Effect.Effect<Source, Error, never> =>398}): Effect.Effect<Source, Error, never> =>
425 Effect.gen(function* () {399 Effect.gen(function* () {
426 const adapter = getSourceAdapter(input.sourceRecord.kind);400 const adapter = getSourceAdapter(input.sourceRecord.kind);
@@ -443,9 +417,9 @@ export const projectSourceFromStorage = (input: { · 16 unmodified lines
443 importAuthPolicy: input.sourceRecord.importAuthPolicy,417 importAuthPolicy: input.sourceRecord.importAuthPolicy,
444 importAuth:418 importAuth:
445 input.sourceRecord.importAuthPolicy === "separate"419 input.sourceRecord.importAuthPolicy === "separate"
446 ? authFromCredential(input.importCredential)420 ? sourceAuthFromAuthArtifact(input.importAuthArtifact)
447 : { kind: "none" },421 : { kind: "none" },
448 auth: authFromCredential(input.runtimeCredential),422 auth: sourceAuthFromAuthArtifact(input.runtimeAuthArtifact),
449 sourceHash: input.sourceRecord.sourceHash,423 sourceHash: input.sourceRecord.sourceHash,
450 lastError: input.sourceRecord.lastError,424 lastError: input.sourceRecord.lastError,
451 createdAt: input.sourceRecord.createdAt,425 createdAt: input.sourceRecord.createdAt,
@@ -459,23 +433,23 @@ export const projectSourceFromStorage = (input: { · 7 unmodified lines
459 433
460export const projectSourcesFromStorage = (input: {434export const projectSourcesFromStorage = (input: {
461 sourceRecords: ReadonlyArray<StoredSourceRecord>;435 sourceRecords: ReadonlyArray<StoredSourceRecord>;
462 credentials: ReadonlyArray<Credential>;436 authArtifacts: ReadonlyArray<AuthArtifact>;
463}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {437}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {
464 const credentialsBySourceId = new Map<string, {438 const authArtifactsBySourceId = new Map<string, {
465 runtime: Credential | null;439 runtime: AuthArtifact | null;
466 import: Credential | null;440 import: AuthArtifact | null;
467 }>();441 }>();
468 442
469 for (const credential of input.credentials) {443 for (const authArtifact of input.authArtifacts) {
470 const existing = credentialsBySourceId.get(credential.sourceId) ?? {444 const existing = authArtifactsBySourceId.get(authArtifact.sourceId) ?? {
471 runtime: null,445 runtime: null,
472 import: null,446 import: null,
473 };447 };
474 const current = credential.slot === "runtime" ? existing.runtime : existing.import;448 const current = authArtifact.slot === "runtime" ? existing.runtime : existing.import;
475 if (current === null || (current.actorAccountId === null && credential.actorAccountId !== null)) {449 if (current === null || (current.actorAccountId === null && authArtifact.actorAccountId !== null)) {
476 credentialsBySourceId.set(credential.sourceId, {450 authArtifactsBySourceId.set(authArtifact.sourceId, {
477 ...existing,451 ...existing,
478 [credential.slot]: credential,452 [authArtifact.slot]: authArtifact,
479 });453 });
480 }454 }
481 }455 }
@@ -483,7 +457,7 @@ export const projectSourcesFromStorage = (input: { · 1 unmodified line
483 return Effect.forEach(input.sourceRecords, (sourceRecord) =>457 return Effect.forEach(input.sourceRecords, (sourceRecord) =>
484 projectSourceFromStorage({458 projectSourceFromStorage({
485 sourceRecord,459 sourceRecord,
486 runtimeCredential: credentialsBySourceId.get(sourceRecord.id)?.runtime ?? null,460 runtimeAuthArtifact: authArtifactsBySourceId.get(sourceRecord.id)?.runtime ?? null,
487 importCredential: credentialsBySourceId.get(sourceRecord.id)?.import ?? null,461 importAuthArtifact: authArtifactsBySourceId.get(sourceRecord.id)?.import ?? null,
488 }));462 }));
489};463};
packages/control-plane/src/runtime/source-discovery.test.ts+77−0
@@ -148,4 +148,81 @@ describe("source-discovery", () => { · 147 unmodified lines
148 await server.close();148 await server.close();
149 }149 }
150 });150 });
151
152 it("detects Google Discovery documents", async () => {
153 const server = await withServer((request, response) => {
154 if (request.url !== "/gmail/$discovery/rest?version=v1") {
155 response.statusCode = 404;
156 response.end();
157 return;
158 }
159
160 response.statusCode = 200;
161 response.setHeader("content-type", "application/json");
162 response.end(JSON.stringify({
163 name: "gmail",
164 version: "v1",
165 title: "Gmail API",
166 description: "Access Gmail mailboxes and settings.",
167 rootUrl: `${server.url}/`,
168 servicePath: "gmail/v1/users/",
169 auth: {
170 oauth2: {
171 scopes: {
172 "https://www.googleapis.com/auth/gmail.readonly": {
173 description: "View your email messages and settings",
174 },
175 },
176 },
177 },
178 methods: {
179 getProfile: {
180 id: "gmail.users.getProfile",
181 path: "{userId}/profile",
182 httpMethod: "GET",
183 parameters: {
184 userId: {
185 type: "string",
186 required: true,
187 location: "path",
188 },
189 },
190 response: {
191 $ref: "Profile",
192 },
193 },
194 },
195 schemas: {
196 Profile: {
197 id: "Profile",
198 type: "object",
199 properties: {
200 emailAddress: {
201 type: "string",
202 },
203 },
204 },
205 },
206 }));
207 });
208
209 try {
210 const result = await Effect.runPromise(discoverSource({
211 url: `${server.url}/gmail/$discovery/rest?version=v1`,
212 }));
213
214 expect(result.detectedKind).toBe("google_discovery");
215 expect(result.specUrl).toBe(`${server.url}/gmail/$discovery/rest?version=v1`);
216 expect(result.endpoint).toBe(`${server.url}/gmail/v1/users/`);
217 expect(result.name).toBe("Gmail API");
218 expect(result.namespace).toBe("gmail");
219 expect(result.authInference.suggestedKind).toBe("oauth2");
220 expect(result.authInference.oauthScopes).toEqual([
221 "https://www.googleapis.com/auth/gmail.readonly",
222 ]);
223 expect(result.toolCount).toBe(1);
224 } finally {
225 await server.close();
226 }
227 });
151});228});
packages/control-plane/src/runtime/source-discovery.ts+99−1
@@ -7,6 +7,9 @@ import { · 6 unmodified lines
7 createSdkMcpConnector,7 createSdkMcpConnector,
8 discoverMcpToolsFromConnector,8 discoverMcpToolsFromConnector,
9} from "@executor/codemode-mcp";9} from "@executor/codemode-mcp";
10import {
11 extractGoogleDiscoveryManifest,
12} from "@executor/codemode-google-discovery";
10import {13import {
11 extractOpenApiManifest,14 extractOpenApiManifest,
12 parseOpenApiDocument,15 parseOpenApiDocument,
@@ -33,6 +36,10 @@ type OpenApiProbeResult = { · 20 unmodified lines
33 result: SourceDiscoveryResult;36 result: SourceDiscoveryResult;
34};37};
35 38
39type GoogleDiscoveryProbeResult = {
40 result: SourceDiscoveryResult;
41};
42
36type GraphqlProbeResult = {43type GraphqlProbeResult = {
37 result: SourceDiscoveryResult;44 result: SourceDiscoveryResult;
38};45};
@@ -607,6 +614,89 @@ const deriveOpenApiEndpoint = (input: { · 568 unmodified lines
607 return new URL(input.normalizedUrl).origin;614 return new URL(input.normalizedUrl).origin;
608};615};
609 616
617const 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
628const 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
648const 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
610const tryDetectOpenApi = (input: {700const tryDetectOpenApi = (input: {
611 normalizedUrl: string;701 normalizedUrl: string;
612 headers: Readonly<Record<string, string>>;702 headers: Readonly<Record<string, string>>;
@@ -851,7 +941,7 @@ const fallbackDiscoveryResult = (normalizedUrl: string): SourceDiscoveryResult = · 238 unmodified lines
851 transport: null,941 transport: null,
852 authInference: unknownAuthInference("Could not infer source kind or auth requirements from the provided URL"),942 authInference: unknownAuthInference("Could not infer source kind or auth requirements from the provided URL"),
853 toolCount: null,943 toolCount: null,
854 warnings: ["Could not confirm whether the URL is OpenAPI, GraphQL, or MCP."],944 warnings: ["Could not confirm whether the URL is Google Discovery, OpenAPI, GraphQL, or MCP."],
855 };945 };
856};946};
857 947
@@ -863,6 +953,14 @@ export const discoverSource = (input: { · 5 unmodified lines
863 const normalizedUrl = normalizeUrl(input.url);953 const normalizedUrl = normalizeUrl(input.url);
864 const headers = probeHeadersFromAuth(input.probeAuth);954 const headers = probeHeadersFromAuth(input.probeAuth);
865 955
956 const googleDiscovery = yield* tryDetectGoogleDiscovery({
957 normalizedUrl,
958 headers,
959 });
960 if (googleDiscovery) {
961 return googleDiscovery.result;
962 }
963
866 const openApi = yield* tryDetectOpenApi({964 const openApi = yield* tryDetectOpenApi({
867 normalizedUrl,965 normalizedUrl,
868 headers,966 headers,
packages/control-plane/src/runtime/source-materialization.ts+4−0
@@ -1,5 +1,6 @@
1import type { SqlControlPlaneRows } from "#persistence";1import type { SqlControlPlaneRows } from "#persistence";
2import type {2import type {
3 AccountId,
3 Source,4 Source,
4} from "#schema";5} from "#schema";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
@@ -22,6 +23,7 @@ const shouldIndexSource = (source: Source): boolean => · 16 unmodified lines
22export const syncSourceMaterialization = (input: {23export const syncSourceMaterialization = (input: {
23 rows: SqlControlPlaneRows;24 rows: SqlControlPlaneRows;
24 source: Source;25 source: Source;
26 actorAccountId?: AccountId | null;
25 resolveSecretMaterial: ResolveSourceSecretMaterial;27 resolveSecretMaterial: ResolveSourceSecretMaterial;
26}): Effect.Effect<void, Error, never> =>28}): Effect.Effect<void, Error, never> =>
27 Effect.gen(function* () {29 Effect.gen(function* () {
@@ -35,8 +37,10 @@ export const syncSourceMaterialization = (input: { · 7 unmodified lines
35 resolveSecretMaterial: input.resolveSecretMaterial,37 resolveSecretMaterial: input.resolveSecretMaterial,
36 resolveAuthMaterialForSlot: (slot) =>38 resolveAuthMaterialForSlot: (slot) =>
37 resolveSourceAuthMaterial({39 resolveSourceAuthMaterial({
40 rows: input.rows,
38 source: input.source,41 source: input.source,
39 slot,42 slot,
43 actorAccountId: input.actorAccountId,
40 resolveSecretMaterial: input.resolveSecretMaterial,44 resolveSecretMaterial: input.resolveSecretMaterial,
41 }),45 }),
42 });46 });
packages/control-plane/src/runtime/source-recipe-support.ts+2−2
@@ -1,4 +1,4 @@
1import { createHash } from "node:crypto";1import { sha256Hex } from "@executor/codemode-core";
2 2
3import type {3import type {
4 Source,4 Source,
@@ -28,7 +28,7 @@ export const normalizeSearchText = ( · 23 unmodified lines
28 .toLowerCase();28 .toLowerCase();
29 29
30export const contentHash = (value: string): string =>30export const contentHash = (value: string): string =>
31 createHash("sha256").update(value).digest("hex");31 sha256Hex(value);
32 32
33export const loadSourceBindingRecord = (input: {33export const loadSourceBindingRecord = (input: {
34 rows: SqlControlPlaneRows;34 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
2 2
3import {3import {
4 AccountIdSchema,4 AccountIdSchema,
5 decodeBuiltInAuthArtifactConfig,
5 McpSourceAuthSessionDataJsonSchema,6 McpSourceAuthSessionDataJsonSchema,
6 OrganizationIdSchema,7 OrganizationIdSchema,
7 SecretMaterialIdSchema,8 SecretMaterialIdSchema,
@@ -186,10 +187,14 @@ describe("source-store", () => { · 178 unmodified lines
186 );187 );
187 188
188 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true);189 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(firstTokenId))).toBe(true);
189 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(1);190 const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId);
190 expect((yield* persistence.rows.credentials.listByWorkspaceId(workspaceId))[0]?.tokenHandle).toBe(191 expect(authArtifacts).toHaveLength(1);
191 secondTokenId,192 const decoded = authArtifacts[0] ? decodeBuiltInAuthArtifactConfig(authArtifacts[0]) : null;
192 );193 expect(
194 decoded !== null && decoded.artifactKind === "static_bearer"
195 ? decoded.config.token.handle
196 : null,
197 ).toBe(secondTokenId);
193 198
194 const removed = yield* removeSourceById(persistence.rows, {199 const removed = yield* removeSourceById(persistence.rows, {
195 workspaceId,200 workspaceId,
@@ -199,7 +204,7 @@ describe("source-store", () => { · 3 unmodified lines
199 204
200 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);205 expect(Option.isNone(yield* persistence.rows.secretMaterials.getById(secondTokenId))).toBe(true);
201 expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0);206 expect(yield* persistence.rows.sources.listByWorkspaceId(workspaceId)).toHaveLength(0);
202 expect(yield* persistence.rows.credentials.listByWorkspaceId(workspaceId)).toHaveLength(0);207 expect(yield* persistence.rows.authArtifacts.listByWorkspaceId(workspaceId)).toHaveLength(0);
203 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);208 expect(yield* persistence.rows.sourceAuthSessions.listByWorkspaceId(workspaceId)).toHaveLength(0);
204 }),209 }),
205 );210 );
@@ -267,14 +272,14 @@ describe("source-store", () => { · 61 unmodified lines
267 },272 },
268 );273 );
269 274
270 const credentials = yield* persistence.rows.credentials.listByWorkspaceAndSourceId({275 const authArtifacts = yield* persistence.rows.authArtifacts.listByWorkspaceAndSourceId({
271 workspaceId,276 workspaceId,
272 sourceId,277 sourceId,
273 });278 });
274 expect(credentials).toHaveLength(2);279 expect(authArtifacts).toHaveLength(2);
275 expect(credentials.some((credential) => credential.actorAccountId === null)).toBe(true);280 expect(authArtifacts.some((artifact) => artifact.actorAccountId === null)).toBe(true);
276 expect(credentials.some((credential) => credential.actorAccountId === accountId)).toBe(true);281 expect(authArtifacts.some((artifact) => artifact.actorAccountId === accountId)).toBe(true);
277 expect(new Set(credentials.map((credential) => credential.id)).size).toBe(2);282 expect(new Set(authArtifacts.map((artifact) => artifact.id)).size).toBe(2);
278 }),283 }),
279 );284 );
280 285
packages/control-plane/src/runtime/source-store.ts+104−80
@@ -1,8 +1,7 @@
1import type {1import type {
2 AccountId,2 AccountId,
3 Credential,3 AuthArtifact,
4 CredentialSlot,4 CredentialSlot,
5 SecretRef,
6 Source,5 Source,
7 SourceRecipeId,6 SourceRecipeId,
8 SourceRecipeRevisionId,7 SourceRecipeRevisionId,
@@ -22,25 +21,15 @@ import { · 13 unmodified lines
22 splitSourceForStorage,21 splitSourceForStorage,
23} from "./source-definitions";22} from "./source-definitions";
24import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";23import { createDefaultSecretMaterialDeleter } from "./secret-material-providers";
24import { authArtifactSecretMaterialRefs } from "./auth-artifacts";
25import { removeAuthLeaseAndSecrets } from "./auth-leases";
25 26
26const credentialSecretRefs = (credential: Credential): ReadonlyArray<SecretRef> => [27const secretRefKey = (ref: { providerId: string; handle: string }): string =>
27 {28 `${ref.providerId}:${ref.handle}`;
28 providerId: credential.tokenProviderId,29
29 handle: credential.tokenHandle,30const cleanupAuthArtifactSecretRefs = (rows: SqlControlPlaneRows, input: {
30 },31 previous: AuthArtifact | null;
31 ...(credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null32 next: AuthArtifact | null;
32 ? [{
33 providerId: credential.refreshTokenProviderId,
34 handle: credential.refreshTokenHandle,
35 } satisfies SecretRef]
36 : []),
37];
38
39const secretRefKey = (ref: SecretRef): string => `${ref.providerId}:${ref.handle}`;
40
41const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: {
42 previous: Credential | null;
43 next: Credential | null;
44}) =>33}) =>
45 Effect.gen(function* () {34 Effect.gen(function* () {
46 if (input.previous === null) {35 if (input.previous === null) {
@@ -49,9 +38,9 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 2 unmodified lines
49 38
50 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows });39 const deleteSecretMaterial = createDefaultSecretMaterialDeleter({ rows });
51 const nextRefKeys = new Set(40 const nextRefKeys = new Set(
52 (input.next === null ? [] : credentialSecretRefs(input.next)).map(secretRefKey),41 (input.next === null ? [] : authArtifactSecretMaterialRefs(input.next)).map(secretRefKey),
53 );42 );
54 const refsToDelete = credentialSecretRefs(input.previous).filter(43 const refsToDelete = authArtifactSecretMaterialRefs(input.previous).filter(
55 (ref) => !nextRefKeys.has(secretRefKey(ref)),44 (ref) => !nextRefKeys.has(secretRefKey(ref)),
56 );45 );
57 46
@@ -62,32 +51,32 @@ const cleanupCredentialSecretRefs = (rows: SqlControlPlaneRows, input: { · 4 unmodified lines
62 );51 );
63 });52 });
64 53
65const selectPreferredCredential = (input: {54const selectPreferredAuthArtifact = (input: {
66 credentials: ReadonlyArray<Credential>;55 authArtifacts: ReadonlyArray<AuthArtifact>;
67 actorAccountId?: AccountId | null;56 actorAccountId?: AccountId | null;
68 slot: CredentialSlot;57 slot: CredentialSlot;
69}): Credential | null => {58}): AuthArtifact | null => {
70 const matchingSlot = input.credentials.filter((credential) => credential.slot === input.slot);59 const matchingSlot = input.authArtifacts.filter((artifact) => artifact.slot === input.slot);
71 60
72 if (input.actorAccountId !== undefined) {61 if (input.actorAccountId !== undefined) {
73 const exact = matchingSlot.find((credential) => credential.actorAccountId === input.actorAccountId);62 const exact = matchingSlot.find((artifact) => artifact.actorAccountId === input.actorAccountId);
74 if (exact) {63 if (exact) {
75 return exact;64 return exact;
76 }65 }
77 }66 }
78 67
79 return matchingSlot.find((credential) => credential.actorAccountId === null) ?? null;68 return matchingSlot.find((artifact) => artifact.actorAccountId === null) ?? null;
80};69};
81 70
82const selectExactCredential = (input: {71const selectExactAuthArtifact = (input: {
83 credentials: ReadonlyArray<Credential>;72 authArtifacts: ReadonlyArray<AuthArtifact>;
84 actorAccountId?: AccountId | null;73 actorAccountId?: AccountId | null;
85 slot: CredentialSlot;74 slot: CredentialSlot;
86}): Credential | null =>75}): AuthArtifact | null =>
87 input.credentials.find(76 input.authArtifacts.find(
88 (credential) =>77 (artifact) =>
89 credential.slot === input.slot78 artifact.slot === input.slot
90 && credential.actorAccountId === (input.actorAccountId ?? null),79 && artifact.actorAccountId === (input.actorAccountId ?? null),
91 ) ?? null;80 ) ?? null;
92 81
93export const loadSourcesInWorkspace = (82export const loadSourcesInWorkspace = (
@@ -99,27 +88,27 @@ export const loadSourcesInWorkspace = ( · 5 unmodified lines
99) =>88) =>
100 Effect.gen(function* () {89 Effect.gen(function* () {
101 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);90 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
102 const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId);91 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceId(workspaceId);
103 const filteredCredentials = sourceRecords.flatMap((sourceRecord) => {92 const filteredAuthArtifacts = sourceRecords.flatMap((sourceRecord) => {
104 const matches = credentials.filter((credential) => credential.sourceId === sourceRecord.id);93 const matches = authArtifacts.filter((artifact) => artifact.sourceId === sourceRecord.id);
105 const preferred = selectPreferredCredential({94 const preferred = selectPreferredAuthArtifact({
106 credentials: matches,95 authArtifacts: matches,
107 actorAccountId: options.actorAccountId,96 actorAccountId: options.actorAccountId,
108 slot: "runtime",97 slot: "runtime",
109 });98 });
110 const preferredImport = selectPreferredCredential({99 const preferredImport = selectPreferredAuthArtifact({
111 credentials: matches,100 authArtifacts: matches,
112 actorAccountId: options.actorAccountId,101 actorAccountId: options.actorAccountId,
113 slot: "import",102 slot: "import",
114 });103 });
115 return [preferred, preferredImport].filter(104 return [preferred, preferredImport].filter(
116 (credential): credential is Credential => credential !== null,105 (artifact): artifact is AuthArtifact => artifact !== null,
117 );106 );
118 });107 });
119 108
120 return yield* projectSourcesFromStorage({109 return yield* projectSourcesFromStorage({
121 sourceRecords,110 sourceRecords,
122 credentials: filteredCredentials,111 authArtifacts: filteredAuthArtifacts,
123 });112 });
124 });113 });
125 114
@@ -140,54 +129,63 @@ export const loadSourceById = (rows: SqlControlPlaneRows, input: { · 14 unmodified lines
140 );129 );
141 }130 }
142 131
143 const credentials = yield* rows.credentials.listByWorkspaceAndSourceId({132 const authArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
144 workspaceId: input.workspaceId,133 workspaceId: input.workspaceId,
145 sourceId: input.sourceId,134 sourceId: input.sourceId,
146 });135 });
147 const credential = selectPreferredCredential({136 const authArtifact = selectPreferredAuthArtifact({
148 credentials,137 authArtifacts,
149 actorAccountId: input.actorAccountId,138 actorAccountId: input.actorAccountId,
150 slot: "runtime",139 slot: "runtime",
151 });140 });
152 const importCredential = selectPreferredCredential({141 const importAuthArtifact = selectPreferredAuthArtifact({
153 credentials,142 authArtifacts,
154 actorAccountId: input.actorAccountId,143 actorAccountId: input.actorAccountId,
155 slot: "import",144 slot: "import",
156 });145 });
157 146
158 return yield* projectSourceFromStorage({147 return yield* projectSourceFromStorage({
159 sourceRecord: sourceRecord.value,148 sourceRecord: sourceRecord.value,
160 runtimeCredential: credential,149 runtimeAuthArtifact: authArtifact,
161 importCredential,150 importAuthArtifact,
162 });151 });
163 });152 });
164 153
165const removeCredentialsForSource = (rows: SqlControlPlaneRows, input: {154const removeAuthArtifactsForSource = (rows: SqlControlPlaneRows, input: {
166 workspaceId: WorkspaceId;155 workspaceId: WorkspaceId;
167 sourceId: Source["id"];156 sourceId: Source["id"];
168}) =>157}) =>
169 Effect.gen(function* () {158 Effect.gen(function* () {
170 const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({159 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
171 workspaceId: input.workspaceId,160 workspaceId: input.workspaceId,
172 sourceId: input.sourceId,161 sourceId: input.sourceId,
173 });162 });
174 163
175 yield* rows.credentials.removeByWorkspaceAndSourceId({164 yield* rows.authArtifacts.removeByWorkspaceAndSourceId({
176 workspaceId: input.workspaceId,165 workspaceId: input.workspaceId,
177 sourceId: input.sourceId,166 sourceId: input.sourceId,
178 });167 });
179 168
180 yield* Effect.forEach(169 yield* Effect.forEach(
181 existingCredentials,170 existingAuthArtifacts,
182 (credential) =>171 (artifact) =>
183 cleanupCredentialSecretRefs(rows, {172 removeAuthLeaseAndSecrets(rows, {
184 previous: credential,173 authArtifactId: artifact.id,
174 }),
175 { discard: true },
176 );
177
178 yield* Effect.forEach(
179 existingAuthArtifacts,
180 (artifact) =>
181 cleanupAuthArtifactSecretRefs(rows, {
182 previous: artifact,
185 next: null,183 next: null,
186 }),184 }),
187 { discard: true },185 { discard: true },
188 );186 );
189 187
190 return existingCredentials.length;188 return existingAuthArtifacts.length;
191 });189 });
192 190
193const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: {191const cleanupOrphanedRecipeData = (rows: SqlControlPlaneRows, input: {
@@ -242,7 +240,7 @@ export const removeSourceById = (rows: SqlControlPlaneRows, input: { · 48 unmodified lines
242 workspaceId: input.workspaceId,240 workspaceId: input.workspaceId,
243 sourceId: input.sourceId,241 sourceId: input.sourceId,
244 });242 });
245 yield* removeCredentialsForSource(rows, input);243 yield* removeAuthArtifactsForSource(rows, input);
246 const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);244 const removed = yield* rows.sources.removeByWorkspaceAndId(input.workspaceId, input.sourceId);
247 if (!removed) {245 if (!removed) {
248 return false;246 return false;
@@ -265,17 +263,17 @@ export const persistSource = ( · 16 unmodified lines
265) =>263) =>
266 Effect.gen(function* () {264 Effect.gen(function* () {
267 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);265 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
268 const existingCredentials = yield* rows.credentials.listByWorkspaceAndSourceId({266 const existingAuthArtifacts = yield* rows.authArtifacts.listByWorkspaceAndSourceId({
269 workspaceId: source.workspaceId,267 workspaceId: source.workspaceId,
270 sourceId: source.id,268 sourceId: source.id,
271 });269 });
272 const existingCredential = selectExactCredential({270 const existingRuntimeAuthArtifact = selectExactAuthArtifact({
273 credentials: existingCredentials,271 authArtifacts: existingAuthArtifacts,
274 actorAccountId: options.actorAccountId,272 actorAccountId: options.actorAccountId,
275 slot: "runtime",273 slot: "runtime",
276 });274 });
277 const existingImportCredential = selectExactCredential({275 const existingImportAuthArtifact = selectExactAuthArtifact({
278 credentials: existingCredentials,276 authArtifacts: existingAuthArtifacts,
279 actorAccountId: options.actorAccountId,277 actorAccountId: options.actorAccountId,
280 slot: "import",278 slot: "import",
281 });279 });
@@ -309,13 +307,13 @@ export const persistSource = ( · 27 unmodified lines
309 latestRevisionId: nextRevision.id,307 latestRevisionId: nextRevision.id,
310 });308 });
311 309
312 const { sourceRecord, runtimeCredential, importCredential } = splitSourceForStorage({310 const { sourceRecord, runtimeAuthArtifact, importAuthArtifact } = splitSourceForStorage({
313 source,311 source,
314 recipeId: nextRecipe.id,312 recipeId: nextRecipe.id,
315 recipeRevisionId: nextRevision.id,313 recipeRevisionId: nextRevision.id,
316 actorAccountId: options.actorAccountId,314 actorAccountId: options.actorAccountId,
317 existingRuntimeCredentialId: existingCredential?.id ?? null,315 existingRuntimeAuthArtifactId: existingRuntimeAuthArtifact?.id ?? null,
318 existingImportCredentialId: existingImportCredential?.id ?? null,316 existingImportAuthArtifactId: existingImportAuthArtifact?.id ?? null,
319 });317 });
320 318
321 if (Option.isNone(existing)) {319 if (Option.isNone(existing)) {
@@ -346,36 +344,62 @@ export const persistSource = ( · 24 unmodified lines
346 });344 });
347 }345 }
348 346
349 if (runtimeCredential === null) {347 if (runtimeAuthArtifact === null) {
350 yield* rows.credentials.removeByWorkspaceSourceAndActor({348 if (existingRuntimeAuthArtifact !== null) {
349 yield* removeAuthLeaseAndSecrets(rows, {
350 authArtifactId: existingRuntimeAuthArtifact.id,
351 });
352 }
353 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
351 workspaceId: source.workspaceId,354 workspaceId: source.workspaceId,
352 sourceId: source.id,355 sourceId: source.id,
353 actorAccountId: options.actorAccountId ?? null,356 actorAccountId: options.actorAccountId ?? null,
354 slot: "runtime",357 slot: "runtime",
355 });358 });
356 } else {359 } else {
357 yield* rows.credentials.upsert(runtimeCredential);360 yield* rows.authArtifacts.upsert(runtimeAuthArtifact);
361 if (
362 existingRuntimeAuthArtifact !== null
363 && existingRuntimeAuthArtifact.id !== runtimeAuthArtifact.id
364 ) {
365 yield* removeAuthLeaseAndSecrets(rows, {
366 authArtifactId: existingRuntimeAuthArtifact.id,
367 });
368 }
358 }369 }
359 370
360 yield* cleanupCredentialSecretRefs(rows, {371 yield* cleanupAuthArtifactSecretRefs(rows, {
361 previous: existingCredential ?? null,372 previous: existingRuntimeAuthArtifact ?? null,
362 next: runtimeCredential,373 next: runtimeAuthArtifact,
363 });374 });
364 375
365 if (importCredential === null) {376 if (importAuthArtifact === null) {
366 yield* rows.credentials.removeByWorkspaceSourceAndActor({377 if (existingImportAuthArtifact !== null) {
378 yield* removeAuthLeaseAndSecrets(rows, {
379 authArtifactId: existingImportAuthArtifact.id,
380 });
381 }
382 yield* rows.authArtifacts.removeByWorkspaceSourceAndActor({
367 workspaceId: source.workspaceId,383 workspaceId: source.workspaceId,
368 sourceId: source.id,384 sourceId: source.id,
369 actorAccountId: options.actorAccountId ?? null,385 actorAccountId: options.actorAccountId ?? null,
370 slot: "import",386 slot: "import",
371 });387 });
372 } else {388 } else {
373 yield* rows.credentials.upsert(importCredential);389 yield* rows.authArtifacts.upsert(importAuthArtifact);
390 if (
391 existingImportAuthArtifact !== null
392 && existingImportAuthArtifact.id !== importAuthArtifact.id
393 ) {
394 yield* removeAuthLeaseAndSecrets(rows, {
395 authArtifactId: existingImportAuthArtifact.id,
396 });
397 }
374 }398 }
375 399
376 yield* cleanupCredentialSecretRefs(rows, {400 yield* cleanupAuthArtifactSecretRefs(rows, {
377 previous: existingImportCredential ?? null,401 previous: existingImportAuthArtifact ?? null,
378 next: importCredential,402 next: importAuthArtifact,
379 });403 });
380 404
381 return source;405 return source;
packages/control-plane/src/runtime/sources-operations.ts+3−1
@@ -72,6 +72,7 @@ const syncArtifactsForSource = (input: { · 71 unmodified lines
72 syncSourceMaterialization({72 syncSourceMaterialization({
73 rows: input.store,73 rows: input.store,
74 source: sourceForSync,74 source: sourceForSync,
75 actorAccountId: input.actorAccountId,
75 resolveSecretMaterial,76 resolveSecretMaterial,
76 }),77 }),
77 );78 );
@@ -276,7 +277,8 @@ export const removeSource = (input: { · 198 unmodified lines
276}) =>277}) =>
277 Effect.flatMap(ControlPlaneStore, (store) =>278 Effect.flatMap(ControlPlaneStore, (store) =>
278 Effect.gen(function* () {279 Effect.gen(function* () {
279 const removed = yield* sourceOps.remove.mapStorage(280 const removed = yield* mapPersistenceError(
281 sourceOps.remove.child("remove"),
280 removeSourceById(store, {282 removeSourceById(store, {
281 workspaceId: input.workspaceId,283 workspaceId: input.workspaceId,
282 sourceId: input.sourceId,284 sourceId: input.sourceId,
packages/control-plane/src/runtime/workspace-execution-environment.ts+2−0
@@ -687,7 +687,9 @@ const createWorkspaceToolInvoker = (input: { · 686 unmodified lines
687 });687 });
688 688
689 const auth = yield* resolveSourceAuthMaterial({689 const auth = yield* resolveSourceAuthMaterial({
690 rows: input.rows,
690 source: recipeTool.source,691 source: recipeTool.source,
692 actorAccountId: input.accountId,
691 resolveSecretMaterial: input.resolveSecretMaterial,693 resolveSecretMaterial: input.resolveSecretMaterial,
692 context: toSecretResolutionContext(invocation.context),694 context: toSecretResolutionContext(invocation.context),
693 });695 });
packages/control-plane/src/schema/enums.ts+11−5
@@ -9,20 +9,22 @@ export { · 8 unmodified lines
9 type Role,9 type Role,
10} from "./models/auth/organization-membership";10} from "./models/auth/organization-membership";
11export {11export {
12 SecretRefSchema,
13 SourceAuthSchema,12 SourceAuthSchema,
14 SourceBindingSchema,13 SourceBindingSchema,
15 SourceBindingVersionSchema,14 SourceBindingVersionSchema,
16 SourceKindSchema,15 SourceKindSchema,
17 SourceStatusSchema,16 SourceStatusSchema,
18 SourceTransportSchema,17 SourceTransportSchema,
19 type SecretRef,
20 type SourceAuth,18 type SourceAuth,
21 type SourceBinding,19 type SourceBinding,
22 type SourceKind,20 type SourceKind,
23 type SourceStatus,21 type SourceStatus,
24 type SourceTransport,22 type SourceTransport,
25} from "./models/source";23} from "./models/source";
24export {
25 SecretRefSchema,
26 type SecretRef,
27} from "./models/auth-artifact";
26export {28export {
27 SourceRecipeAdapterKeySchema,29 SourceRecipeAdapterKeySchema,
28 SourceRecipeDocumentKindSchema,30 SourceRecipeDocumentKindSchema,
@@ -56,9 +58,13 @@ export { · 27 unmodified lines
56 type SourceProbeAuth,58 type SourceProbeAuth,
57} from "./models/source-discovery";59} from "./models/source-discovery";
58export {60export {
59 CredentialAuthKindSchema,61 AuthArtifactKindSchema,
60 type CredentialAuthKind,62 AuthArtifactSlotSchema,
61} from "./models/credential";63 BuiltInAuthArtifactKindSchema,
64 type AuthArtifactKind,
65 type AuthArtifactSlot,
66 type BuiltInAuthArtifactKind,
67} from "./models/auth-artifact";
62export {68export {
63 SourceAuthSessionProviderKindSchema,69 SourceAuthSessionProviderKindSchema,
64 SourceAuthSessionStatusSchema,70 SourceAuthSessionStatusSchema,
packages/control-plane/src/schema/ids.ts+8−2
@@ -17,9 +17,13 @@ export const SourceRecipeSchemaBundleIdSchema = Schema.String.pipe( · 16 unmodified lines
17export const SourceAuthSessionIdSchema = Schema.String.pipe(17export const SourceAuthSessionIdSchema = Schema.String.pipe(
18 Schema.brand("SourceAuthSessionId"),18 Schema.brand("SourceAuthSessionId"),
19);19);
20export const CredentialIdSchema = Schema.String.pipe(20export const AuthArtifactIdSchema = Schema.String.pipe(
21 Schema.brand("CredentialId"),21 Schema.brand("AuthArtifactId"),
22);22);
23export const AuthLeaseIdSchema = Schema.String.pipe(
24 Schema.brand("AuthLeaseId"),
25);
26export const CredentialIdSchema = AuthArtifactIdSchema;
23export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(27export const WorkspaceSourceOauthClientIdSchema = Schema.String.pipe(
24 Schema.brand("WorkspaceSourceOauthClientId"),28 Schema.brand("WorkspaceSourceOauthClientId"),
25);29);
@@ -42,6 +46,8 @@ export type SourceRecipeId = typeof SourceRecipeIdSchema.Type; · 16 unmodified lines
42export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;46export type SourceRecipeRevisionId = typeof SourceRecipeRevisionIdSchema.Type;
43export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type;47export type SourceRecipeSchemaBundleId = typeof SourceRecipeSchemaBundleIdSchema.Type;
44export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;48export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
49export type AuthArtifactId = typeof AuthArtifactIdSchema.Type;
50export type AuthLeaseId = typeof AuthLeaseIdSchema.Type;
45export type CredentialId = typeof CredentialIdSchema.Type;51export type CredentialId = typeof CredentialIdSchema.Type;
46export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;52export type WorkspaceSourceOauthClientId = typeof WorkspaceSourceOauthClientIdSchema.Type;
47export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;53export 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
6export * from "./models/workspace";6export * from "./models/workspace";
7export * from "./models/source";7export * from "./models/source";
8export * from "./models/source-recipe";8export * from "./models/source-recipe";
9export * from "./models/credential";9export * from "./models/auth-artifact";
10export * from "./models/auth-lease";
10export * from "./models/source-oauth-client";11export * from "./models/source-oauth-client";
11export * from "./models/secret-material";12export * from "./models/secret-material";
12export * from "./models/source-auth-session";13export * from "./models/source-auth-session";
packages/control-plane/src/schema/models/auth-artifact.ts+306−0
@@ -0,0 +1,306 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";
2import { Schema } from "effect";
3
4import { authArtifactsTable } from "../../persistence/schema";
5import { TimestampMsSchema } from "../common";
6import {
7 AccountIdSchema,
8 AuthArtifactIdSchema,
9 SourceIdSchema,
10 WorkspaceIdSchema,
11} from "../ids";
12
13export const SecretRefSchema = Schema.Struct({
14 providerId: Schema.String,
15 handle: Schema.String,
16});
17
18export const AuthArtifactSlotSchema = Schema.Literal("runtime", "import");
19export const CredentialSlotSchema = AuthArtifactSlotSchema;
20
21export const AuthArtifactKindSchema = Schema.String;
22export const AuthGrantSetSchema = Schema.Array(Schema.String);
23
24export 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
35export 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
58export 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
81export const RequestPlacementTemplatesJsonSchema = Schema.parseJson(
82 Schema.Array(RequestPlacementTemplateSchema),
83);
84
85export const StaticBearerAuthArtifactKind = "static_bearer" as const;
86export const StaticOAuth2AuthArtifactKind = "static_oauth2" as const;
87export const StaticPlacementsAuthArtifactKind = "static_placements" as const;
88export const RefreshableOAuth2AuthorizedUserAuthArtifactKind =
89 "oauth2_authorized_user" as const;
90
91export const OAuth2ClientAuthenticationMethodSchema = Schema.Literal(
92 "none",
93 "client_secret_post",
94);
95
96export const BuiltInAuthArtifactKindSchema = Schema.Literal(
97 StaticBearerAuthArtifactKind,
98 StaticOAuth2AuthArtifactKind,
99 StaticPlacementsAuthArtifactKind,
100 RefreshableOAuth2AuthorizedUserAuthArtifactKind,
101);
102
103export const StaticBearerAuthArtifactConfigSchema = Schema.Struct({
104 headerName: Schema.String,
105 prefix: Schema.String,
106 token: SecretRefSchema,
107});
108
109export const StaticOAuth2AuthArtifactConfigSchema = Schema.Struct({
110 headerName: Schema.String,
111 prefix: Schema.String,
112 accessToken: SecretRefSchema,
113 refreshToken: Schema.NullOr(SecretRefSchema),
114});
115
116export const StaticPlacementsAuthArtifactConfigSchema = Schema.Struct({
117 placements: Schema.Array(RequestPlacementTemplateSchema),
118});
119
120export 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
130export const StaticBearerAuthArtifactConfigJsonSchema = Schema.parseJson(
131 StaticBearerAuthArtifactConfigSchema,
132);
133
134export const StaticOAuth2AuthArtifactConfigJsonSchema = Schema.parseJson(
135 StaticOAuth2AuthArtifactConfigSchema,
136);
137
138export const StaticPlacementsAuthArtifactConfigJsonSchema = Schema.parseJson(
139 StaticPlacementsAuthArtifactConfigSchema,
140);
141
142export const RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema = Schema.parseJson(
143 RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema,
144);
145
146export const RequestPlacementsJsonSchema = Schema.parseJson(
147 Schema.Array(RequestPlacementSchema),
148);
149
150export const AuthGrantSetJsonSchema = Schema.parseJson(AuthGrantSetSchema);
151
152const decodeStaticBearerAuthArtifactConfigOption = Schema.decodeUnknownOption(
153 StaticBearerAuthArtifactConfigJsonSchema,
154);
155
156const decodeStaticOAuth2AuthArtifactConfigOption = Schema.decodeUnknownOption(
157 StaticOAuth2AuthArtifactConfigJsonSchema,
158);
159
160const decodeStaticPlacementsAuthArtifactConfigOption = Schema.decodeUnknownOption(
161 StaticPlacementsAuthArtifactConfigJsonSchema,
162);
163
164const decodeRefreshableOAuth2AuthorizedUserAuthArtifactConfigOption = Schema.decodeUnknownOption(
165 RefreshableOAuth2AuthorizedUserAuthArtifactConfigJsonSchema,
166);
167
168const decodeAuthGrantSetOption = Schema.decodeUnknownOption(AuthGrantSetJsonSchema);
169
170const 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
183export const AuthArtifactSchema = createSelectSchema(
184 authArtifactsTable,
185 authArtifactSchemaOverrides,
186);
187
188export type SecretRef = typeof SecretRefSchema.Type;
189export type AuthArtifactSlot = typeof AuthArtifactSlotSchema.Type;
190export type CredentialSlot = typeof CredentialSlotSchema.Type;
191export type AuthArtifactKind = typeof AuthArtifactKindSchema.Type;
192export type AuthGrantSet = typeof AuthGrantSetSchema.Type;
193export type RequestPlacementPart = typeof RequestPlacementPartSchema.Type;
194export type RequestPlacementTemplate = typeof RequestPlacementTemplateSchema.Type;
195export type RequestPlacement = typeof RequestPlacementSchema.Type;
196export type OAuth2ClientAuthenticationMethod =
197 typeof OAuth2ClientAuthenticationMethodSchema.Type;
198export type BuiltInAuthArtifactKind = typeof BuiltInAuthArtifactKindSchema.Type;
199export type StaticBearerAuthArtifactConfig = typeof StaticBearerAuthArtifactConfigSchema.Type;
200export type StaticOAuth2AuthArtifactConfig = typeof StaticOAuth2AuthArtifactConfigSchema.Type;
201export type StaticPlacementsAuthArtifactConfig = typeof StaticPlacementsAuthArtifactConfigSchema.Type;
202export type RefreshableOAuth2AuthorizedUserAuthArtifactConfig =
203 typeof RefreshableOAuth2AuthorizedUserAuthArtifactConfigSchema.Type;
204export type AuthArtifact = typeof AuthArtifactSchema.Type;
205
206export 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
224export 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
271export 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
280export 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 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";
2import { Schema } from "effect";
3
4import { authLeasesTable } from "../../persistence/schema";
5import { TimestampMsSchema } from "../common";
6import {
7 AccountIdSchema,
8 AuthArtifactIdSchema,
9 AuthLeaseIdSchema,
10 SourceIdSchema,
11 WorkspaceIdSchema,
12} from "../ids";
13
14import {
15 AuthArtifactSlotSchema,
16 RequestPlacementTemplatesJsonSchema,
17 type RequestPlacementTemplate,
18} from "./auth-artifact";
19
20const 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
34export const AuthLeaseSchema = createSelectSchema(
35 authLeasesTable,
36 authLeaseSchemaOverrides,
37);
38
39export type AuthLease = typeof AuthLeaseSchema.Type;
40
41const decodeLeasePlacementTemplatesOption = Schema.decodeUnknownOption(
42 RequestPlacementTemplatesJsonSchema,
43);
44
45export 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
52export 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 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";1export {
2import { Schema } from "effect";2 AuthArtifactSlotSchema as CredentialSlotSchema,
3 3 type AuthArtifactSlot as CredentialSlot,
4import { credentialsTable } from "../../persistence/schema";4} from "./auth-artifact";
5import { TimestampMsSchema } from "../common";
6import {
7 AccountIdSchema,
8 CredentialIdSchema,
9 SourceIdSchema,
10 WorkspaceIdSchema,
11} from "../ids";
12
13export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2");
14export const CredentialSlotSchema = Schema.Literal("runtime", "import");
15
16const 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
33export const CredentialSchema = createSelectSchema(
34 credentialsTable,
35 credentialSchemaOverrides,
36);
37
38export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;
39export type CredentialSlot = typeof CredentialSlotSchema.Type;
40export type Credential = typeof CredentialSchema.Type;
packages/control-plane/src/schema/models/source-auth-session.ts+32−5
@@ -11,12 +11,13 @@ import { · 10 unmodified lines
11 SourceIdSchema,11 SourceIdSchema,
12 WorkspaceIdSchema,12 WorkspaceIdSchema,
13} from "../ids";13} from "../ids";
14import { CredentialSlotSchema } from "./credential";14import {
15 CredentialSlotSchema,
16 OAuth2ClientAuthenticationMethodSchema,
17 SecretRefSchema,
18} from "./auth-artifact";
15 19
16export const SourceAuthSessionProviderKindSchema = Schema.Literal(20export const SourceAuthSessionProviderKindSchema = Schema.String;
17 "mcp_oauth",
18 "oauth2_pkce",
19);
20 21
21export const SourceAuthSessionStatusSchema = Schema.Literal(22export const SourceAuthSessionStatusSchema = Schema.Literal(
22 "pending",23 "pending",
@@ -74,6 +75,30 @@ export const McpSourceAuthSessionDataJsonSchema = Schema.parseJson( · 51 unmodified lines
74 McpSourceAuthSessionDataSchema,75 McpSourceAuthSessionDataSchema,
75);76);
76 77
78export 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
98export const OAuth2PkceSourceAuthSessionDataJsonSchema = Schema.parseJson(
99 OAuth2PkceSourceAuthSessionDataSchema,
100);
101
77const sourceAuthSessionSchemaOverrides = {102const sourceAuthSessionSchemaOverrides = {
78 id: SourceAuthSessionIdSchema,103 id: SourceAuthSessionIdSchema,
79 workspaceId: WorkspaceIdSchema,104 workspaceId: WorkspaceIdSchema,
@@ -97,4 +122,6 @@ export const SourceAuthSessionSchema = createSelectSchema( · 17 unmodified lines
97export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type;122export type SourceAuthSessionProviderKind = typeof SourceAuthSessionProviderKindSchema.Type;
98export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type;123export type SourceAuthSessionStatus = typeof SourceAuthSessionStatusSchema.Type;
99export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type;124export type McpSourceAuthSessionData = typeof McpSourceAuthSessionDataSchema.Type;
125export type OAuth2PkceSourceAuthSessionData =
126 typeof OAuth2PkceSourceAuthSessionDataSchema.Type;
100export type SourceAuthSession = typeof SourceAuthSessionSchema.Type;127export 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
5export const SourceDiscoveryKindSchema = Schema.Literal(5export const SourceDiscoveryKindSchema = Schema.Literal(
6 "mcp",6 "mcp",
7 "openapi",7 "openapi",
8 "google_discovery",
8 "graphql",9 "graphql",
9 "unknown",10 "unknown",
10);11);
packages/control-plane/src/schema/models/source-oauth-client.ts+17−0
@@ -9,6 +9,19 @@ import { · 8 unmodified lines
9 WorkspaceSourceOauthClientIdSchema,9 WorkspaceSourceOauthClientIdSchema,
10} from "../ids";10} from "../ids";
11 11
12export const WorkspaceSourceOauthClientRedirectModeSchema = Schema.Literal(
13 "app_callback",
14 "loopback",
15);
16
17export const WorkspaceSourceOauthClientMetadataSchema = Schema.Struct({
18 redirectMode: Schema.optional(WorkspaceSourceOauthClientRedirectModeSchema),
19});
20
21export const WorkspaceSourceOauthClientMetadataJsonSchema = Schema.parseJson(
22 WorkspaceSourceOauthClientMetadataSchema,
23);
24
12export const WorkspaceSourceOauthClientSchema = createSelectSchema(25export const WorkspaceSourceOauthClientSchema = createSelectSchema(
13 workspaceSourceOauthClientsTable,26 workspaceSourceOauthClientsTable,
14 {27 {
@@ -21,3 +34,7 @@ export const WorkspaceSourceOauthClientSchema = createSelectSchema( · 6 unmodified lines
21);34);
22 35
23export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type;36export type WorkspaceSourceOauthClient = typeof WorkspaceSourceOauthClientSchema.Type;
37export type WorkspaceSourceOauthClientRedirectMode =
38 typeof WorkspaceSourceOauthClientRedirectModeSchema.Type;
39export type WorkspaceSourceOauthClientMetadata =
40 typeof WorkspaceSourceOauthClientMetadataSchema.Type;
packages/control-plane/src/schema/models/source.ts+12−6
@@ -11,6 +11,7 @@ import { · 10 unmodified lines
11 SourceRecipeRevisionIdSchema,11 SourceRecipeRevisionIdSchema,
12 WorkspaceIdSchema,12 WorkspaceIdSchema,
13} from "../ids";13} from "../ids";
14import { SecretRefSchema } from "./auth-artifact";
14import { JsonObjectSchema } from "./source-auth-session";15import { JsonObjectSchema } from "./source-auth-session";
15 16
16export const SourceKindSchema = Schema.String;17export const SourceKindSchema = Schema.String;
@@ -35,11 +36,6 @@ export const SourceImportAuthPolicySchema = Schema.Literal( · 18 unmodified lines
35 "separate",36 "separate",
36);37);
37 38
38export const SecretRefSchema = Schema.Struct({
39 providerId: Schema.String,
40 handle: Schema.String,
41});
42
43export const SourceAuthSchema = Schema.Union(39export const SourceAuthSchema = Schema.Union(
44 Schema.Struct({40 Schema.Struct({
45 kind: Schema.Literal("none"),41 kind: Schema.Literal("none"),
@@ -57,6 +53,17 @@ export const SourceAuthSchema = Schema.Union( · 11 unmodified lines
57 accessToken: SecretRefSchema,53 accessToken: SecretRefSchema,
58 refreshToken: Schema.NullOr(SecretRefSchema),54 refreshToken: Schema.NullOr(SecretRefSchema),
59 }),55 }),
56 Schema.Struct({
57 kind: Schema.Literal("oauth2_authorized_user"),
58 headerName: Schema.String,
59 prefix: Schema.String,
60 tokenEndpoint: Schema.String,
61 clientId: Schema.String,
62 clientAuthentication: Schema.Literal("none", "client_secret_post"),
63 clientSecret: Schema.NullOr(SecretRefSchema),
64 refreshToken: SecretRefSchema,
65 grantSet: Schema.NullOr(Schema.Array(Schema.String)),
66 }),
60);67);
61 68
62export const StringMapSchema = Schema.Record({69export const StringMapSchema = Schema.Record({
@@ -170,7 +177,6 @@ export type SourceKind = typeof SourceKindSchema.Type; · 107 unmodified lines
170export type SourceStatus = typeof SourceStatusSchema.Type;177export type SourceStatus = typeof SourceStatusSchema.Type;
171export type SourceTransport = typeof SourceTransportSchema.Type;178export type SourceTransport = typeof SourceTransportSchema.Type;
172export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;179export type SourceImportAuthPolicy = typeof SourceImportAuthPolicySchema.Type;
173export type SecretRef = typeof SecretRefSchema.Type;
174export type SourceAuth = typeof SourceAuthSchema.Type;180export type SourceAuth = typeof SourceAuthSchema.Type;
175export type SourceBinding = typeof SourceBindingSchema.Type;181export type SourceBinding = typeof SourceBindingSchema.Type;
176export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;182export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;
packages/server/src/config.ts+4−0
@@ -1,6 +1,10 @@
1import { homedir } from "node:os";1import { homedir } from "node:os";
2import { join } from "node:path";2import { join } from "node:path";
3 3
4import { loadMonorepoRootEnv } from "./env";
5
6loadMonorepoRootEnv();
7
4export const EXECUTOR_HOME_ENV = "EXECUTOR_HOME";8export const EXECUTOR_HOME_ENV = "EXECUTOR_HOME";
5export const EXECUTOR_DATA_DIR_ENV = "EXECUTOR_DATA_DIR";9export const EXECUTOR_DATA_DIR_ENV = "EXECUTOR_DATA_DIR";
6export const EXECUTOR_LOCAL_DATA_DIR_ENV = "EXECUTOR_LOCAL_DATA_DIR";10export const EXECUTOR_LOCAL_DATA_DIR_ENV = "EXECUTOR_LOCAL_DATA_DIR";
packages/server/src/env.test.ts+65−0
@@ -0,0 +1,65 @@
1import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
2import { tmpdir } from "node:os";
3import { join } from "node:path";
4import { afterEach, describe, expect, it } from "bun:test";
5
6import { loadMonorepoRootEnv } from "./env";
7
8const tempDirs: string[] = [];
9
10const makeTempDir = async (): Promise<string> => {
11 const directory = await mkdtemp(join(tmpdir(), "executor-server-env-"));
12 tempDirs.push(directory);
13 return directory;
14};
15
16afterEach(async () => {
17 await Promise.all(tempDirs.splice(0).map((directory) =>
18 rm(directory, { recursive: true, force: true })
19 ));
20});
21
22describe("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 @@
1import { existsSync, readFileSync } from "node:fs";
2import { dirname, join, resolve } from "node:path";
3
4type LoadMonorepoRootEnvOptions = {
5 cwd?: string;
6 env?: NodeJS.ProcessEnv;
7};
8
9const 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
51const 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
67const 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
84export 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