diff --git a/CHANGELOG.md b/CHANGELOG.md index d67263859..6f2a7b5f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Require authentication for the streaming and blocking Ask APIs in Public SaaS deployments. [#1679](https://github.com/sourcebot-dev/sourcebot/pull/1679) + ## [5.1.14] - 2026-09-17 ### Added diff --git a/packages/web/src/app/api/(server)/ee/chat/route.ts b/packages/web/src/app/api/(server)/ee/chat/route.ts index c8a34aa8f..b3a5bbdbf 100644 --- a/packages/web/src/app/api/(server)/ee/chat/route.ts +++ b/packages/web/src/app/api/(server)/ee/chat/route.ts @@ -12,6 +12,7 @@ import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server"; import { resolveContextWindow } from "@/features/chat/modelContextWindow.server"; import { materializeCommandMessageTexts } from "@/ee/features/chat/skills/commandResolution"; import { getAskSkillAvailabilityAnalytics, getAskSkillTurnCompletedAnalytics } from "@/ee/features/chat/skills/skillAnalytics.server"; +import { checkAskAuthentication } from "@/features/chat/askAuth"; import { apiHandler } from "@/lib/apiHandler"; import { ErrorCode } from "@/lib/errorCodes"; import { captureEvent } from "@/lib/posthog"; @@ -50,6 +51,11 @@ export const POST = apiHandler(async (req: NextRequest) => { const response = await sew(() => withOptionalAuth(async ({ org, user, prisma }) => { + const authError = checkAskAuthentication(user); + if (authError) { + return authError; + } + // Gate the generative path behind the `ask` entitlement. The client // also gates this, but server-side enforcement can't be bypassed. const askError = await checkAskEntitlement(); diff --git a/packages/web/src/app/api/(server)/ee/mcp/route.ts b/packages/web/src/app/api/(server)/ee/mcp/route.ts index d5e57224d..56ac91bad 100644 --- a/packages/web/src/app/api/(server)/ee/mcp/route.ts +++ b/packages/web/src/app/api/(server)/ee/mcp/route.ts @@ -2,9 +2,10 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { createMcpServer } from '@/ee/features/mcp/server'; import { MCP_PAID_PLAN_REQUIRED_MESSAGE } from '@/ee/features/mcp/constants'; +import { checkAskAuthentication } from '@/features/chat/askAuth'; import { withOptionalAuth } from '@/middleware/withAuth'; import { isServiceError } from '@/lib/utils'; -import { notAuthenticated, serviceErrorResponse, ServiceError } from '@/lib/serviceError'; +import { serviceErrorResponse, ServiceError } from '@/lib/serviceError'; import { ErrorCode } from '@/lib/errorCodes'; import { StatusCodes } from 'http-status-codes'; import { NextRequest } from 'next/server'; @@ -85,8 +86,9 @@ export const POST = apiHandler(async (request: NextRequest) => { const response = await sew(() => withOptionalAuth(async ({ user, principal }) => { - if (env.EXPERIMENT_ASK_GH_ENABLED === 'true' && !user) { - return notAuthenticated(); + const authError = checkAskAuthentication(user); + if (authError) { + return authError; } const ownerId = user?.id ?? null; const sessionId = request.headers.get(MCP_SESSION_ID_HEADER); @@ -151,8 +153,9 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const result = await sew(() => withOptionalAuth(async ({ user }) => { - if (env.EXPERIMENT_ASK_GH_ENABLED === 'true' && !user) { - return notAuthenticated(); + const authError = checkAskAuthentication(user); + if (authError) { + return authError; } const ownerId = user?.id ?? null; const sessionId = request.headers.get(MCP_SESSION_ID_HEADER); diff --git a/packages/web/src/ee/features/mcp/askCodebase.ts b/packages/web/src/ee/features/mcp/askCodebase.ts index 35337d29f..7ff405441 100644 --- a/packages/web/src/ee/features/mcp/askCodebase.ts +++ b/packages/web/src/ee/features/mcp/askCodebase.ts @@ -4,6 +4,7 @@ import { generateChatNameFromMessage } from "@/ee/features/chat/llm.server"; import { getAISDKLanguageModelAndOptions } from "@/features/chat/llm.server"; import { resolveContextWindow } from "@/features/chat/modelContextWindow.server"; import { LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/types"; +import { checkAskAuthentication } from "@/features/chat/askAuth"; import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils"; import { resolveModelCapabilities } from "@/features/chat/modelCapabilities.server"; import { ErrorCode } from "@/lib/errorCodes"; @@ -49,6 +50,11 @@ const blockStreamUntilFinish = async => sew(() => withOptionalAuth(async ({ org, user, prisma }) => { + const authError = checkAskAuthentication(user); + if (authError) { + return authError; + } + // Ask Sourcebot is a paid feature. askCodebase() is the single choke point // for the programmatic ask path (the MCP `ask_codebase` tool and the // /api/chat/blocking route both wrap it), so gating here covers both without diff --git a/packages/web/src/features/chat/askAuth.test.ts b/packages/web/src/features/chat/askAuth.test.ts new file mode 100644 index 000000000..a40c25203 --- /dev/null +++ b/packages/web/src/features/chat/askAuth.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + env: { + EXPERIMENT_ASK_GH_ENABLED: "false", + }, +})); + +vi.mock("@sourcebot/shared", () => ({ + env: mocks.env, +})); + +const { checkAskAuthentication } = await import("./askAuth"); + +beforeEach(() => { + mocks.env.EXPERIMENT_ASK_GH_ENABLED = "false"; +}); + +describe("checkAskAuthentication", () => { + test("rejects anonymous Ask requests when Public SaaS is enabled", () => { + mocks.env.EXPERIMENT_ASK_GH_ENABLED = "true"; + + expect(checkAskAuthentication(undefined)).toEqual({ + statusCode: 401, + errorCode: "NOT_AUTHENTICATED", + message: "Not authenticated", + }); + }); + + test("allows authenticated Ask requests when Public SaaS is enabled", () => { + mocks.env.EXPERIMENT_ASK_GH_ENABLED = "true"; + + expect(checkAskAuthentication({ id: "user-1" })).toBeNull(); + }); + + test("allows anonymous Ask requests when Public SaaS is disabled", () => { + expect(checkAskAuthentication(undefined)).toBeNull(); + }); +}); diff --git a/packages/web/src/features/chat/askAuth.ts b/packages/web/src/features/chat/askAuth.ts new file mode 100644 index 000000000..4914d3e9b --- /dev/null +++ b/packages/web/src/features/chat/askAuth.ts @@ -0,0 +1,14 @@ +import { notAuthenticated, type ServiceError } from "@/lib/serviceError"; +import { env } from "@sourcebot/shared"; + +/** + * Public SaaS requires an authenticated user for Ask requests. Self-hosted + * deployments retain their existing anonymous-access behavior. + */ +export const checkAskAuthentication = (user: object | undefined): ServiceError | null => { + if (env.EXPERIMENT_ASK_GH_ENABLED === "true" && !user) { + return notAuthenticated(); + } + + return null; +};