Make MCP and runtime callbacks run with mandatory account identity and propagate it through task runs into source credential resolution. Tighten control-plane auth defaults and sanitize proxied response headers for safer direct Convex access.
Unified · Split
| @@ -26,6 +26,7 @@ import type * as control_plane_workspaces from "../control_plane/workspaces.js"; · 25 unmodified lines | ||
| 26 | 26 | import type * as credential_crypto from "../credential_crypto.js"; |
| 27 | 27 | import type * as executor from "../executor.js"; |
| 28 | 28 | import type * as http from "../http.js"; |
| 29 | +import type * as http_security from "../http_security.js"; | |
| 29 | 30 | import type * as mcp from "../mcp.js"; |
| 30 | 31 | import type * as mcp_auth from "../mcp_auth.js"; |
| 31 | 32 | import type * as run_executor from "../run_executor.js"; |
| @@ -61,6 +62,7 @@ declare const fullApi: ApiFromModules<{ · 29 unmodified lines | ||
| 61 | 62 | credential_crypto: typeof credential_crypto; |
| 62 | 63 | executor: typeof executor; |
| 63 | 64 | http: typeof http; |
| 65 | + http_security: typeof http_security; | |
| 64 | 66 | mcp: typeof mcp; |
| 65 | 67 | mcp_auth: typeof mcp_auth; |
| 66 | 68 | run_executor: typeof run_executor; |
| @@ -21,7 +21,6 @@ const providers: AuthConfig["providers"] = clientId · 20 unmodified lines | ||
| 21 | 21 | type: "customJwt", |
| 22 | 22 | issuer: `https://api.workos.com/user_management/${clientId}`, |
| 23 | 23 | algorithm: "RS256", |
| 24 | − applicationID: clientId, | |
| 25 | 24 | jwks: `https://api.workos.com/sso/jwks/${clientId}`, |
| 26 | 25 | }, |
| 27 | 26 | ] |
| @@ -294,7 +294,7 @@ export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => · 293 unmodified lines | ||
| 294 | 294 | |
| 295 | 295 | const principal = yield* resolveAuthenticatedPrincipal(ctx); |
| 296 | 296 | |
| 297 | − const organizationMemberships = yield* runQueryEffect( | |
| 297 | + let organizationMemberships = yield* runQueryEffect( | |
| 298 | 298 | "organizationMembership.list", |
| 299 | 299 | () => |
| 300 | 300 | ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, { |
| @@ -302,17 +302,47 @@ export const ConvexControlPlaneActorLive = (ctx: ActionCtx) => · 1 unmodified line | ||
| 302 | 302 | }), |
| 303 | 303 | ); |
| 304 | 304 | |
| 305 | − const organizationIds = organizationMemberships.map( | |
| 305 | + let organizationIds = organizationMemberships.map( | |
| 306 | 306 | (membership) => membership.organizationId, |
| 307 | 307 | ); |
| 308 | 308 | |
| 309 | − const workspaces = yield* runQueryEffect("workspace.list", () => | |
| 309 | + let workspaces = yield* runQueryEffect("workspace.list", () => | |
| 310 | 310 | ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, { |
| 311 | 311 | accountId: principal.accountId, |
| 312 | 312 | organizationIds, |
| 313 | 313 | }), |
| 314 | 314 | ); |
| 315 | 315 | |
| 316 | + if (organizationMemberships.length === 0) { | |
| 317 | + const workspaceId = workspaces[0]?.id ?? `ws_${principal.accountId}`; | |
| 318 | + | |
| 319 | + yield* runQueryEffect("workspace.ensureDefault", () => | |
| 320 | + ctx.runMutation(internal.control_plane.actor.ensureWorkspaceForActor, { | |
| 321 | + workspaceId, | |
| 322 | + accountId: principal.accountId, | |
| 323 | + }), | |
| 324 | + ); | |
| 325 | + | |
| 326 | + organizationMemberships = yield* runQueryEffect( | |
| 327 | + "organizationMembership.list", | |
| 328 | + () => | |
| 329 | + ctx.runQuery(internal.control_plane.actor.listOrganizationMembershipsForActor, { | |
| 330 | + accountId: principal.accountId, | |
| 331 | + }), | |
| 332 | + ); | |
| 333 | + | |
| 334 | + organizationIds = organizationMemberships.map( | |
| 335 | + (membership) => membership.organizationId, | |
| 336 | + ); | |
| 337 | + | |
| 338 | + workspaces = yield* runQueryEffect("workspace.list", () => | |
| 339 | + ctx.runQuery(internal.control_plane.actor.listWorkspacesForActor, { | |
| 340 | + accountId: principal.accountId, | |
| 341 | + organizationIds, | |
| 342 | + }), | |
| 343 | + ); | |
| 344 | + } | |
| 345 | + | |
| 316 | 346 | const workspaceMemberships = workspaces.flatMap((workspace) => |
| 317 | 347 | deriveWorkspaceMembershipsForPrincipal({ |
| 318 | 348 | principalAccountId: principal.accountId, |
| @@ -1410,6 +1410,7 @@ describe("Convex executor and control-plane", () => { · 1409 unmodified lines | ||
| 1410 | 1410 | t.mutation(runtimeInternal.task_runs.startTaskRun, { |
| 1411 | 1411 | workspaceId: "ws_1", |
| 1412 | 1412 | runId: "run_approval_1", |
| 1413 | + accountId: "acct_1", | |
| 1413 | 1414 | }), |
| 1414 | 1415 | ); |
| 1415 | 1416 | |
| @@ -19,19 +19,11 @@ import { · 18 unmodified lines | ||
| 19 | 19 | } from "./mcp_auth"; |
| 20 | 20 | import { createConvexSourceToolRegistry } from "./source_tool_registry"; |
| 21 | 21 | |
| 22 | −const readConfiguredWorkspaceId = (value: string | undefined): string => { | |
| 23 | − const normalized = value?.trim(); | |
| 24 | − return normalized && normalized.length > 0 ? normalized : "ws_local"; | |
| 25 | −}; | |
| 26 | − | |
| 27 | 22 | const readWorkspaceIdFromRequest = (request: Request): string | null => { |
| 28 | 23 | const value = new URL(request.url).searchParams.get("workspaceId")?.trim(); |
| 29 | 24 | return value && value.length > 0 ? value : null; |
| 30 | 25 | }; |
| 31 | 26 | |
| 32 | −const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string => | |
| 33 | − readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId; | |
| 34 | − | |
| 35 | 27 | const readToolExposureModeFromRequest = ( |
| 36 | 28 | request: Request, |
| 37 | 29 | fallbackMode: ExecuteToolExposureMode, |
| @@ -96,7 +88,6 @@ const hasWorkspaceAccess = async ( · 58 unmodified lines | ||
| 96 | 88 | } |
| 97 | 89 | }; |
| 98 | 90 | |
| 99 | −const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); | |
| 100 | 91 | const defaultToolExposureMode = |
| 101 | 92 | parseExecuteToolExposureMode(process.env.CONVEX_TOOL_EXPOSURE_MODE) ?? |
| 102 | 93 | defaultExecuteToolExposureMode; |
| @@ -104,45 +95,41 @@ const runtimeInternal = internal as any; · 1 unmodified line | ||
| 104 | 95 | |
| 105 | 96 | export const mcpHandler = httpAction(async (ctx, request) => { |
| 106 | 97 | const mcpAuthConfig = getMcpAuthConfig(); |
| 107 | − const requestWorkspaceId = readWorkspaceIdFromRequest(request); | |
| 108 | − const workspaceId = readRequestedWorkspaceId(request, fallbackWorkspaceId); | |
| 109 | − let accountId: string | null = null; | |
| 98 | + const workspaceId = readWorkspaceIdFromRequest(request); | |
| 110 | 99 | |
| 111 | 100 | if (!mcpAuthConfig.enabled) { |
| 112 | − if (mcpAuthConfig.required) { | |
| 113 | − return Response.json( | |
| 114 | − { | |
| 115 | − error: "MCP OAuth must be configured for cloud deployments", | |
| 116 | − }, | |
| 117 | − { status: 503 }, | |
| 118 | − ); | |
| 119 | − } | |
| 120 | − } else { | |
| 121 | − const auth = await verifyMcpToken(request, mcpAuthConfig); | |
| 122 | − if (!auth) { | |
| 123 | − return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 124 | − } | |
| 101 | + return Response.json( | |
| 102 | + { | |
| 103 | + error: "MCP OAuth must be configured", | |
| 104 | + }, | |
| 105 | + { status: 503 }, | |
| 106 | + ); | |
| 107 | + } | |
| 125 | 108 | |
| 126 | − accountId = auth.subject; | |
| 109 | + const auth = await verifyMcpToken(request, mcpAuthConfig); | |
| 110 | + if (!auth) { | |
| 111 | + return unauthorizedMcpResponse(request, "No valid bearer token provided."); | |
| 112 | + } | |
| 127 | 113 | |
| 128 | − if (!requestWorkspaceId) { | |
| 129 | − return Response.json( | |
| 130 | − { | |
| 131 | − error: "workspaceId query parameter is required when MCP OAuth is enabled", | |
| 132 | − }, | |
| 133 | − { status: 400 }, | |
| 134 | − ); | |
| 135 | − } | |
| 114 | + if (!workspaceId) { | |
| 115 | + return Response.json( | |
| 116 | + { | |
| 117 | + error: "workspaceId query parameter is required", | |
| 118 | + }, | |
| 119 | + { status: 400 }, | |
| 120 | + ); | |
| 121 | + } | |
| 136 | 122 | |
| 137 | − const authorized = await hasWorkspaceAccess(ctx, requestWorkspaceId, auth.subject); | |
| 138 | − if (!authorized) { | |
| 139 | − return Response.json( | |
| 140 | − { | |
| 141 | − error: "Workspace authorization failed", | |
| 142 | − }, | |
| 143 | − { status: 403 }, | |
| 144 | − ); | |
| 145 | − } | |
| 123 | + const accountId = auth.subject; | |
| 124 | + | |
| 125 | + const authorized = await hasWorkspaceAccess(ctx, workspaceId, accountId); | |
| 126 | + if (!authorized) { | |
| 127 | + return Response.json( | |
| 128 | + { | |
| 129 | + error: "Workspace authorization failed", | |
| 130 | + }, | |
| 131 | + { status: 403 }, | |
| 132 | + ); | |
| 146 | 133 | } |
| 147 | 134 | |
| 148 | 135 | const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId, { |
| @@ -88,7 +88,7 @@ const decodeToolCallRequest = (request: Request): Effect.Effect<RuntimeToolCallR · 87 unmodified lines | ||
| 88 | 88 | const resolveWorkspaceIdForToolCall = ( |
| 89 | 89 | ctx: ActionCtx, |
| 90 | 90 | input: RuntimeToolCallRequest, |
| 91 | −): Effect.Effect<{ workspaceId: string; accountId: string | null }, RuntimeToolCallBadRequestError> => | |
| 91 | +): Effect.Effect<{ workspaceId: string; accountId: string }, RuntimeToolCallBadRequestError> => | |
| 92 | 92 | Effect.gen(function* () { |
| 93 | 93 | const runContext = yield* Effect.tryPromise({ |
| 94 | 94 | try: () => |
| @@ -103,12 +103,17 @@ const resolveWorkspaceIdForToolCall = ( · 8 unmodified lines | ||
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | 105 | if (runContext && runContext.workspaceId.trim().length > 0) { |
| 106 | + const accountId = runContext.accountId.trim(); | |
| 107 | + if (accountId.length === 0) { | |
| 108 | + return yield* new RuntimeToolCallBadRequestError({ | |
| 109 | + message: "Runtime callback run is missing account identity", | |
| 110 | + details: `runId=${input.runId}`, | |
| 111 | + }); | |
| 112 | + } | |
| 113 | + | |
| 106 | 114 | return { |
| 107 | 115 | workspaceId: runContext.workspaceId.trim(), |
| 108 | − accountId: | |
| 109 | − typeof runContext.accountId === "string" && runContext.accountId.trim().length > 0 | |
| 110 | − ? runContext.accountId.trim() | |
| 111 | − : null, | |
| 116 | + accountId, | |
| 112 | 117 | }; |
| 113 | 118 | } |
| 114 | 119 | |
| @@ -268,21 +268,15 @@ const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<st · 267 unmodified lines | ||
| 268 | 268 | const resolveSourceCredentialHeadersForInvocation = async ( |
| 269 | 269 | ctx: ActionCtx, |
| 270 | 270 | source: Source, |
| 271 | − accountId: string | null, | |
| 271 | + accountId: string, | |
| 272 | 272 | ): Promise<Record<string, string>> => { |
| 273 | 273 | try { |
| 274 | − const accountInput = accountId | |
| 275 | − ? { | |
| 276 | − accountId, | |
| 277 | − } | |
| 278 | − : {}; | |
| 279 | − | |
| 280 | 274 | const resolved = (await ctx.runAction( |
| 281 | 275 | runtimeInternal.control_plane.credentials.resolveSourceCredentialHeaders, |
| 282 | 276 | { |
| 283 | 277 | workspaceId: source.workspaceId, |
| 284 | 278 | sourceId: source.id, |
| 285 | − ...accountInput, | |
| 279 | + accountId, | |
| 286 | 280 | }, |
| 287 | 281 | )) as { headers?: unknown }; |
| 288 | 282 | |
| @@ -306,7 +300,7 @@ const resolveSourceCredentialHeadersForInvocation = async ( · 17 unmodified lines | ||
| 306 | 300 | const resolveSourceHeadersForInvocation = async ( |
| 307 | 301 | ctx: ActionCtx, |
| 308 | 302 | source: Source, |
| 309 | − accountId: string | null, | |
| 303 | + accountId: string, | |
| 310 | 304 | ): Promise<Record<string, string>> => { |
| 311 | 305 | const config = parseSourceConfig(source); |
| 312 | 306 | |
| @@ -1047,7 +1041,7 @@ const invokeToolWithRegistry = ( · 734 unmodified lines | ||
| 1047 | 1041 | source: Source, |
| 1048 | 1042 | artifactTool: ArtifactToolRow, |
| 1049 | 1043 | args: Record<string, unknown>, |
| 1050 | − accountId: string | null, | |
| 1044 | + accountId: string, | |
| 1051 | 1045 | ): Effect.Effect<{ output: unknown; isError: boolean }, RuntimeAdapterError> => |
| 1052 | 1046 | Effect.gen(function* () { |
| 1053 | 1047 | const openApiProvider = makeOpenApiToolProvider(); |
| @@ -1583,21 +1577,21 @@ const describeSource = (row: { · 529 unmodified lines | ||
| 1583 | 1577 | }): string => `${row.sourceKind.toUpperCase()} source: ${row.sourceName}`; |
| 1584 | 1578 | |
| 1585 | 1579 | export type ConvexSourceToolRegistryOptions = { |
| 1580 | + accountId: string; | |
| 1586 | 1581 | requireToolApprovals?: boolean; |
| 1587 | 1582 | approvalRetryAfterMs?: number; |
| 1588 | − accountId?: string | null; | |
| 1589 | 1583 | }; |
| 1590 | 1584 | |
| 1591 | 1585 | export const createConvexSourceToolRegistry = ( |
| 1592 | 1586 | ctx: ActionCtx, |
| 1593 | 1587 | workspaceId: string, |
| 1594 | − options: ConvexSourceToolRegistryOptions = {}, | |
| 1588 | + options: ConvexSourceToolRegistryOptions, | |
| 1595 | 1589 | ): ToolRegistry => { |
| 1596 | 1590 | const requireApprovals = options.requireToolApprovals ?? requireToolApprovalsByDefault; |
| 1597 | − const accountId = | |
| 1598 | − typeof options.accountId === "string" && options.accountId.trim().length > 0 | |
| 1599 | − ? options.accountId.trim() | |
| 1600 | − : null; | |
| 1591 | + const accountId = options.accountId.trim(); | |
| 1592 | + if (accountId.length === 0) { | |
| 1593 | + throw new Error("Tool registry accountId is required"); | |
| 1594 | + } | |
| 1601 | 1595 | const approvalRetryAfterMs = |
| 1602 | 1596 | typeof options.approvalRetryAfterMs === "number" && |
| 1603 | 1597 | Number.isFinite(options.approvalRetryAfterMs) && |
| @@ -14,7 +14,7 @@ export const startTaskRun = internalMutation({ · 13 unmodified lines | ||
| 14 | 14 | args: { |
| 15 | 15 | workspaceId: v.string(), |
| 16 | 16 | runId: v.string(), |
| 17 | − accountId: v.optional(v.union(v.string(), v.null())), | |
| 17 | + accountId: v.string(), | |
| 18 | 18 | sessionId: v.optional(v.string()), |
| 19 | 19 | runtimeId: v.optional(v.string()), |
| 20 | 20 | codeHash: v.optional(v.string()), |
| @@ -29,13 +29,18 @@ export const startTaskRun = internalMutation({ · 8 unmodified lines | ||
| 29 | 29 | if (existing.workspaceId !== args.workspaceId) { |
| 30 | 30 | throw new Error(`Task run workspace mismatch: ${args.runId}`); |
| 31 | 31 | } |
| 32 | + | |
| 33 | + if (existing.accountId !== args.accountId) { | |
| 34 | + throw new Error(`Task run account mismatch: ${args.runId}`); | |
| 35 | + } | |
| 36 | + | |
| 32 | 37 | return; |
| 33 | 38 | } |
| 34 | 39 | |
| 35 | 40 | const taskRun = { |
| 36 | 41 | id: args.runId, |
| 37 | 42 | workspaceId: args.workspaceId, |
| 38 | − accountId: args.accountId ?? null, | |
| 43 | + accountId: args.accountId, | |
| 39 | 44 | sessionId: args.sessionId ?? "session_runtime", |
| 40 | 45 | runtimeId: args.runtimeId ?? "runtime", |
| 41 | 46 | codeHash: args.codeHash ?? "runtime", |
| @@ -83,7 +88,7 @@ export const getTaskRunContext = internalQuery({ · 41 unmodified lines | ||
| 83 | 88 | args: { |
| 84 | 89 | runId: v.string(), |
| 85 | 90 | }, |
| 86 | − handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string | null } | null> => { | |
| 91 | + handler: async (ctx, args): Promise<{ workspaceId: string; accountId: string } | null> => { | |
| 87 | 92 | const existing = await ctx.db |
| 88 | 93 | .query("taskRuns") |
| 89 | 94 | .withIndex("by_domainId", (q) => q.eq("id", args.runId)) |
| @@ -95,7 +100,7 @@ export const getTaskRunContext = internalQuery({ · 5 unmodified lines | ||
| 95 | 100 | |
| 96 | 101 | return { |
| 97 | 102 | workspaceId: existing.workspaceId, |
| 98 | − accountId: typeof existing.accountId === "string" ? existing.accountId : null, | |
| 103 | + accountId: existing.accountId, | |
| 99 | 104 | }; |
| 100 | 105 | }, |
| 101 | 106 | }); |
| @@ -57,9 +57,29 @@ const buildUpstreamHeaders = ( · 56 unmodified lines | ||
| 57 | 57 | } |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | + headers.set("accept-encoding", "identity"); | |
| 61 | + | |
| 60 | 62 | return headers; |
| 61 | 63 | }; |
| 62 | 64 | |
| 65 | +const responseHeadersToStrip = [ | |
| 66 | + "content-encoding", | |
| 67 | + "content-length", | |
| 68 | + "transfer-encoding", | |
| 69 | + "connection", | |
| 70 | + "keep-alive", | |
| 71 | +] as const; | |
| 72 | + | |
| 73 | +const sanitizeUpstreamResponseHeaders = (headers: Headers): Headers => { | |
| 74 | + const sanitized = new Headers(headers); | |
| 75 | + | |
| 76 | + for (const name of responseHeadersToStrip) { | |
| 77 | + sanitized.delete(name); | |
| 78 | + } | |
| 79 | + | |
| 80 | + return sanitized; | |
| 81 | +}; | |
| 82 | + | |
| 63 | 83 | const resolveUpstreamUrl = ( |
| 64 | 84 | request: NextRequest, |
| 65 | 85 | pathSegments: ReadonlyArray<string>, |
| @@ -118,7 +138,7 @@ const handle = async (request: NextRequest, context: RouteContext): Promise<Resp · 52 unmodified lines | ||
| 118 | 138 | return new Response(upstreamResponse.body, { |
| 119 | 139 | status: upstreamResponse.status, |
| 120 | 140 | statusText: upstreamResponse.statusText, |
| 121 | − headers: upstreamResponse.headers, | |
| 141 | + headers: sanitizeUpstreamResponseHeaders(upstreamResponse.headers), | |
| 122 | 142 | }); |
| 123 | 143 | }; |
| 124 | 144 | |
| @@ -9,19 +9,15 @@ const trim = (value: string | undefined): string | undefined => { · 8 unmodified lines | ||
| 9 | 9 | return candidate && candidate.length > 0 ? candidate : undefined; |
| 10 | 10 | }; |
| 11 | 11 | |
| 12 | −const publicControlPlaneBaseUrl = | |
| 13 | − trim(process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL) | |
| 14 | − ?? trim(process.env.CONTROL_PLANE_SERVER_BASE_URL) | |
| 15 | − ?? trim(process.env.CONTROL_PLANE_UPSTREAM_URL) | |
| 16 | − ?? trim(process.env.NEXT_PUBLIC_CONVEX_URL) | |
| 17 | − ?? trim(process.env.CONVEX_URL) | |
| 18 | − ?? ""; | |
| 12 | +const publicControlPlaneBaseUrl = trim(process.env.NEXT_PUBLIC_CONTROL_PLANE_BASE_URL); | |
| 19 | 13 | |
| 20 | 14 | const nextConfig: NextConfig = { |
| 21 | 15 | outputFileTracingRoot: resolve(appDir, "../.."), |
| 22 | − env: { | |
| 23 | − NEXT_PUBLIC_CONTROL_PLANE_BASE_URL: publicControlPlaneBaseUrl, | |
| 24 | − }, | |
| 16 | + env: publicControlPlaneBaseUrl | |
| 17 | + ? { | |
| 18 | + NEXT_PUBLIC_CONTROL_PLANE_BASE_URL: publicControlPlaneBaseUrl, | |
| 19 | + } | |
| 20 | + : {}, | |
| 25 | 21 | }; |
| 26 | 22 | |
| 27 | 23 | export default nextConfig; |
| @@ -7,7 +7,7 @@ import { AccountIdSchema, TaskRunIdSchema, WorkspaceIdSchema } from "../ids"; · 6 unmodified lines | ||
| 7 | 7 | export const TaskRunSchema = Schema.Struct({ |
| 8 | 8 | id: TaskRunIdSchema, |
| 9 | 9 | workspaceId: WorkspaceIdSchema, |
| 10 | − accountId: Schema.NullOr(AccountIdSchema), | |
| 10 | + accountId: AccountIdSchema, | |
| 11 | 11 | sessionId: Schema.String, |
| 12 | 12 | runtimeId: Schema.String, |
| 13 | 13 | codeHash: Schema.String, |