Merge pull request 'fix(serve): error handling, CORS, body limit, CAS store reuse' (#136) from fix/120-serve-hardening into main

This commit is contained in:
2026-05-08 15:00:32 +00:00
3 changed files with 112 additions and 10 deletions
@@ -77,6 +77,83 @@ describe("serve /api/cas", () => {
});
});
describe("serve error handling", () => {
test("POST /api/threads with invalid JSON body → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("invalid JSON body");
});
test("POST /api/cas with invalid JSON body → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/cas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("invalid JSON body");
});
test("POST /api/threads with missing required fields → 400", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const res = await fetch("/api/threads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ foo: "bar" }),
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toContain("required");
});
test("global error handler returns 500 with JSON", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
app.get("/test-error", () => {
throw new Error("boom");
});
const res = await app.fetch(new Request("http://localhost/test-error"));
expect(res.status).toBe(500);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Internal server error");
});
});
describe("serve security", () => {
test("CORS headers present on responses", async () => {
const app = createApp("/tmp/uncaged-serve-test-nonexistent");
const res2 = await app.fetch(
new Request("http://localhost/healthz", {
headers: { Origin: "http://localhost:5173" },
}),
);
expect(res2.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:5173");
});
test("POST with body > 1MB → 413", async () => {
const { fetch } = buildApp("/tmp/uncaged-serve-test-nonexistent");
const largeBody = "x".repeat(1_048_577);
const res = await fetch("/api/cas", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": String(largeBody.length),
},
body: largeBody,
});
expect(res.status).toBe(413);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Payload too large");
});
});
describe("serve CAS round-trip", () => {
const tmpDir = `/tmp/uncaged-serve-cas-test-${Date.now()}`;
@@ -6,10 +6,36 @@ import { createLiveRoutes } from "./routes-live.js";
import { createThreadRoutes } from "./routes-thread.js";
import { createWorkflowRoutes } from "./routes-workflow.js";
const MAX_BODY_SIZE = 1_048_576; // 1 MB
export function createApp(storageRoot: string): Hono {
const app = new Hono();
app.use("*", cors());
app.onError((_err, c) => {
return c.json({ error: "Internal server error" }, 500);
});
app.use(
"*",
cors({
origin: [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:7860",
"http://127.0.0.1:7860",
],
}),
);
app.use("*", async (c, next) => {
if (c.req.method === "POST") {
const contentLength = c.req.header("content-length");
if (contentLength !== undefined && Number(contentLength) > MAX_BODY_SIZE) {
return c.json({ error: "Payload too large" }, 413);
}
}
await next();
});
app.get("/healthz", (c) => c.json({ ok: true }));
@@ -3,17 +3,15 @@ import { Hono } from "hono";
export function createCasRoutes(storageRoot: string): Hono {
const app = new Hono();
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
app.get("/", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hashes = await cas.list();
return c.json({ hashes });
});
app.get("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const content = await cas.get(c.req.param("hash"));
if (content === null) {
return c.json({ error: "not found" }, 404);
@@ -22,19 +20,20 @@ export function createCasRoutes(storageRoot: string): Hono {
});
app.post("/", async (c) => {
const body = await c.req.json<{ content: string }>();
let body: { content: string };
try {
body = (await c.req.json()) as { content: string };
} catch {
return c.json({ error: "invalid JSON body" }, 400);
}
if (typeof body.content !== "string") {
return c.json({ error: "content field required" }, 400);
}
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = await cas.put(body.content);
return c.json({ hash }, 201);
});
app.delete("/:hash", async (c) => {
const casDir = getGlobalCasDir(storageRoot);
const cas = createCasStore(casDir);
const hash = c.req.param("hash");
const content = await cas.get(hash);
if (content === null) {