From 690d6846fdad22e59f2199d139431d5c704b84fb Mon Sep 17 00:00:00 2001 From: Michael Sukkarieh <22405198+msukkari@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:14:31 +0000 Subject: [PATCH 1/2] fix(web): require authentication for AskGH Ask APIs Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- .../app/api/(server)/ee/chat/route.test.ts | 107 ++++++++++++++++++ .../web/src/app/api/(server)/ee/chat/route.ts | 6 +- .../web/src/ee/features/mcp/askCodebase.ts | 6 +- 3 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 packages/web/src/app/api/(server)/ee/chat/route.test.ts diff --git a/packages/web/src/app/api/(server)/ee/chat/route.test.ts b/packages/web/src/app/api/(server)/ee/chat/route.test.ts new file mode 100644 index 000000000..7f7641360 --- /dev/null +++ b/packages/web/src/app/api/(server)/ee/chat/route.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { NextRequest } from 'next/server'; +import { notAuthenticated, serviceErrorSchema } from '@/lib/serviceError'; +import { ErrorCode } from '@/lib/errorCodes'; + +const mocks = vi.hoisted(() => ({ + env: { EXPERIMENT_ASK_GH_ENABLED: 'false' }, + withOptionalAuth: vi.fn(), + checkAskEntitlement: vi.fn(), + createMessageStream: vi.fn(), + findChat: vi.fn(), + createChat: vi.fn(), +})); + +vi.mock('@/lib/apiHandler', () => ({ + apiHandler: (handler: unknown) => handler, +})); +vi.mock('@/middleware/withAuth', () => ({ + withOptionalAuth: mocks.withOptionalAuth, +})); +vi.mock('@sourcebot/shared', () => ({ + env: mocks.env, + createLogger: () => ({ error: vi.fn() }), +})); +vi.mock('@/lib/utils', () => ({ + isServiceError: (value: unknown) => serviceErrorSchema.safeParse(value).success, +})); +vi.mock('@/features/chat/utils.server', () => ({ + checkAskEntitlement: mocks.checkAskEntitlement, +})); +vi.mock('@/ee/features/chat/agent', () => ({ + createMessageStream: mocks.createMessageStream, +})); +vi.mock('@/features/chat/utils', () => ({})); +vi.mock('@/features/chat/llm.server', () => ({})); +vi.mock('@/features/chat/modelCapabilities.server', () => ({})); +vi.mock('@/features/chat/modelContextWindow.server', () => ({})); +vi.mock('@/ee/features/chat/llm.server', () => ({})); +vi.mock('@/ee/features/chat/askMcpAnalytics.server', () => ({})); +vi.mock('@/ee/features/chat/skills/commandResolution', () => ({})); +vi.mock('@/ee/features/chat/skills/skillAnalytics.server', () => ({})); +vi.mock('@/ee/features/audit/audit', () => ({})); +vi.mock('@/lib/posthog', () => ({})); +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })); + +const { POST: streamingPost } = await import('./route'); +const { POST: blockingPost } = await import('../../chat/blocking/route'); + +// Stop requests that pass authentication at the next gate, before model or DB work. +const entitlementError = { + statusCode: 403, + errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, + message: 'Ask entitlement required', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.checkAskEntitlement.mockResolvedValue(entitlementError); +}); + +describe.each([ + { + name: 'streaming', + post: streamingPost, + path: '/api/ee/chat', + body: { + id: 'chat-id', + messages: [{ id: 'message-id', role: 'user', parts: [{ type: 'text', text: 'Explain this code' }] }], + selectedSearchScopes: [], + languageModel: { provider: 'openai', model: 'gpt-4o' }, + }, + }, + { + name: 'blocking', + post: blockingPost, + path: '/api/chat/blocking', + body: { query: 'Explain this code' }, + }, +])('$name Ask authentication', ({ post, path, body }) => { + test.each([ + { askGhEnabled: 'true', authenticated: false, denied: true }, + { askGhEnabled: 'true', authenticated: true, denied: false }, + { askGhEnabled: 'false', authenticated: false, denied: false }, + { askGhEnabled: 'false', authenticated: true, denied: false }, + ])('AskGH=$askGhEnabled, authenticated=$authenticated', async ({ askGhEnabled, authenticated, denied }) => { + mocks.env.EXPERIMENT_ASK_GH_ENABLED = askGhEnabled; + // Simulate an org with anonymous access enabled by allowing either context. + mocks.withOptionalAuth.mockImplementation((callback) => callback({ + org: { id: 1 }, + user: authenticated ? { id: 'user-id' } : undefined, + prisma: { chat: { findUnique: mocks.findChat, create: mocks.createChat } }, + })); + + const response = await post(new NextRequest(`https://sourcebot.example.com${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + })); + + expect(response.status).toBe(denied ? 401 : entitlementError.statusCode); + expect(await response.json()).toEqual(denied ? notAuthenticated() : entitlementError); + expect(mocks.checkAskEntitlement).toHaveBeenCalledTimes(denied ? 0 : 1); + expect(mocks.findChat).not.toHaveBeenCalled(); + expect(mocks.createChat).not.toHaveBeenCalled(); + expect(mocks.createMessageStream).not.toHaveBeenCalled(); + }); +}); 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..58f85f1b8 100644 --- a/packages/web/src/app/api/(server)/ee/chat/route.ts +++ b/packages/web/src/app/api/(server)/ee/chat/route.ts @@ -15,7 +15,7 @@ import { getAskSkillAvailabilityAnalytics, getAskSkillTurnCompletedAnalytics } f import { apiHandler } from "@/lib/apiHandler"; import { ErrorCode } from "@/lib/errorCodes"; import { captureEvent } from "@/lib/posthog"; -import { notFound, requestBodySchemaValidationError, ServiceError, serviceErrorResponse } from "@/lib/serviceError"; +import { notAuthenticated, notFound, requestBodySchemaValidationError, ServiceError, serviceErrorResponse } from "@/lib/serviceError"; import { isServiceError } from "@/lib/utils"; import { withOptionalAuth } from "@/middleware/withAuth"; import * as Sentry from "@sentry/nextjs"; @@ -50,6 +50,10 @@ export const POST = apiHandler(async (req: NextRequest) => { const response = await sew(() => withOptionalAuth(async ({ org, user, prisma }) => { + if (env.EXPERIMENT_ASK_GH_ENABLED === 'true' && !user) { + return notAuthenticated(); + } + // 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/ee/features/mcp/askCodebase.ts b/packages/web/src/ee/features/mcp/askCodebase.ts index 35337d29f..40cf3cb62 100644 --- a/packages/web/src/ee/features/mcp/askCodebase.ts +++ b/packages/web/src/ee/features/mcp/askCodebase.ts @@ -7,7 +7,7 @@ import { LanguageModelInfo, SBChatMessage, SearchScope } from "@/features/chat/t import { convertLLMOutputToPortableMarkdown, getAnswerPartFromAssistantMessage, getLanguageModelKey } from "@/features/chat/utils"; import { resolveModelCapabilities } from "@/features/chat/modelCapabilities.server"; import { ErrorCode } from "@/lib/errorCodes"; -import { ServiceError, ServiceErrorException } from "@/lib/serviceError"; +import { notAuthenticated, ServiceError, ServiceErrorException } from "@/lib/serviceError"; import { withOptionalAuth } from "@/middleware/withAuth"; import { ChatVisibility, Prisma } from "@sourcebot/db"; import { createLogger, env } from "@sourcebot/shared"; @@ -49,6 +49,10 @@ const blockStreamUntilFinish = async => sew(() => withOptionalAuth(async ({ org, user, prisma }) => { + if (env.EXPERIMENT_ASK_GH_ENABLED === 'true' && !user) { + return notAuthenticated(); + } + // 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 From 25788cc3761fccb13560e9e697110940399d183b Mon Sep 17 00:00:00 2001 From: Michael Sukkarieh <22405198+msukkari@users.noreply.github.com> Date: Tue, 22 Sep 2026 04:14:47 +0000 Subject: [PATCH 2/2] docs: add AskGH authentication changelog entry Co-authored-by: linear-code[bot] <222613912+linear-code[bot]@users.noreply.github.com> --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d67263859..f7662b102 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 +- [EE] Required authentication for streaming and blocking Ask API requests when AskGH is enabled. [#1678](https://github.com/sourcebot-dev/sourcebot/pull/1678) + ## [5.1.14] - 2026-09-17 ### Added