History

refactor control-plane credentials

Move durable source auth into first-class credentials and resolve secrets through provider refs so local auth can grow into shared credential scopes cleanly.

Rhys Sullivan committed 8 months ago

Commit c1825511 parent b99d0b532 files · +1494 −534Browse files

Unified · Split

32 files
  1. packages/control-plane/drizzle/20260308041000_credentials_model/migration.sql +41 −0
  2. packages/control-plane/src/persistence/control-plane-rows.test.ts +0 −3
  3. packages/control-plane/src/persistence/index.ts +2 −0
  4. packages/control-plane/src/persistence/repos/credentials-repo.ts +69 −0
  5. packages/control-plane/src/persistence/repos/index.ts +1 −0
  6. packages/control-plane/src/persistence/repos/organizations-repo.ts +4 −0
  7. packages/control-plane/src/persistence/repos/sources-repo.ts +0 −12
  8. packages/control-plane/src/persistence/repos/workspaces-repo.ts +4 −0
  9. packages/control-plane/src/persistence/schema.ts +34 −12
  10. packages/control-plane/src/runtime/control-plane-runtime.test.ts +16 −10
  11. packages/control-plane/src/runtime/executor-tools.ts +8 −17
  12. packages/control-plane/src/runtime/index.ts +3 −1
  13. packages/control-plane/src/runtime/live-execution.test.ts +47 −0
  14. packages/control-plane/src/runtime/live-execution.ts +37 −1
  15. packages/control-plane/src/runtime/local-operations.ts +7 −8
  16. packages/control-plane/src/runtime/secret-material-providers.ts +471 −0
  17. packages/control-plane/src/runtime/source-auth-service.test.ts +57 −0
  18. packages/control-plane/src/runtime/source-auth-service.ts +100 −191
  19. packages/control-plane/src/runtime/source-credential-interactions.ts +31 −0
  20. packages/control-plane/src/runtime/source-definitions.ts +71 −47
  21. packages/control-plane/src/runtime/source-inspection.ts +4 −13
  22. packages/control-plane/src/runtime/source-store.ts +132 −0
  23. packages/control-plane/src/runtime/sources-operations.ts +82 −77
  24. packages/control-plane/src/runtime/tool-artifacts.ts +9 −23
  25. packages/control-plane/src/runtime/workspace-execution-environment.test.ts +113 −9
  26. packages/control-plane/src/runtime/workspace-execution-environment.ts +84 −77
  27. packages/control-plane/src/schema/enums.ts +4 −0
  28. packages/control-plane/src/schema/ids.ts +4 −0
  29. packages/control-plane/src/schema/index.ts +1 −0
  30. packages/control-plane/src/schema/models/credential.ts +52 −0
  31. packages/control-plane/src/schema/models/source.ts +1 −33
  32. personal-notes.md +5 −0
packages/control-plane/drizzle/20260308041000_credentials_model/migration.sql+41−0
@@ -0,0 +1,41 @@
1CREATE TABLE "credentials" (
2 "id" text PRIMARY KEY NOT NULL,
3 "workspace_id" text NOT NULL,
4 "auth_kind" text NOT NULL,
5 "auth_header_name" text NOT NULL,
6 "auth_prefix" text NOT NULL,
7 "token_provider_id" text NOT NULL,
8 "token_handle" text NOT NULL,
9 "refresh_token_provider_id" text,
10 "refresh_token_handle" text,
11 "created_at" bigint NOT NULL,
12 "updated_at" bigint NOT NULL,
13 CONSTRAINT "credentials_auth_kind_check" CHECK ("auth_kind" in ('bearer', 'oauth2'))
14);
15--> statement-breakpoint
16CREATE INDEX "credentials_workspace_idx" ON "credentials" ("workspace_id","updated_at","id");
17--> statement-breakpoint
18ALTER TABLE "sources" DROP CONSTRAINT "sources_auth_kind_check";
19--> statement-breakpoint
20ALTER TABLE "sources" DROP COLUMN "auth_kind";
21--> statement-breakpoint
22ALTER TABLE "sources" DROP COLUMN "auth_header_name";
23--> statement-breakpoint
24ALTER TABLE "sources" DROP COLUMN "auth_prefix";
25--> statement-breakpoint
26DROP INDEX IF EXISTS "source_credential_bindings_workspace_idx";
27--> statement-breakpoint
28DROP TABLE "source_credential_bindings";
29--> statement-breakpoint
30CREATE TABLE "source_credential_bindings" (
31 "id" text PRIMARY KEY NOT NULL,
32 "workspace_id" text NOT NULL,
33 "source_id" text NOT NULL,
34 "credential_id" text NOT NULL,
35 "created_at" bigint NOT NULL,
36 "updated_at" bigint NOT NULL
37);
38--> statement-breakpoint
39CREATE UNIQUE INDEX "source_credential_bindings_workspace_source_idx" ON "source_credential_bindings" ("workspace_id","source_id");
40--> statement-breakpoint
41CREATE INDEX "source_credential_bindings_workspace_idx" ON "source_credential_bindings" ("workspace_id","updated_at","source_id");
packages/control-plane/src/persistence/control-plane-rows.test.ts+0−3
@@ -66,9 +66,6 @@ describe("control-plane-persistence-drizzle", () => { · 65 unmodified lines
66 headersJson: null,66 headersJson: null,
67 specUrl: "https://api.github.com/openapi.json",67 specUrl: "https://api.github.com/openapi.json",
68 defaultHeadersJson: null,68 defaultHeadersJson: null,
69 authKind: "none",
70 authHeaderName: null,
71 authPrefix: null,
72 sourceHash: null,69 sourceHash: null,
73 sourceDocumentText: null,70 sourceDocumentText: null,
74 lastError: null,71 lastError: null,
packages/control-plane/src/persistence/index.ts+2−0
@@ -6,6 +6,7 @@ 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 createExecutionInteractionsRepo,10 createExecutionInteractionsRepo,
10 createExecutionsRepo,11 createExecutionsRepo,
11 createLocalInstallationsRepo,12 createLocalInstallationsRepo,
@@ -51,6 +52,7 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 39 unmodified lines
51 organizationMemberships: createOrganizationMembershipsRepo(client, tables),52 organizationMemberships: createOrganizationMembershipsRepo(client, tables),
52 workspaces: createWorkspacesRepo(client, tables),53 workspaces: createWorkspacesRepo(client, tables),
53 sources: createSourcesRepo(client, tables),54 sources: createSourcesRepo(client, tables),
55 credentials: createCredentialsRepo(client, tables),
54 toolArtifacts: createToolArtifactsRepo(client, tables),56 toolArtifacts: createToolArtifactsRepo(client, tables),
55 sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables),57 sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables),
56 secretMaterials: createSecretMaterialsRepo(client, tables),58 secretMaterials: createSecretMaterialsRepo(client, tables),
packages/control-plane/src/persistence/repos/credentials-repo.ts+69−0
@@ -0,0 +1,69 @@
1import {
2 CredentialSchema,
3 type Credential,
4} from "#schema";
5import * as Option from "effect/Option";
6import { Schema } from "effect";
7import { asc, eq } from "drizzle-orm";
8
9import type { DrizzleClient } from "../client";
10import type { DrizzleTables } from "../schema";
11import { firstOption, withoutCreatedAt } from "./shared";
12
13const decodeCredential = Schema.decodeUnknownSync(CredentialSchema);
14
15export const createCredentialsRepo = (
16 client: DrizzleClient,
17 tables: DrizzleTables,
18) => ({
19 listByWorkspaceId: (workspaceId: Credential["workspaceId"]) =>
20 client.use("rows.credentials.list_by_workspace", async (db) => {
21 const rows = await db
22 .select()
23 .from(tables.credentialsTable)
24 .where(eq(tables.credentialsTable.workspaceId, workspaceId))
25 .orderBy(
26 asc(tables.credentialsTable.updatedAt),
27 asc(tables.credentialsTable.id),
28 );
29
30 return rows.map((row) => decodeCredential(row));
31 }),
32
33 getById: (id: Credential["id"]) =>
34 client.use("rows.credentials.get_by_id", async (db) => {
35 const rows = await db
36 .select()
37 .from(tables.credentialsTable)
38 .where(eq(tables.credentialsTable.id, id))
39 .limit(1);
40
41 const row = firstOption(rows);
42 return Option.isSome(row)
43 ? Option.some(decodeCredential(row.value))
44 : Option.none<Credential>();
45 }),
46
47 upsert: (credential: Credential) =>
48 client.use("rows.credentials.upsert", async (db) => {
49 await db
50 .insert(tables.credentialsTable)
51 .values(credential)
52 .onConflictDoUpdate({
53 target: [tables.credentialsTable.id],
54 set: {
55 ...withoutCreatedAt(credential),
56 },
57 });
58 }),
59
60 removeById: (id: Credential["id"]) =>
61 client.use("rows.credentials.remove", async (db) => {
62 const deleted = await db
63 .delete(tables.credentialsTable)
64 .where(eq(tables.credentialsTable.id, id))
65 .returning();
66
67 return deleted.length > 0;
68 }),
69});
packages/control-plane/src/persistence/repos/index.ts+1−0
@@ -3,6 +3,7 @@ export { createOrganizationsRepo } from "./organizations-repo"; · 2 unmodified lines
3export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";3export { createOrganizationMembershipsRepo } from "./organization-memberships-repo";
4export { createWorkspacesRepo } from "./workspaces-repo";4export { createWorkspacesRepo } from "./workspaces-repo";
5export { createSourcesRepo } from "./sources-repo";5export { createSourcesRepo } from "./sources-repo";
6export { createCredentialsRepo } from "./credentials-repo";
6export { createToolArtifactsRepo } from "./tool-artifacts-repo";7export { createToolArtifactsRepo } from "./tool-artifacts-repo";
7export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo";8export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo";
8export { createSecretMaterialsRepo } from "./secret-materials-repo";9export { createSecretMaterialsRepo } from "./secret-materials-repo";
packages/control-plane/src/persistence/repos/organizations-repo.ts+4−0
@@ -151,6 +151,10 @@ export const createOrganizationsRepo = ( · 150 unmodified lines
151 .delete(tables.sourceCredentialBindingsTable)151 .delete(tables.sourceCredentialBindingsTable)
152 .where(inArray(tables.sourceCredentialBindingsTable.workspaceId, workspaceIds));152 .where(inArray(tables.sourceCredentialBindingsTable.workspaceId, workspaceIds));
153 153
154 await tx
155 .delete(tables.credentialsTable)
156 .where(inArray(tables.credentialsTable.workspaceId, workspaceIds));
157
154 await tx158 await tx
155 .delete(tables.sourcesTable)159 .delete(tables.sourcesTable)
156 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));160 .where(inArray(tables.sourcesTable.workspaceId, workspaceIds));
packages/control-plane/src/persistence/repos/sources-repo.ts+0−12
@@ -31,9 +31,6 @@ const toSourceUpdateSet = ( · 30 unmodified lines
31 if (patch.defaultHeadersJson !== undefined) {31 if (patch.defaultHeadersJson !== undefined) {
32 set.defaultHeadersJson = patch.defaultHeadersJson;32 set.defaultHeadersJson = patch.defaultHeadersJson;
33 }33 }
34 if (patch.authKind !== undefined) set.authKind = patch.authKind;
35 if (patch.authHeaderName !== undefined) set.authHeaderName = patch.authHeaderName;
36 if (patch.authPrefix !== undefined) set.authPrefix = patch.authPrefix;
37 if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash;34 if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash;
38 if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText;35 if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText;
39 if (patch.lastError !== undefined) set.lastError = patch.lastError;36 if (patch.lastError !== undefined) set.lastError = patch.lastError;
@@ -172,15 +169,6 @@ export const createSourcesRepo = ( · 132 unmodified lines
172 ),169 ),
173 );170 );
174 171
175 await tx
176 .delete(tables.sourceCredentialBindingsTable)
177 .where(
178 and(
179 eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId),
180 eq(tables.sourceCredentialBindingsTable.sourceId, sourceId),
181 ),
182 );
183
184 const deleted = await tx172 const deleted = await tx
185 .delete(tables.sourcesTable)173 .delete(tables.sourcesTable)
186 .where(174 .where(
packages/control-plane/src/persistence/repos/workspaces-repo.ts+4−0
@@ -85,6 +85,10 @@ export const createWorkspacesRepo = ( · 84 unmodified lines
85 .delete(tables.sourceCredentialBindingsTable)85 .delete(tables.sourceCredentialBindingsTable)
86 .where(eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId));86 .where(eq(tables.sourceCredentialBindingsTable.workspaceId, workspaceId));
87 87
88 await tx
89 .delete(tables.credentialsTable)
90 .where(eq(tables.credentialsTable.workspaceId, workspaceId));
91
88 await tx92 await tx
89 .delete(tables.sourcesTable)93 .delete(tables.sourcesTable)
90 .where(eq(tables.sourcesTable.workspaceId, workspaceId));94 .where(eq(tables.sourcesTable.workspaceId, workspaceId));
packages/control-plane/src/persistence/schema.ts+34−12
@@ -20,6 +20,7 @@ export const tableNames = { · 19 unmodified lines
20 toolArtifactParameters: "tool_artifact_parameters",20 toolArtifactParameters: "tool_artifact_parameters",
21 toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",21 toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types",
22 toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",22 toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys",
23 credentials: "credentials",
23 sourceCredentialBindings: "source_credential_bindings",24 sourceCredentialBindings: "source_credential_bindings",
24 secretMaterials: "secret_materials",25 secretMaterials: "secret_materials",
25 sourceAuthSessions: "source_auth_sessions",26 sourceAuthSessions: "source_auth_sessions",
@@ -131,9 +132,6 @@ export const sourcesTable = pgTable( · 105 unmodified lines
131 headersJson: text("headers_json"),132 headersJson: text("headers_json"),
132 specUrl: text("spec_url"),133 specUrl: text("spec_url"),
133 defaultHeadersJson: text("default_headers_json"),134 defaultHeadersJson: text("default_headers_json"),
134 authKind: text("auth_kind").notNull(),
135 authHeaderName: text("auth_header_name"),
136 authPrefix: text("auth_prefix"),
137 sourceHash: text("source_hash"),135 sourceHash: text("source_hash"),
138 sourceDocumentText: text("source_document_text"),136 sourceDocumentText: text("source_document_text"),
139 lastError: text("last_error"),137 lastError: text("last_error"),
@@ -157,9 +155,33 @@ export const sourcesTable = pgTable( · 17 unmodified lines
157 "sources_transport_check",155 "sources_transport_check",
158 sql`${table.transport} is null or ${table.transport} in ('auto', 'streamable-http', 'sse')`,156 sql`${table.transport} is null or ${table.transport} in ('auto', 'streamable-http', 'sse')`,
159 ),157 ),
158 ],
159);
160
161export const credentialsTable = pgTable(
162 tableNames.credentials,
163 {
164 id: text("id").notNull().primaryKey(),
165 workspaceId: text("workspace_id").notNull(),
166 authKind: text("auth_kind").notNull(),
167 authHeaderName: text("auth_header_name").notNull(),
168 authPrefix: text("auth_prefix").notNull(),
169 tokenProviderId: text("token_provider_id").notNull(),
170 tokenHandle: text("token_handle").notNull(),
171 refreshTokenProviderId: text("refresh_token_provider_id"),
172 refreshTokenHandle: text("refresh_token_handle"),
173 createdAt: bigint("created_at", { mode: "number" }).notNull(),
174 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
175 },
176 (table) => [
177 index("credentials_workspace_idx").on(
178 table.workspaceId,
179 table.updatedAt,
180 table.id,
181 ),
160 check(182 check(
161 "sources_auth_kind_check",183 "credentials_auth_kind_check",
162 sql`${table.authKind} in ('none', 'bearer', 'oauth2')`,184 sql`${table.authKind} in ('bearer', 'oauth2')`,
163 ),185 ),
164 ],186 ],
165);187);
@@ -167,19 +189,18 @@ export const sourcesTable = pgTable( · 1 unmodified line
167export const sourceCredentialBindingsTable = pgTable(189export const sourceCredentialBindingsTable = pgTable(
168 tableNames.sourceCredentialBindings,190 tableNames.sourceCredentialBindings,
169 {191 {
192 id: text("id").notNull().primaryKey(),
170 workspaceId: text("workspace_id").notNull(),193 workspaceId: text("workspace_id").notNull(),
171 sourceId: text("source_id").notNull(),194 sourceId: text("source_id").notNull(),
172 tokenProviderId: text("token_provider_id"),195 credentialId: text("credential_id").notNull(),
173 tokenHandle: text("token_handle"),
174 refreshTokenProviderId: text("refresh_token_provider_id"),
175 refreshTokenHandle: text("refresh_token_handle"),
176 createdAt: bigint("created_at", { mode: "number" }).notNull(),196 createdAt: bigint("created_at", { mode: "number" }).notNull(),
177 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),197 updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
178 },198 },
179 (table) => [199 (table) => [
180 primaryKey({200 uniqueIndex("source_credential_bindings_workspace_source_idx").on(
181 columns: [table.workspaceId, table.sourceId],201 table.workspaceId,
182 }),202 table.sourceId,
203 ),
183 index("source_credential_bindings_workspace_idx").on(204 index("source_credential_bindings_workspace_idx").on(
184 table.workspaceId,205 table.workspaceId,
185 table.updatedAt,206 table.updatedAt,
@@ -493,6 +514,7 @@ export const drizzleSchema = { · 307 unmodified lines
493 organizationMembershipsTable,514 organizationMembershipsTable,
494 workspacesTable,515 workspacesTable,
495 sourcesTable,516 sourcesTable,
517 credentialsTable,
496 toolArtifactsTable,518 toolArtifactsTable,
497 toolArtifactParametersTable,519 toolArtifactParametersTable,
498 toolArtifactRequestBodyContentTypesTable,520 toolArtifactRequestBodyContentTypesTable,
packages/control-plane/src/runtime/control-plane-runtime.test.ts+16−10
@@ -17,6 +17,7 @@ import { · 16 unmodified lines
17 createSqlControlPlaneRuntime,17 createSqlControlPlaneRuntime,
18 LiveExecutionManagerService,18 LiveExecutionManagerService,
19} from "./index";19} from "./index";
20import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
20import { withControlPlaneClient } from "./test-http-client";21import { withControlPlaneClient } from "./test-http-client";
21 22
22const makeRuntime = Effect.acquireRelease(23const makeRuntime = Effect.acquireRelease(
@@ -155,9 +156,6 @@ describe("control-plane-runtime", () => { · 132 unmodified lines
155 headersJson: null,156 headersJson: null,
156 specUrl: "https://example.com/github-openapi.yaml",157 specUrl: "https://example.com/github-openapi.yaml",
157 defaultHeadersJson: null,158 defaultHeadersJson: null,
158 authKind: "none",
159 authHeaderName: null,
160 authPrefix: null,
161 sourceHash: null,159 sourceHash: null,
162 sourceDocumentText: null,160 sourceDocumentText: null,
163 lastError: null,161 lastError: null,
@@ -242,10 +240,21 @@ describe("control-plane-runtime", () => { · 78 unmodified lines
242 const response = yield* Fiber.join(interactionFiber);240 const response = yield* Fiber.join(interactionFiber);
243 expect(response.action).toBe("accept");241 expect(response.action).toBe("accept");
244 expect(response.content?.authKind).toBe("bearer");242 expect(response.content?.authKind).toBe("bearer");
245 expect(typeof response.content?.tokenSecretMaterialId).toBe("string");243
244 const decodedCredentialSelection = decodeSourceCredentialSelectionContent(
245 response.content,
246 );
247 expect(decodedCredentialSelection.authKind).toBe("bearer");
248 expect(
249 decodedCredentialSelection.authKind === "bearer"
250 ? decodedCredentialSelection.tokenRef.providerId
251 : null,
252 ).toBe("postgres");
246 253
247 const tokenSecretMaterialId = SecretMaterialIdSchema.make(254 const tokenSecretMaterialId = SecretMaterialIdSchema.make(
248 String(response.content?.tokenSecretMaterialId),255 decodedCredentialSelection.authKind === "bearer"
256 ? decodedCredentialSelection.tokenRef.handle
257 : "",
249 );258 );
250 const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById(259 const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById(
251 tokenSecretMaterialId,260 tokenSecretMaterialId,
@@ -258,7 +267,7 @@ describe("control-plane-runtime", () => { · 6 unmodified lines
258 );267 );
259 assertTrue(Option.isSome(storedInteraction));268 assertTrue(Option.isSome(storedInteraction));
260 expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"bearer\"");269 expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"bearer\"");
261 expect(storedInteraction.value.responseJson).toContain("tokenSecretMaterialId");270 expect(storedInteraction.value.responseJson).not.toContain("tokenRef");
262 expect(storedInteraction.value.responseJson).not.toContain("ghp_local_test_token");271 expect(storedInteraction.value.responseJson).not.toContain("ghp_local_test_token");
263 }),272 }),
264 );273 );
@@ -304,9 +313,6 @@ describe("control-plane-runtime", () => { · 39 unmodified lines
304 headersJson: null,313 headersJson: null,
305 specUrl: "https://example.com/github-openapi.yaml",314 specUrl: "https://example.com/github-openapi.yaml",
306 defaultHeadersJson: null,315 defaultHeadersJson: null,
307 authKind: "none",
308 authHeaderName: null,
309 authPrefix: null,
310 sourceHash: null,316 sourceHash: null,
311 sourceDocumentText: null,317 sourceDocumentText: null,
312 lastError: null,318 lastError: null,
@@ -381,7 +387,7 @@ describe("control-plane-runtime", () => { · 68 unmodified lines
381 );387 );
382 assertTrue(Option.isSome(storedInteraction));388 assertTrue(Option.isSome(storedInteraction));
383 expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"none\"");389 expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"none\"");
384 expect(storedInteraction.value.responseJson).not.toContain("tokenSecretMaterialId");390 expect(storedInteraction.value.responseJson).not.toContain("tokenRef");
385 }),391 }),
386 );392 );
387 393
packages/control-plane/src/runtime/executor-tools.ts+8−17
@@ -26,6 +26,7 @@ import { · 25 unmodified lines
26 deriveSchemaJson,26 deriveSchemaJson,
27 deriveSchemaTypeSignature,27 deriveSchemaTypeSignature,
28} from "./schema-type-signature";28} from "./schema-type-signature";
29import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions";
29 30
30const ExecutorMcpSourceAddInputSchema = Schema.Struct({31const ExecutorMcpSourceAddInputSchema = Schema.Struct({
31 kind: Schema.optional(Schema.Literal("mcp")),32 kind: Schema.optional(Schema.Literal("mcp")),
@@ -192,29 +193,19 @@ const promptForSourceCredentialSelection = (input: { · 160 unmodified lines
192 );193 );
193 }194 }
194 195
195 const authKind =196 const content = yield* Effect.try({
196 response.content && typeof response.content.authKind === "string"197 try: () => decodeSourceCredentialSelectionContent(response.content),
197 ? response.content.authKind.trim()198 catch: () =>
198 : "";199 new Error("Credential capture did not return a valid source auth choice for executor.sources.add"),
200 });
199 201
200 if (authKind === "none") {202 if (content.authKind === "none") {
201 return { kind: "none" } satisfies ExecutorOpenApiSourceAuthInput;203 return { kind: "none" } satisfies ExecutorOpenApiSourceAuthInput;
202 }204 }
203 205
204 const tokenSecretMaterialId =
205 response.content && typeof response.content.tokenSecretMaterialId === "string"
206 ? response.content.tokenSecretMaterialId.trim()
207 : "";
208
209 if (authKind !== "bearer" || tokenSecretMaterialId.length === 0) {
210 return yield* Effect.fail(
211 new Error("Credential capture did not return a valid source auth choice for executor.sources.add"),
212 );
213 }
214
215 return {206 return {
216 kind: "bearer",207 kind: "bearer",
217 tokenSecretMaterialId,208 tokenRef: content.tokenRef,
218 } satisfies ExecutorOpenApiSourceAuthInput;209 } satisfies ExecutorOpenApiSourceAuthInput;
219 });210 });
220 211
packages/control-plane/src/runtime/index.ts+3−1
@@ -37,8 +37,8 @@ import { · 36 unmodified lines
37import {37import {
38 RuntimeSourceAuthServiceLive,38 RuntimeSourceAuthServiceLive,
39 RuntimeSourceAuthServiceTag,39 RuntimeSourceAuthServiceTag,
40 type ResolveSecretMaterial,
41} from "./source-auth-service";40} from "./source-auth-service";
41import type { ResolveSecretMaterial } from "./secret-material-providers";
42import {42import {
43 RuntimeExecutionResolverLive,43 RuntimeExecutionResolverLive,
44 RuntimeExecutionResolverService,44 RuntimeExecutionResolverService,
@@ -55,6 +55,8 @@ export * from "./live-execution"; · 10 unmodified lines
55export * from "./local-installation";55export * from "./local-installation";
56export * from "./schema-type-signature";56export * from "./schema-type-signature";
57export * from "./source-auth-service";57export * from "./source-auth-service";
58export * from "./secret-material-providers";
59export * from "./source-credential-interactions";
58export * from "./store";60export * from "./store";
59export * from "./workspace-execution-environment";61export * from "./workspace-execution-environment";
60export * from "./source-inspection";62export * from "./source-inspection";
packages/control-plane/src/runtime/live-execution.test.ts+47−0
@@ -0,0 +1,47 @@
1import { describe, expect, it } from "vitest";
2
3import { sanitizePersistedElicitationResponse } from "./live-execution";
4
5describe("live-execution", () => {
6 it("redacts secret refs from persisted interaction responses", () => {
7 const sanitized = sanitizePersistedElicitationResponse({
8 action: "accept",
9 content: {
10 authKind: "bearer",
11 tokenRef: {
12 providerId: "postgres",
13 handle: "sec_123",
14 },
15 },
16 });
17
18 expect(sanitized).toEqual({
19 action: "accept",
20 content: {
21 authKind: "bearer",
22 },
23 });
24 });
25
26 it("keeps unrelated interaction response content intact", () => {
27 const sanitized = sanitizePersistedElicitationResponse({
28 action: "cancel",
29 content: {
30 reason: "User declined",
31 nested: {
32 keep: true,
33 },
34 },
35 });
36
37 expect(sanitized).toEqual({
38 action: "cancel",
39 content: {
40 reason: "User declined",
41 nested: {
42 keep: true,
43 },
44 },
45 });
46 });
47});
packages/control-plane/src/runtime/live-execution.ts+37−1
@@ -58,6 +58,42 @@ const serializeJson = (value: unknown): string | null => { · 57 unmodified lines
58 return JSON.stringify(value);58 return JSON.stringify(value);
59};59};
60 60
61const SENSITIVE_INTERACTION_CONTENT_KEYS = new Set([
62 "tokenRef",
63 "tokenSecretMaterialId",
64]);
65
66const redactSensitiveInteractionContent = (value: unknown): unknown => {
67 if (Array.isArray(value)) {
68 return value.map(redactSensitiveInteractionContent);
69 }
70
71 if (!value || typeof value !== "object") {
72 return value;
73 }
74
75 const entries = Object.entries(value as Record<string, unknown>)
76 .filter(([key]) => !SENSITIVE_INTERACTION_CONTENT_KEYS.has(key))
77 .map(([key, entry]) => [key, redactSensitiveInteractionContent(entry)]);
78
79 return Object.fromEntries(entries);
80};
81
82export const sanitizePersistedElicitationResponse = (
83 response: ElicitationResponse,
84): ElicitationResponse => {
85 if (response.content === undefined) {
86 return response;
87 }
88
89 const redactedContent = redactSensitiveInteractionContent(response.content);
90 return {
91 ...response,
92 content: redactedContent as Record<string, unknown>,
93 };
94};
95
96
61export const createLiveExecutionManager = () => {97export const createLiveExecutionManager = () => {
62 const runs = new Map<Execution["id"], LiveRunEntry>();98 const runs = new Map<Execution["id"], LiveRunEntry>();
63 99
@@ -141,7 +177,7 @@ export const createLiveExecutionManager = () => { · 77 unmodified lines
141 177
142 yield* rows.executionInteractions.update(interaction.id, {178 yield* rows.executionInteractions.update(interaction.id, {
143 status: resolved.action === "cancel" ? "cancelled" : "resolved",179 status: resolved.action === "cancel" ? "cancelled" : "resolved",
144 responseJson: serializeJson(resolved),180 responseJson: serializeJson(sanitizePersistedElicitationResponse(resolved)),
145 updatedAt: resolvedAt,181 updatedAt: resolvedAt,
146 });182 });
147 yield* rows.executions.update(executionId, {183 yield* rows.executions.update(executionId, {
packages/control-plane/src/runtime/local-operations.ts+7−8
@@ -18,6 +18,10 @@ import { · 17 unmodified lines
18 operationErrors,18 operationErrors,
19} from "./operation-errors";19} from "./operation-errors";
20import { RuntimeSourceAuthServiceTag } from "./source-auth-service";20import { RuntimeSourceAuthServiceTag } from "./source-auth-service";
21import {
22 createSourceCredentialSelectionBearerContent,
23 createSourceCredentialSelectionNoneContent,
24} from "./source-credential-interactions";
21import { ControlPlaneStore } from "./store";25import { ControlPlaneStore } from "./store";
22 26
23const localOps = {27const localOps = {
@@ -253,9 +257,7 @@ export const submitSourceCredentialInteraction = (input: { · 229 unmodified lines
253 executionId: interaction.executionId,257 executionId: interaction.executionId,
254 response: {258 response: {
255 action: "accept",259 action: "accept",
256 content: {260 content: createSourceCredentialSelectionNoneContent(),
257 authKind: "none",
258 },
259 },261 },
260 });262 });
261 263
@@ -286,7 +288,7 @@ export const submitSourceCredentialInteraction = (input: { · 24 unmodified lines
286 }288 }
287 289
288 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;290 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
289 const tokenSecretMaterialId = yield* sourceAuthService.storeSecretMaterial({291 const tokenRef = yield* sourceAuthService.storeSecretMaterial({
290 purpose: "auth_material",292 purpose: "auth_material",
291 value: token,293 value: token,
292 }).pipe(294 }).pipe(
@@ -303,10 +305,7 @@ export const submitSourceCredentialInteraction = (input: { · 10 unmodified lines
303 executionId: interaction.executionId,305 executionId: interaction.executionId,
304 response: {306 response: {
305 action: "accept",307 action: "accept",
306 content: {308 content: createSourceCredentialSelectionBearerContent(tokenRef),
307 authKind: "bearer",
308 tokenSecretMaterialId,
309 },
310 },309 },
311 });310 });
312 311
packages/control-plane/src/runtime/secret-material-providers.ts+471−0
@@ -0,0 +1,471 @@
1import { randomUUID } from "node:crypto";
2import { spawn } from "node:child_process";
3
4import type { SqlControlPlaneRows } from "#persistence";
5import {
6 type SecretMaterialPurpose,
7 SecretMaterialIdSchema,
8 type SecretRef,
9} from "#schema";
10import * as Effect from "effect/Effect";
11import * as Option from "effect/Option";
12
13export const ENV_SECRET_PROVIDER_ID = "env";
14export const PARAMS_SECRET_PROVIDER_ID = "params";
15export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain";
16export const POSTGRES_SECRET_PROVIDER_ID = "postgres";
17
18export type SecretStoreProviderId =
19 | typeof KEYCHAIN_SECRET_PROVIDER_ID
20 | typeof POSTGRES_SECRET_PROVIDER_ID;
21
22export type SecretMaterialResolveContext = {
23 params?: Readonly<Record<string, string | undefined>>;
24};
25
26export type ResolveSecretMaterial = (input: {
27 ref: SecretRef;
28 context?: SecretMaterialResolveContext;
29}) => Effect.Effect<string, Error, never>;
30
31export type StoreSecretMaterial = (input: {
32 purpose: SecretMaterialPurpose;
33 value: string;
34}) => Effect.Effect<SecretRef, Error, never>;
35
36type SecretMaterialProviderRuntime = {
37 rows: SqlControlPlaneRows;
38 env: NodeJS.ProcessEnv;
39 dangerouslyAllowEnvSecrets: boolean;
40 keychainServiceName: string;
41};
42
43type SecretMaterialProvider = {
44 resolve: (input: {
45 ref: SecretRef;
46 context: SecretMaterialResolveContext;
47 runtime: SecretMaterialProviderRuntime;
48 }) => Effect.Effect<string, Error, never>;
49 store?: (input: {
50 purpose: SecretMaterialPurpose;
51 value: string;
52 runtime: SecretMaterialProviderRuntime;
53 }) => Effect.Effect<SecretRef, Error, never>;
54};
55
56type SecretMaterialProviderRegistry = ReadonlyMap<string, SecretMaterialProvider>;
57
58type SpawnResult = {
59 exitCode: number;
60 stdout: string;
61 stderr: string;
62};
63
64const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor-v3";
65const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS";
66const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER";
67const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME";
68
69const trimOrNull = (value: string | null | undefined): string | null => {
70 if (value === null || value === undefined) {
71 return null;
72 }
73
74 const trimmed = value.trim();
75 return trimmed.length > 0 ? trimmed : null;
76};
77
78const parseBooleanEnv = (value: string | undefined): boolean => {
79 const normalized = trimOrNull(value)?.toLowerCase();
80 return normalized === "1" || normalized === "true" || normalized === "yes";
81};
82
83export const parseSecretStoreProviderId = (value: string | undefined): SecretStoreProviderId | null => {
84 const normalized = trimOrNull(value)?.toLowerCase();
85 if (normalized === KEYCHAIN_SECRET_PROVIDER_ID) {
86 return KEYCHAIN_SECRET_PROVIDER_ID;
87 }
88
89 if (normalized === POSTGRES_SECRET_PROVIDER_ID) {
90 return POSTGRES_SECRET_PROVIDER_ID;
91 }
92
93 return null;
94};
95
96const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean =>
97 value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]);
98
99const resolveSecretStoreProviderId = (value: SecretStoreProviderId | undefined): SecretStoreProviderId =>
100 value
101 ?? parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV])
102 ?? POSTGRES_SECRET_PROVIDER_ID;
103
104const resolveKeychainServiceName = (value: string | undefined): string =>
105 trimOrNull(value)
106 ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV])
107 ?? DEFAULT_KEYCHAIN_SERVICE_NAME;
108
109const ensureNonEmptyString = (value: string | undefined): string | null => {
110 const trimmed = value?.trim();
111 return trimmed && trimmed.length > 0 ? trimmed : null;
112};
113
114const runCommand = (input: {
115 command: string;
116 args: ReadonlyArray<string>;
117 stdin?: string;
118 operation: string;
119}): Effect.Effect<SpawnResult, Error, never> =>
120 Effect.tryPromise({
121 try: () =>
122 new Promise<SpawnResult>((resolve, reject) => {
123 const child = spawn(input.command, [...input.args], {
124 stdio: "pipe",
125 });
126
127 let stdout = "";
128 let stderr = "";
129
130 child.stdout.on("data", (chunk) => {
131 stdout += chunk.toString("utf8");
132 });
133
134 child.stderr.on("data", (chunk) => {
135 stderr += chunk.toString("utf8");
136 });
137
138 child.on("error", (error) => {
139 reject(new Error(`${input.operation}: failed spawning '${input.command}': ${error.message}`));
140 });
141
142 child.on("close", (code) => {
143 resolve({
144 exitCode: code ?? 0,
145 stdout,
146 stderr,
147 });
148 });
149
150 if (input.stdin !== undefined) {
151 child.stdin.write(input.stdin);
152 }
153
154 child.stdin.end();
155 }),
156 catch: (cause) =>
157 cause instanceof Error
158 ? cause
159 : new Error(`${input.operation}: command execution failed: ${String(cause)}`),
160 });
161
162const ensureCommandSuccess = (input: {
163 result: SpawnResult;
164 operation: string;
165 message: string;
166}): Effect.Effect<SpawnResult, Error, never> => {
167 if (input.result.exitCode === 0) {
168 return Effect.succeed(input.result);
169 }
170
171 const details = ensureNonEmptyString(input.result.stderr)
172 ?? ensureNonEmptyString(input.result.stdout)
173 ?? "command returned non-zero exit code";
174
175 return Effect.fail(new Error(`${input.operation}: ${input.message}: ${details}`));
176};
177
178const parseKeychainHandle = (handle: string): string | null => {
179 if (!handle.startsWith(`${KEYCHAIN_SECRET_PROVIDER_ID}:`)) {
180 return null;
181 }
182
183 return trimOrNull(handle.slice(`${KEYCHAIN_SECRET_PROVIDER_ID}:`.length));
184};
185
186const createParamsSecretMaterialProvider = (): SecretMaterialProvider => ({
187 resolve: ({ ref, context }) => {
188 const value = ensureNonEmptyString(context.params?.[ref.handle]);
189 if (value === null) {
190 return Effect.fail(new Error(`Secret parameter ${ref.handle} is not set`));
191 }
192
193 return Effect.succeed(value);
194 },
195});
196
197const createEnvSecretMaterialProvider = (): SecretMaterialProvider => ({
198 resolve: ({ ref, runtime }) => {
199 if (!runtime.dangerouslyAllowEnvSecrets) {
200 return Effect.fail(
201 new Error(
202 `Env-backed secrets are disabled. Set ${DANGEROUSLY_ALLOW_ENV_SECRETS_ENV}=true to allow provider '${ENV_SECRET_PROVIDER_ID}'.`,
203 ),
204 );
205 }
206
207 const value = ensureNonEmptyString(runtime.env[ref.handle]);
208 if (value === null) {
209 return Effect.fail(new Error(`Environment variable ${ref.handle} is not set`));
210 }
211
212 return Effect.succeed(value);
213 },
214});
215
216const createPostgresSecretMaterialProvider = (): SecretMaterialProvider => ({
217 resolve: ({ ref, runtime }) =>
218 Effect.gen(function* () {
219 const materialId = SecretMaterialIdSchema.make(ref.handle);
220 const stored = yield* runtime.rows.secretMaterials.getById(materialId);
221 if (Option.isNone(stored)) {
222 return yield* Effect.fail(new Error(`Secret material not found: ${ref.handle}`));
223 }
224
225 return stored.value.value;
226 }),
227
228 store: ({ purpose, value, runtime }) =>
229 Effect.gen(function* () {
230 const now = Date.now();
231 const id = SecretMaterialIdSchema.make(`sec_${randomUUID()}`);
232 yield* runtime.rows.secretMaterials.upsert({
233 id,
234 purpose,
235 value,
236 createdAt: now,
237 updatedAt: now,
238 });
239
240 return {
241 providerId: POSTGRES_SECRET_PROVIDER_ID,
242 handle: id,
243 } satisfies SecretRef;
244 }),
245});
246
247const keychainStoreWithSecurityCli = (): SecretMaterialProvider => ({
248 resolve: ({ ref, runtime }) => {
249 const id = parseKeychainHandle(ref.handle);
250 if (id === null) {
251 return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
252 }
253
254 return runCommand({
255 command: "security",
256 args: [
257 "find-generic-password",
258 "-a",
259 id,
260 "-s",
261 runtime.keychainServiceName,
262 "-w",
263 ],
264 operation: "keychain.get",
265 }).pipe(
266 Effect.flatMap((result) =>
267 ensureCommandSuccess({
268 result,
269 operation: "keychain.get",
270 message: "Failed loading secret from macOS keychain",
271 }),
272 ),
273 Effect.map((result) => result.stdout.trimEnd()),
274 );
275 },
276
277 store: ({ value, runtime }) => {
278 const id = randomUUID();
279
280 return runCommand({
281 command: "security",
282 args: [
283 "add-generic-password",
284 "-a",
285 id,
286 "-s",
287 runtime.keychainServiceName,
288 "-w",
289 value,
290 "-U",
291 ],
292 operation: "keychain.put",
293 }).pipe(
294 Effect.flatMap((result) =>
295 ensureCommandSuccess({
296 result,
297 operation: "keychain.put",
298 message: "Failed storing secret in macOS keychain",
299 }),
300 ),
301 Effect.as({
302 providerId: KEYCHAIN_SECRET_PROVIDER_ID,
303 handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
304 } satisfies SecretRef),
305 );
306 },
307});
308
309const keychainStoreWithSecretTool = (): SecretMaterialProvider => ({
310 resolve: ({ ref, runtime }) => {
311 const id = parseKeychainHandle(ref.handle);
312 if (id === null) {
313 return Effect.fail(new Error(`Invalid keychain secret handle: ${ref.handle}`));
314 }
315
316 return runCommand({
317 command: "secret-tool",
318 args: [
319 "lookup",
320 "service",
321 runtime.keychainServiceName,
322 "account",
323 id,
324 ],
325 operation: "keychain.get",
326 }).pipe(
327 Effect.flatMap((result) =>
328 ensureCommandSuccess({
329 result,
330 operation: "keychain.get",
331 message: "Failed loading secret from desktop keyring",
332 }),
333 ),
334 Effect.map((result) => result.stdout.trimEnd()),
335 );
336 },
337
338 store: ({ value, runtime }) => {
339 const id = randomUUID();
340
341 return runCommand({
342 command: "secret-tool",
343 args: [
344 "store",
345 "--label",
346 runtime.keychainServiceName,
347 "service",
348 runtime.keychainServiceName,
349 "account",
350 id,
351 ],
352 stdin: value,
353 operation: "keychain.put",
354 }).pipe(
355 Effect.flatMap((result) =>
356 ensureCommandSuccess({
357 result,
358 operation: "keychain.put",
359 message: "Failed storing secret in desktop keyring",
360 }),
361 ),
362 Effect.as({
363 providerId: KEYCHAIN_SECRET_PROVIDER_ID,
364 handle: `${KEYCHAIN_SECRET_PROVIDER_ID}:${id}`,
365 } satisfies SecretRef),
366 );
367 },
368});
369
370const createKeychainSecretMaterialProvider = (): SecretMaterialProvider => {
371 if (process.platform === "darwin") {
372 return keychainStoreWithSecurityCli();
373 }
374
375 if (process.platform === "linux") {
376 return keychainStoreWithSecretTool();
377 }
378
379 const unsupported = (operation: string) =>
380 Effect.fail(new Error(`${operation}: keychain provider is unsupported on platform '${process.platform}'`));
381
382 return {
383 resolve: () => unsupported("keychain.get"),
384 store: () => unsupported("keychain.put"),
385 } satisfies SecretMaterialProvider;
386};
387
388const createSecretMaterialProviderRegistry = (): SecretMaterialProviderRegistry =>
389 new Map([
390 [PARAMS_SECRET_PROVIDER_ID, createParamsSecretMaterialProvider()],
391 [ENV_SECRET_PROVIDER_ID, createEnvSecretMaterialProvider()],
392 [KEYCHAIN_SECRET_PROVIDER_ID, createKeychainSecretMaterialProvider()],
393 [POSTGRES_SECRET_PROVIDER_ID, createPostgresSecretMaterialProvider()],
394 ]);
395
396const getSecretMaterialProvider = (input: {
397 providers: SecretMaterialProviderRegistry;
398 providerId: string;
399}): Effect.Effect<SecretMaterialProvider, Error, never> => {
400 const provider = input.providers.get(input.providerId);
401 if (provider) {
402 return Effect.succeed(provider);
403 }
404
405 return Effect.fail(new Error(`Unsupported secret provider: ${input.providerId}`));
406};
407
408const createSecretMaterialProviderRuntime = (input: {
409 rows: SqlControlPlaneRows;
410 dangerouslyAllowEnvSecrets?: boolean;
411 keychainServiceName?: string;
412}): SecretMaterialProviderRuntime => ({
413 rows: input.rows,
414 env: process.env,
415 dangerouslyAllowEnvSecrets: resolveDangerouslyAllowEnvSecrets(input.dangerouslyAllowEnvSecrets),
416 keychainServiceName: resolveKeychainServiceName(input.keychainServiceName),
417});
418
419export const createDefaultSecretMaterialResolver = (input: {
420 rows: SqlControlPlaneRows;
421 dangerouslyAllowEnvSecrets?: boolean;
422 keychainServiceName?: string;
423}): ResolveSecretMaterial => {
424 const providers = createSecretMaterialProviderRegistry();
425 const runtime = createSecretMaterialProviderRuntime(input);
426
427 return ({ ref, context }) =>
428 Effect.gen(function* () {
429 const provider = yield* getSecretMaterialProvider({
430 providers,
431 providerId: ref.providerId,
432 });
433
434 return yield* provider.resolve({
435 ref,
436 context: context ?? {},
437 runtime,
438 });
439 });
440};
441
442export const createDefaultSecretMaterialStorer = (input: {
443 rows: SqlControlPlaneRows;
444 storeProviderId?: SecretStoreProviderId;
445 dangerouslyAllowEnvSecrets?: boolean;
446 keychainServiceName?: string;
447}): StoreSecretMaterial => {
448 const providers = createSecretMaterialProviderRegistry();
449 const runtime = createSecretMaterialProviderRuntime(input);
450 const defaultStoreProviderId = resolveSecretStoreProviderId(input.storeProviderId);
451
452 return ({ purpose, value }) =>
453 Effect.gen(function* () {
454 const provider = yield* getSecretMaterialProvider({
455 providers,
456 providerId: defaultStoreProviderId,
457 });
458
459 if (!provider.store) {
460 return yield* Effect.fail(
461 new Error(`Secret provider ${defaultStoreProviderId} does not support storing secret material`),
462 );
463 }
464
465 return yield* provider.store({
466 purpose,
467 value,
468 runtime,
469 });
470 });
471};
packages/control-plane/src/runtime/source-auth-service.test.ts+57−0
@@ -0,0 +1,57 @@
1import { describe, expect, it } from "vitest";
2
3import { createTerminalSourceAuthSessionPatch } from "./source-auth-service";
4
5describe("source-auth-service", () => {
6 it("clears ephemeral OAuth session fields when failing a session", () => {
7 const patch = createTerminalSourceAuthSessionPatch({
8 status: "failed",
9 now: 123,
10 errorText: "OAuth authorization failed",
11 resourceMetadataUrl: "https://example.com/resource",
12 authorizationServerUrl: "https://example.com/as",
13 resourceMetadataJson: '{"issuer":"https://example.com"}',
14 authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
15 });
16
17 expect(patch).toMatchObject({
18 status: "failed",
19 errorText: "OAuth authorization failed",
20 completedAt: 123,
21 updatedAt: 123,
22 codeVerifier: null,
23 authorizationUrl: null,
24 clientInformationJson: null,
25 resourceMetadataUrl: "https://example.com/resource",
26 authorizationServerUrl: "https://example.com/as",
27 resourceMetadataJson: '{"issuer":"https://example.com"}',
28 authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
29 });
30 });
31
32 it("clears ephemeral OAuth session fields when completing a session", () => {
33 const patch = createTerminalSourceAuthSessionPatch({
34 status: "completed",
35 now: 456,
36 errorText: null,
37 resourceMetadataUrl: "https://example.com/resource",
38 authorizationServerUrl: "https://example.com/as",
39 resourceMetadataJson: '{"issuer":"https://example.com"}',
40 authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
41 });
42
43 expect(patch).toMatchObject({
44 status: "completed",
45 errorText: null,
46 completedAt: 456,
47 updatedAt: 456,
48 codeVerifier: null,
49 authorizationUrl: null,
50 clientInformationJson: null,
51 resourceMetadataUrl: "https://example.com/resource",
52 authorizationServerUrl: "https://example.com/as",
53 resourceMetadataJson: '{"issuer":"https://example.com"}',
54 authorizationServerMetadataJson: '{"token_endpoint":"https://example.com/token"}',
55 });
56 });
57});
packages/control-plane/src/runtime/source-auth-service.ts+100−191
@@ -18,8 +18,7 @@ import { · 17 unmodified lines
18} from "#persistence";18} from "#persistence";
19import {19import {
20 ExecutionIdSchema,20 ExecutionIdSchema,
21 type SecretMaterialId,21 type SecretMaterialPurpose,
22 SecretMaterialIdSchema,
23 Source,22 Source,
24 SourceAuthSession,23 SourceAuthSession,
25 SourceAuthSessionIdSchema,24 SourceAuthSessionIdSchema,
@@ -41,18 +40,23 @@ import { · 15 unmodified lines
41} from "./live-execution";40} from "./live-execution";
42import {41import {
43 createSourceFromPayload,42 createSourceFromPayload,
44 projectSourceFromStorage,
45 projectSourcesFromStorage,
46 splitSourceForStorage,
47 updateSourceFromPayload,43 updateSourceFromPayload,
48} from "./source-definitions";44} from "./source-definitions";
49import {45import {
50 createEnvSecretMaterialResolver,46 createDefaultSecretMaterialResolver,
47 createDefaultSecretMaterialStorer,
48 type ResolveSecretMaterial,
49 type StoreSecretMaterial,
50} from "./secret-material-providers";
51import {
51 persistMcpToolArtifactsFromManifest,52 persistMcpToolArtifactsFromManifest,
52 syncSourceToolArtifacts,53 syncSourceToolArtifacts,
53} from "./tool-artifacts";54} from "./tool-artifacts";
54 55import {
55export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane";56 loadSourceById,
57 loadSourcesInWorkspace,
58 persistSource,
59} from "./source-store";
56 60
57const trimOrNull = (value: string | null | undefined): string | null => {61const trimOrNull = (value: string | null | undefined): string | null => {
58 if (value === null || value === undefined) {62 if (value === null || value === undefined) {
@@ -119,67 +123,7 @@ const normalizeEndpoint = (endpoint: string): string => { · 60 unmodified lines
119const toError = (cause: unknown): Error =>123const toError = (cause: unknown): Error =>
120 cause instanceof Error ? cause : new Error(String(cause));124 cause instanceof Error ? cause : new Error(String(cause));
121 125
122const loadSourcesInWorkspace = (rows: SqlControlPlaneRows, workspaceId: WorkspaceId) =>
123 Effect.gen(function* () {
124 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
125 const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId);
126
127 return yield* projectSourcesFromStorage({
128 sourceRecords,
129 credentialBindings,
130 });
131 });
132
133const loadSourceById = (rows: SqlControlPlaneRows, input: {
134 workspaceId: WorkspaceId;
135 sourceId: Source["id"];
136}) =>
137 Effect.gen(function* () {
138 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
139 input.workspaceId,
140 input.sourceId,
141 );
142
143 if (Option.isNone(sourceRecord)) {
144 return yield* Effect.fail(
145 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
146 );
147 }
148
149 const credentialBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
150 input.workspaceId,
151 input.sourceId,
152 );
153
154 return yield* projectSourceFromStorage({
155 sourceRecord: sourceRecord.value,
156 credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
157 });
158 });
159
160const persistSource = (rows: SqlControlPlaneRows, source: Source) =>
161 Effect.gen(function* () {
162 const { sourceRecord, credentialBinding } = splitSourceForStorage({ source });
163 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
164
165 if (Option.isNone(existing)) {
166 yield* rows.sources.insert(sourceRecord);
167 } else {
168 const { id: _id, workspaceId: _workspaceId, createdAt: _createdAt, sourceDocumentText: _sourceDocumentText, ...patch } = sourceRecord;
169 yield* rows.sources.update(source.workspaceId, source.id, patch);
170 }
171
172 if (credentialBinding === null) {
173 yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
174 source.workspaceId,
175 source.id,
176 );
177 } else {
178 yield* rows.sourceCredentialBindings.upsert(credentialBinding);
179 }
180 126
181 return source;
182 });
183 127
184const probeMcpSourceWithoutAuth = (128const probeMcpSourceWithoutAuth = (
185 source: Source,129 source: Source,
@@ -303,7 +247,6 @@ const startOAuthAuthorization = (input: { · 117 unmodified lines
303 247
304type ExchangedTokens = {248type ExchangedTokens = {
305 tokens: OAuthTokens;249 tokens: OAuthTokens;
306 clientInformationJson: string | null;
307 resourceMetadataUrl: string | null;250 resourceMetadataUrl: string | null;
308 authorizationServerUrl: string | null;251 authorizationServerUrl: string | null;
309 resourceMetadataJson: string | null;252 resourceMetadataJson: string | null;
@@ -385,7 +328,6 @@ const exchangeOAuthAuthorizationCode = (input: { · 75 unmodified lines
385 328
386 return {329 return {
387 tokens: captured.tokens,330 tokens: captured.tokens,
388 clientInformationJson: serializeJson(captured.clientInformation),
389 resourceMetadataUrl: captured.discoveryState?.resourceMetadataUrl ?? null,331 resourceMetadataUrl: captured.discoveryState?.resourceMetadataUrl ?? null,
390 authorizationServerUrl: captured.discoveryState?.authorizationServerUrl ?? null,332 authorizationServerUrl: captured.discoveryState?.authorizationServerUrl ?? null,
391 resourceMetadataJson: serializeJson(captured.discoveryState?.resourceMetadata),333 resourceMetadataJson: serializeJson(captured.discoveryState?.resourceMetadata),
@@ -395,6 +337,29 @@ const exchangeOAuthAuthorizationCode = (input: { · 3 unmodified lines
395 } satisfies ExchangedTokens;337 } satisfies ExchangedTokens;
396 });338 });
397 339
340export const createTerminalSourceAuthSessionPatch = (input: {
341 status: Extract<SourceAuthSession["status"], "completed" | "failed" | "cancelled">;
342 now: number;
343 errorText: string | null;
344 resourceMetadataUrl?: string | null;
345 authorizationServerUrl?: string | null;
346 resourceMetadataJson?: string | null;
347 authorizationServerMetadataJson?: string | null;
348}) => ({
349 status: input.status,
350 errorText: input.errorText,
351 completedAt: input.now,
352 updatedAt: input.now,
353 codeVerifier: null,
354 authorizationUrl: null,
355 clientInformationJson: null,
356 resourceMetadataUrl: input.resourceMetadataUrl ?? null,
357 authorizationServerUrl: input.authorizationServerUrl ?? null,
358 resourceMetadataJson: input.resourceMetadataJson ?? null,
359 authorizationServerMetadataJson: input.authorizationServerMetadataJson ?? null,
360}) satisfies Partial<SourceAuthSession>;
361
362
398const completeLiveInteraction = (input: {363const completeLiveInteraction = (input: {
399 liveExecutionManager: LiveExecutionManager;364 liveExecutionManager: LiveExecutionManager;
400 session: SourceAuthSession;365 session: SourceAuthSession;
@@ -446,24 +411,6 @@ const updateSourceStatus = (rows: SqlControlPlaneRows, source: Source, input: { · 45 unmodified lines
446 });411 });
447 });412 });
448 413
449const upsertSecretMaterial = (rows: SqlControlPlaneRows, input: {
450 purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token";
451 value: string;
452}) =>
453 Effect.gen(function* () {
454 const now = Date.now();
455 const materialId = SecretMaterialIdSchema.make(`sec_${crypto.randomUUID()}`);
456 yield* rows.secretMaterials.upsert({
457 id: materialId,
458 purpose: input.purpose,
459 value: input.value,
460 createdAt: now,
461 updatedAt: now,
462 });
463
464 return materialId;
465 });
466
467export type ExecutorSourceAddResult =414export type ExecutorSourceAddResult =
468 | {415 | {
469 kind: "connected";416 kind: "connected";
@@ -489,8 +436,7 @@ export type ExecutorOpenApiSourceAuthInput = · 19 unmodified lines
489 headerName?: string | null;436 headerName?: string | null;
490 prefix?: string | null;437 prefix?: string | null;
491 token?: string | null;438 token?: string | null;
492 tokenEnvVar?: string | null;439 tokenRef?: SecretRef | null;
493 tokenSecretMaterialId?: string | null;
494 };440 };
495 441
496export type ExecutorAddSourceInput =442export type ExecutorAddSourceInput =
@@ -527,9 +473,9 @@ const shouldPromptForOpenApiCredentialSetup = (input: { · 30 unmodified lines
527};473};
528 474
529const materializeExecutorOpenApiAuth = (input: {475const materializeExecutorOpenApiAuth = (input: {
530 rows: SqlControlPlaneRows;
531 existing?: Source;476 existing?: Source;
532 auth?: ExecutorOpenApiSourceAuthInput | null;477 auth?: ExecutorOpenApiSourceAuthInput | null;
478 storeSecretMaterial: StoreSecretMaterial;
533}): Effect.Effect<Source["auth"], Error, never> =>479}): Effect.Effect<Source["auth"], Error, never> =>
534 Effect.gen(function* () {480 Effect.gen(function* () {
535 if (input.auth === undefined && input.existing?.kind === "openapi") {481 if (input.auth === undefined && input.existing?.kind === "openapi") {
@@ -544,42 +490,46 @@ const materializeExecutorOpenApiAuth = (input: { · 8 unmodified lines
544 const headerName = trimOrNull(auth.headerName) ?? "Authorization";490 const headerName = trimOrNull(auth.headerName) ?? "Authorization";
545 const prefix = auth.prefix ?? "Bearer ";491 const prefix = auth.prefix ?? "Bearer ";
546 const token = trimOrNull(auth.token);492 const token = trimOrNull(auth.token);
547 const tokenEnvVar = trimOrNull(auth.tokenEnvVar);493 const tokenRefInput = auth.tokenRef ?? null;
548 const tokenSecretMaterialId = trimOrNull(auth.tokenSecretMaterialId);
549 494
550 if (495 if (
551 token === null496 token === null
552 && tokenEnvVar === null497 && tokenRefInput === null
553 && tokenSecretMaterialId === null
554 && input.existing?.kind === "openapi"498 && input.existing?.kind === "openapi"
555 && input.existing.auth.kind === "bearer"499 && input.existing.auth.kind === "bearer"
556 ) {500 ) {
557 return input.existing.auth;501 return input.existing.auth;
558 }502 }
559 503
560 if (token === null && tokenEnvVar === null && tokenSecretMaterialId === null) {504 if (token === null && tokenRefInput === null) {
561 return yield* Effect.fail(505 return yield* Effect.fail(
562 new Error("Bearer auth requires token, tokenEnvVar, or tokenSecretMaterialId"),506 new Error("Bearer auth requires token or tokenRef"),
563 );507 );
564 }508 }
565 509
566 const tokenRef: SecretRef = token !== null510 const tokenRef = token !== null
567 ? {511 ? yield* input.storeSecretMaterial({
568 providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,512 purpose: "auth_material",
569 handle: yield* upsertSecretMaterial(input.rows, {513 value: token,
570 purpose: "auth_material",514 })
571 value: token,515 : (() => {
572 }),516 const providerId = trimOrNull(tokenRefInput?.providerId);
573 }517 const handle = trimOrNull(tokenRefInput?.handle);
574 : tokenSecretMaterialId !== null518 if (providerId === null || handle === null) {
575 ? {519 return null;
576 providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,
577 handle: tokenSecretMaterialId,
578 }520 }
579 : {521
580 providerId: "env",522 return {
581 handle: tokenEnvVar!,523 providerId,
582 };524 handle,
525 } satisfies SecretRef;
526 })();
527
528 if (tokenRef === null) {
529 return yield* Effect.fail(
530 new Error("Bearer auth tokenRef must include providerId and handle"),
531 );
532 }
583 533
584 return {534 return {
585 kind: "bearer",535 kind: "bearer",
@@ -596,9 +546,9 @@ type RuntimeSourceAuthServiceShape = { · 10 unmodified lines
596 }) => Effect.Effect<Source, Error, never>;546 }) => Effect.Effect<Source, Error, never>;
597 getLocalServerBaseUrl: () => string | null;547 getLocalServerBaseUrl: () => string | null;
598 storeSecretMaterial: (input: {548 storeSecretMaterial: (input: {
599 purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token";549 purpose: SecretMaterialPurpose;
600 value: string;550 value: string;
601 }) => Effect.Effect<SecretMaterialId, Error, never>;551 }) => Effect.Effect<SecretRef, Error, never>;
602 addExecutorSource: (552 addExecutorSource: (
603 input: ExecutorAddSourceInput,553 input: ExecutorAddSourceInput,
604 options?: {554 options?: {
@@ -615,50 +565,23 @@ type RuntimeSourceAuthServiceShape = { · 10 unmodified lines
615 }) => Effect.Effect<Source, Error, never>;565 }) => Effect.Effect<Source, Error, never>;
616};566};
617 567
618export type ResolveSecretMaterial = (ref: SecretRef) => Effect.Effect<string, Error, never>;
619
620export const createDbBackedSecretMaterialResolver = (input: {
621 rows: SqlControlPlaneRows;
622 fallback?: ResolveSecretMaterial;
623}): ResolveSecretMaterial =>
624 (ref) =>
625 Effect.gen(function* () {
626 if (ref.providerId === CONTROL_PLANE_SECRET_PROVIDER_ID) {
627 const materialId = SecretMaterialIdSchema.make(ref.handle);
628 const stored = yield* input.rows.secretMaterials.getById(materialId);
629 if (Option.isNone(stored)) {
630 return yield* Effect.fail(
631 new Error(`Secret material not found: ${ref.handle}`),
632 );
633 }
634
635 return stored.value.value;
636 }
637
638 if (input.fallback) {
639 return yield* input.fallback(ref);
640 }
641
642 return yield* Effect.fail(
643 new Error(`Unsupported secret provider ${ref.providerId}`),
644 );
645 });
646
647export const createRuntimeSourceAuthService = (input: {568export const createRuntimeSourceAuthService = (input: {
648 rows: SqlControlPlaneRows;569 rows: SqlControlPlaneRows;
649 liveExecutionManager: LiveExecutionManager;570 liveExecutionManager: LiveExecutionManager;
650 getLocalServerBaseUrl?: () => string | undefined;571 getLocalServerBaseUrl?: () => string | undefined;
651}) => {572}) => {
652 const resolveSecretMaterial = createDbBackedSecretMaterialResolver({573 const resolveSecretMaterial = createDefaultSecretMaterialResolver({
574 rows: input.rows,
575 });
576 const storeSecretMaterial = createDefaultSecretMaterialStorer({
653 rows: input.rows,577 rows: input.rows,
654 fallback: createEnvSecretMaterialResolver(),
655 });578 });
656 579
657 return {580 return {
658 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,581 getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null,
659 582
660 storeSecretMaterial: ({ purpose, value }) =>583 storeSecretMaterial: ({ purpose, value }) =>
661 upsertSecretMaterial(input.rows, {584 storeSecretMaterial({
662 purpose,585 purpose,
663 value,586 value,
664 }),587 }),
@@ -739,9 +662,9 @@ export const createRuntimeSourceAuthService = (input: { · 74 unmodified lines
739 }662 }
740 663
741 const auth = yield* materializeExecutorOpenApiAuth({664 const auth = yield* materializeExecutorOpenApiAuth({
742 rows: input.rows,
743 existing,665 existing,
744 auth: sourceInput.auth,666 auth: sourceInput.auth,
667 storeSecretMaterial,
745 });668 });
746 669
747 const draftSource = existing670 const draftSource = existing
@@ -1015,12 +938,18 @@ export const createRuntimeSourceAuthService = (input: { · 267 unmodified lines
1015 const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed";938 const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed";
1016 const failedAt = Date.now();939 const failedAt = Date.now();
1017 940
1018 yield* input.rows.sourceAuthSessions.update(session.id, {941 yield* input.rows.sourceAuthSessions.update(
1019 status: "failed",942 session.id,
1020 errorText: reason,943 createTerminalSourceAuthSessionPatch({
1021 completedAt: failedAt,944 status: "failed",
1022 updatedAt: failedAt,945 now: failedAt,
1023 });946 errorText: reason,
947 resourceMetadataUrl: session.resourceMetadataUrl,
948 authorizationServerUrl: session.authorizationServerUrl,
949 resourceMetadataJson: session.resourceMetadataJson,
950 authorizationServerMetadataJson: session.authorizationServerMetadataJson,
951 }),
952 );
1024 const failedSource = yield* updateSourceStatus(input.rows, source, {953 const failedSource = yield* updateSourceStatus(input.rows, source, {
1025 status: "error",954 status: "error",
1026 lastError: reason,955 lastError: reason,
@@ -1052,30 +981,18 @@ export const createRuntimeSourceAuthService = (input: { · 25 unmodified lines
1052 code: authorizationCode,981 code: authorizationCode,
1053 });982 });
1054 983
1055 const accessTokenId = yield* upsertSecretMaterial(input.rows, {984 const accessTokenRef = yield* storeSecretMaterial({
1056 purpose: "oauth_access_token",985 purpose: "oauth_access_token",
1057 value: exchanged.tokens.access_token,986 value: exchanged.tokens.access_token,
1058 });987 });
1059 const refreshTokenId = exchanged.tokens.refresh_token988 const refreshTokenRef = exchanged.tokens.refresh_token
1060 ? yield* upsertSecretMaterial(input.rows, {989 ? yield* storeSecretMaterial({
1061 purpose: "oauth_refresh_token",990 purpose: "oauth_refresh_token",
1062 value: exchanged.tokens.refresh_token,991 value: exchanged.tokens.refresh_token,
1063 })992 })
1064 : null;993 : null;
1065 994
1066 const now = Date.now();995 const now = Date.now();
1067 yield* input.rows.sourceCredentialBindings.upsert({
1068 workspaceId: session.workspaceId,
1069 sourceId: session.sourceId,
1070 tokenProviderId: CONTROL_PLANE_SECRET_PROVIDER_ID,
1071 tokenHandle: accessTokenId,
1072 refreshTokenProviderId:
1073 refreshTokenId === null ? null : CONTROL_PLANE_SECRET_PROVIDER_ID,
1074 refreshTokenHandle: refreshTokenId,
1075 createdAt: now,
1076 updatedAt: now,
1077 });
1078
1079 const connectedSource = yield* updateSourceStatus(input.rows, source, {996 const connectedSource = yield* updateSourceStatus(input.rows, source, {
1080 status: "connected",997 status: "connected",
1081 lastError: null,998 lastError: null,
@@ -1083,17 +1000,8 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line
1083 kind: "oauth2",1000 kind: "oauth2",
1084 headerName: "Authorization",1001 headerName: "Authorization",
1085 prefix: "Bearer ",1002 prefix: "Bearer ",
1086 accessToken: {1003 accessToken: accessTokenRef,
1087 providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,1004 refreshToken: refreshTokenRef,
1088 handle: accessTokenId,
1089 },
1090 refreshToken:
1091 refreshTokenId === null
1092 ? null
1093 : {
1094 providerId: CONTROL_PLANE_SECRET_PROVIDER_ID,
1095 handle: refreshTokenId,
1096 },
1097 },1005 },
1098 });1006 });
1099 const indexed = yield* Effect.either(1007 const indexed = yield* Effect.either(
@@ -1114,17 +1022,18 @@ export const createRuntimeSourceAuthService = (input: { · 14 unmodified lines
1114 onRight: () => Effect.succeed(undefined),1022 onRight: () => Effect.succeed(undefined),
1115 });1023 });
1116 1024
1117 yield* input.rows.sourceAuthSessions.update(session.id, {1025 yield* input.rows.sourceAuthSessions.update(
1118 status: "completed",1026 session.id,
1119 errorText: null,1027 createTerminalSourceAuthSessionPatch({
1120 completedAt: now,1028 status: "completed",
1121 updatedAt: now,1029 now,
1122 resourceMetadataUrl: exchanged.resourceMetadataUrl,1030 errorText: null,
1123 authorizationServerUrl: exchanged.authorizationServerUrl,1031 resourceMetadataUrl: exchanged.resourceMetadataUrl,
1124 resourceMetadataJson: exchanged.resourceMetadataJson,1032 authorizationServerUrl: exchanged.authorizationServerUrl,
1125 authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,1033 resourceMetadataJson: exchanged.resourceMetadataJson,
1126 clientInformationJson: exchanged.clientInformationJson,1034 authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson,
1127 });1035 }),
1036 );
1128 1037
1129 yield* completeLiveInteraction({1038 yield* completeLiveInteraction({
1130 liveExecutionManager: input.liveExecutionManager,1039 liveExecutionManager: input.liveExecutionManager,
packages/control-plane/src/runtime/source-credential-interactions.ts+31−0
@@ -0,0 +1,31 @@
1import { SecretRefSchema, type SecretRef } from "#schema";
2import * as Schema from "effect/Schema";
3
4export const SourceCredentialSelectionContentSchema = Schema.Union(
5 Schema.Struct({
6 authKind: Schema.Literal("none"),
7 }),
8 Schema.Struct({
9 authKind: Schema.Literal("bearer"),
10 tokenRef: SecretRefSchema,
11 }),
12);
13
14export type SourceCredentialSelectionContent =
15 typeof SourceCredentialSelectionContentSchema.Type;
16
17export const decodeSourceCredentialSelectionContent = Schema.decodeUnknownSync(
18 SourceCredentialSelectionContentSchema,
19);
20
21export const createSourceCredentialSelectionNoneContent =
22 (): SourceCredentialSelectionContent => ({
23 authKind: "none",
24 });
25
26export const createSourceCredentialSelectionBearerContent = (
27 tokenRef: SecretRef,
28): SourceCredentialSelectionContent => ({
29 authKind: "bearer",
30 tokenRef,
31});
packages/control-plane/src/runtime/source-definitions.ts+71−47
@@ -3,6 +3,7 @@ import type { · 2 unmodified lines
3 UpdateSourcePayload,3 UpdateSourcePayload,
4} from "#api";4} from "#api";
5import type {5import type {
6 Credential,
6 Source,7 Source,
7 SourceAuth,8 SourceAuth,
8 SourceCredentialBinding,9 SourceCredentialBinding,
@@ -11,6 +12,7 @@ import type { · 2 unmodified lines
11 StringMap,12 StringMap,
12 WorkspaceId,13 WorkspaceId,
13} from "#schema";14} from "#schema";
15import { CredentialIdSchema } from "#schema";
14import * as Effect from "effect/Effect";16import * as Effect from "effect/Effect";
15import * as Schema from "effect/Schema";17import * as Schema from "effect/Schema";
16 18
@@ -232,8 +234,11 @@ export const updateSourceFromPayload = (input: { · 215 unmodified lines
232 234
233export const splitSourceForStorage = (input: {235export const splitSourceForStorage = (input: {
234 source: Source;236 source: Source;
237 existingCredentialId?: Credential["id"] | null;
238 existingBindingId?: SourceCredentialBinding["id"] | null;
235}): {239}): {
236 sourceRecord: StoredSourceRecord;240 sourceRecord: StoredSourceRecord;
241 credential: Credential | null;
237 credentialBinding: SourceCredentialBinding | null;242 credentialBinding: SourceCredentialBinding | null;
238} => {243} => {
239 const sourceRecord: StoredSourceRecord = {244 const sourceRecord: StoredSourceRecord = {
@@ -250,10 +255,6 @@ export const splitSourceForStorage = (input: { · 10 unmodified lines
250 headersJson: serializeStringMap(input.source.headers),255 headersJson: serializeStringMap(input.source.headers),
251 specUrl: input.source.specUrl,256 specUrl: input.source.specUrl,
252 defaultHeadersJson: serializeStringMap(input.source.defaultHeaders),257 defaultHeadersJson: serializeStringMap(input.source.defaultHeaders),
253 authKind: input.source.auth.kind,
254 authHeaderName:
255 input.source.auth.kind === "none" ? null : input.source.auth.headerName,
256 authPrefix: input.source.auth.kind === "none" ? null : input.source.auth.prefix,
257 sourceHash: input.source.sourceHash,258 sourceHash: input.source.sourceHash,
258 sourceDocumentText: null,259 sourceDocumentText: null,
259 lastError: input.source.lastError,260 lastError: input.source.lastError,
@@ -264,31 +265,49 @@ export const splitSourceForStorage = (input: { · 4 unmodified lines
264 if (input.source.auth.kind === "none") {265 if (input.source.auth.kind === "none") {
265 return {266 return {
266 sourceRecord,267 sourceRecord,
268 credential: null,
267 credentialBinding: null,269 credentialBinding: null,
268 };270 };
269 }271 }
270 272
273 const credentialId = input.existingCredentialId
274 ?? CredentialIdSchema.make(`cred_${crypto.randomUUID()}`);
275 const bindingId = input.existingBindingId ?? `src_cred_bind_${crypto.randomUUID()}`;
276
277 const credential: Credential = {
278 id: credentialId,
279 workspaceId: input.source.workspaceId,
280 authKind: input.source.auth.kind,
281 authHeaderName: input.source.auth.headerName,
282 authPrefix: input.source.auth.prefix,
283 tokenProviderId:
284 input.source.auth.kind === "bearer"
285 ? input.source.auth.token.providerId
286 : input.source.auth.accessToken.providerId,
287 tokenHandle:
288 input.source.auth.kind === "bearer"
289 ? input.source.auth.token.handle
290 : input.source.auth.accessToken.handle,
291 refreshTokenProviderId:
292 input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
293 ? input.source.auth.refreshToken.providerId
294 : null,
295 refreshTokenHandle:
296 input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
297 ? input.source.auth.refreshToken.handle
298 : null,
299 createdAt: input.source.createdAt,
300 updatedAt: input.source.updatedAt,
301 };
302
271 return {303 return {
272 sourceRecord,304 sourceRecord,
305 credential,
273 credentialBinding: {306 credentialBinding: {
307 id: bindingId,
274 workspaceId: input.source.workspaceId,308 workspaceId: input.source.workspaceId,
275 sourceId: input.source.id,309 sourceId: input.source.id,
276 tokenProviderId:310 credentialId,
277 input.source.auth.kind === "bearer"
278 ? input.source.auth.token.providerId
279 : input.source.auth.accessToken.providerId,
280 tokenHandle:
281 input.source.auth.kind === "bearer"
282 ? input.source.auth.token.handle
283 : input.source.auth.accessToken.handle,
284 refreshTokenProviderId:
285 input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
286 ? input.source.auth.refreshToken.providerId
287 : null,
288 refreshTokenHandle:
289 input.source.auth.kind === "oauth2" && input.source.auth.refreshToken !== null
290 ? input.source.auth.refreshToken.handle
291 : null,
292 createdAt: input.source.createdAt,311 createdAt: input.source.createdAt,
293 updatedAt: input.source.updatedAt,312 updatedAt: input.source.updatedAt,
294 },313 },
@@ -298,6 +317,7 @@ export const splitSourceForStorage = (input: { · 3 unmodified lines
298export const projectSourceFromStorage = (input: {317export const projectSourceFromStorage = (input: {
299 sourceRecord: StoredSourceRecord;318 sourceRecord: StoredSourceRecord;
300 credentialBinding: SourceCredentialBinding | null;319 credentialBinding: SourceCredentialBinding | null;
320 credential: Credential | null;
301}): Effect.Effect<Source, Error, never> =>321}): Effect.Effect<Source, Error, never> =>
302 Effect.gen(function* () {322 Effect.gen(function* () {
303 const queryParams = yield* parseStringMapJson(323 const queryParams = yield* parseStringMapJson(
@@ -313,44 +333,39 @@ export const projectSourceFromStorage = (input: { · 9 unmodified lines
313 input.sourceRecord.defaultHeadersJson,333 input.sourceRecord.defaultHeadersJson,
314 );334 );
315 335
316 let auth: SourceAuth;336 let auth: SourceAuth = { kind: "none" };
317 if (input.sourceRecord.authKind === "none") {337 if (input.credentialBinding !== null) {
318 auth = { kind: "none" };338 const credential = input.credential;
319 } else {339 if (credential === null) {
320 const binding = input.credentialBinding;
321 if (binding === null || binding.tokenProviderId === null || binding.tokenHandle === null) {
322 return yield* Effect.fail(340 return yield* Effect.fail(
323 new Error(`Missing credential binding for source ${input.sourceRecord.id}`),341 new Error(`Missing credential ${input.credentialBinding.credentialId} for source ${input.sourceRecord.id}`),
324 );342 );
325 }343 }
326 344
327 const headerName = trimOrNull(input.sourceRecord.authHeaderName) ?? "Authorization";345 if (credential.authKind === "bearer") {
328 const prefix = input.sourceRecord.authPrefix ?? "Bearer ";
329
330 if (input.sourceRecord.authKind === "bearer") {
331 auth = {346 auth = {
332 kind: "bearer",347 kind: "bearer",
333 headerName,348 headerName: credential.authHeaderName,
334 prefix,349 prefix: credential.authPrefix,
335 token: {350 token: {
336 providerId: binding.tokenProviderId,351 providerId: credential.tokenProviderId,
337 handle: binding.tokenHandle,352 handle: credential.tokenHandle,
338 },353 },
339 };354 };
340 } else {355 } else {
341 auth = {356 auth = {
342 kind: "oauth2",357 kind: "oauth2",
343 headerName,358 headerName: credential.authHeaderName,
344 prefix,359 prefix: credential.authPrefix,
345 accessToken: {360 accessToken: {
346 providerId: binding.tokenProviderId,361 providerId: credential.tokenProviderId,
347 handle: binding.tokenHandle,362 handle: credential.tokenHandle,
348 },363 },
349 refreshToken:364 refreshToken:
350 binding.refreshTokenProviderId !== null && binding.refreshTokenHandle !== null365 credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null
351 ? {366 ? {
352 providerId: binding.refreshTokenProviderId,367 providerId: credential.refreshTokenProviderId,
353 handle: binding.refreshTokenHandle,368 handle: credential.refreshTokenHandle,
354 }369 }
355 : null,370 : null,
356 };371 };
@@ -386,15 +401,24 @@ export const projectSourceFromStorage = (input: { · 29 unmodified lines
386export const projectSourcesFromStorage = (input: {401export const projectSourcesFromStorage = (input: {
387 sourceRecords: ReadonlyArray<StoredSourceRecord>;402 sourceRecords: ReadonlyArray<StoredSourceRecord>;
388 credentialBindings: ReadonlyArray<SourceCredentialBinding>;403 credentialBindings: ReadonlyArray<SourceCredentialBinding>;
404 credentials: ReadonlyArray<Credential>;
389}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {405}): Effect.Effect<ReadonlyArray<Source>, Error, never> => {
390 const bindingsBySourceId = new Map(406 const bindingsBySourceId = new Map(
391 input.credentialBindings.map((binding) => [binding.sourceId, binding]),407 input.credentialBindings.map((binding) => [binding.sourceId, binding]),
392 );408 );
409 const credentialsById = new Map(
410 input.credentials.map((credential) => [credential.id, credential]),
411 );
393 412
394 return Effect.forEach(input.sourceRecords, (sourceRecord) =>413 return Effect.forEach(input.sourceRecords, (sourceRecord) => {
395 projectSourceFromStorage({414 const credentialBinding = bindingsBySourceId.get(sourceRecord.id) ?? null;
415 return projectSourceFromStorage({
396 sourceRecord,416 sourceRecord,
397 credentialBinding: bindingsBySourceId.get(sourceRecord.id) ?? null,417 credentialBinding,
398 }),418 credential:
399 );419 credentialBinding === null
420 ? null
421 : credentialsById.get(credentialBinding.credentialId) ?? null,
422 });
423 });
400};424};
packages/control-plane/src/runtime/source-inspection.ts+4−13
@@ -27,7 +27,7 @@ import * as Effect from "effect/Effect"; · 26 unmodified lines
27import * as Option from "effect/Option";27import * as Option from "effect/Option";
28 28
29import { operationErrors } from "./operation-errors";29import { operationErrors } from "./operation-errors";
30import { projectSourceFromStorage } from "./source-definitions";30import { loadSourceById as loadStoredSourceById } from "./source-store";
31import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";31import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
32import { namespaceFromSourceName } from "./tool-artifacts";32import { namespaceFromSourceName } from "./tool-artifacts";
33 33
@@ -97,18 +97,9 @@ const loadSourceRecord = (input: { · 63 unmodified lines
97 );97 );
98 }98 }
99 99
100 const credentialBinding = yield* sourceInspectOps.bundle.child("binding").mapStorage(100 const source = yield* loadStoredSourceById(store, {
101 store.sourceCredentialBindings.getByWorkspaceAndSourceId(101 workspaceId: input.workspaceId,
102 input.workspaceId,102 sourceId: input.sourceId,
103 input.sourceId,
104 ),
105 );
106
107 const source = yield* projectSourceFromStorage({
108 sourceRecord: sourceRecord.value,
109 credentialBinding: Option.isSome(credentialBinding)
110 ? credentialBinding.value
111 : null,
112 }).pipe(103 }).pipe(
113 Effect.mapError((cause) =>104 Effect.mapError((cause) =>
114 sourceInspectOps.bundle.unknownStorage(105 sourceInspectOps.bundle.unknownStorage(
packages/control-plane/src/runtime/source-store.ts+132−0
@@ -0,0 +1,132 @@
1import type {
2 Credential,
3 Source,
4 WorkspaceId,
5} from "#schema";
6import { type SqlControlPlaneRows } from "#persistence";
7import * as Effect from "effect/Effect";
8import * as Option from "effect/Option";
9
10import {
11 projectSourceFromStorage,
12 projectSourcesFromStorage,
13 splitSourceForStorage,
14} from "./source-definitions";
15
16export const loadSourcesInWorkspace = (
17 rows: SqlControlPlaneRows,
18 workspaceId: WorkspaceId,
19) =>
20 Effect.gen(function* () {
21 const sourceRecords = yield* rows.sources.listByWorkspaceId(workspaceId);
22 const credentialBindings = yield* rows.sourceCredentialBindings.listByWorkspaceId(workspaceId);
23 const credentials = yield* rows.credentials.listByWorkspaceId(workspaceId);
24
25 return yield* projectSourcesFromStorage({
26 sourceRecords,
27 credentialBindings,
28 credentials,
29 });
30 });
31
32export const loadSourceById = (rows: SqlControlPlaneRows, input: {
33 workspaceId: WorkspaceId;
34 sourceId: Source["id"];
35}) =>
36 Effect.gen(function* () {
37 const sourceRecord = yield* rows.sources.getByWorkspaceAndId(
38 input.workspaceId,
39 input.sourceId,
40 );
41
42 if (Option.isNone(sourceRecord)) {
43 return yield* Effect.fail(
44 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
45 );
46 }
47
48 const credentialBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
49 input.workspaceId,
50 input.sourceId,
51 );
52 const credential =
53 Option.isSome(credentialBinding)
54 ? yield* rows.credentials.getById(credentialBinding.value.credentialId)
55 : Option.none<Credential>();
56
57 return yield* projectSourceFromStorage({
58 sourceRecord: sourceRecord.value,
59 credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
60 credential: Option.isSome(credential) ? credential.value : null,
61 });
62 });
63
64export const removeCredentialBindingForSource = (rows: SqlControlPlaneRows, input: {
65 workspaceId: WorkspaceId;
66 sourceId: Source["id"];
67}) =>
68 Effect.gen(function* () {
69 const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
70 input.workspaceId,
71 input.sourceId,
72 );
73
74 if (Option.isNone(existingBinding)) {
75 return false;
76 }
77
78 yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
79 input.workspaceId,
80 input.sourceId,
81 );
82 yield* rows.credentials.removeById(existingBinding.value.credentialId);
83 return true;
84 });
85
86export const persistSource = (rows: SqlControlPlaneRows, source: Source) =>
87 Effect.gen(function* () {
88 const existing = yield* rows.sources.getByWorkspaceAndId(source.workspaceId, source.id);
89 const existingBinding = yield* rows.sourceCredentialBindings.getByWorkspaceAndSourceId(
90 source.workspaceId,
91 source.id,
92 );
93 const existingCredentialId = Option.isSome(existingBinding)
94 ? existingBinding.value.credentialId
95 : null;
96 const existingBindingId = Option.isSome(existingBinding)
97 ? existingBinding.value.id
98 : null;
99 const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
100 source,
101 existingCredentialId,
102 existingBindingId,
103 });
104
105 if (Option.isNone(existing)) {
106 yield* rows.sources.insert(sourceRecord);
107 } else {
108 const {
109 id: _id,
110 workspaceId: _workspaceId,
111 createdAt: _createdAt,
112 sourceDocumentText: _sourceDocumentText,
113 ...patch
114 } = sourceRecord;
115 yield* rows.sources.update(source.workspaceId, source.id, patch);
116 }
117
118 if (credential === null || credentialBinding === null) {
119 if (Option.isSome(existingBinding)) {
120 yield* rows.sourceCredentialBindings.removeByWorkspaceAndSourceId(
121 source.workspaceId,
122 source.id,
123 );
124 yield* rows.credentials.removeById(existingBinding.value.credentialId);
125 }
126 } else {
127 yield* rows.credentials.upsert(credential);
128 yield* rows.sourceCredentialBindings.upsert(credentialBinding);
129 }
130
131 return source;
132 });
packages/control-plane/src/runtime/sources-operations.ts+82−77
@@ -25,14 +25,14 @@ import { · 24 unmodified lines
25import {25import {
26 operationErrors,26 operationErrors,
27} from "./operation-errors";27} from "./operation-errors";
28import {28import { createDefaultSecretMaterialResolver } from "./secret-material-providers";
29 createDbBackedSecretMaterialResolver,
30} from "./source-auth-service";
31import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";29import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store";
30import { syncSourceToolArtifacts } from "./tool-artifacts";
32import {31import {
33 createEnvSecretMaterialResolver,32 loadSourceById,
34 syncSourceToolArtifacts,33 loadSourcesInWorkspace,
35} from "./tool-artifacts";34 removeCredentialBindingForSource,
35} from "./source-store";
36 36
37const sourceOps = {37const sourceOps = {
38 list: operationErrors("sources.list"),38 list: operationErrors("sources.list"),
@@ -50,9 +50,8 @@ const syncArtifactsForSource = (input: { · 11 unmodified lines
50 | typeof sourceOps.update;50 | typeof sourceOps.update;
51}) =>51}) =>
52 Effect.gen(function* () {52 Effect.gen(function* () {
53 const resolveSecretMaterial = createDbBackedSecretMaterialResolver({53 const resolveSecretMaterial = createDefaultSecretMaterialResolver({
54 rows: input.store,54 rows: input.store,
55 fallback: createEnvSecretMaterialResolver(),
56 });55 });
57 56
58 const synced = yield* Effect.either(57 const synced = yield* Effect.either(
@@ -106,26 +105,14 @@ const syncArtifactsForSource = (input: { · 47 unmodified lines
106 105
107export const listSources = (workspaceId: WorkspaceId) =>106export const listSources = (workspaceId: WorkspaceId) =>
108 Effect.flatMap(ControlPlaneStore, (store) =>107 Effect.flatMap(ControlPlaneStore, (store) =>
109 Effect.gen(function* () {108 loadSourcesInWorkspace(store, workspaceId).pipe(
110 const sourceRecords = yield* sourceOps.list.child("records").mapStorage(109 Effect.mapError((error) =>
111 store.sources.listByWorkspaceId(workspaceId),110 sourceOps.list.unknownStorage(
112 );111 error,
113 const credentialBindings = yield* sourceOps.list.child("bindings").mapStorage(112 "Failed projecting stored sources",
114 store.sourceCredentialBindings.listByWorkspaceId(workspaceId),
115 );
116
117 return yield* projectSourcesFromStorage({
118 sourceRecords,
119 credentialBindings,
120 }).pipe(
121 Effect.mapError((error) =>
122 sourceOps.list.unknownStorage(
123 error,
124 "Failed projecting stored sources",
125 ),
126 ),113 ),
127 );114 ),
128 }));115 ));
129 116
130export const createSource = (input: {117export const createSource = (input: {
131 workspaceId: WorkspaceId;118 workspaceId: WorkspaceId;
@@ -149,7 +136,7 @@ export const createSource = (input: { · 17 unmodified lines
149 ),136 ),
150 );137 );
151 138
152 const { sourceRecord, credentialBinding } = splitSourceForStorage({139 const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
153 source,140 source,
154 });141 });
155 142
@@ -157,7 +144,11 @@ export const createSource = (input: { · 1 unmodified line
157 sourceOps.create.child("source"),144 sourceOps.create.child("source"),
158 store.sources.insert(sourceRecord),145 store.sources.insert(sourceRecord),
159 );146 );
160 if (credentialBinding !== null) {147 if (credential !== null && credentialBinding !== null) {
148 yield* mapPersistenceError(
149 sourceOps.create.child("credential"),
150 store.credentials.upsert(credential),
151 );
161 yield* mapPersistenceError(152 yield* mapPersistenceError(
162 sourceOps.create.child("binding"),153 sourceOps.create.child("binding"),
163 store.sourceCredentialBindings.upsert(credentialBinding),154 store.sourceCredentialBindings.upsert(credentialBinding),
@@ -176,39 +167,22 @@ export const getSource = (input: { · 12 unmodified lines
176 sourceId: SourceId;167 sourceId: SourceId;
177}) =>168}) =>
178 Effect.flatMap(ControlPlaneStore, (store) =>169 Effect.flatMap(ControlPlaneStore, (store) =>
179 Effect.gen(function* () {170 loadSourceById(store, {
180 const existing = yield* sourceOps.get.mapStorage(171 workspaceId: input.workspaceId,
181 store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId),172 sourceId: input.sourceId,
182 );173 }).pipe(
183 174 Effect.mapError((cause) =>
184 if (Option.isNone(existing)) {175 cause instanceof Error && cause.message.startsWith("Source not found:")
185 return yield* Effect.fail(176 ? sourceOps.get.notFound(
186 sourceOps.get.notFound(177 "Source not found",
187 "Source not found",178 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
188 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,179 )
189 ),180 : sourceOps.get.unknownStorage(
190 );181 cause,
191 }182 "Failed projecting stored source",
192 183 ),
193 const credentialBinding = yield* sourceOps.get.child("binding").mapStorage(184 ),
194 store.sourceCredentialBindings.getByWorkspaceAndSourceId(185 ));
195 input.workspaceId,
196 input.sourceId,
197 ),
198 );
199
200 return yield* projectSourceFromStorage({
201 sourceRecord: existing.value,
202 credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
203 }).pipe(
204 Effect.mapError((cause) =>
205 sourceOps.get.unknownStorage(
206 cause,
207 "Failed projecting stored source",
208 ),
209 ),
210 );
211 }));
212 186
213export const updateSource = (input: {187export const updateSource = (input: {
214 workspaceId: WorkspaceId;188 workspaceId: WorkspaceId;
@@ -237,15 +211,20 @@ export const updateSource = (input: { · 22 unmodified lines
237 ),211 ),
238 );212 );
239 213
240 const existingSource = yield* projectSourceFromStorage({214 const existingSource = yield* loadSourceById(store, {
241 sourceRecord: existing.value,215 workspaceId: input.workspaceId,
242 credentialBinding: Option.isSome(existingBinding) ? existingBinding.value : null,216 sourceId: input.sourceId,
243 }).pipe(217 }).pipe(
244 Effect.mapError((cause) =>218 Effect.mapError((cause) =>
245 sourceOps.update.unknownStorage(219 cause instanceof Error && cause.message.startsWith("Source not found:")
246 cause,220 ? sourceOps.update.notFound(
247 "Failed projecting stored source",221 "Source not found",
248 ),222 `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`,
223 )
224 : sourceOps.update.unknownStorage(
225 cause,
226 "Failed projecting stored source",
227 ),
249 ),228 ),
250 );229 );
251 230
@@ -262,8 +241,14 @@ export const updateSource = (input: { · 10 unmodified lines
262 ),241 ),
263 );242 );
264 243
265 const { sourceRecord, credentialBinding } = splitSourceForStorage({244 const { sourceRecord, credential, credentialBinding } = splitSourceForStorage({
266 source: updatedSource,245 source: updatedSource,
246 existingCredentialId: Option.isSome(existingBinding)
247 ? existingBinding.value.credentialId
248 : null,
249 existingBindingId: Option.isSome(existingBinding)
250 ? existingBinding.value.id
251 : null,
267 });252 });
268 const { sourceDocumentText: _sourceDocumentText, ...sourcePatch } = sourceRecord;253 const { sourceDocumentText: _sourceDocumentText, ...sourcePatch } = sourceRecord;
269 254
@@ -284,14 +269,23 @@ export const updateSource = (input: { · 14 unmodified lines
284 );269 );
285 }270 }
286 271
287 if (credentialBinding === null) {272 if (credential === null || credentialBinding === null) {
288 yield* sourceOps.update.child("binding.remove").mapStorage(273 if (Option.isSome(existingBinding)) {
289 store.sourceCredentialBindings.removeByWorkspaceAndSourceId(274 yield* sourceOps.update.child("binding.remove").mapStorage(
290 input.workspaceId,275 store.sourceCredentialBindings.removeByWorkspaceAndSourceId(
291 input.sourceId,276 input.workspaceId,
292 ),277 input.sourceId,
293 );278 ),
279 );
280 yield* sourceOps.update.child("credential.remove").mapStorage(
281 store.credentials.removeById(existingBinding.value.credentialId),
282 );
283 }
294 } else {284 } else {
285 yield* mapPersistenceError(
286 sourceOps.update.child("credential"),
287 store.credentials.upsert(credential),
288 );
295 yield* mapPersistenceError(289 yield* mapPersistenceError(
296 sourceOps.update.child("binding"),290 sourceOps.update.child("binding"),
297 store.sourceCredentialBindings.upsert(credentialBinding),291 store.sourceCredentialBindings.upsert(credentialBinding),
@@ -311,6 +305,17 @@ export const removeSource = (input: { · 13 unmodified lines
311}) =>305}) =>
312 Effect.flatMap(ControlPlaneStore, (store) =>306 Effect.flatMap(ControlPlaneStore, (store) =>
313 Effect.gen(function* () {307 Effect.gen(function* () {
308 yield* sourceOps.remove.child("credential.remove").mapStorage(
309 removeCredentialBindingForSource(store, {
310 workspaceId: input.workspaceId,
311 sourceId: input.sourceId,
312 }),
313 );
314
315 yield* sourceOps.remove.child("artifacts").mapStorage(
316 store.toolArtifacts.removeByWorkspaceAndSourceId(input.workspaceId, input.sourceId),
317 );
318
314 yield* sourceOps.remove.child("artifacts").mapStorage(319 yield* sourceOps.remove.child("artifacts").mapStorage(
315 store.toolArtifacts.removeByWorkspaceAndSourceId(input.workspaceId, input.sourceId),320 store.toolArtifacts.removeByWorkspaceAndSourceId(input.workspaceId, input.sourceId),
316 );321 );
packages/control-plane/src/runtime/tool-artifacts.ts+9−23
@@ -21,9 +21,10 @@ import { · 20 unmodified lines
21import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
22import * as Option from "effect/Option";22import * as Option from "effect/Option";
23 23
24export type ResolveSourceSecretMaterial = (24import type {
25 ref: SecretRef,25 ResolveSecretMaterial as ResolveSourceSecretMaterial,
26) => Effect.Effect<string, Error, never>;26 SecretMaterialResolveContext,
27} from "./secret-material-providers";
27 28
28export type ResolvedSourceAuthMaterial = {29export type ResolvedSourceAuthMaterial = {
29 headers: Readonly<Record<string, string>>;30 headers: Readonly<Record<string, string>>;
@@ -122,28 +123,10 @@ export const namespaceFromSourceName = (name: string): string => { · 92 unmodified lines
122 return normalized.length > 0 ? normalized : "source";123 return normalized.length > 0 ? normalized : "source";
123};124};
124 125
125export const createEnvSecretMaterialResolver = (): ResolveSourceSecretMaterial =>
126 (ref) =>
127 Effect.gen(function* () {
128 if (ref.providerId !== "env") {
129 return yield* Effect.fail(
130 new Error(`Unsupported secret provider ${ref.providerId}`),
131 );
132 }
133
134 const value = process.env[ref.handle]?.trim();
135 if (!value) {
136 return yield* Effect.fail(
137 new Error(`Environment variable ${ref.handle} is not set`),
138 );
139 }
140
141 return value;
142 });
143
144export const resolveSourceAuthMaterial = (input: {126export const resolveSourceAuthMaterial = (input: {
145 source: Source;127 source: Source;
146 resolveSecretMaterial: ResolveSourceSecretMaterial;128 resolveSecretMaterial: ResolveSourceSecretMaterial;
129 context?: SecretMaterialResolveContext;
147}): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>130}): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> =>
148 Effect.gen(function* () {131 Effect.gen(function* () {
149 if (input.source.auth.kind === "none") {132 if (input.source.auth.kind === "none") {
@@ -155,7 +138,10 @@ export const resolveSourceAuthMaterial = (input: { · 5 unmodified lines
155 ? input.source.auth.token138 ? input.source.auth.token
156 : input.source.auth.accessToken;139 : input.source.auth.accessToken;
157 140
158 const token = yield* input.resolveSecretMaterial(tokenRef);141 const token = yield* input.resolveSecretMaterial({
142 ref: tokenRef,
143 context: input.context,
144 });
159 145
160 return {146 return {
161 headers: {147 headers: {
packages/control-plane/src/runtime/workspace-execution-environment.test.ts+113−9
@@ -15,6 +15,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ · 14 unmodified lines
15import { describe, expect, it } from "@effect/vitest";15import { describe, expect, it } from "@effect/vitest";
16import {16import {
17 AccountIdSchema,17 AccountIdSchema,
18 CredentialIdSchema,
18 ExecutionIdSchema,19 ExecutionIdSchema,
19 SecretMaterialIdSchema,20 SecretMaterialIdSchema,
20 SourceIdSchema,21 SourceIdSchema,
@@ -369,9 +370,6 @@ const persistConnectedEchoTool = (input: { · 348 unmodified lines
369 headersJson: null,370 headersJson: null,
370 specUrl: null,371 specUrl: null,
371 defaultHeadersJson: null,372 defaultHeadersJson: null,
372 authKind: "none",
373 authHeaderName: null,
374 authPrefix: null,
375 sourceHash: null,373 sourceHash: null,
376 sourceDocumentText: null,374 sourceDocumentText: null,
377 lastError: null,375 lastError: null,
@@ -423,6 +421,11 @@ const persistConnectedGithubOpenApiSource = (input: { · 45 unmodified lines
423 persistence: SqlControlPlanePersistence;421 persistence: SqlControlPlanePersistence;
424 workspaceId: WorkspaceId;422 workspaceId: WorkspaceId;
425 sourceId: SourceId;423 sourceId: SourceId;
424 endpoint?: string;
425 auth?: {
426 providerId: string;
427 handle: string;
428 } | null;
426}) =>429}) =>
427 Effect.gen(function* () {430 Effect.gen(function* () {
428 const now = Date.now();431 const now = Date.now();
@@ -481,6 +484,28 @@ const persistConnectedGithubOpenApiSource = (input: { · 52 unmodified lines
481 },484 },
482 },485 },
483 },486 },
487 "/repos/{owner}/{repo}": {
488 get: {
489 operationId: "repos/get-repo",
490 tags: ["repos"],
491 summary: "Get a repository",
492 description: "Gets a repository by owner and name.",
493 parameters: [
494 { name: "owner", in: "path", required: true, schema: { type: "string" } },
495 { name: "repo", in: "path", required: true, schema: { type: "string" } },
496 ],
497 responses: {
498 200: {
499 description: "ok",
500 content: {
501 "application/json": {
502 schema: { type: "object" },
503 },
504 },
505 },
506 },
507 },
508 },
484 "/user": {509 "/user": {
485 get: {510 get: {
486 operationId: "users/get-authenticated",511 operationId: "users/get-authenticated",
@@ -508,7 +533,7 @@ const persistConnectedGithubOpenApiSource = (input: { · 21 unmodified lines
508 workspaceId: input.workspaceId,533 workspaceId: input.workspaceId,
509 name: "GitHub",534 name: "GitHub",
510 kind: "openapi",535 kind: "openapi",
511 endpoint: "https://api.github.com",536 endpoint: input.endpoint ?? "https://api.github.com",
512 status: "connected",537 status: "connected",
513 enabled: true,538 enabled: true,
514 namespace: "github",539 namespace: "github",
@@ -517,15 +542,38 @@ const persistConnectedGithubOpenApiSource = (input: { · 2 unmodified lines
517 headersJson: null,542 headersJson: null,
518 specUrl: "https://example.com/github-openapi.json",543 specUrl: "https://example.com/github-openapi.json",
519 defaultHeadersJson: null,544 defaultHeadersJson: null,
520 authKind: "none",
521 authHeaderName: null,
522 authPrefix: null,
523 sourceHash: null,545 sourceHash: null,
524 sourceDocumentText: openApiDocumentText,546 sourceDocumentText: openApiDocumentText,
525 lastError: null,547 lastError: null,
526 createdAt: now,548 createdAt: now,
527 updatedAt: now,549 updatedAt: now,
528 });550 });
551
552 if (input.auth) {
553 const credentialId = CredentialIdSchema.make(`cred_${randomUUID()}`);
554 yield* input.persistence.rows.credentials.upsert({
555 id: credentialId,
556 workspaceId: input.workspaceId,
557 authKind: "bearer",
558 authHeaderName: "Authorization",
559 authPrefix: "Bearer ",
560 tokenProviderId: input.auth.providerId,
561 tokenHandle: input.auth.handle,
562 refreshTokenProviderId: null,
563 refreshTokenHandle: null,
564 createdAt: now,
565 updatedAt: now,
566 });
567 yield* input.persistence.rows.sourceCredentialBindings.upsert({
568 id: `src_cred_bind_${randomUUID()}`,
569 workspaceId: input.workspaceId,
570 sourceId: input.sourceId,
571 credentialId,
572 createdAt: now,
573 updatedAt: now,
574 });
575 };
576
529 });577 });
530 578
531const makeResolver = (persistence: SqlControlPlanePersistence) =>579const makeResolver = (persistence: SqlControlPlanePersistence) =>
@@ -719,7 +767,10 @@ describe("workspace-execution-environment", () => { · 187 unmodified lines
719 action: "accept" as const,767 action: "accept" as const,
720 content: {768 content: {
721 authKind: "bearer",769 authKind: "bearer",
722 tokenSecretMaterialId,770 tokenRef: {
771 providerId: "postgres",
772 handle: tokenSecretMaterialId,
773 },
723 },774 },
724 };775 };
725 }),776 }),
@@ -752,7 +803,7 @@ describe("workspace-execution-environment", () => { · 26 unmodified lines
752 expect(added.kind).toBe("openapi");803 expect(added.kind).toBe("openapi");
753 expect(added.status).toBe("connected");804 expect(added.status).toBe("connected");
754 expect(added.auth.kind).toBe("bearer");805 expect(added.auth.kind).toBe("bearer");
755 expect(added.auth.token?.providerId).toBe("control-plane");806 expect(added.auth.token?.providerId).toBe("postgres");
756 expect(capturedElicitation).toMatchObject({807 expect(capturedElicitation).toMatchObject({
757 mode: "url",808 mode: "url",
758 url: expect.stringContaining("/v1/workspaces/"),809 url: expect.stringContaining("/v1/workspaces/"),
@@ -812,6 +863,59 @@ describe("workspace-execution-environment", () => { · 53 unmodified lines
812 }),863 }),
813 );864 );
814 865
866 it.scoped("resolves params-backed source auth from invocation context", () =>
867 Effect.gen(function* () {
868 const specServer = yield* makeOpenApiSpecServer;
869 const persistence = yield* makePersistence;
870 const workspaceId = WorkspaceIdSchema.make("ws_params_auth");
871 const sourceId = SourceIdSchema.make("src_params_auth");
872
873 yield* persistConnectedGithubOpenApiSource({
874 persistence,
875 workspaceId,
876 sourceId,
877 endpoint: specServer.baseUrl,
878 auth: {
879 providerId: "params",
880 handle: "githubToken",
881 },
882 });
883
884 const resolveEnvironment = makeResolver(persistence);
885 const environment = yield* resolveEnvironment({
886 workspaceId,
887 accountId: AccountIdSchema.make("acc_params_auth"),
888 executionId: ExecutionIdSchema.make("exec_params_auth"),
889 });
890
891 const invoked = (yield* environment.toolInvoker.invoke({
892 path: "github.repos.getRepo",
893 args: {
894 owner: "vercel",
895 repo: "ai",
896 },
897 context: {
898 params: {
899 githubToken: "ghp_from_params",
900 },
901 },
902 })) as {
903 status: number;
904 body: {
905 ok: boolean;
906 full_name: string;
907 };
908 };
909
910 expect(invoked.status).toBe(200);
911 expect(invoked.body).toEqual({
912 ok: true,
913 full_name: "vercel/ai",
914 });
915 expect(specServer.seenAuthHeaders).toEqual(["Bearer ghp_from_params"]);
916 }),
917 );
918
815 it.scoped("describes executor.sources.add with derived type info and schemas", () =>919 it.scoped("describes executor.sources.add with derived type info and schemas", () =>
816 Effect.gen(function* () {920 Effect.gen(function* () {
817 const persistence = yield* makePersistence;921 const persistence = yield* makePersistence;
packages/control-plane/src/runtime/workspace-execution-environment.ts+84−77
@@ -33,24 +33,31 @@ import type { · 32 unmodified lines
33 StoredToolArtifactRecord,33 StoredToolArtifactRecord,
34} from "#schema";34} from "#schema";
35import * as Context from "effect/Context";35import * as Context from "effect/Context";
36import * as Either from "effect/Either";
36import * as Effect from "effect/Effect";37import * as Effect from "effect/Effect";
37import * as Layer from "effect/Layer";38import * as Layer from "effect/Layer";
38import * as Option from "effect/Option";39import * as Option from "effect/Option";
40import * as Schema from "effect/Schema";
39 41
40import type {42import type {
41 ExecutionEnvironment,43 ExecutionEnvironment,
42 ResolveExecutionEnvironment,44 ResolveExecutionEnvironment,
43} from "./execution-state";45} from "./execution-state";
44import { createExecutorToolMap } from "./executor-tools";46import { createExecutorToolMap } from "./executor-tools";
45import { projectSourceFromStorage, projectSourcesFromStorage } from "./source-definitions";47import {
48 loadSourceById as loadStoredSourceById,
49 loadSourcesInWorkspace,
50} from "./source-store";
46import {51import {
47 RuntimeSourceAuthServiceTag,52 RuntimeSourceAuthServiceTag,
48 createDbBackedSecretMaterialResolver,
49 type ResolveSecretMaterial,
50 type RuntimeSourceAuthService,53 type RuntimeSourceAuthService,
51} from "./source-auth-service";54} from "./source-auth-service";
52import {55import {
53 createEnvSecretMaterialResolver,56 createDefaultSecretMaterialResolver,
57 type ResolveSecretMaterial,
58 type SecretMaterialResolveContext,
59} from "./secret-material-providers";
60import {
54 namespaceFromSourceName,61 namespaceFromSourceName,
55 resolveSourceAuthMaterial,62 resolveSourceAuthMaterial,
56 storedToolIdFromArtifact,63 storedToolIdFromArtifact,
@@ -109,6 +116,32 @@ const hasSubstringMatch = (value: string, queryToken: string): boolean => { · 52 unmodified lines
109 return singular !== queryToken && value.includes(singular);116 return singular !== queryToken && value.includes(singular);
110};117};
111 118
119const SecretResolutionContextEnvelopeSchema = Schema.Struct({
120 params: Schema.optional(
121 Schema.Record({
122 key: Schema.String,
123 value: Schema.String,
124 }),
125 ),
126});
127
128const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither(
129 SecretResolutionContextEnvelopeSchema,
130);
131
132const toSecretResolutionContext = (
133 value: unknown,
134): SecretMaterialResolveContext | undefined => {
135 const decoded = decodeSecretResolutionContextEnvelope(value);
136 if (Either.isLeft(decoded) || decoded.right.params === undefined) {
137 return undefined;
138 }
139
140 return {
141 params: decoded.right.params,
142 };
143};
144
112const queryTokenWeight = (token: string): number =>145const queryTokenWeight = (token: string): number =>
113 LOW_SIGNAL_QUERY_TOKENS.has(token) ? 0.25 : 1;146 LOW_SIGNAL_QUERY_TOKENS.has(token) ? 0.25 : 1;
114 147
@@ -236,14 +269,14 @@ const toPersistedDescriptor = (input: { · 121 unmodified lines
236 outputType:269 outputType:
237 input.artifact.providerKind === "openapi"270 input.artifact.providerKind === "openapi"
238 ? openApiOutputTypeSignatureFromSchemaJson(271 ? openApiOutputTypeSignatureFromSchemaJson(
239 input.artifact.outputSchemaJson ?? undefined,272 input.artifact.outputSchemaJson ?? undefined,
240 320,273 320,
241 )274 )
242 : typeSignatureFromSchemaJson(275 : typeSignatureFromSchemaJson(
243 input.artifact.outputSchemaJson ?? undefined,276 input.artifact.outputSchemaJson ?? undefined,
244 "unknown",277 "unknown",
245 320,278 320,
246 ),279 ),
247 inputSchemaJson: input.includeSchemas ? input.artifact.inputSchemaJson ?? undefined : undefined,280 inputSchemaJson: input.includeSchemas ? input.artifact.inputSchemaJson ?? undefined : undefined,
248 outputSchemaJson: input.includeSchemas ? input.artifact.outputSchemaJson ?? undefined : undefined,281 outputSchemaJson: input.includeSchemas ? input.artifact.outputSchemaJson ?? undefined : undefined,
249 ...(input.artifact.providerKind ? { providerKind: input.artifact.providerKind } : {}),282 ...(input.artifact.providerKind ? { providerKind: input.artifact.providerKind } : {}),
@@ -265,22 +298,15 @@ const loadOpenApiWorkspaceTools = (input: { · 15 unmodified lines
265 includeSchemas: boolean;298 includeSchemas: boolean;
266}): Effect.Effect<ReadonlyArray<OpenApiWorkspaceTool>, Error, never> =>299}): Effect.Effect<ReadonlyArray<OpenApiWorkspaceTool>, Error, never> =>
267 Effect.gen(function* () {300 Effect.gen(function* () {
268 const [sourceRecords, credentialBindings] = yield* Effect.all([301 const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId).pipe(
269 input.rows.sources.listByWorkspaceId(input.workspaceId),
270 input.rows.sourceCredentialBindings.listByWorkspaceId(input.workspaceId),
271 ]).pipe(
272 Effect.mapError((cause) =>302 Effect.mapError((cause) =>
273 cause instanceof Error ? cause : new Error(String(cause)),303 cause instanceof Error ? cause : new Error(String(cause)),
274 ),304 ),
275 );305 );
276
277 const documentBySourceId = new Map(306 const documentBySourceId = new Map(
278 sourceRecords.map((record) => [record.id, record.sourceDocumentText]),307 sourceRecords.map((record) => [record.id, record.sourceDocumentText]),
279 );308 );
280 const sources = yield* projectSourcesFromStorage({309 const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId);
281 sourceRecords,
282 credentialBindings,
283 });
284 310
285 const connectedOpenApiSources = sources.filter((source) =>311 const connectedOpenApiSources = sources.filter((source) =>
286 source.enabled312 source.enabled
@@ -373,35 +399,14 @@ const loadSourceById = (input: { · 86 unmodified lines
373 workspaceId: Source["workspaceId"];399 workspaceId: Source["workspaceId"];
374 sourceId: Source["id"];400 sourceId: Source["id"];
375}): Effect.Effect<Source, Error, never> =>401}): Effect.Effect<Source, Error, never> =>
376 Effect.gen(function* () {402 loadStoredSourceById(input.rows, {
377 const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId(403 workspaceId: input.workspaceId,
378 input.workspaceId,404 sourceId: input.sourceId,
379 input.sourceId,405 }).pipe(
380 ).pipe(406 Effect.mapError((cause) =>
381 Effect.mapError((cause) =>407 cause instanceof Error ? cause : new Error(String(cause)),
382 cause instanceof Error ? cause : new Error(String(cause)),408 ),
383 ),409 );
384 );
385
386 if (Option.isNone(sourceRecord)) {
387 return yield* Effect.fail(
388 new Error(`Source not found: workspaceId=${input.workspaceId} sourceId=${input.sourceId}`),
389 );
390 }
391
392 const credentialBinding = yield* input.rows.sourceCredentialBindings
393 .getByWorkspaceAndSourceId(input.workspaceId, input.sourceId)
394 .pipe(
395 Effect.mapError((cause) =>
396 cause instanceof Error ? cause : new Error(String(cause)),
397 ),
398 );
399
400 return yield* projectSourceFromStorage({
401 sourceRecord: sourceRecord.value,
402 credentialBinding: Option.isSome(credentialBinding) ? credentialBinding.value : null,
403 });
404 });
405 410
406const createWorkspaceToolCatalog = (input: {411const createWorkspaceToolCatalog = (input: {
407 workspaceId: Source["workspaceId"];412 workspaceId: Source["workspaceId"];
@@ -460,14 +465,14 @@ const createWorkspaceToolCatalog = (input: { · 52 unmodified lines
460 namespace?.startsWith("executor")465 namespace?.startsWith("executor")
461 ? Effect.succeed([] as readonly StoredToolArtifactRecord[])466 ? Effect.succeed([] as readonly StoredToolArtifactRecord[])
462 : input.rows.toolArtifacts.listByWorkspaceId(input.workspaceId, {467 : input.rows.toolArtifacts.listByWorkspaceId(input.workspaceId, {
463 namespace,468 namespace,
464 query,469 query,
465 limit,470 limit,
466 }).pipe(471 }).pipe(
467 Effect.mapError((cause) =>472 Effect.mapError((cause) =>
468 cause instanceof Error ? cause : new Error(String(cause)),473 cause instanceof Error ? cause : new Error(String(cause)),
469 ),
470 ),474 ),
475 ),
471 loadOpenApiWorkspaceTools({476 loadOpenApiWorkspaceTools({
472 rows: input.rows,477 rows: input.rows,
473 workspaceId: input.workspaceId,478 workspaceId: input.workspaceId,
@@ -555,13 +560,13 @@ const createWorkspaceToolCatalog = (input: { · 81 unmodified lines
555 namespace?.startsWith("executor")560 namespace?.startsWith("executor")
556 ? Effect.succeed([] as readonly StoredToolArtifactRecord[])561 ? Effect.succeed([] as readonly StoredToolArtifactRecord[])
557 : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, {562 : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, {
558 namespace,563 namespace,
559 query,564 query,
560 }).pipe(565 }).pipe(
561 Effect.mapError((cause) =>566 Effect.mapError((cause) =>
562 cause instanceof Error ? cause : new Error(String(cause)),567 cause instanceof Error ? cause : new Error(String(cause)),
563 ),
564 ),568 ),
569 ),
565 loadOpenApiWorkspaceTools({570 loadOpenApiWorkspaceTools({
566 rows: input.rows,571 rows: input.rows,
567 workspaceId: input.workspaceId,572 workspaceId: input.workspaceId,
@@ -653,6 +658,7 @@ const createWorkspaceToolInvoker = (input: { · 85 unmodified lines
653 const auth = yield* resolveSourceAuthMaterial({658 const auth = yield* resolveSourceAuthMaterial({
654 source: openApiTool.source,659 source: openApiTool.source,
655 resolveSecretMaterial: input.resolveSecretMaterial,660 resolveSecretMaterial: input.resolveSecretMaterial,
661 context: toSecretResolutionContext(invocation.context),
656 });662 });
657 663
658 const tools = createOpenApiToolsFromManifest({664 const tools = createOpenApiToolsFromManifest({
@@ -702,6 +708,7 @@ const createWorkspaceToolInvoker = (input: { · 43 unmodified lines
702 const auth = yield* resolveSourceAuthMaterial({708 const auth = yield* resolveSourceAuthMaterial({
703 source,709 source,
704 resolveSecretMaterial: input.resolveSecretMaterial,710 resolveSecretMaterial: input.resolveSecretMaterial,
711 context: toSecretResolutionContext(invocation.context),
705 });712 });
706 713
707 if (artifact.providerKind === "mcp") {714 if (artifact.providerKind === "mcp") {
@@ -764,9 +771,8 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 56 unmodified lines
764}): ResolveExecutionEnvironment => {771}): ResolveExecutionEnvironment => {
765 const resolveSecretMaterial =772 const resolveSecretMaterial =
766 input.resolveSecretMaterial773 input.resolveSecretMaterial
767 ?? createDbBackedSecretMaterialResolver({774 ?? createDefaultSecretMaterialResolver({
768 rows: input.rows,775 rows: input.rows,
769 fallback: createEnvSecretMaterialResolver(),
770 });776 });
771 777
772 return ({ workspaceId, onElicitation }) =>778 return ({ workspaceId, onElicitation }) =>
@@ -792,7 +798,8 @@ export class RuntimeExecutionResolverService extends Context.Tag( · 19 unmodified lines
792)<798)<
793 RuntimeExecutionResolverService,799 RuntimeExecutionResolverService,
794 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>800 ReturnType<typeof createWorkspaceExecutionEnvironmentResolver>
795>() {}801>() {
802}
796 803
797export const RuntimeExecutionResolverLive = (input: {804export const RuntimeExecutionResolverLive = (input: {
798 executionResolver?: ResolveExecutionEnvironment;805 executionResolver?: ResolveExecutionEnvironment;
@@ -801,15 +808,15 @@ export const RuntimeExecutionResolverLive = (input: { · 2 unmodified lines
801 input.executionResolver808 input.executionResolver
802 ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver)809 ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver)
803 : Layer.effect(810 : Layer.effect(
804 RuntimeExecutionResolverService,811 RuntimeExecutionResolverService,
805 Effect.gen(function* () {812 Effect.gen(function* () {
806 const rows = yield* SqlControlPlaneRowsService;813 const rows = yield* SqlControlPlaneRowsService;
807 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;814 const sourceAuthService = yield* RuntimeSourceAuthServiceTag;
808 815
809 return createWorkspaceExecutionEnvironmentResolver({816 return createWorkspaceExecutionEnvironmentResolver({
810 rows,817 rows,
811 sourceAuthService,818 sourceAuthService,
812 resolveSecretMaterial: input.resolveSecretMaterial,819 resolveSecretMaterial: input.resolveSecretMaterial,
813 });820 });
814 }),821 }),
815 );822 );
packages/control-plane/src/schema/enums.ts+4−0
@@ -20,6 +20,10 @@ export { · 19 unmodified lines
20 type SourceStatus,20 type SourceStatus,
21 type SourceTransport,21 type SourceTransport,
22} from "./models/source";22} from "./models/source";
23export {
24 CredentialAuthKindSchema,
25 type CredentialAuthKind,
26} from "./models/credential";
23export {27export {
24 PolicyApprovalModeSchema,28 PolicyApprovalModeSchema,
25 PolicyEffectSchema,29 PolicyEffectSchema,
packages/control-plane/src/schema/ids.ts+4−0
@@ -10,6 +10,9 @@ export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); · 9 unmodified lines
10export const SourceAuthSessionIdSchema = Schema.String.pipe(10export const SourceAuthSessionIdSchema = Schema.String.pipe(
11 Schema.brand("SourceAuthSessionId"),11 Schema.brand("SourceAuthSessionId"),
12);12);
13export const CredentialIdSchema = Schema.String.pipe(
14 Schema.brand("CredentialId"),
15);
13export const SecretMaterialIdSchema = Schema.String.pipe(16export const SecretMaterialIdSchema = Schema.String.pipe(
14 Schema.brand("SecretMaterialId"),17 Schema.brand("SecretMaterialId"),
15);18);
@@ -26,6 +29,7 @@ export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; · 10 unmodified lines
26export type WorkspaceId = typeof WorkspaceIdSchema.Type;29export type WorkspaceId = typeof WorkspaceIdSchema.Type;
27export type SourceId = typeof SourceIdSchema.Type;30export type SourceId = typeof SourceIdSchema.Type;
28export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;31export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type;
32export type CredentialId = typeof CredentialIdSchema.Type;
29export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;33export type SecretMaterialId = typeof SecretMaterialIdSchema.Type;
30export type PolicyId = typeof PolicyIdSchema.Type;34export type PolicyId = typeof PolicyIdSchema.Type;
31export type InstallationId = typeof InstallationIdSchema.Type;35export type InstallationId = typeof InstallationIdSchema.Type;
packages/control-plane/src/schema/index.ts+1−0
@@ -5,6 +5,7 @@ export * from "./ids"; · 4 unmodified lines
5export * from "./models/auth";5export * from "./models/auth";
6export * from "./models/workspace";6export * from "./models/workspace";
7export * from "./models/source";7export * from "./models/source";
8export * from "./models/credential";
8export * from "./models/tool-artifact";9export * from "./models/tool-artifact";
9export * from "./models/secret-material";10export * from "./models/secret-material";
10export * from "./models/source-auth-session";11export * from "./models/source-auth-session";
packages/control-plane/src/schema/models/credential.ts+52−0
@@ -0,0 +1,52 @@
1import { createSelectSchema } from "drizzle-orm/effect-schema";
2import { Schema } from "effect";
3
4import {
5 credentialsTable,
6 sourceCredentialBindingsTable,
7} from "../../persistence/schema";
8import { TimestampMsSchema } from "../common";
9import {
10 CredentialIdSchema,
11 SourceIdSchema,
12 WorkspaceIdSchema,
13} from "../ids";
14
15export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2");
16
17const credentialSchemaOverrides = {
18 id: CredentialIdSchema,
19 workspaceId: WorkspaceIdSchema,
20 authKind: CredentialAuthKindSchema,
21 authHeaderName: Schema.String,
22 authPrefix: Schema.String,
23 tokenProviderId: Schema.String,
24 tokenHandle: Schema.String,
25 refreshTokenProviderId: Schema.NullOr(Schema.String),
26 refreshTokenHandle: Schema.NullOr(Schema.String),
27 createdAt: TimestampMsSchema,
28 updatedAt: TimestampMsSchema,
29} as const;
30
31const sourceCredentialBindingSchemaOverrides = {
32 id: Schema.String,
33 workspaceId: WorkspaceIdSchema,
34 sourceId: SourceIdSchema,
35 credentialId: CredentialIdSchema,
36 createdAt: TimestampMsSchema,
37 updatedAt: TimestampMsSchema,
38} as const;
39
40export const CredentialSchema = createSelectSchema(
41 credentialsTable,
42 credentialSchemaOverrides,
43);
44
45export const SourceCredentialBindingSchema = createSelectSchema(
46 sourceCredentialBindingsTable,
47 sourceCredentialBindingSchemaOverrides,
48);
49
50export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type;
51export type Credential = typeof CredentialSchema.Type;
52export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type;
packages/control-plane/src/schema/models/source.ts+1−33
@@ -3,10 +3,7 @@ import { · 2 unmodified lines
3} from "drizzle-orm/effect-schema";3} from "drizzle-orm/effect-schema";
4import { Schema } from "effect";4import { Schema } from "effect";
5 5
6import {6import { sourcesTable } from "../../persistence/schema";
7 sourceCredentialBindingsTable,
8 sourcesTable,
9} from "../../persistence/schema";
10import { TimestampMsSchema } from "../common";7import { TimestampMsSchema } from "../common";
11import { SourceIdSchema, WorkspaceIdSchema } from "../ids";8import { SourceIdSchema, WorkspaceIdSchema } from "../ids";
12 9
@@ -66,9 +63,6 @@ const sourceRowSchemaOverrides = { · 53 unmodified lines
66 kind: SourceKindSchema,63 kind: SourceKindSchema,
67 status: SourceStatusSchema,64 status: SourceStatusSchema,
68 transport: Schema.NullOr(SourceTransportSchema),65 transport: Schema.NullOr(SourceTransportSchema),
69 authKind: Schema.Literal("none", "bearer", "oauth2"),
70 authHeaderName: Schema.NullOr(Schema.String),
71 authPrefix: Schema.NullOr(Schema.String),
72 createdAt: TimestampMsSchema,66 createdAt: TimestampMsSchema,
73 updatedAt: TimestampMsSchema,67 updatedAt: TimestampMsSchema,
74} as const;68} as const;
@@ -91,9 +85,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 16 unmodified lines
91 headersJson: Schema.NullOr(Schema.String),85 headersJson: Schema.NullOr(Schema.String),
92 specUrl: Schema.NullOr(Schema.String),86 specUrl: Schema.NullOr(Schema.String),
93 defaultHeadersJson: Schema.NullOr(Schema.String),87 defaultHeadersJson: Schema.NullOr(Schema.String),
94 authKind: Schema.Literal("none", "bearer", "oauth2"),
95 authHeaderName: Schema.NullOr(Schema.String),
96 authPrefix: Schema.NullOr(Schema.String),
97 sourceHash: Schema.NullOr(Schema.String),88 sourceHash: Schema.NullOr(Schema.String),
98 sourceDocumentText: Schema.NullOr(Schema.String),89 sourceDocumentText: Schema.NullOr(Schema.String),
99 lastError: Schema.NullOr(Schema.String),90 lastError: Schema.NullOr(Schema.String),
@@ -116,9 +107,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 16 unmodified lines
116 headersJson: row.headersJson,107 headersJson: row.headersJson,
117 specUrl: row.specUrl,108 specUrl: row.specUrl,
118 defaultHeadersJson: row.defaultHeadersJson,109 defaultHeadersJson: row.defaultHeadersJson,
119 authKind: row.authKind,
120 authHeaderName: row.authHeaderName,
121 authPrefix: row.authPrefix,
122 sourceHash: row.sourceHash,110 sourceHash: row.sourceHash,
123 sourceDocumentText: row.sourceDocumentText,111 sourceDocumentText: row.sourceDocumentText,
124 lastError: row.lastError,112 lastError: row.lastError,
@@ -139,9 +127,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 14 unmodified lines
139 headersJson: source.headersJson,127 headersJson: source.headersJson,
140 specUrl: source.specUrl,128 specUrl: source.specUrl,
141 defaultHeadersJson: source.defaultHeadersJson,129 defaultHeadersJson: source.defaultHeadersJson,
142 authKind: source.authKind,
143 authHeaderName: source.authHeaderName,
144 authPrefix: source.authPrefix,
145 sourceHash: source.sourceHash,130 sourceHash: source.sourceHash,
146 sourceDocumentText: source.sourceDocumentText,131 sourceDocumentText: source.sourceDocumentText,
147 lastError: source.lastError,132 lastError: source.lastError,
@@ -151,22 +136,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 3 unmodified lines
151 },136 },
152);137);
153 138
154const sourceCredentialBindingSchemaOverrides = {
155 workspaceId: WorkspaceIdSchema,
156 sourceId: SourceIdSchema,
157 tokenProviderId: Schema.NullOr(Schema.String),
158 tokenHandle: Schema.NullOr(Schema.String),
159 refreshTokenProviderId: Schema.NullOr(Schema.String),
160 refreshTokenHandle: Schema.NullOr(Schema.String),
161 createdAt: TimestampMsSchema,
162 updatedAt: TimestampMsSchema,
163} as const;
164
165export const SourceCredentialBindingSchema = createSelectSchema(
166 sourceCredentialBindingsTable,
167 sourceCredentialBindingSchemaOverrides,
168);
169
170export const SourceSchema = Schema.Struct({139export const SourceSchema = Schema.Struct({
171 id: SourceIdSchema,140 id: SourceIdSchema,
172 workspaceId: WorkspaceIdSchema,141 workspaceId: WorkspaceIdSchema,
@@ -194,6 +163,5 @@ export type SourceTransport = typeof SourceTransportSchema.Type; · 21 unmodified lines
194export type SecretRef = typeof SecretRefSchema.Type;163export type SecretRef = typeof SecretRefSchema.Type;
195export type SourceAuth = typeof SourceAuthSchema.Type;164export type SourceAuth = typeof SourceAuthSchema.Type;
196export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;165export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type;
197export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type;
198export type StringMap = typeof StringMapSchema.Type;166export type StringMap = typeof StringMapSchema.Type;
199export type Source = typeof SourceSchema.Type;167export type Source = typeof SourceSchema.Type;
personal-notes.md+5−0
@@ -0,0 +1,5 @@
1# Personal Notes
2
3- Credential system direction: keep `SecretRef { providerId, handle }` as the stable runtime abstraction, but move durable auth ownership to first-class `credentials` records instead of source-centric bindings.
4- Local-first is fine with `postgres`, `keychain`, dangerous `env`, and `params`, but future cloud/team support needs scoped credentials, policy around use/manage/reveal, and source-to-credential binding by `credentialId`.
5- Avoid leaking durable auth state into generic interaction history or source-specific public APIs; keep the current provider model, but treat current source-level binding as transitional.