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 | 66 | headersJson: null, |
| 67 | 67 | specUrl: "https://api.github.com/openapi.json", |
| 68 | 68 | defaultHeadersJson: null, |
| 69 | − authKind: "none", | |
| 70 | − authHeaderName: null, | |
| 71 | − authPrefix: null, | |
| 72 | 69 | sourceHash: null, |
| 73 | 70 | sourceDocumentText: null, |
| 74 | 71 | lastError: null, |
| @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; · 5 unmodified lines | ||
| 6 | 6 | import { createDrizzleClient, type DrizzleClient } from "./client"; |
| 7 | 7 | import { |
| 8 | 8 | createAccountsRepo, |
| 9 | + createCredentialsRepo, | |
| 9 | 10 | createExecutionInteractionsRepo, |
| 10 | 11 | createExecutionsRepo, |
| 11 | 12 | createLocalInstallationsRepo, |
| @@ -51,6 +52,7 @@ const createRows = (client: DrizzleClient, tables: DrizzleTables = drizzleSchema · 39 unmodified lines | ||
| 51 | 52 | organizationMemberships: createOrganizationMembershipsRepo(client, tables), |
| 52 | 53 | workspaces: createWorkspacesRepo(client, tables), |
| 53 | 54 | sources: createSourcesRepo(client, tables), |
| 55 | + credentials: createCredentialsRepo(client, tables), | |
| 54 | 56 | toolArtifacts: createToolArtifactsRepo(client, tables), |
| 55 | 57 | sourceCredentialBindings: createSourceCredentialBindingsRepo(client, tables), |
| 56 | 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 | 3 | export { createOrganizationMembershipsRepo } from "./organization-memberships-repo"; |
| 4 | 4 | export { createWorkspacesRepo } from "./workspaces-repo"; |
| 5 | 5 | export { createSourcesRepo } from "./sources-repo"; |
| 6 | +export { createCredentialsRepo } from "./credentials-repo"; | |
| 6 | 7 | export { createToolArtifactsRepo } from "./tool-artifacts-repo"; |
| 7 | 8 | export { createSourceCredentialBindingsRepo } from "./source-credential-bindings-repo"; |
| 8 | 9 | export { createSecretMaterialsRepo } from "./secret-materials-repo"; |
| @@ -151,6 +151,10 @@ export const createOrganizationsRepo = ( · 150 unmodified lines | ||
| 151 | 151 | .delete(tables.sourceCredentialBindingsTable) |
| 152 | 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 | 158 | await tx |
| 155 | 159 | .delete(tables.sourcesTable) |
| 156 | 160 | .where(inArray(tables.sourcesTable.workspaceId, workspaceIds)); |
| @@ -31,9 +31,6 @@ const toSourceUpdateSet = ( · 30 unmodified lines | ||
| 31 | 31 | if (patch.defaultHeadersJson !== undefined) { |
| 32 | 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 | 34 | if (patch.sourceHash !== undefined) set.sourceHash = patch.sourceHash; |
| 38 | 35 | if (patch.sourceDocumentText !== undefined) set.sourceDocumentText = patch.sourceDocumentText; |
| 39 | 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 | 172 | const deleted = await tx |
| 185 | 173 | .delete(tables.sourcesTable) |
| 186 | 174 | .where( |
| @@ -85,6 +85,10 @@ export const createWorkspacesRepo = ( · 84 unmodified lines | ||
| 85 | 85 | .delete(tables.sourceCredentialBindingsTable) |
| 86 | 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 | 92 | await tx |
| 89 | 93 | .delete(tables.sourcesTable) |
| 90 | 94 | .where(eq(tables.sourcesTable.workspaceId, workspaceId)); |
| @@ -20,6 +20,7 @@ export const tableNames = { · 19 unmodified lines | ||
| 20 | 20 | toolArtifactParameters: "tool_artifact_parameters", |
| 21 | 21 | toolArtifactRequestBodyContentTypes: "tool_artifact_request_body_content_types", |
| 22 | 22 | toolArtifactRefHintKeys: "tool_artifact_ref_hint_keys", |
| 23 | + credentials: "credentials", | |
| 23 | 24 | sourceCredentialBindings: "source_credential_bindings", |
| 24 | 25 | secretMaterials: "secret_materials", |
| 25 | 26 | sourceAuthSessions: "source_auth_sessions", |
| @@ -131,9 +132,6 @@ export const sourcesTable = pgTable( · 105 unmodified lines | ||
| 131 | 132 | headersJson: text("headers_json"), |
| 132 | 133 | specUrl: text("spec_url"), |
| 133 | 134 | defaultHeadersJson: text("default_headers_json"), |
| 134 | − authKind: text("auth_kind").notNull(), | |
| 135 | − authHeaderName: text("auth_header_name"), | |
| 136 | − authPrefix: text("auth_prefix"), | |
| 137 | 135 | sourceHash: text("source_hash"), |
| 138 | 136 | sourceDocumentText: text("source_document_text"), |
| 139 | 137 | lastError: text("last_error"), |
| @@ -157,9 +155,33 @@ export const sourcesTable = pgTable( · 17 unmodified lines | ||
| 157 | 155 | "sources_transport_check", |
| 158 | 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 | 182 | check( |
| 161 | − "sources_auth_kind_check", | |
| 162 | − sql`${table.authKind} in ('none', 'bearer', 'oauth2')`, | |
| 183 | + "credentials_auth_kind_check", | |
| 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 | 189 | export const sourceCredentialBindingsTable = pgTable( |
| 168 | 190 | tableNames.sourceCredentialBindings, |
| 169 | 191 | { |
| 192 | + id: text("id").notNull().primaryKey(), | |
| 170 | 193 | workspaceId: text("workspace_id").notNull(), |
| 171 | 194 | sourceId: text("source_id").notNull(), |
| 172 | − tokenProviderId: text("token_provider_id"), | |
| 173 | − tokenHandle: text("token_handle"), | |
| 174 | − refreshTokenProviderId: text("refresh_token_provider_id"), | |
| 175 | − refreshTokenHandle: text("refresh_token_handle"), | |
| 195 | + credentialId: text("credential_id").notNull(), | |
| 176 | 196 | createdAt: bigint("created_at", { mode: "number" }).notNull(), |
| 177 | 197 | updatedAt: bigint("updated_at", { mode: "number" }).notNull(), |
| 178 | 198 | }, |
| 179 | 199 | (table) => [ |
| 180 | − primaryKey({ | |
| 181 | − columns: [table.workspaceId, table.sourceId], | |
| 182 | − }), | |
| 200 | + uniqueIndex("source_credential_bindings_workspace_source_idx").on( | |
| 201 | + table.workspaceId, | |
| 202 | + table.sourceId, | |
| 203 | + ), | |
| 183 | 204 | index("source_credential_bindings_workspace_idx").on( |
| 184 | 205 | table.workspaceId, |
| 185 | 206 | table.updatedAt, |
| @@ -493,6 +514,7 @@ export const drizzleSchema = { · 307 unmodified lines | ||
| 493 | 514 | organizationMembershipsTable, |
| 494 | 515 | workspacesTable, |
| 495 | 516 | sourcesTable, |
| 517 | + credentialsTable, | |
| 496 | 518 | toolArtifactsTable, |
| 497 | 519 | toolArtifactParametersTable, |
| 498 | 520 | toolArtifactRequestBodyContentTypesTable, |
| @@ -17,6 +17,7 @@ import { · 16 unmodified lines | ||
| 17 | 17 | createSqlControlPlaneRuntime, |
| 18 | 18 | LiveExecutionManagerService, |
| 19 | 19 | } from "./index"; |
| 20 | +import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; | |
| 20 | 21 | import { withControlPlaneClient } from "./test-http-client"; |
| 21 | 22 | |
| 22 | 23 | const makeRuntime = Effect.acquireRelease( |
| @@ -155,9 +156,6 @@ describe("control-plane-runtime", () => { · 132 unmodified lines | ||
| 155 | 156 | headersJson: null, |
| 156 | 157 | specUrl: "https://example.com/github-openapi.yaml", |
| 157 | 158 | defaultHeadersJson: null, |
| 158 | − authKind: "none", | |
| 159 | − authHeaderName: null, | |
| 160 | − authPrefix: null, | |
| 161 | 159 | sourceHash: null, |
| 162 | 160 | sourceDocumentText: null, |
| 163 | 161 | lastError: null, |
| @@ -242,10 +240,21 @@ describe("control-plane-runtime", () => { · 78 unmodified lines | ||
| 242 | 240 | const response = yield* Fiber.join(interactionFiber); |
| 243 | 241 | expect(response.action).toBe("accept"); |
| 244 | 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 | 254 | const tokenSecretMaterialId = SecretMaterialIdSchema.make( |
| 248 | − String(response.content?.tokenSecretMaterialId), | |
| 255 | + decodedCredentialSelection.authKind === "bearer" | |
| 256 | + ? decodedCredentialSelection.tokenRef.handle | |
| 257 | + : "", | |
| 249 | 258 | ); |
| 250 | 259 | const storedSecret = yield* runtime.persistence.rows.secretMaterials.getById( |
| 251 | 260 | tokenSecretMaterialId, |
| @@ -258,7 +267,7 @@ describe("control-plane-runtime", () => { · 6 unmodified lines | ||
| 258 | 267 | ); |
| 259 | 268 | assertTrue(Option.isSome(storedInteraction)); |
| 260 | 269 | expect(storedInteraction.value.responseJson).toContain("\"authKind\":\"bearer\""); |
| 261 | − expect(storedInteraction.value.responseJson).toContain("tokenSecretMaterialId"); | |
| 270 | + expect(storedInteraction.value.responseJson).not.toContain("tokenRef"); | |
| 262 | 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 | 313 | headersJson: null, |
| 305 | 314 | specUrl: "https://example.com/github-openapi.yaml", |
| 306 | 315 | defaultHeadersJson: null, |
| 307 | − authKind: "none", | |
| 308 | − authHeaderName: null, | |
| 309 | − authPrefix: null, | |
| 310 | 316 | sourceHash: null, |
| 311 | 317 | sourceDocumentText: null, |
| 312 | 318 | lastError: null, |
| @@ -381,7 +387,7 @@ describe("control-plane-runtime", () => { · 68 unmodified lines | ||
| 381 | 387 | ); |
| 382 | 388 | assertTrue(Option.isSome(storedInteraction)); |
| 383 | 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 | 26 | deriveSchemaJson, |
| 27 | 27 | deriveSchemaTypeSignature, |
| 28 | 28 | } from "./schema-type-signature"; |
| 29 | +import { decodeSourceCredentialSelectionContent } from "./source-credential-interactions"; | |
| 29 | 30 | |
| 30 | 31 | const ExecutorMcpSourceAddInputSchema = Schema.Struct({ |
| 31 | 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 | − response.content && typeof response.content.authKind === "string" | |
| 197 | − ? response.content.authKind.trim() | |
| 198 | − : ""; | |
| 196 | + const content = yield* Effect.try({ | |
| 197 | + try: () => decodeSourceCredentialSelectionContent(response.content), | |
| 198 | + catch: () => | |
| 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 | 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 | 206 | return { |
| 216 | 207 | kind: "bearer", |
| 217 | − tokenSecretMaterialId, | |
| 208 | + tokenRef: content.tokenRef, | |
| 218 | 209 | } satisfies ExecutorOpenApiSourceAuthInput; |
| 219 | 210 | }); |
| 220 | 211 | |
| @@ -37,8 +37,8 @@ import { · 36 unmodified lines | ||
| 37 | 37 | import { |
| 38 | 38 | RuntimeSourceAuthServiceLive, |
| 39 | 39 | RuntimeSourceAuthServiceTag, |
| 40 | − type ResolveSecretMaterial, | |
| 41 | 40 | } from "./source-auth-service"; |
| 41 | +import type { ResolveSecretMaterial } from "./secret-material-providers"; | |
| 42 | 42 | import { |
| 43 | 43 | RuntimeExecutionResolverLive, |
| 44 | 44 | RuntimeExecutionResolverService, |
| @@ -55,6 +55,8 @@ export * from "./live-execution"; · 10 unmodified lines | ||
| 55 | 55 | export * from "./local-installation"; |
| 56 | 56 | export * from "./schema-type-signature"; |
| 57 | 57 | export * from "./source-auth-service"; |
| 58 | +export * from "./secret-material-providers"; | |
| 59 | +export * from "./source-credential-interactions"; | |
| 58 | 60 | export * from "./store"; |
| 59 | 61 | export * from "./workspace-execution-environment"; |
| 60 | 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 | 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 | 97 | export const createLiveExecutionManager = () => { |
| 62 | 98 | const runs = new Map<Execution["id"], LiveRunEntry>(); |
| 63 | 99 | |
| @@ -141,7 +177,7 @@ export const createLiveExecutionManager = () => { · 77 unmodified lines | ||
| 141 | 177 | |
| 142 | 178 | yield* rows.executionInteractions.update(interaction.id, { |
| 143 | 179 | status: resolved.action === "cancel" ? "cancelled" : "resolved", |
| 144 | − responseJson: serializeJson(resolved), | |
| 180 | + responseJson: serializeJson(sanitizePersistedElicitationResponse(resolved)), | |
| 145 | 181 | updatedAt: resolvedAt, |
| 146 | 182 | }); |
| 147 | 183 | yield* rows.executions.update(executionId, { |
| @@ -18,6 +18,10 @@ import { · 17 unmodified lines | ||
| 18 | 18 | operationErrors, |
| 19 | 19 | } from "./operation-errors"; |
| 20 | 20 | import { RuntimeSourceAuthServiceTag } from "./source-auth-service"; |
| 21 | +import { | |
| 22 | + createSourceCredentialSelectionBearerContent, | |
| 23 | + createSourceCredentialSelectionNoneContent, | |
| 24 | +} from "./source-credential-interactions"; | |
| 21 | 25 | import { ControlPlaneStore } from "./store"; |
| 22 | 26 | |
| 23 | 27 | const localOps = { |
| @@ -253,9 +257,7 @@ export const submitSourceCredentialInteraction = (input: { · 229 unmodified lines | ||
| 253 | 257 | executionId: interaction.executionId, |
| 254 | 258 | response: { |
| 255 | 259 | action: "accept", |
| 256 | − content: { | |
| 257 | − authKind: "none", | |
| 258 | − }, | |
| 260 | + content: createSourceCredentialSelectionNoneContent(), | |
| 259 | 261 | }, |
| 260 | 262 | }); |
| 261 | 263 | |
| @@ -286,7 +288,7 @@ export const submitSourceCredentialInteraction = (input: { · 24 unmodified lines | ||
| 286 | 288 | } |
| 287 | 289 | |
| 288 | 290 | const sourceAuthService = yield* RuntimeSourceAuthServiceTag; |
| 289 | − const tokenSecretMaterialId = yield* sourceAuthService.storeSecretMaterial({ | |
| 291 | + const tokenRef = yield* sourceAuthService.storeSecretMaterial({ | |
| 290 | 292 | purpose: "auth_material", |
| 291 | 293 | value: token, |
| 292 | 294 | }).pipe( |
| @@ -303,10 +305,7 @@ export const submitSourceCredentialInteraction = (input: { · 10 unmodified lines | ||
| 303 | 305 | executionId: interaction.executionId, |
| 304 | 306 | response: { |
| 305 | 307 | action: "accept", |
| 306 | − content: { | |
| 307 | − authKind: "bearer", | |
| 308 | − tokenSecretMaterialId, | |
| 309 | − }, | |
| 308 | + content: createSourceCredentialSelectionBearerContent(tokenRef), | |
| 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 | 18 | } from "#persistence"; |
| 19 | 19 | import { |
| 20 | 20 | ExecutionIdSchema, |
| 21 | − type SecretMaterialId, | |
| 22 | − SecretMaterialIdSchema, | |
| 21 | + type SecretMaterialPurpose, | |
| 23 | 22 | Source, |
| 24 | 23 | SourceAuthSession, |
| 25 | 24 | SourceAuthSessionIdSchema, |
| @@ -41,18 +40,23 @@ import { · 15 unmodified lines | ||
| 41 | 40 | } from "./live-execution"; |
| 42 | 41 | import { |
| 43 | 42 | createSourceFromPayload, |
| 44 | − projectSourceFromStorage, | |
| 45 | − projectSourcesFromStorage, | |
| 46 | − splitSourceForStorage, | |
| 47 | 43 | updateSourceFromPayload, |
| 48 | 44 | } from "./source-definitions"; |
| 49 | 45 | import { |
| 50 | − createEnvSecretMaterialResolver, | |
| 46 | + createDefaultSecretMaterialResolver, | |
| 47 | + createDefaultSecretMaterialStorer, | |
| 48 | + type ResolveSecretMaterial, | |
| 49 | + type StoreSecretMaterial, | |
| 50 | +} from "./secret-material-providers"; | |
| 51 | +import { | |
| 51 | 52 | persistMcpToolArtifactsFromManifest, |
| 52 | 53 | syncSourceToolArtifacts, |
| 53 | 54 | } from "./tool-artifacts"; |
| 54 | − | |
| 55 | −export const CONTROL_PLANE_SECRET_PROVIDER_ID = "control-plane"; | |
| 55 | +import { | |
| 56 | + loadSourceById, | |
| 57 | + loadSourcesInWorkspace, | |
| 58 | + persistSource, | |
| 59 | +} from "./source-store"; | |
| 56 | 60 | |
| 57 | 61 | const trimOrNull = (value: string | null | undefined): string | null => { |
| 58 | 62 | if (value === null || value === undefined) { |
| @@ -119,67 +123,7 @@ const normalizeEndpoint = (endpoint: string): string => { · 60 unmodified lines | ||
| 119 | 123 | const toError = (cause: unknown): Error => |
| 120 | 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 | 128 | const probeMcpSourceWithoutAuth = ( |
| 185 | 129 | source: Source, |
| @@ -303,7 +247,6 @@ const startOAuthAuthorization = (input: { · 117 unmodified lines | ||
| 303 | 247 | |
| 304 | 248 | type ExchangedTokens = { |
| 305 | 249 | tokens: OAuthTokens; |
| 306 | − clientInformationJson: string | null; | |
| 307 | 250 | resourceMetadataUrl: string | null; |
| 308 | 251 | authorizationServerUrl: string | null; |
| 309 | 252 | resourceMetadataJson: string | null; |
| @@ -385,7 +328,6 @@ const exchangeOAuthAuthorizationCode = (input: { · 75 unmodified lines | ||
| 385 | 328 | |
| 386 | 329 | return { |
| 387 | 330 | tokens: captured.tokens, |
| 388 | − clientInformationJson: serializeJson(captured.clientInformation), | |
| 389 | 331 | resourceMetadataUrl: captured.discoveryState?.resourceMetadataUrl ?? null, |
| 390 | 332 | authorizationServerUrl: captured.discoveryState?.authorizationServerUrl ?? null, |
| 391 | 333 | resourceMetadataJson: serializeJson(captured.discoveryState?.resourceMetadata), |
| @@ -395,6 +337,29 @@ const exchangeOAuthAuthorizationCode = (input: { · 3 unmodified lines | ||
| 395 | 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 | 363 | const completeLiveInteraction = (input: { |
| 399 | 364 | liveExecutionManager: LiveExecutionManager; |
| 400 | 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 | 414 | export type ExecutorSourceAddResult = |
| 468 | 415 | | { |
| 469 | 416 | kind: "connected"; |
| @@ -489,8 +436,7 @@ export type ExecutorOpenApiSourceAuthInput = · 19 unmodified lines | ||
| 489 | 436 | headerName?: string | null; |
| 490 | 437 | prefix?: string | null; |
| 491 | 438 | token?: string | null; |
| 492 | − tokenEnvVar?: string | null; | |
| 493 | − tokenSecretMaterialId?: string | null; | |
| 439 | + tokenRef?: SecretRef | null; | |
| 494 | 440 | }; |
| 495 | 441 | |
| 496 | 442 | export type ExecutorAddSourceInput = |
| @@ -527,9 +473,9 @@ const shouldPromptForOpenApiCredentialSetup = (input: { · 30 unmodified lines | ||
| 527 | 473 | }; |
| 528 | 474 | |
| 529 | 475 | const materializeExecutorOpenApiAuth = (input: { |
| 530 | − rows: SqlControlPlaneRows; | |
| 531 | 476 | existing?: Source; |
| 532 | 477 | auth?: ExecutorOpenApiSourceAuthInput | null; |
| 478 | + storeSecretMaterial: StoreSecretMaterial; | |
| 533 | 479 | }): Effect.Effect<Source["auth"], Error, never> => |
| 534 | 480 | Effect.gen(function* () { |
| 535 | 481 | if (input.auth === undefined && input.existing?.kind === "openapi") { |
| @@ -544,42 +490,46 @@ const materializeExecutorOpenApiAuth = (input: { · 8 unmodified lines | ||
| 544 | 490 | const headerName = trimOrNull(auth.headerName) ?? "Authorization"; |
| 545 | 491 | const prefix = auth.prefix ?? "Bearer "; |
| 546 | 492 | const token = trimOrNull(auth.token); |
| 547 | − const tokenEnvVar = trimOrNull(auth.tokenEnvVar); | |
| 548 | − const tokenSecretMaterialId = trimOrNull(auth.tokenSecretMaterialId); | |
| 493 | + const tokenRefInput = auth.tokenRef ?? null; | |
| 549 | 494 | |
| 550 | 495 | if ( |
| 551 | 496 | token === null |
| 552 | − && tokenEnvVar === null | |
| 553 | − && tokenSecretMaterialId === null | |
| 497 | + && tokenRefInput === null | |
| 554 | 498 | && input.existing?.kind === "openapi" |
| 555 | 499 | && input.existing.auth.kind === "bearer" |
| 556 | 500 | ) { |
| 557 | 501 | return input.existing.auth; |
| 558 | 502 | } |
| 559 | 503 | |
| 560 | − if (token === null && tokenEnvVar === null && tokenSecretMaterialId === null) { | |
| 504 | + if (token === null && tokenRefInput === null) { | |
| 561 | 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 | |
| 567 | − ? { | |
| 568 | − providerId: CONTROL_PLANE_SECRET_PROVIDER_ID, | |
| 569 | − handle: yield* upsertSecretMaterial(input.rows, { | |
| 570 | − purpose: "auth_material", | |
| 571 | − value: token, | |
| 572 | − }), | |
| 573 | − } | |
| 574 | − : tokenSecretMaterialId !== null | |
| 575 | − ? { | |
| 576 | − providerId: CONTROL_PLANE_SECRET_PROVIDER_ID, | |
| 577 | − handle: tokenSecretMaterialId, | |
| 510 | + const tokenRef = token !== null | |
| 511 | + ? yield* input.storeSecretMaterial({ | |
| 512 | + purpose: "auth_material", | |
| 513 | + value: token, | |
| 514 | + }) | |
| 515 | + : (() => { | |
| 516 | + const providerId = trimOrNull(tokenRefInput?.providerId); | |
| 517 | + const handle = trimOrNull(tokenRefInput?.handle); | |
| 518 | + if (providerId === null || handle === null) { | |
| 519 | + return null; | |
| 578 | 520 | } |
| 579 | − : { | |
| 580 | − providerId: "env", | |
| 581 | − handle: tokenEnvVar!, | |
| 582 | − }; | |
| 521 | + | |
| 522 | + return { | |
| 523 | + providerId, | |
| 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 | 534 | return { |
| 585 | 535 | kind: "bearer", |
| @@ -596,9 +546,9 @@ type RuntimeSourceAuthServiceShape = { · 10 unmodified lines | ||
| 596 | 546 | }) => Effect.Effect<Source, Error, never>; |
| 597 | 547 | getLocalServerBaseUrl: () => string | null; |
| 598 | 548 | storeSecretMaterial: (input: { |
| 599 | − purpose: "auth_material" | "oauth_access_token" | "oauth_refresh_token"; | |
| 549 | + purpose: SecretMaterialPurpose; | |
| 600 | 550 | value: string; |
| 601 | − }) => Effect.Effect<SecretMaterialId, Error, never>; | |
| 551 | + }) => Effect.Effect<SecretRef, Error, never>; | |
| 602 | 552 | addExecutorSource: ( |
| 603 | 553 | input: ExecutorAddSourceInput, |
| 604 | 554 | options?: { |
| @@ -615,50 +565,23 @@ type RuntimeSourceAuthServiceShape = { · 10 unmodified lines | ||
| 615 | 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 | 568 | export const createRuntimeSourceAuthService = (input: { |
| 648 | 569 | rows: SqlControlPlaneRows; |
| 649 | 570 | liveExecutionManager: LiveExecutionManager; |
| 650 | 571 | getLocalServerBaseUrl?: () => string | undefined; |
| 651 | 572 | }) => { |
| 652 | − const resolveSecretMaterial = createDbBackedSecretMaterialResolver({ | |
| 573 | + const resolveSecretMaterial = createDefaultSecretMaterialResolver({ | |
| 574 | + rows: input.rows, | |
| 575 | + }); | |
| 576 | + const storeSecretMaterial = createDefaultSecretMaterialStorer({ | |
| 653 | 577 | rows: input.rows, |
| 654 | − fallback: createEnvSecretMaterialResolver(), | |
| 655 | 578 | }); |
| 656 | 579 | |
| 657 | 580 | return { |
| 658 | 581 | getLocalServerBaseUrl: () => input.getLocalServerBaseUrl?.() ?? null, |
| 659 | 582 | |
| 660 | 583 | storeSecretMaterial: ({ purpose, value }) => |
| 661 | − upsertSecretMaterial(input.rows, { | |
| 584 | + storeSecretMaterial({ | |
| 662 | 585 | purpose, |
| 663 | 586 | value, |
| 664 | 587 | }), |
| @@ -739,9 +662,9 @@ export const createRuntimeSourceAuthService = (input: { · 74 unmodified lines | ||
| 739 | 662 | } |
| 740 | 663 | |
| 741 | 664 | const auth = yield* materializeExecutorOpenApiAuth({ |
| 742 | − rows: input.rows, | |
| 743 | 665 | existing, |
| 744 | 666 | auth: sourceInput.auth, |
| 667 | + storeSecretMaterial, | |
| 745 | 668 | }); |
| 746 | 669 | |
| 747 | 670 | const draftSource = existing |
| @@ -1015,12 +938,18 @@ export const createRuntimeSourceAuthService = (input: { · 267 unmodified lines | ||
| 1015 | 938 | const reason = trimOrNull(errorDescription) ?? trimOrNull(error) ?? "OAuth authorization failed"; |
| 1016 | 939 | const failedAt = Date.now(); |
| 1017 | 940 | |
| 1018 | − yield* input.rows.sourceAuthSessions.update(session.id, { | |
| 1019 | − status: "failed", | |
| 1020 | − errorText: reason, | |
| 1021 | − completedAt: failedAt, | |
| 1022 | − updatedAt: failedAt, | |
| 1023 | − }); | |
| 941 | + yield* input.rows.sourceAuthSessions.update( | |
| 942 | + session.id, | |
| 943 | + createTerminalSourceAuthSessionPatch({ | |
| 944 | + status: "failed", | |
| 945 | + now: failedAt, | |
| 946 | + errorText: reason, | |
| 947 | + resourceMetadataUrl: session.resourceMetadataUrl, | |
| 948 | + authorizationServerUrl: session.authorizationServerUrl, | |
| 949 | + resourceMetadataJson: session.resourceMetadataJson, | |
| 950 | + authorizationServerMetadataJson: session.authorizationServerMetadataJson, | |
| 951 | + }), | |
| 952 | + ); | |
| 1024 | 953 | const failedSource = yield* updateSourceStatus(input.rows, source, { |
| 1025 | 954 | status: "error", |
| 1026 | 955 | lastError: reason, |
| @@ -1052,30 +981,18 @@ export const createRuntimeSourceAuthService = (input: { · 25 unmodified lines | ||
| 1052 | 981 | code: authorizationCode, |
| 1053 | 982 | }); |
| 1054 | 983 | |
| 1055 | − const accessTokenId = yield* upsertSecretMaterial(input.rows, { | |
| 984 | + const accessTokenRef = yield* storeSecretMaterial({ | |
| 1056 | 985 | purpose: "oauth_access_token", |
| 1057 | 986 | value: exchanged.tokens.access_token, |
| 1058 | 987 | }); |
| 1059 | − const refreshTokenId = exchanged.tokens.refresh_token | |
| 1060 | − ? yield* upsertSecretMaterial(input.rows, { | |
| 988 | + const refreshTokenRef = exchanged.tokens.refresh_token | |
| 989 | + ? yield* storeSecretMaterial({ | |
| 1061 | 990 | purpose: "oauth_refresh_token", |
| 1062 | 991 | value: exchanged.tokens.refresh_token, |
| 1063 | 992 | }) |
| 1064 | 993 | : null; |
| 1065 | 994 | |
| 1066 | 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 | 996 | const connectedSource = yield* updateSourceStatus(input.rows, source, { |
| 1080 | 997 | status: "connected", |
| 1081 | 998 | lastError: null, |
| @@ -1083,17 +1000,8 @@ export const createRuntimeSourceAuthService = (input: { · 1 unmodified line | ||
| 1083 | 1000 | kind: "oauth2", |
| 1084 | 1001 | headerName: "Authorization", |
| 1085 | 1002 | prefix: "Bearer ", |
| 1086 | − accessToken: { | |
| 1087 | − providerId: CONTROL_PLANE_SECRET_PROVIDER_ID, | |
| 1088 | − handle: accessTokenId, | |
| 1089 | − }, | |
| 1090 | − refreshToken: | |
| 1091 | − refreshTokenId === null | |
| 1092 | − ? null | |
| 1093 | − : { | |
| 1094 | − providerId: CONTROL_PLANE_SECRET_PROVIDER_ID, | |
| 1095 | − handle: refreshTokenId, | |
| 1096 | − }, | |
| 1003 | + accessToken: accessTokenRef, | |
| 1004 | + refreshToken: refreshTokenRef, | |
| 1097 | 1005 | }, |
| 1098 | 1006 | }); |
| 1099 | 1007 | const indexed = yield* Effect.either( |
| @@ -1114,17 +1022,18 @@ export const createRuntimeSourceAuthService = (input: { · 14 unmodified lines | ||
| 1114 | 1022 | onRight: () => Effect.succeed(undefined), |
| 1115 | 1023 | }); |
| 1116 | 1024 | |
| 1117 | − yield* input.rows.sourceAuthSessions.update(session.id, { | |
| 1118 | − status: "completed", | |
| 1119 | − errorText: null, | |
| 1120 | − completedAt: now, | |
| 1121 | − updatedAt: now, | |
| 1122 | − resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1123 | − authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 1124 | − resourceMetadataJson: exchanged.resourceMetadataJson, | |
| 1125 | − authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson, | |
| 1126 | − clientInformationJson: exchanged.clientInformationJson, | |
| 1127 | − }); | |
| 1025 | + yield* input.rows.sourceAuthSessions.update( | |
| 1026 | + session.id, | |
| 1027 | + createTerminalSourceAuthSessionPatch({ | |
| 1028 | + status: "completed", | |
| 1029 | + now, | |
| 1030 | + errorText: null, | |
| 1031 | + resourceMetadataUrl: exchanged.resourceMetadataUrl, | |
| 1032 | + authorizationServerUrl: exchanged.authorizationServerUrl, | |
| 1033 | + resourceMetadataJson: exchanged.resourceMetadataJson, | |
| 1034 | + authorizationServerMetadataJson: exchanged.authorizationServerMetadataJson, | |
| 1035 | + }), | |
| 1036 | + ); | |
| 1128 | 1037 | |
| 1129 | 1038 | yield* completeLiveInteraction({ |
| 1130 | 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 | 3 | UpdateSourcePayload, |
| 4 | 4 | } from "#api"; |
| 5 | 5 | import type { |
| 6 | + Credential, | |
| 6 | 7 | Source, |
| 7 | 8 | SourceAuth, |
| 8 | 9 | SourceCredentialBinding, |
| @@ -11,6 +12,7 @@ import type { · 2 unmodified lines | ||
| 11 | 12 | StringMap, |
| 12 | 13 | WorkspaceId, |
| 13 | 14 | } from "#schema"; |
| 15 | +import { CredentialIdSchema } from "#schema"; | |
| 14 | 16 | import * as Effect from "effect/Effect"; |
| 15 | 17 | import * as Schema from "effect/Schema"; |
| 16 | 18 | |
| @@ -232,8 +234,11 @@ export const updateSourceFromPayload = (input: { · 215 unmodified lines | ||
| 232 | 234 | |
| 233 | 235 | export const splitSourceForStorage = (input: { |
| 234 | 236 | source: Source; |
| 237 | + existingCredentialId?: Credential["id"] | null; | |
| 238 | + existingBindingId?: SourceCredentialBinding["id"] | null; | |
| 235 | 239 | }): { |
| 236 | 240 | sourceRecord: StoredSourceRecord; |
| 241 | + credential: Credential | null; | |
| 237 | 242 | credentialBinding: SourceCredentialBinding | null; |
| 238 | 243 | } => { |
| 239 | 244 | const sourceRecord: StoredSourceRecord = { |
| @@ -250,10 +255,6 @@ export const splitSourceForStorage = (input: { · 10 unmodified lines | ||
| 250 | 255 | headersJson: serializeStringMap(input.source.headers), |
| 251 | 256 | specUrl: input.source.specUrl, |
| 252 | 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 | 258 | sourceHash: input.source.sourceHash, |
| 258 | 259 | sourceDocumentText: null, |
| 259 | 260 | lastError: input.source.lastError, |
| @@ -264,31 +265,49 @@ export const splitSourceForStorage = (input: { · 4 unmodified lines | ||
| 264 | 265 | if (input.source.auth.kind === "none") { |
| 265 | 266 | return { |
| 266 | 267 | sourceRecord, |
| 268 | + credential: null, | |
| 267 | 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 | 303 | return { |
| 272 | 304 | sourceRecord, |
| 305 | + credential, | |
| 273 | 306 | credentialBinding: { |
| 307 | + id: bindingId, | |
| 274 | 308 | workspaceId: input.source.workspaceId, |
| 275 | 309 | sourceId: input.source.id, |
| 276 | − tokenProviderId: | |
| 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, | |
| 310 | + credentialId, | |
| 292 | 311 | createdAt: input.source.createdAt, |
| 293 | 312 | updatedAt: input.source.updatedAt, |
| 294 | 313 | }, |
| @@ -298,6 +317,7 @@ export const splitSourceForStorage = (input: { · 3 unmodified lines | ||
| 298 | 317 | export const projectSourceFromStorage = (input: { |
| 299 | 318 | sourceRecord: StoredSourceRecord; |
| 300 | 319 | credentialBinding: SourceCredentialBinding | null; |
| 320 | + credential: Credential | null; | |
| 301 | 321 | }): Effect.Effect<Source, Error, never> => |
| 302 | 322 | Effect.gen(function* () { |
| 303 | 323 | const queryParams = yield* parseStringMapJson( |
| @@ -313,44 +333,39 @@ export const projectSourceFromStorage = (input: { · 9 unmodified lines | ||
| 313 | 333 | input.sourceRecord.defaultHeadersJson, |
| 314 | 334 | ); |
| 315 | 335 | |
| 316 | − let auth: SourceAuth; | |
| 317 | − if (input.sourceRecord.authKind === "none") { | |
| 318 | − auth = { kind: "none" }; | |
| 319 | − } else { | |
| 320 | − const binding = input.credentialBinding; | |
| 321 | − if (binding === null || binding.tokenProviderId === null || binding.tokenHandle === null) { | |
| 336 | + let auth: SourceAuth = { kind: "none" }; | |
| 337 | + if (input.credentialBinding !== null) { | |
| 338 | + const credential = input.credential; | |
| 339 | + if (credential === null) { | |
| 322 | 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"; | |
| 328 | − const prefix = input.sourceRecord.authPrefix ?? "Bearer "; | |
| 329 | − | |
| 330 | − if (input.sourceRecord.authKind === "bearer") { | |
| 345 | + if (credential.authKind === "bearer") { | |
| 331 | 346 | auth = { |
| 332 | 347 | kind: "bearer", |
| 333 | − headerName, | |
| 334 | − prefix, | |
| 348 | + headerName: credential.authHeaderName, | |
| 349 | + prefix: credential.authPrefix, | |
| 335 | 350 | token: { |
| 336 | − providerId: binding.tokenProviderId, | |
| 337 | − handle: binding.tokenHandle, | |
| 351 | + providerId: credential.tokenProviderId, | |
| 352 | + handle: credential.tokenHandle, | |
| 338 | 353 | }, |
| 339 | 354 | }; |
| 340 | 355 | } else { |
| 341 | 356 | auth = { |
| 342 | 357 | kind: "oauth2", |
| 343 | − headerName, | |
| 344 | − prefix, | |
| 358 | + headerName: credential.authHeaderName, | |
| 359 | + prefix: credential.authPrefix, | |
| 345 | 360 | accessToken: { |
| 346 | − providerId: binding.tokenProviderId, | |
| 347 | − handle: binding.tokenHandle, | |
| 361 | + providerId: credential.tokenProviderId, | |
| 362 | + handle: credential.tokenHandle, | |
| 348 | 363 | }, |
| 349 | 364 | refreshToken: |
| 350 | − binding.refreshTokenProviderId !== null && binding.refreshTokenHandle !== null | |
| 365 | + credential.refreshTokenProviderId !== null && credential.refreshTokenHandle !== null | |
| 351 | 366 | ? { |
| 352 | − providerId: binding.refreshTokenProviderId, | |
| 353 | − handle: binding.refreshTokenHandle, | |
| 367 | + providerId: credential.refreshTokenProviderId, | |
| 368 | + handle: credential.refreshTokenHandle, | |
| 354 | 369 | } |
| 355 | 370 | : null, |
| 356 | 371 | }; |
| @@ -386,15 +401,24 @@ export const projectSourceFromStorage = (input: { · 29 unmodified lines | ||
| 386 | 401 | export const projectSourcesFromStorage = (input: { |
| 387 | 402 | sourceRecords: ReadonlyArray<StoredSourceRecord>; |
| 388 | 403 | credentialBindings: ReadonlyArray<SourceCredentialBinding>; |
| 404 | + credentials: ReadonlyArray<Credential>; | |
| 389 | 405 | }): Effect.Effect<ReadonlyArray<Source>, Error, never> => { |
| 390 | 406 | const bindingsBySourceId = new Map( |
| 391 | 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) => | |
| 395 | − projectSourceFromStorage({ | |
| 413 | + return Effect.forEach(input.sourceRecords, (sourceRecord) => { | |
| 414 | + const credentialBinding = bindingsBySourceId.get(sourceRecord.id) ?? null; | |
| 415 | + return projectSourceFromStorage({ | |
| 396 | 416 | sourceRecord, |
| 397 | − credentialBinding: bindingsBySourceId.get(sourceRecord.id) ?? null, | |
| 398 | − }), | |
| 399 | − ); | |
| 417 | + credentialBinding, | |
| 418 | + credential: | |
| 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 | 27 | import * as Option from "effect/Option"; |
| 28 | 28 | |
| 29 | 29 | import { operationErrors } from "./operation-errors"; |
| 30 | −import { projectSourceFromStorage } from "./source-definitions"; | |
| 30 | +import { loadSourceById as loadStoredSourceById } from "./source-store"; | |
| 31 | 31 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; |
| 32 | 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( | |
| 101 | − store.sourceCredentialBindings.getByWorkspaceAndSourceId( | |
| 102 | − input.workspaceId, | |
| 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, | |
| 100 | + const source = yield* loadStoredSourceById(store, { | |
| 101 | + workspaceId: input.workspaceId, | |
| 102 | + sourceId: input.sourceId, | |
| 112 | 103 | }).pipe( |
| 113 | 104 | Effect.mapError((cause) => |
| 114 | 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 | 25 | import { |
| 26 | 26 | operationErrors, |
| 27 | 27 | } from "./operation-errors"; |
| 28 | −import { | |
| 29 | − createDbBackedSecretMaterialResolver, | |
| 30 | −} from "./source-auth-service"; | |
| 28 | +import { createDefaultSecretMaterialResolver } from "./secret-material-providers"; | |
| 31 | 29 | import { ControlPlaneStore, type ControlPlaneStoreShape } from "./store"; |
| 30 | +import { syncSourceToolArtifacts } from "./tool-artifacts"; | |
| 32 | 31 | import { |
| 33 | − createEnvSecretMaterialResolver, | |
| 34 | − syncSourceToolArtifacts, | |
| 35 | −} from "./tool-artifacts"; | |
| 32 | + loadSourceById, | |
| 33 | + loadSourcesInWorkspace, | |
| 34 | + removeCredentialBindingForSource, | |
| 35 | +} from "./source-store"; | |
| 36 | 36 | |
| 37 | 37 | const sourceOps = { |
| 38 | 38 | list: operationErrors("sources.list"), |
| @@ -50,9 +50,8 @@ const syncArtifactsForSource = (input: { · 11 unmodified lines | ||
| 50 | 50 | | typeof sourceOps.update; |
| 51 | 51 | }) => |
| 52 | 52 | Effect.gen(function* () { |
| 53 | − const resolveSecretMaterial = createDbBackedSecretMaterialResolver({ | |
| 53 | + const resolveSecretMaterial = createDefaultSecretMaterialResolver({ | |
| 54 | 54 | rows: input.store, |
| 55 | − fallback: createEnvSecretMaterialResolver(), | |
| 56 | 55 | }); |
| 57 | 56 | |
| 58 | 57 | const synced = yield* Effect.either( |
| @@ -106,26 +105,14 @@ const syncArtifactsForSource = (input: { · 47 unmodified lines | ||
| 106 | 105 | |
| 107 | 106 | export const listSources = (workspaceId: WorkspaceId) => |
| 108 | 107 | Effect.flatMap(ControlPlaneStore, (store) => |
| 109 | − Effect.gen(function* () { | |
| 110 | − const sourceRecords = yield* sourceOps.list.child("records").mapStorage( | |
| 111 | − store.sources.listByWorkspaceId(workspaceId), | |
| 112 | − ); | |
| 113 | − const credentialBindings = yield* sourceOps.list.child("bindings").mapStorage( | |
| 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 | − ), | |
| 108 | + loadSourcesInWorkspace(store, workspaceId).pipe( | |
| 109 | + Effect.mapError((error) => | |
| 110 | + sourceOps.list.unknownStorage( | |
| 111 | + error, | |
| 112 | + "Failed projecting stored sources", | |
| 126 | 113 | ), |
| 127 | − ); | |
| 128 | − })); | |
| 114 | + ), | |
| 115 | + )); | |
| 129 | 116 | |
| 130 | 117 | export const createSource = (input: { |
| 131 | 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 | 140 | source, |
| 154 | 141 | }); |
| 155 | 142 | |
| @@ -157,7 +144,11 @@ export const createSource = (input: { · 1 unmodified line | ||
| 157 | 144 | sourceOps.create.child("source"), |
| 158 | 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 | 152 | yield* mapPersistenceError( |
| 162 | 153 | sourceOps.create.child("binding"), |
| 163 | 154 | store.sourceCredentialBindings.upsert(credentialBinding), |
| @@ -176,39 +167,22 @@ export const getSource = (input: { · 12 unmodified lines | ||
| 176 | 167 | sourceId: SourceId; |
| 177 | 168 | }) => |
| 178 | 169 | Effect.flatMap(ControlPlaneStore, (store) => |
| 179 | − Effect.gen(function* () { | |
| 180 | − const existing = yield* sourceOps.get.mapStorage( | |
| 181 | − store.sources.getByWorkspaceAndId(input.workspaceId, input.sourceId), | |
| 182 | − ); | |
| 183 | − | |
| 184 | − if (Option.isNone(existing)) { | |
| 185 | − return yield* Effect.fail( | |
| 186 | − sourceOps.get.notFound( | |
| 187 | − "Source not found", | |
| 188 | − `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 189 | − ), | |
| 190 | − ); | |
| 191 | − } | |
| 192 | − | |
| 193 | − const credentialBinding = yield* sourceOps.get.child("binding").mapStorage( | |
| 194 | − store.sourceCredentialBindings.getByWorkspaceAndSourceId( | |
| 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 | − })); | |
| 170 | + loadSourceById(store, { | |
| 171 | + workspaceId: input.workspaceId, | |
| 172 | + sourceId: input.sourceId, | |
| 173 | + }).pipe( | |
| 174 | + Effect.mapError((cause) => | |
| 175 | + cause instanceof Error && cause.message.startsWith("Source not found:") | |
| 176 | + ? sourceOps.get.notFound( | |
| 177 | + "Source not found", | |
| 178 | + `workspaceId=${input.workspaceId} sourceId=${input.sourceId}`, | |
| 179 | + ) | |
| 180 | + : sourceOps.get.unknownStorage( | |
| 181 | + cause, | |
| 182 | + "Failed projecting stored source", | |
| 183 | + ), | |
| 184 | + ), | |
| 185 | + )); | |
| 212 | 186 | |
| 213 | 187 | export const updateSource = (input: { |
| 214 | 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({ | |
| 241 | − sourceRecord: existing.value, | |
| 242 | − credentialBinding: Option.isSome(existingBinding) ? existingBinding.value : null, | |
| 214 | + const existingSource = yield* loadSourceById(store, { | |
| 215 | + workspaceId: input.workspaceId, | |
| 216 | + sourceId: input.sourceId, | |
| 243 | 217 | }).pipe( |
| 244 | 218 | Effect.mapError((cause) => |
| 245 | − sourceOps.update.unknownStorage( | |
| 246 | − cause, | |
| 247 | − "Failed projecting stored source", | |
| 248 | − ), | |
| 219 | + cause instanceof Error && cause.message.startsWith("Source not found:") | |
| 220 | + ? sourceOps.update.notFound( | |
| 221 | + "Source not found", | |
| 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 | 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 | 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) { | |
| 288 | − yield* sourceOps.update.child("binding.remove").mapStorage( | |
| 289 | − store.sourceCredentialBindings.removeByWorkspaceAndSourceId( | |
| 290 | − input.workspaceId, | |
| 291 | − input.sourceId, | |
| 292 | − ), | |
| 293 | − ); | |
| 272 | + if (credential === null || credentialBinding === null) { | |
| 273 | + if (Option.isSome(existingBinding)) { | |
| 274 | + yield* sourceOps.update.child("binding.remove").mapStorage( | |
| 275 | + store.sourceCredentialBindings.removeByWorkspaceAndSourceId( | |
| 276 | + input.workspaceId, | |
| 277 | + input.sourceId, | |
| 278 | + ), | |
| 279 | + ); | |
| 280 | + yield* sourceOps.update.child("credential.remove").mapStorage( | |
| 281 | + store.credentials.removeById(existingBinding.value.credentialId), | |
| 282 | + ); | |
| 283 | + } | |
| 294 | 284 | } else { |
| 285 | + yield* mapPersistenceError( | |
| 286 | + sourceOps.update.child("credential"), | |
| 287 | + store.credentials.upsert(credential), | |
| 288 | + ); | |
| 295 | 289 | yield* mapPersistenceError( |
| 296 | 290 | sourceOps.update.child("binding"), |
| 297 | 291 | store.sourceCredentialBindings.upsert(credentialBinding), |
| @@ -311,6 +305,17 @@ export const removeSource = (input: { · 13 unmodified lines | ||
| 311 | 305 | }) => |
| 312 | 306 | Effect.flatMap(ControlPlaneStore, (store) => |
| 313 | 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 | 319 | yield* sourceOps.remove.child("artifacts").mapStorage( |
| 315 | 320 | store.toolArtifacts.removeByWorkspaceAndSourceId(input.workspaceId, input.sourceId), |
| 316 | 321 | ); |
| @@ -21,9 +21,10 @@ import { · 20 unmodified lines | ||
| 21 | 21 | import * as Effect from "effect/Effect"; |
| 22 | 22 | import * as Option from "effect/Option"; |
| 23 | 23 | |
| 24 | −export type ResolveSourceSecretMaterial = ( | |
| 25 | − ref: SecretRef, | |
| 26 | −) => Effect.Effect<string, Error, never>; | |
| 24 | +import type { | |
| 25 | + ResolveSecretMaterial as ResolveSourceSecretMaterial, | |
| 26 | + SecretMaterialResolveContext, | |
| 27 | +} from "./secret-material-providers"; | |
| 27 | 28 | |
| 28 | 29 | export type ResolvedSourceAuthMaterial = { |
| 29 | 30 | headers: Readonly<Record<string, string>>; |
| @@ -122,28 +123,10 @@ export const namespaceFromSourceName = (name: string): string => { · 92 unmodified lines | ||
| 122 | 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 | 126 | export const resolveSourceAuthMaterial = (input: { |
| 145 | 127 | source: Source; |
| 146 | 128 | resolveSecretMaterial: ResolveSourceSecretMaterial; |
| 129 | + context?: SecretMaterialResolveContext; | |
| 147 | 130 | }): Effect.Effect<ResolvedSourceAuthMaterial, Error, never> => |
| 148 | 131 | Effect.gen(function* () { |
| 149 | 132 | if (input.source.auth.kind === "none") { |
| @@ -155,7 +138,10 @@ export const resolveSourceAuthMaterial = (input: { · 5 unmodified lines | ||
| 155 | 138 | ? input.source.auth.token |
| 156 | 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 | 146 | return { |
| 161 | 147 | headers: { |
| @@ -15,6 +15,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/ · 14 unmodified lines | ||
| 15 | 15 | import { describe, expect, it } from "@effect/vitest"; |
| 16 | 16 | import { |
| 17 | 17 | AccountIdSchema, |
| 18 | + CredentialIdSchema, | |
| 18 | 19 | ExecutionIdSchema, |
| 19 | 20 | SecretMaterialIdSchema, |
| 20 | 21 | SourceIdSchema, |
| @@ -369,9 +370,6 @@ const persistConnectedEchoTool = (input: { · 348 unmodified lines | ||
| 369 | 370 | headersJson: null, |
| 370 | 371 | specUrl: null, |
| 371 | 372 | defaultHeadersJson: null, |
| 372 | − authKind: "none", | |
| 373 | − authHeaderName: null, | |
| 374 | − authPrefix: null, | |
| 375 | 373 | sourceHash: null, |
| 376 | 374 | sourceDocumentText: null, |
| 377 | 375 | lastError: null, |
| @@ -423,6 +421,11 @@ const persistConnectedGithubOpenApiSource = (input: { · 45 unmodified lines | ||
| 423 | 421 | persistence: SqlControlPlanePersistence; |
| 424 | 422 | workspaceId: WorkspaceId; |
| 425 | 423 | sourceId: SourceId; |
| 424 | + endpoint?: string; | |
| 425 | + auth?: { | |
| 426 | + providerId: string; | |
| 427 | + handle: string; | |
| 428 | + } | null; | |
| 426 | 429 | }) => |
| 427 | 430 | Effect.gen(function* () { |
| 428 | 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 | 509 | "/user": { |
| 485 | 510 | get: { |
| 486 | 511 | operationId: "users/get-authenticated", |
| @@ -508,7 +533,7 @@ const persistConnectedGithubOpenApiSource = (input: { · 21 unmodified lines | ||
| 508 | 533 | workspaceId: input.workspaceId, |
| 509 | 534 | name: "GitHub", |
| 510 | 535 | kind: "openapi", |
| 511 | − endpoint: "https://api.github.com", | |
| 536 | + endpoint: input.endpoint ?? "https://api.github.com", | |
| 512 | 537 | status: "connected", |
| 513 | 538 | enabled: true, |
| 514 | 539 | namespace: "github", |
| @@ -517,15 +542,38 @@ const persistConnectedGithubOpenApiSource = (input: { · 2 unmodified lines | ||
| 517 | 542 | headersJson: null, |
| 518 | 543 | specUrl: "https://example.com/github-openapi.json", |
| 519 | 544 | defaultHeadersJson: null, |
| 520 | − authKind: "none", | |
| 521 | − authHeaderName: null, | |
| 522 | − authPrefix: null, | |
| 523 | 545 | sourceHash: null, |
| 524 | 546 | sourceDocumentText: openApiDocumentText, |
| 525 | 547 | lastError: null, |
| 526 | 548 | createdAt: now, |
| 527 | 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 | 579 | const makeResolver = (persistence: SqlControlPlanePersistence) => |
| @@ -719,7 +767,10 @@ describe("workspace-execution-environment", () => { · 187 unmodified lines | ||
| 719 | 767 | action: "accept" as const, |
| 720 | 768 | content: { |
| 721 | 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 | 803 | expect(added.kind).toBe("openapi"); |
| 753 | 804 | expect(added.status).toBe("connected"); |
| 754 | 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 | 807 | expect(capturedElicitation).toMatchObject({ |
| 757 | 808 | mode: "url", |
| 758 | 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 | 919 | it.scoped("describes executor.sources.add with derived type info and schemas", () => |
| 816 | 920 | Effect.gen(function* () { |
| 817 | 921 | const persistence = yield* makePersistence; |
| @@ -33,24 +33,31 @@ import type { · 32 unmodified lines | ||
| 33 | 33 | StoredToolArtifactRecord, |
| 34 | 34 | } from "#schema"; |
| 35 | 35 | import * as Context from "effect/Context"; |
| 36 | +import * as Either from "effect/Either"; | |
| 36 | 37 | import * as Effect from "effect/Effect"; |
| 37 | 38 | import * as Layer from "effect/Layer"; |
| 38 | 39 | import * as Option from "effect/Option"; |
| 40 | +import * as Schema from "effect/Schema"; | |
| 39 | 41 | |
| 40 | 42 | import type { |
| 41 | 43 | ExecutionEnvironment, |
| 42 | 44 | ResolveExecutionEnvironment, |
| 43 | 45 | } from "./execution-state"; |
| 44 | 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 | 51 | import { |
| 47 | 52 | RuntimeSourceAuthServiceTag, |
| 48 | − createDbBackedSecretMaterialResolver, | |
| 49 | − type ResolveSecretMaterial, | |
| 50 | 53 | type RuntimeSourceAuthService, |
| 51 | 54 | } from "./source-auth-service"; |
| 52 | 55 | import { |
| 53 | − createEnvSecretMaterialResolver, | |
| 56 | + createDefaultSecretMaterialResolver, | |
| 57 | + type ResolveSecretMaterial, | |
| 58 | + type SecretMaterialResolveContext, | |
| 59 | +} from "./secret-material-providers"; | |
| 60 | +import { | |
| 54 | 61 | namespaceFromSourceName, |
| 55 | 62 | resolveSourceAuthMaterial, |
| 56 | 63 | storedToolIdFromArtifact, |
| @@ -109,6 +116,32 @@ const hasSubstringMatch = (value: string, queryToken: string): boolean => { · 52 unmodified lines | ||
| 109 | 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 | 145 | const queryTokenWeight = (token: string): number => |
| 113 | 146 | LOW_SIGNAL_QUERY_TOKENS.has(token) ? 0.25 : 1; |
| 114 | 147 | |
| @@ -236,14 +269,14 @@ const toPersistedDescriptor = (input: { · 121 unmodified lines | ||
| 236 | 269 | outputType: |
| 237 | 270 | input.artifact.providerKind === "openapi" |
| 238 | 271 | ? openApiOutputTypeSignatureFromSchemaJson( |
| 239 | − input.artifact.outputSchemaJson ?? undefined, | |
| 240 | − 320, | |
| 241 | − ) | |
| 272 | + input.artifact.outputSchemaJson ?? undefined, | |
| 273 | + 320, | |
| 274 | + ) | |
| 242 | 275 | : typeSignatureFromSchemaJson( |
| 243 | − input.artifact.outputSchemaJson ?? undefined, | |
| 244 | − "unknown", | |
| 245 | − 320, | |
| 246 | − ), | |
| 276 | + input.artifact.outputSchemaJson ?? undefined, | |
| 277 | + "unknown", | |
| 278 | + 320, | |
| 279 | + ), | |
| 247 | 280 | inputSchemaJson: input.includeSchemas ? input.artifact.inputSchemaJson ?? undefined : undefined, |
| 248 | 281 | outputSchemaJson: input.includeSchemas ? input.artifact.outputSchemaJson ?? undefined : undefined, |
| 249 | 282 | ...(input.artifact.providerKind ? { providerKind: input.artifact.providerKind } : {}), |
| @@ -265,22 +298,15 @@ const loadOpenApiWorkspaceTools = (input: { · 15 unmodified lines | ||
| 265 | 298 | includeSchemas: boolean; |
| 266 | 299 | }): Effect.Effect<ReadonlyArray<OpenApiWorkspaceTool>, Error, never> => |
| 267 | 300 | Effect.gen(function* () { |
| 268 | − const [sourceRecords, credentialBindings] = yield* Effect.all([ | |
| 269 | − input.rows.sources.listByWorkspaceId(input.workspaceId), | |
| 270 | − input.rows.sourceCredentialBindings.listByWorkspaceId(input.workspaceId), | |
| 271 | − ]).pipe( | |
| 301 | + const sourceRecords = yield* input.rows.sources.listByWorkspaceId(input.workspaceId).pipe( | |
| 272 | 302 | Effect.mapError((cause) => |
| 273 | 303 | cause instanceof Error ? cause : new Error(String(cause)), |
| 274 | 304 | ), |
| 275 | 305 | ); |
| 276 | − | |
| 277 | 306 | const documentBySourceId = new Map( |
| 278 | 307 | sourceRecords.map((record) => [record.id, record.sourceDocumentText]), |
| 279 | 308 | ); |
| 280 | − const sources = yield* projectSourcesFromStorage({ | |
| 281 | − sourceRecords, | |
| 282 | − credentialBindings, | |
| 283 | − }); | |
| 309 | + const sources = yield* loadSourcesInWorkspace(input.rows, input.workspaceId); | |
| 284 | 310 | |
| 285 | 311 | const connectedOpenApiSources = sources.filter((source) => |
| 286 | 312 | source.enabled |
| @@ -373,35 +399,14 @@ const loadSourceById = (input: { · 86 unmodified lines | ||
| 373 | 399 | workspaceId: Source["workspaceId"]; |
| 374 | 400 | sourceId: Source["id"]; |
| 375 | 401 | }): Effect.Effect<Source, Error, never> => |
| 376 | − Effect.gen(function* () { | |
| 377 | − const sourceRecord = yield* input.rows.sources.getByWorkspaceAndId( | |
| 378 | − input.workspaceId, | |
| 379 | − input.sourceId, | |
| 380 | − ).pipe( | |
| 381 | − Effect.mapError((cause) => | |
| 382 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 383 | − ), | |
| 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 | − }); | |
| 402 | + loadStoredSourceById(input.rows, { | |
| 403 | + workspaceId: input.workspaceId, | |
| 404 | + sourceId: input.sourceId, | |
| 405 | + }).pipe( | |
| 406 | + Effect.mapError((cause) => | |
| 407 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 408 | + ), | |
| 409 | + ); | |
| 405 | 410 | |
| 406 | 411 | const createWorkspaceToolCatalog = (input: { |
| 407 | 412 | workspaceId: Source["workspaceId"]; |
| @@ -460,14 +465,14 @@ const createWorkspaceToolCatalog = (input: { · 52 unmodified lines | ||
| 460 | 465 | namespace?.startsWith("executor") |
| 461 | 466 | ? Effect.succeed([] as readonly StoredToolArtifactRecord[]) |
| 462 | 467 | : input.rows.toolArtifacts.listByWorkspaceId(input.workspaceId, { |
| 463 | − namespace, | |
| 464 | − query, | |
| 465 | − limit, | |
| 466 | − }).pipe( | |
| 467 | − Effect.mapError((cause) => | |
| 468 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 469 | − ), | |
| 468 | + namespace, | |
| 469 | + query, | |
| 470 | + limit, | |
| 471 | + }).pipe( | |
| 472 | + Effect.mapError((cause) => | |
| 473 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 470 | 474 | ), |
| 475 | + ), | |
| 471 | 476 | loadOpenApiWorkspaceTools({ |
| 472 | 477 | rows: input.rows, |
| 473 | 478 | workspaceId: input.workspaceId, |
| @@ -555,13 +560,13 @@ const createWorkspaceToolCatalog = (input: { · 81 unmodified lines | ||
| 555 | 560 | namespace?.startsWith("executor") |
| 556 | 561 | ? Effect.succeed([] as readonly StoredToolArtifactRecord[]) |
| 557 | 562 | : input.rows.toolArtifacts.searchByWorkspaceId(input.workspaceId, { |
| 558 | − namespace, | |
| 559 | − query, | |
| 560 | − }).pipe( | |
| 561 | − Effect.mapError((cause) => | |
| 562 | − cause instanceof Error ? cause : new Error(String(cause)), | |
| 563 | − ), | |
| 563 | + namespace, | |
| 564 | + query, | |
| 565 | + }).pipe( | |
| 566 | + Effect.mapError((cause) => | |
| 567 | + cause instanceof Error ? cause : new Error(String(cause)), | |
| 564 | 568 | ), |
| 569 | + ), | |
| 565 | 570 | loadOpenApiWorkspaceTools({ |
| 566 | 571 | rows: input.rows, |
| 567 | 572 | workspaceId: input.workspaceId, |
| @@ -653,6 +658,7 @@ const createWorkspaceToolInvoker = (input: { · 85 unmodified lines | ||
| 653 | 658 | const auth = yield* resolveSourceAuthMaterial({ |
| 654 | 659 | source: openApiTool.source, |
| 655 | 660 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 661 | + context: toSecretResolutionContext(invocation.context), | |
| 656 | 662 | }); |
| 657 | 663 | |
| 658 | 664 | const tools = createOpenApiToolsFromManifest({ |
| @@ -702,6 +708,7 @@ const createWorkspaceToolInvoker = (input: { · 43 unmodified lines | ||
| 702 | 708 | const auth = yield* resolveSourceAuthMaterial({ |
| 703 | 709 | source, |
| 704 | 710 | resolveSecretMaterial: input.resolveSecretMaterial, |
| 711 | + context: toSecretResolutionContext(invocation.context), | |
| 705 | 712 | }); |
| 706 | 713 | |
| 707 | 714 | if (artifact.providerKind === "mcp") { |
| @@ -764,9 +771,8 @@ export const createWorkspaceExecutionEnvironmentResolver = (input: { · 56 unmodified lines | ||
| 764 | 771 | }): ResolveExecutionEnvironment => { |
| 765 | 772 | const resolveSecretMaterial = |
| 766 | 773 | input.resolveSecretMaterial |
| 767 | − ?? createDbBackedSecretMaterialResolver({ | |
| 774 | + ?? createDefaultSecretMaterialResolver({ | |
| 768 | 775 | rows: input.rows, |
| 769 | − fallback: createEnvSecretMaterialResolver(), | |
| 770 | 776 | }); |
| 771 | 777 | |
| 772 | 778 | return ({ workspaceId, onElicitation }) => |
| @@ -792,7 +798,8 @@ export class RuntimeExecutionResolverService extends Context.Tag( · 19 unmodified lines | ||
| 792 | 798 | )< |
| 793 | 799 | RuntimeExecutionResolverService, |
| 794 | 800 | ReturnType<typeof createWorkspaceExecutionEnvironmentResolver> |
| 795 | −>() {} | |
| 801 | +>() { | |
| 802 | +} | |
| 796 | 803 | |
| 797 | 804 | export const RuntimeExecutionResolverLive = (input: { |
| 798 | 805 | executionResolver?: ResolveExecutionEnvironment; |
| @@ -801,15 +808,15 @@ export const RuntimeExecutionResolverLive = (input: { · 2 unmodified lines | ||
| 801 | 808 | input.executionResolver |
| 802 | 809 | ? Layer.succeed(RuntimeExecutionResolverService, input.executionResolver) |
| 803 | 810 | : Layer.effect( |
| 804 | − RuntimeExecutionResolverService, | |
| 805 | − Effect.gen(function* () { | |
| 806 | − const rows = yield* SqlControlPlaneRowsService; | |
| 807 | − const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | |
| 808 | − | |
| 809 | − return createWorkspaceExecutionEnvironmentResolver({ | |
| 810 | − rows, | |
| 811 | − sourceAuthService, | |
| 812 | − resolveSecretMaterial: input.resolveSecretMaterial, | |
| 813 | − }); | |
| 814 | − }), | |
| 815 | − ); | |
| 811 | + RuntimeExecutionResolverService, | |
| 812 | + Effect.gen(function* () { | |
| 813 | + const rows = yield* SqlControlPlaneRowsService; | |
| 814 | + const sourceAuthService = yield* RuntimeSourceAuthServiceTag; | |
| 815 | + | |
| 816 | + return createWorkspaceExecutionEnvironmentResolver({ | |
| 817 | + rows, | |
| 818 | + sourceAuthService, | |
| 819 | + resolveSecretMaterial: input.resolveSecretMaterial, | |
| 820 | + }); | |
| 821 | + }), | |
| 822 | + ); | |
| @@ -20,6 +20,10 @@ export { · 19 unmodified lines | ||
| 20 | 20 | type SourceStatus, |
| 21 | 21 | type SourceTransport, |
| 22 | 22 | } from "./models/source"; |
| 23 | +export { | |
| 24 | + CredentialAuthKindSchema, | |
| 25 | + type CredentialAuthKind, | |
| 26 | +} from "./models/credential"; | |
| 23 | 27 | export { |
| 24 | 28 | PolicyApprovalModeSchema, |
| 25 | 29 | PolicyEffectSchema, |
| @@ -10,6 +10,9 @@ export const SourceIdSchema = Schema.String.pipe(Schema.brand("SourceId")); · 9 unmodified lines | ||
| 10 | 10 | export const SourceAuthSessionIdSchema = Schema.String.pipe( |
| 11 | 11 | Schema.brand("SourceAuthSessionId"), |
| 12 | 12 | ); |
| 13 | +export const CredentialIdSchema = Schema.String.pipe( | |
| 14 | + Schema.brand("CredentialId"), | |
| 15 | +); | |
| 13 | 16 | export const SecretMaterialIdSchema = Schema.String.pipe( |
| 14 | 17 | Schema.brand("SecretMaterialId"), |
| 15 | 18 | ); |
| @@ -26,6 +29,7 @@ export type OrganizationMemberId = typeof OrganizationMemberIdSchema.Type; · 10 unmodified lines | ||
| 26 | 29 | export type WorkspaceId = typeof WorkspaceIdSchema.Type; |
| 27 | 30 | export type SourceId = typeof SourceIdSchema.Type; |
| 28 | 31 | export type SourceAuthSessionId = typeof SourceAuthSessionIdSchema.Type; |
| 32 | +export type CredentialId = typeof CredentialIdSchema.Type; | |
| 29 | 33 | export type SecretMaterialId = typeof SecretMaterialIdSchema.Type; |
| 30 | 34 | export type PolicyId = typeof PolicyIdSchema.Type; |
| 31 | 35 | export type InstallationId = typeof InstallationIdSchema.Type; |
| @@ -5,6 +5,7 @@ export * from "./ids"; · 4 unmodified lines | ||
| 5 | 5 | export * from "./models/auth"; |
| 6 | 6 | export * from "./models/workspace"; |
| 7 | 7 | export * from "./models/source"; |
| 8 | +export * from "./models/credential"; | |
| 8 | 9 | export * from "./models/tool-artifact"; |
| 9 | 10 | export * from "./models/secret-material"; |
| 10 | 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 | 3 | } from "drizzle-orm/effect-schema"; |
| 4 | 4 | import { Schema } from "effect"; |
| 5 | 5 | |
| 6 | −import { | |
| 7 | − sourceCredentialBindingsTable, | |
| 8 | − sourcesTable, | |
| 9 | −} from "../../persistence/schema"; | |
| 6 | +import { sourcesTable } from "../../persistence/schema"; | |
| 10 | 7 | import { TimestampMsSchema } from "../common"; |
| 11 | 8 | import { SourceIdSchema, WorkspaceIdSchema } from "../ids"; |
| 12 | 9 | |
| @@ -66,9 +63,6 @@ const sourceRowSchemaOverrides = { · 53 unmodified lines | ||
| 66 | 63 | kind: SourceKindSchema, |
| 67 | 64 | status: SourceStatusSchema, |
| 68 | 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 | 66 | createdAt: TimestampMsSchema, |
| 73 | 67 | updatedAt: TimestampMsSchema, |
| 74 | 68 | } as const; |
| @@ -91,9 +85,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 16 unmodified lines | ||
| 91 | 85 | headersJson: Schema.NullOr(Schema.String), |
| 92 | 86 | specUrl: Schema.NullOr(Schema.String), |
| 93 | 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 | 88 | sourceHash: Schema.NullOr(Schema.String), |
| 98 | 89 | sourceDocumentText: Schema.NullOr(Schema.String), |
| 99 | 90 | lastError: Schema.NullOr(Schema.String), |
| @@ -116,9 +107,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 16 unmodified lines | ||
| 116 | 107 | headersJson: row.headersJson, |
| 117 | 108 | specUrl: row.specUrl, |
| 118 | 109 | defaultHeadersJson: row.defaultHeadersJson, |
| 119 | − authKind: row.authKind, | |
| 120 | − authHeaderName: row.authHeaderName, | |
| 121 | − authPrefix: row.authPrefix, | |
| 122 | 110 | sourceHash: row.sourceHash, |
| 123 | 111 | sourceDocumentText: row.sourceDocumentText, |
| 124 | 112 | lastError: row.lastError, |
| @@ -139,9 +127,6 @@ export const StoredSourceRecordSchema = Schema.transform( · 14 unmodified lines | ||
| 139 | 127 | headersJson: source.headersJson, |
| 140 | 128 | specUrl: source.specUrl, |
| 141 | 129 | defaultHeadersJson: source.defaultHeadersJson, |
| 142 | − authKind: source.authKind, | |
| 143 | − authHeaderName: source.authHeaderName, | |
| 144 | − authPrefix: source.authPrefix, | |
| 145 | 130 | sourceHash: source.sourceHash, |
| 146 | 131 | sourceDocumentText: source.sourceDocumentText, |
| 147 | 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 | 139 | export const SourceSchema = Schema.Struct({ |
| 171 | 140 | id: SourceIdSchema, |
| 172 | 141 | workspaceId: WorkspaceIdSchema, |
| @@ -194,6 +163,5 @@ export type SourceTransport = typeof SourceTransportSchema.Type; · 21 unmodified lines | ||
| 194 | 163 | export type SecretRef = typeof SecretRefSchema.Type; |
| 195 | 164 | export type SourceAuth = typeof SourceAuthSchema.Type; |
| 196 | 165 | export type StoredSourceRecord = typeof StoredSourceRecordSchema.Type; |
| 197 | −export type SourceCredentialBinding = typeof SourceCredentialBindingSchema.Type; | |
| 198 | 166 | export type StringMap = typeof StringMapSchema.Type; |
| 199 | 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. | |