diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fad59961b1..fa8b75de72 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -190,17 +190,45 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe Layer.provide(authOnlyRouterLayer), ) -const uiRoute = HttpRouter.use((router) => +const uiRoute = (corsOptions?: CorsOptions) => HttpRouter.use((router) => Effect.gen(function* () { const fs = yield* FSUtil.Service const client = yield* HttpClient.HttpClient const flags = yield* RuntimeFlags.Service yield* router.add("*", "/*", (request) => - serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }), + serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }).pipe( + Effect.map((response) => withNotFoundCors(response, request.headers.origin, corsOptions)), + ), ) }), ).pipe(Layer.provide(authOnlyRouterLayer)) +const apiNotFoundRoute = (corsOptions?: CorsOptions) => + HttpRouter.use((router) => + router.add("*", "/api/*", (request) => + Effect.succeed( + withNotFoundCors( + HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }), + request.headers.origin, + corsOptions, + ), + ), + ), + ) + +function withNotFoundCors( + response: HttpServerResponse.HttpServerResponse, + origin: string | undefined, + opts?: CorsOptions, +) { + if (response.status !== 404 || !origin || !isAllowedCorsOrigin(origin, opts)) return response + const vary = response.headers["vary"] + const next = HttpServerResponse.setHeader(response, "access-control-allow-origin", origin) + if (!vary) return HttpServerResponse.setHeader(next, "vary", "Origin") + if (vary.split(",").some((value) => value.trim().toLowerCase() === "origin")) return next + return HttpServerResponse.setHeader(next, "vary", `${vary}, Origin`) +} + type RouteRequirements = | HttpRouter.HttpRouter | HttpRouter.Request<"Error", unknown> @@ -278,7 +306,8 @@ export function createRoutes( instanceRoutes, serverRoutes, docRoute, - uiRoute, + apiNotFoundRoute(corsOptions), + uiRoute(corsOptions), ).pipe( Layer.provide([ errorLayer, diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts index 4e9680c7ce..e769619c3d 100644 --- a/packages/opencode/test/server/httpapi-cors.test.ts +++ b/packages/opencode/test/server/httpapi-cors.test.ts @@ -82,6 +82,35 @@ describe("HttpApi CORS", () => { }), ) + it.live("adds CORS headers to not found responses", () => + Effect.gen(function* () { + const handler = HttpRouter.toWebHandler( + HttpApiApp.createRoutes().pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true", + }), + ), + ), + ), + { disableLogger: true }, + ).handler + const response = yield* Effect.promise(() => + handler( + new Request(new URL("/api/health", "http://localhost"), { + method: "POST", + headers: { origin: "https://app.opencode.ai" }, + }), + HttpApiApp.context, + ), + ) + + expect(response.status).toBe(404) + expect(response.headers.get("access-control-allow-origin")).toBe("https://app.opencode.ai") + }), + ) + it.live("uses custom CORS origins passed to the server", () => Effect.gen(function* () { const listener = yield* Effect.acquireRelease(