Move durable source auth into first-class credentials and resolve secrets through provider refs so local auth can grow into shared credential scopes cleanly.
Unified · Split
| @@ -0,0 +1,41 @@ | |||
| 1 | CREATE 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 | ||
| 16 | CREATE INDEX "credentials_workspace_idx" ON "credentials" ("workspace_id","updated_at","id"); | ||
| 17 | --> statement-breakpoint | ||
| 18 | ALTER TABLE "sources" DROP CONSTRAINT "sources_auth_kind_check"; | ||
| 19 | --> statement-breakpoint | ||
| 20 | ALTER TABLE "sources" DROP COLUMN "auth_kind"; | ||
| 21 | --> statement-breakpoint | ||
| 22 | ALTER TABLE "sources" DROP COLUMN "auth_header_name"; | ||
| 23 | --> statement-breakpoint | ||
| 24 | ALTER TABLE "sources" DROP COLUMN "auth_prefix"; | ||
| 25 | --> statement-breakpoint | ||
| 26 | DROP INDEX IF EXISTS "source_credential_bindings_workspace_idx"; | ||
| 27 | --> statement-breakpoint | ||
| 28 | DROP TABLE "source_credential_bindings"; | ||
| 29 | --> statement-breakpoint | ||
| 30 | CREATE 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 | ||
| 39 | CREATE UNIQUE INDEX "source_credential_bindings_workspace_source_idx" ON "source_credential_bindings" ("workspace_id","source_id"); | ||
| 40 | --> statement-breakpoint | ||
| 41 | CREATE INDEX "source_credential_bindings_workspace_idx" ON "source_credential_bindings" ("workspace_id","updated_at","source_id"); | ||
| @@ -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, |
| @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; · 5 unmodified lines | |||
| 6 | import { createDrizzleClient, type DrizzleClient } from "./client"; | 6 | import { createDrizzleClient, type DrizzleClient } from "./client"; |
| 7 | import { | 7 | import { |
| 8 | createAccountsRepo, | 8 | createAccountsRepo, |
| 9 | createCredentialsRepo, | ||
| 9 | 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), |
| @@ -0,0 +1,69 @@ | |||
| 1 | import { | ||
| 2 | CredentialSchema, | ||
| 3 | type Credential, | ||
| 4 | } from "#schema"; | ||
| 5 | import * as Option from "effect/Option"; | ||
| 6 | import { Schema } from "effect"; | ||
| 7 | import { asc, eq } from "drizzle-orm"; | ||
| 8 | |||
| 9 | import type { DrizzleClient } from "../client"; | ||
| 10 | import type { DrizzleTables } from "../schema"; | ||
| 11 | import { firstOption, withoutCreatedAt } from "./shared"; | ||
| 12 | |||
| 13 | const decodeCredential = Schema.decodeUnknownSync(CredentialSchema); | ||
| 14 | |||
| 15 | export 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 | }); | ||
| @@ -3,6 +3,7 @@ export { createOrganizationsRepo } from "./organizations-repo"; · 2 unmodified lines | |||
| 3 | export { createOrganizationMembershipsRepo } from "./organization-memberships-repo"; | 3 | export { createOrganizationMembershipsRepo } from "./organization-memberships-repo"; |
| 4 | export { createWorkspacesRepo } from "./workspaces-repo"; | 4 | export { createWorkspacesRepo } from "./workspaces-repo"; |
| 5 | export { createSourcesRepo } from "./sources-repo"; | 5 | export { createSourcesRepo } from "./sources-repo"; |
| 6 | export { createCredentialsRepo } from "./credentials-repo"; | ||
| 6 | export { createToolArtifactsRepo } from "./tool-artifacts-repo"; | 7 | export { createToolArtifactsRepo } from "./tool-artifacts-repo"; |
| 7 | export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo"; | 8 | export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo"; |
| 8 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; | 9 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| @@ -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 tx | 158 | await tx |
| 155 | .delete(tables.sourcesTable) | 159 | .delete(tables.sourcesTable) |
| 156 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); | 160 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| @@ -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 tx | 172 | const deleted = await tx |
| 185 | .delete(tables.sourcesTable) | 173 | .delete(tables.sourcesTable) |
| 186 | .where( | 174 | .where( |
| @@ -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 tx | 92 | await tx |
| 89 | .delete(tables.sourcesTable) | 93 | .delete(tables.sourcesTable) |
| 90 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); | 94 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); |
| @@ -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 | |||
| 161 | export 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 | |||
| 167 | export const sourceCredentialBindingsTable = pgTable( | 189 | export 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, |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | |||
| 17 | createSqlControlPlaneRuntime, | 17 | createSqlControlPlaneRuntime, |
| 18 | LiveExecutionManagerService, | 18 | LiveExecutionManagerService, |
| 19 | } from "./index"; | 19 | } from "./index"; |
| 20 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; | ||
| 20 | import { withControlPlaneClient } from "./test-http-client"; | 21 | import { withControlPlaneClient } from "./test-http-client"; |
| 21 | 22 | ||
| 22 | const makeRuntime = Effect.acquireRelease( | 23 | const 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 | ||
| @@ -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"; |
| 29 | import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; | ||
| 29 | 30 | ||
| 30 | const ExecutorMcpSourceAddInputSchema = Schema.Struct({ | 31 | const 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 | ||
| @@ -37,8 +37,8 @@ import { · 36 unmodified lines | |||
| 37 | import { | 37 | import { |
| 38 | RuntimeSourceAuthServiceLive, | 38 | RuntimeSourceAuthServiceLive, |
| 39 | RuntimeSourceAuthServiceTag, | 39 | RuntimeSourceAuthServiceTag, |
| 40 | type ResolveSecretMaterial, | ||
| 41 | } from "./source-auth-service"; | 40 | } from "./source-auth-service"; |
| 41 | import type { ResolveSecretMaterial } from "./secret-material-providers"; | ||
| 42 | import { | 42 | import { |
| 43 | RuntimeExecutionResolverLive, | 43 | RuntimeExecutionResolverLive, |
| 44 | RuntimeExecutionResolverService, | 44 | RuntimeExecutionResolverService, |
| @@ -55,6 +55,8 @@ export * from "./live-execution"; · 10 unmodified lines | |||
| 55 | export * from "./local-installation"; | 55 | export * from "./local-installation"; |
| 56 | export * from "./schema-type-signature"; | 56 | export * from "./schema-type-signature"; |
| 57 | export * from "./source-auth-service"; | 57 | export * from "./source-auth-service"; |
| 58 | export * from "./secret-material-providers"; | ||
| 59 | export * from "./source-credential-interactions"; | ||
| 58 | export * from "./store"; | 60 | export * from "./store"; |
| 59 | export * from "./workspace-execution-environment"; | 61 | export * from "./workspace-execution-environment"; |
| 60 | export * from "./source-inspection"; | 62 | export * from "./source-inspection"; |
| @@ -0,0 +1,47 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { sanitizePersistedElicitationResponse } from "./live-execution"; | ||
| 4 | |||
| 5 | describe("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 | }); | ||
| @@ -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 | ||
| 61 | const SENSITIVE_INTERACTION_CONTENT_KEYS = new Set([ | ||
| 62 | "tokenRef", | ||
| 63 | "tokenSecretMaterialId", | ||
| 64 | ]); | ||
| 65 | |||
| 66 | const 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 | |||
| 82 | export 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 | |||
| 61 | export const createLiveExecutionManager = () => { | 97 | export 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, { |
| @@ -18,6 +18,10 @@ import { · 17 unmodified lines | |||
| 18 | operationErrors, | 18 | operationErrors, |
| 19 | } from "./operation-errors"; | 19 | } from "./operation-errors"; |
| 20 | import { RuntimeSourceAuthServiceTag } from "./source-auth-service"; | 20 | import { RuntimeSourceAuthServiceTag } from "./source-auth-service"; |
| 21 | import { | ||
| 22 | createSourceCredentialSelectionBearerContent, | ||
| 23 | createSourceCredentialSelectionNoneContent, | ||
| 24 | } from "./source-credential-interactions"; | ||
| 21 | import { ControlPlaneStore } from "./store"; | 25 | import { ControlPlaneStore } from "./store"; |
| 22 | 26 | ||
| 23 | const localOps = { | 27 | const 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 | ||
| @@ -0,0 +1,471 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | import { spawn } from "node:child_process"; | ||
| 3 | |||
| 4 | import type { SqlControlPlaneRows } from "#persistence"; | ||
| 5 | import { | ||
| 6 | type SecretMaterialPurpose, | ||
| 7 | SecretMaterialIdSchema, | ||
| 8 | type SecretRef, | ||
| 9 | } from "#schema"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | |||
| 13 | export const ENV_SECRET_PROVIDER_ID = "env"; | ||
| 14 | export const PARAMS_SECRET_PROVIDER_ID = "params"; | ||
| 15 | export const KEYCHAIN_SECRET_PROVIDER_ID = "keychain"; | ||
| 16 | export const POSTGRES_SECRET_PROVIDER_ID = "postgres"; | ||
| 17 | |||
| 18 | export type SecretStoreProviderId = | ||
| 19 | | typeof KEYCHAIN_SECRET_PROVIDER_ID | ||
| 20 | | typeof POSTGRES_SECRET_PROVIDER_ID; | ||
| 21 | |||
| 22 | export type SecretMaterialResolveContext = { | ||
| 23 | params?: Readonly<Record<string, string | undefined>>; | ||
| 24 | }; | ||
| 25 | |||
| 26 | export type ResolveSecretMaterial = (input: { | ||
| 27 | ref: SecretRef; | ||
| 28 | context?: SecretMaterialResolveContext; | ||
| 29 | }) => Effect.Effect<string, Error, never>; | ||
| 30 | |||
| 31 | export type StoreSecretMaterial = (input: { | ||
| 32 | purpose: SecretMaterialPurpose; | ||
| 33 | value: string; | ||
| 34 | }) => Effect.Effect<SecretRef, Error, never>; | ||
| 35 | |||
| 36 | type SecretMaterialProviderRuntime = { | ||
| 37 | rows: SqlControlPlaneRows; | ||
| 38 | env: NodeJS.ProcessEnv; | ||
| 39 | dangerouslyAllowEnvSecrets: boolean; | ||
| 40 | keychainServiceName: string; | ||
| 41 | }; | ||
| 42 | |||
| 43 | type 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 | |||
| 56 | type SecretMaterialProviderRegistry = ReadonlyMap<string, SecretMaterialProvider>; | ||
| 57 | |||
| 58 | type SpawnResult = { | ||
| 59 | exitCode: number; | ||
| 60 | stdout: string; | ||
| 61 | stderr: string; | ||
| 62 | }; | ||
| 63 | |||
| 64 | const DEFAULT_KEYCHAIN_SERVICE_NAME = "executor-v3"; | ||
| 65 | const DANGEROUSLY_ALLOW_ENV_SECRETS_ENV = "DANGEROUSLY_ALLOW_ENV_SECRETS"; | ||
| 66 | const SECRET_STORE_PROVIDER_ENV = "EXECUTOR_SECRET_STORE_PROVIDER"; | ||
| 67 | const KEYCHAIN_SERVICE_NAME_ENV = "EXECUTOR_KEYCHAIN_SERVICE_NAME"; | ||
| 68 | |||
| 69 | const 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 | |||
| 78 | const parseBooleanEnv = (value: string | undefined): boolean => { | ||
| 79 | const normalized = trimOrNull(value)?.toLowerCase(); | ||
| 80 | return normalized === "1" || normalized === "true" || normalized === "yes"; | ||
| 81 | }; | ||
| 82 | |||
| 83 | export 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 | |||
| 96 | const resolveDangerouslyAllowEnvSecrets = (value: boolean | undefined): boolean => | ||
| 97 | value ?? parseBooleanEnv(process.env[DANGEROUSLY_ALLOW_ENV_SECRETS_ENV]); | ||
| 98 | |||
| 99 | const resolveSecretStoreProviderId = (value: SecretStoreProviderId | undefined): SecretStoreProviderId => | ||
| 100 | value | ||
| 101 | ?? parseSecretStoreProviderId(process.env[SECRET_STORE_PROVIDER_ENV]) | ||
| 102 | ?? POSTGRES_SECRET_PROVIDER_ID; | ||
| 103 | |||
| 104 | const resolveKeychainServiceName = (value: string | undefined): string => | ||
| 105 | trimOrNull(value) | ||
| 106 | ?? trimOrNull(process.env[KEYCHAIN_SERVICE_NAME_ENV]) | ||
| 107 | ?? DEFAULT_KEYCHAIN_SERVICE_NAME; | ||
| 108 | |||
| 109 | const ensureNonEmptyString = (value: string | undefined): string | null => { | ||
| 110 | const trimmed = value?.trim(); | ||
| 111 | return trimmed && trimmed.length > 0 ? trimmed : null; | ||
| 112 | }; | ||
| 113 | |||
| 114 | const 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 | |||
| 162 | const 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 | |||
| 178 | const 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 | |||
| 186 | const 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 | |||
| 197 | const 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 | |||
| 216 | const 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 | |||
| 247 | const 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 | |||
| 309 | const 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 | |||
| 370 | const 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 | |||
| 388 | const 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 | |||
| 396 | const 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 | |||
| 408 | const 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 | |||
| 419 | export 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 | |||
| 442 | export 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 | }; | ||
| @@ -0,0 +1,57 @@ | |||
| 1 | import { describe, expect, it } from "vitest"; | ||
| 2 | |||
| 3 | import { createTerminalSourceAuthSessionPatch } from "./source-auth-service"; | ||
| 4 | |||
| 5 | describe("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 | }); | ||
| @@ -18,8 +18,7 @@ import { · 17 unmodified lines | |||
| 18 | } from "#persistence"; | 18 | } from "#persistence"; |
| 19 | import { | 19 | import { |
| 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"; |
| 42 | import { | 41 | import { |
| 43 | createSourceFromPayload, | 42 | createSourceFromPayload, |
| 44 | projectSourceFromStorage, | ||
| 45 | projectSourcesFromStorage, | ||
| 46 | splitSourceForStorage, | ||
| 47 | updateSourceFromPayload, | 43 | updateSourceFromPayload, |
| 48 | } from "./source-definitions"; | 44 | } from "./source-definitions"; |
| 49 | import { | 45 | import { |
| 50 | createEnvSecretMaterialResolver, | 46 | createDefaultSecretMaterialResolver, |
| 47 | createDefaultSecretMaterialStorer, | ||
| 48 | type ResolveSecretMaterial, | ||
| 49 | type StoreSecretMaterial, | ||
| 50 | } from "./secret-material-providers"; | ||
| 51 | import { | ||
| 51 | persistMcpToolArtifactsFromManifest, | 52 | persistMcpToolArtifactsFromManifest, |
| 52 | syncSourceToolArtifacts, | 53 | syncSourceToolArtifacts, |
| 53 | } from "./tool-artifacts"; | 54 | } from "./tool-artifacts"; |
| 54 | 55 | import { | |
| 55 | export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane"; | 56 | loadSourceById, |
| 57 | loadSourcesInWorkspace, | ||
| 58 | persistSource, | ||
| 59 | } from "./source-store"; | ||
| 56 | 60 | ||
| 57 | const trimOrNull = (value: string | null | undefined): string | null => { | 61 | const 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 | |||
| 119 | const toError = (cause: unknown): Error => | 123 | const toError = (cause: unknown): Error => |
| 120 | cause instanceof Error ? cause : new Error(String(cause)); | 124 | cause instanceof Error ? cause : new Error(String(cause)); |
| 121 | 125 | ||
| 122 | const 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 | |||
| 133 | const 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 | |||
| 160 | const 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 | ||
| 184 | const probeMcpSourceWithoutAuth = ( | 128 | const probeMcpSourceWithoutAuth = ( |
| 185 | source: Source, | 129 | source: Source, |
| @@ -303,7 +247,6 @@ const startOAuthAuthorization = (input: { · 117 unmodified lines | |||
| 303 | 247 | ||
| 304 | type ExchangedTokens = { | 248 | type 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 | ||
| 340 | export 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 | |||
| 398 | const completeLiveInteraction = (input: { | 363 | const 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 | ||
| 449 | const 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 | |||
| 467 | export type ExecutorSourceAddResult = | 414 | export 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 | ||
| 496 | export type ExecutorAddSourceInput = | 442 | export type ExecutorAddSourceInput = |
| @@ -527,9 +473,9 @@ const shouldPromptForOpenApiCredentialSetup = (input: { · 30 unmodified lines | |||
| 527 | }; | 473 | }; |
| 528 | 474 | ||
| 529 | const materializeExecutorOpenApiAuth = (input: { | 475 | const 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 === null | 496 | token === null |
| 552 | && tokenEnvVar === null | 497 | && 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 !== null | 510 | 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 !== null | 518 | 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 | ||
| 618 | export type ResolveSecretMaterial = (ref: SecretRef) => Effect.Effect<string, Error, never>; | ||
| 619 | |||
| 620 | export 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 | |||
| 647 | export const createRuntimeSourceAuthService = (input: { | 568 | export 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 = existing | 670 | 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_token | 988 | 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, |
| @@ -0,0 +1,31 @@ | |||
| 1 | import { SecretRefSchema, type SecretRef } from "#schema"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | export 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 | |||
| 14 | export type SourceCredentialSelectionContent = | ||
| 15 | typeof SourceCredentialSelectionContentSchema.Type; | ||
| 16 | |||
| 17 | export const decodeSourceCredentialSelectionContent = Schema.decodeUnknownSync( | ||
| 18 | SourceCredentialSelectionContentSchema, | ||
| 19 | ); | ||
| 20 | |||
| 21 | export const createSourceCredentialSelectionNoneContent = | ||
| 22 | (): SourceCredentialSelectionContent => ({ | ||
| 23 | authKind: "none", | ||
| 24 | }); | ||
| 25 | |||
| 26 | export const createSourceCredentialSelectionBearerContent = ( | ||
| 27 | tokenRef: SecretRef, | ||
| 28 | ): SourceCredentialSelectionContent => ({ | ||
| 29 | authKind: "bearer", | ||
| 30 | tokenRef, | ||
| 31 | }); | ||
| @@ -3,6 +3,7 @@ import type { · 2 unmodified lines | |||
| 3 | UpdateSourcePayload, | 3 | UpdateSourcePayload, |
| 4 | } from "#api"; | 4 | } from "#api"; |
| 5 | import type { | 5 | import 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"; |
| 15 | import { CredentialIdSchema } from "#schema"; | ||
| 14 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 15 | import * as Schema from "effect/Schema"; | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | ||
| @@ -232,8 +234,11 @@ export const updateSourceFromPayload = (input: { · 215 unmodified lines | |||
| 232 | 234 | ||
| 233 | export const splitSourceForStorage = (input: { | 235 | export 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 | |||
| 298 | export const projectSourceFromStorage = (input: { | 317 | export 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 !== null | 365 | 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 | |||
| 386 | export const projectSourcesFromStorage = (input: { | 401 | export 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 | }; |
| @@ -27,7 +27,7 @@ import * as Effect from "effect/Effect"; · 26 unmodified lines | |||
| 27 | import * as Option from "effect/Option"; | 27 | import * as Option from "effect/Option"; |
| 28 | 28 | ||
| 29 | import { operationErrors } from "./operation-errors"; | 29 | import { operationErrors } from "./operation-errors"; |
| 30 | import { projectSourceFromStorage } from "./source-definitions"; | 30 | import { loadSourceById as loadStoredSourceById } from "./source-store"; |
| 31 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; | 31 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; |
| 32 | import { namespaceFromSourceName } from "./tool-artifacts"; | 32 | import { 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( |
| @@ -0,0 +1,132 @@ | |||
| 1 | import type { | ||
| 2 | Credential, | ||
| 3 | Source, | ||
| 4 | WorkspaceId, | ||
| 5 | } from "#schema"; | ||
| 6 | import { type SqlControlPlaneRows } from "#persistence"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Option from "effect/Option"; | ||
| 9 | |||
| 10 | import { | ||
| 11 | projectSourceFromStorage, | ||
| 12 | projectSourcesFromStorage, | ||
| 13 | splitSourceForStorage, | ||
| 14 | } from "./source-definitions"; | ||
| 15 | |||
| 16 | export 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 | |||
| 32 | export 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 | |||
| 64 | export 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 | |||
| 86 | export 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 | }); | ||
| @@ -25,14 +25,14 @@ import { · 24 unmodified lines | |||
| 25 | import { | 25 | import { |
| 26 | operationErrors, | 26 | operationErrors, |
| 27 | } from "./operation-errors"; | 27 | } from "./operation-errors"; |
| 28 | import { | 28 | import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; |
| 29 | createDbBackedSecretMaterialResolver, | ||
| 30 | } from "./source-auth-service"; | ||
| 31 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; | 29 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; |
| 30 | import { syncSourceToolArtifacts } from "./tool-artifacts"; | ||
| 32 | import { | 31 | import { |
| 33 | createEnvSecretMaterialResolver, | 32 | loadSourceById, |
| 34 | syncSourceToolArtifacts, | 33 | loadSourcesInWorkspace, |
| 35 | } from "./tool-artifacts"; | 34 | removeCredentialBindingForSource, |
| 35 | } from "./source-store"; | ||
| 36 | 36 | ||
| 37 | const sourceOps = { | 37 | const 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 | ||
| 107 | export const listSources = (workspaceId: WorkspaceId) => | 106 | export 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 | ||
| 130 | export const createSource = (input: { | 117 | export 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 | ||
| 213 | export const updateSource = (input: { | 187 | export 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 | ); |
| @@ -21,9 +21,10 @@ import { · 20 unmodified lines | |||
| 21 | import * as Effect from "effect/Effect"; | 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| 23 | 23 | ||
| 24 | export type ResolveSourceSecretMaterial = ( | 24 | import type { |
| 25 | ref: SecretRef, | 25 | ResolveSecretMaterial as ResolveSourceSecretMaterial, |
| 26 | ) => Effect.Effect<string, Error, never>; | 26 | SecretMaterialResolveContext, |
| 27 | } from "./secret-material-providers"; | ||
| 27 | 28 | ||
| 28 | export type ResolvedSourceAuthMaterial = { | 29 | export 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 | ||
| 125 | export 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 | |||
| 144 | export const resolveSourceAuthMaterial = (input: { | 126 | export 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.token | 138 | ? 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: { |
| @@ -15,6 +15,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ · 14 unmodified lines | |||
| 15 | import { describe, expect, it } from "@effect/vitest"; | 15 | import { describe, expect, it } from "@effect/vitest"; |
| 16 | import { | 16 | import { |
| 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 | ||
| 531 | const makeResolver = (persistence: SqlControlPlanePersistence) => | 579 | const 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; |
| @@ -33,24 +33,31 @@ import type { · 32 unmodified lines | |||
| 33 | StoredToolArtifactRecord, | 33 | StoredToolArtifactRecord, |
| 34 | } from "#schema"; | 34 | } from "#schema"; |
| 35 | import * as Context from "effect/Context"; | 35 | import * as Context from "effect/Context"; |
| 36 | import * as Either from "effect/Either"; | ||
| 36 | import * as Effect from "effect/Effect"; | 37 | import * as Effect from "effect/Effect"; |
| 37 | import * as Layer from "effect/Layer"; | 38 | import * as Layer from "effect/Layer"; |
| 38 | import * as Option from "effect/Option"; | 39 | import * as Option from "effect/Option"; |
| 40 | import * as Schema from "effect/Schema"; | ||
| 39 | 41 | ||
| 40 | import type { | 42 | import type { |
| 41 | ExecutionEnvironment, | 43 | ExecutionEnvironment, |
| 42 | ResolveExecutionEnvironment, | 44 | ResolveExecutionEnvironment, |
| 43 | } from "./execution-state"; | 45 | } from "./execution-state"; |
| 44 | import { createExecutorToolMap } from "./executor-tools"; | 46 | import { createExecutorToolMap } from "./executor-tools"; |
| 45 | import { projectSourceFromStorage, projectSourcesFromStorage } from "./source-definitions"; | 47 | import { |
| 48 | loadSourceById as loadStoredSourceById, | ||
| 49 | loadSourcesInWorkspace, | ||
| 50 | } from "./source-store"; | ||
| 46 | import { | 51 | import { |
| 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"; |
| 52 | import { | 55 | import { |
| 53 | createEnvSecretMaterialResolver, | 56 | createDefaultSecretMaterialResolver, |
| 57 | type ResolveSecretMaterial, | ||
| 58 | type SecretMaterialResolveContext, | ||
| 59 | } from "./secret-material-providers"; | ||
| 60 | import { | ||
| 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 | ||
| 119 | const SecretResolutionContextEnvelopeSchema = Schema.Struct({ | ||
| 120 | params: Schema.optional( | ||
| 121 | Schema.Record({ | ||
| 122 | key: Schema.String, | ||
| 123 | value: Schema.String, | ||
| 124 | }), | ||
| 125 | ), | ||
| 126 | }); | ||
| 127 | |||
| 128 | const decodeSecretResolutionContextEnvelope = Schema.decodeUnknownEither( | ||
| 129 | SecretResolutionContextEnvelopeSchema, | ||
| 130 | ); | ||
| 131 | |||
| 132 | const 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 | |||
| 112 | const queryTokenWeight = (token: string): number => | 145 | const 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.enabled | 312 | 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 | ||
| 406 | const createWorkspaceToolCatalog = (input: { | 411 | const 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.resolveSecretMaterial | 773 | 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 | ||
| 797 | export const RuntimeExecutionResolverLive = (input: { | 804 | export const RuntimeExecutionResolverLive = (input: { |
| 798 | executionResolver?: ResolveExecutionEnvironment; | 805 | executionResolver?: ResolveExecutionEnvironment; |
| @@ -801,15 +808,15 @@ export const RuntimeExecutionResolverLive = (input: { · 2 unmodified lines | |||
| 801 | input.executionResolver | 808 | 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 | ); |
| @@ -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"; |
| 23 | export { | ||
| 24 | CredentialAuthKindSchema, | ||
| 25 | type CredentialAuthKind, | ||
| 26 | } from "./models/credential"; | ||
| 23 | export { | 27 | export { |
| 24 | PolicyApprovalModeSchema, | 28 | PolicyApprovalModeSchema, |
| 25 | PolicyEffectSchema, | 29 | PolicyEffectSchema, |
| @@ -10,6 +10,9 @@ export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); · 9 unmodified lines | |||
| 10 | export const SourceAuthSessionIdSchema = Schema.String.pipe( | 10 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 11 | Schema.brand("SourceAuthSessionId"), | 11 | Schema.brand("SourceAuthSessionId"), |
| 12 | ); | 12 | ); |
| 13 | export const CredentialIdSchema = Schema.String.pipe( | ||
| 14 | Schema.brand("CredentialId"), | ||
| 15 | ); | ||
| 13 | export const SecretMaterialIdSchema = Schema.String.pipe( | 16 | export 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 | |||
| 26 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; | 29 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 27 | export type SourceId = typeof SourceIdSchema.Type; | 30 | export type SourceId = typeof SourceIdSchema.Type; |
| 28 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; | 31 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 32 | export type CredentialId = typeof CredentialIdSchema.Type; | ||
| 29 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; | 33 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 30 | export type PolicyId = typeof PolicyIdSchema.Type; | 34 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 31 | export type InstallationId = typeof InstallationIdSchema.Type; | 35 | export type InstallationId = typeof InstallationIdSchema.Type; |
| @@ -5,6 +5,7 @@ export * from "./ids"; · 4 unmodified lines | |||
| 5 | export * from "./models/auth"; | 5 | export * from "./models/auth"; |
| 6 | export * from "./models/workspace"; | 6 | export * from "./models/workspace"; |
| 7 | export * from "./models/source"; | 7 | export * from "./models/source"; |
| 8 | export * from "./models/credential"; | ||
| 8 | export * from "./models/tool-artifact"; | 9 | export * from "./models/tool-artifact"; |
| 9 | export * from "./models/secret-material"; | 10 | export * from "./models/secret-material"; |
| 10 | export * from "./models/source-auth-session"; | 11 | export * from "./models/source-auth-session"; |
| @@ -0,0 +1,52 @@ | |||
| 1 | import { createSelectSchema } from "drizzle-orm/effect-schema"; | ||
| 2 | import { Schema } from "effect"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | credentialsTable, | ||
| 6 | sourceCredentialBindingsTable, | ||
| 7 | } from "../../persistence/schema"; | ||
| 8 | import { TimestampMsSchema } from "../common"; | ||
| 9 | import { | ||
| 10 | CredentialIdSchema, | ||
| 11 | SourceIdSchema, | ||
| 12 | WorkspaceIdSchema, | ||
| 13 | } from "../ids"; | ||
| 14 | |||
| 15 | export const CredentialAuthKindSchema = Schema.Literal("bearer", "oauth2"); | ||
| 16 | |||
| 17 | const 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 | |||
| 31 | const sourceCredentialBindingSchemaOverrides = { | ||
| 32 | id: Schema.String, | ||
| 33 | workspaceId: WorkspaceIdSchema, | ||
| 34 | sourceId: SourceIdSchema, | ||
| 35 | credentialId: CredentialIdSchema, | ||
| 36 | createdAt: TimestampMsSchema, | ||
| 37 | updatedAt: TimestampMsSchema, | ||
| 38 | } as const; | ||
| 39 | |||
| 40 | export const CredentialSchema = createSelectSchema( | ||
| 41 | credentialsTable, | ||
| 42 | credentialSchemaOverrides, | ||
| 43 | ); | ||
| 44 | |||
| 45 | export const SourceCredentialBindingSchema = createSelectSchema( | ||
| 46 | sourceCredentialBindingsTable, | ||
| 47 | sourceCredentialBindingSchemaOverrides, | ||
| 48 | ); | ||
| 49 | |||
| 50 | export type CredentialAuthKind = typeof CredentialAuthKindSchema.Type; | ||
| 51 | export type Credential = typeof CredentialSchema.Type; | ||
| 52 | export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type; | ||
| @@ -3,10 +3,7 @@ import { · 2 unmodified lines | |||
| 3 | } from "drizzle-orm/effect-schema"; | 3 | } from "drizzle-orm/effect-schema"; |
| 4 | import { Schema } from "effect"; | 4 | import { Schema } from "effect"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { sourcesTable } from "../../persistence/schema"; |
| 7 | sourceCredentialBindingsTable, | ||
| 8 | sourcesTable, | ||
| 9 | } from "../../persistence/schema"; | ||
| 10 | import { TimestampMsSchema } from "../common"; | 7 | import { TimestampMsSchema } from "../common"; |
| 11 | import { SourceIdSchema, WorkspaceIdSchema } from "../ids"; | 8 | import { 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 | ||
| 154 | const 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 | |||
| 165 | export const SourceCredentialBindingSchema = createSelectSchema( | ||
| 166 | sourceCredentialBindingsTable, | ||
| 167 | sourceCredentialBindingSchemaOverrides, | ||
| 168 | ); | ||
| 169 | |||
| 170 | export const SourceSchema = Schema.Struct({ | 139 | export 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 | |||
| 194 | export type SecretRef = typeof SecretRefSchema.Type; | 163 | export type SecretRef = typeof SecretRefSchema.Type; |
| 195 | export type SourceAuth = typeof SourceAuthSchema.Type; | 164 | export type SourceAuth = typeof SourceAuthSchema.Type; |
| 196 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; | 165 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; |
| 197 | export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type; | ||
| 198 | export type StringMap = typeof StringMapSchema.Type; | 166 | export type StringMap = typeof StringMapSchema.Type; |
| 199 | export type Source = typeof SourceSchema.Type; | 167 | export type Source = typeof SourceSchema.Type; |
| @@ -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. | ||