From 6b1e728700c4968ba13f456e356859b5c7251449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Fri, 8 May 2026 18:11:59 +0800 Subject: [PATCH] fix(serve): error handling, CORS, body limit, CAS store reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global error handler (app.onError → 500 JSON) - JSON parse validation on POST routes (400) - CORS restricted to localhost origins - 1MB body size limit on POST (413) - CAS store created once per route group, not per-request - 6 new tests covering all changes Closes #120 --- packages/cli-workflow/__tests__/serve.test.ts | 77 +++++++++++++++++++ .../cli-workflow/src/commands/serve/app.ts | 28 ++++++- .../src/commands/serve/routes-cas.ts | 17 ++-- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/packages/cli-workflow/__tests__/serve.test.ts b/packages/cli-workflow/__tests__/serve.test.ts index 1672e8d..155f5de 100644 --- a/packages/cli-workflow/__tests__/serve.test.ts +++ b/packages/cli-workflow/__tests__/serve.test.ts @@ -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()}`; diff --git a/packages/cli-workflow/src/commands/serve/app.ts b/packages/cli-workflow/src/commands/serve/app.ts index b544c8f..25f8b81 100644 --- a/packages/cli-workflow/src/commands/serve/app.ts +++ b/packages/cli-workflow/src/commands/serve/app.ts @@ -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 })); diff --git a/packages/cli-workflow/src/commands/serve/routes-cas.ts b/packages/cli-workflow/src/commands/serve/routes-cas.ts index 7aeb89a..6ad827a 100644 --- a/packages/cli-workflow/src/commands/serve/routes-cas.ts +++ b/packages/cli-workflow/src/commands/serve/routes-cas.ts @@ -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) { -- 2.43.0