Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Fixed the public offers endpoint returning a JSON body with a `text/plain` content type. [#1670](https://github.com/sourcebot-dev/sourcebot/pull/1670)

## [5.1.14] - 2026-09-17

### Added
Expand Down
47 changes: 47 additions & 0 deletions packages/web/src/app/api/(server)/offers/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";

const mocks = vi.hoisted(() => ({
offers: vi.fn(),
}));

vi.mock("@/features/billing/client", () => ({
client: {
offers: mocks.offers,
},
}));

vi.mock("@sourcebot/shared", () => ({
env: {
SOURCEBOT_INSTALL_ID: "install-id",
},
}));

vi.mock("@/lib/posthog", () => ({
captureEvent: vi.fn(),
}));

const importRoute = async () => {
vi.resetModules();
return import("./route");
};

describe("GET /api/offers", () => {
beforeEach(() => {
vi.clearAllMocks();
});

test("returns offers as JSON", async () => {
const offers = [{ id: "startup" }];
mocks.offers.mockResolvedValue(offers);
const { GET } = await importRoute();

const response = await GET(new NextRequest("https://sourcebot.example.com/api/offers"));

expect(mocks.offers).toHaveBeenCalledTimes(1);
expect(mocks.offers).toHaveBeenCalledWith({ installId: "install-id" });
expect(response.headers.get("content-type")).toContain("application/json");
expect(response.headers.get("cache-control")).toBe("public, max-age=300");
await expect(response.json()).resolves.toEqual(offers);
});
});
4 changes: 2 additions & 2 deletions packages/web/src/app/api/(server)/offers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ export const GET = apiHandler(async () => {
installId: env.SOURCEBOT_INSTALL_ID,
});

return new Response(JSON.stringify(offers), {
return Response.json(offers, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When client.offers resolves to a ServiceError (Lighthouse unreachable, or response body fails schema validation), this endpoint still responds HTTP 200 and stamps the error body with Cache-Control: public, max-age=300, so the error gets served as a successful 200 and cached publicly for 5 minutes. client.offers is typed Promise<OffersResponse | ServiceError> (packages/web/src/features/billing/client.ts, requestLighthouse returns lighthouseUnreachable with statusCode: 500), but this touched response never branches on that. Since this PR is rewriting the exact response construction, handle the error case: respond with the error's status code and skip the public cache header.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/web/src/app/api/(server)/offers/route.ts, line 11:

<comment>When `client.offers` resolves to a `ServiceError` (Lighthouse unreachable, or response body fails schema validation), this endpoint still responds HTTP 200 and stamps the error body with `Cache-Control: public, max-age=300`, so the error gets served as a successful 200 and cached publicly for 5 minutes. `client.offers` is typed `Promise<OffersResponse | ServiceError>` (packages/web/src/features/billing/client.ts, `requestLighthouse` returns `lighthouseUnreachable` with `statusCode: 500`), but this touched response never branches on that. Since this PR is rewriting the exact response construction, handle the error case: respond with the error's status code and skip the public cache header.</comment>

<file context>
@@ -8,9 +8,9 @@ export const GET = apiHandler(async () => {
     });
 
-    return new Response(JSON.stringify(offers), {
+    return Response.json(offers, {
         headers: {
             'Cache-Control': 'public, max-age=300'
</file context>

headers: {
'Cache-Control': 'public, max-age=300'
}
});
})
})