Skip to content
Open
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
8 changes: 8 additions & 0 deletions packages/server/annotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@ export async function startAnnotateServer(
// Favicon
if (url.pathname === "/favicon.svg") return handleFavicon();

// API 404 guard: unknown /api/* routes should return JSON, not HTML
if (url.pathname.startsWith("/api/")) {
return Response.json(
{ error: "Not found", path: url.pathname },
{ status: 404 },
);
}

// Serve embedded HTML for all other routes (SPA)
return new Response(htmlContent, {
headers: { "Content-Type": "text/html" },
Expand Down
60 changes: 60 additions & 0 deletions packages/server/api-404-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* PR B: Tests for API route 404 guards
*
* Unknown /api/* paths should return JSON 404, not HTML.
*/
import { describe, test, expect, afterAll, beforeAll } from "bun:test";

describe("API route 404 guards", () => {
const controllers: AbortController[] = [];
let savedPort: string | undefined;
let savedRemote: string | undefined;

beforeAll(() => {
savedPort = process.env.PLANNOTATOR_PORT;
savedRemote = process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_PORT;
delete process.env.PLANNOTATOR_REMOTE;
delete process.env.PLANNOTATOR_SERVER_URL;
});

afterAll(() => {
for (const c of controllers) c.abort();
if (savedPort) process.env.PLANNOTATOR_PORT = savedPort;
if (savedRemote) process.env.PLANNOTATOR_REMOTE = savedRemote;
});

test("/api/nonexistent on plan server returns JSON 404", async () => {
const { startPlannotatorServer } = await import("./index");

const controller = new AbortController();
controllers.push(controller);
const server = await startPlannotatorServer({
plan: "# Test Plan\n\nHello",
signal: controller.signal,
});

const response = await fetch(`${server.url}/api/nonexistent-route`);
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toContain("application/json");

const body = await response.json() as any;
expect(body.error).toBe("Not found");
expect(body.path).toBe("/api/nonexistent-route");
});

test("non-API route still serves HTML (SPA fallback)", async () => {
const { startPlannotatorServer } = await import("./index");

const controller = new AbortController();
controllers.push(controller);
const server = await startPlannotatorServer({
plan: "# Test",
signal: controller.signal,
});

const response = await fetch(`${server.url}/some/random/path`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
});
});
8 changes: 8 additions & 0 deletions packages/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,14 @@ export async function startPlannotatorServer(
// Favicon
if (url.pathname === "/favicon.svg") return handleFavicon();

// API 404 guard: unknown /api/* routes should return JSON, not HTML
if (url.pathname.startsWith("/api/")) {
return Response.json(
{ error: "Not found", path: url.pathname },
{ status: 404 },
);
}

// Serve embedded HTML for all other routes (SPA)
return new Response(htmlContent, {
headers: { "Content-Type": "text/html" },
Expand Down
8 changes: 8 additions & 0 deletions packages/server/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,14 @@ export async function startReviewServer(
// Favicon
if (url.pathname === "/favicon.svg") return handleFavicon();

// API 404 guard: unknown /api/* routes should return JSON, not HTML
if (url.pathname.startsWith("/api/")) {
return Response.json(
{ error: "Not found", path: url.pathname },
{ status: 404 },
);
}

// Serve embedded HTML for all other routes (SPA)
return new Response(htmlContent, {
headers: { "Content-Type": "text/html" },
Expand Down