From 84b53d17c97bd5103b23d49aa5ba46b5d791c4d9 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 20 May 2026 23:13:56 +0530 Subject: [PATCH] fix(httpapi): expose v2 request errors --- .../instance/httpapi/groups/v2/message.ts | 9 ++- .../instance/httpapi/groups/v2/model.ts | 4 +- .../instance/httpapi/groups/v2/provider.ts | 4 +- .../instance/httpapi/groups/v2/session.ts | 9 ++- .../instance/httpapi/handlers/v2/message.ts | 8 +- .../instance/httpapi/handlers/v2/session.ts | 28 +++++-- .../httpapi/middleware/authorization.ts | 32 ++++++++ .../httpapi/middleware/schema-error.ts | 14 +++- .../httpapi/middleware/workspace-routing.ts | 30 +++++++- .../server/routes/instance/httpapi/public.ts | 6 +- .../server/routes/instance/httpapi/server.ts | 4 +- .../test/server/httpapi-authorization.test.ts | 38 +++++++++- .../server/httpapi-public-openapi.test.ts | 5 +- .../server/httpapi-schema-error-body.test.ts | 18 +++++ .../test/server/httpapi-session.test.ts | 75 ++++++++++++++++++- 15 files changed, 249 insertions(+), 35 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts index 131a14258611..47bb01cd8a3a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts @@ -1,8 +1,9 @@ import { SessionID } from "@/session/schema" import { SessionMessage } from "@opencode-ai/core/session-message" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../../middleware/authorization" +import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { InvalidCursorError } from "../../errors" +import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" export const MessagesQuery = Schema.Struct({ @@ -35,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message") next: Schema.String.pipe(Schema.optional), }), }).annotate({ identifier: "V2SessionMessagesResponse" }), - error: HttpApiError.BadRequest, + error: InvalidCursorError, }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.messages", @@ -51,4 +52,4 @@ export const MessageGroup = HttpApiGroup.make("v2.message") description: "Experimental v2 message routes.", }), ) - .middleware(Authorization) + .middleware(V2Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts index d265ac7fc24e..b2586387d5e2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts @@ -1,7 +1,7 @@ import { ModelV2 } from "@opencode-ai/core/model" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../../middleware/authorization" +import { V2Authorization } from "../../middleware/authorization" import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" export const ModelGroup = HttpApiGroup.make("v2.model") @@ -26,4 +26,4 @@ export const ModelGroup = HttpApiGroup.make("v2.model") }), ) .middleware(V2LocationMiddleware) - .middleware(Authorization) + .middleware(V2Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts index 7a482ce11491..deebcdc9f435 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts @@ -2,7 +2,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { ApiNotFoundError } from "../../errors" -import { Authorization } from "../../middleware/authorization" +import { V2Authorization } from "../../middleware/authorization" import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location" export const ProviderGroup = HttpApiGroup.make("v2.provider") @@ -44,4 +44,4 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider") }), ) .middleware(V2LocationMiddleware) - .middleware(Authorization) + .middleware(V2Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts index 0313b5c09776..f74a9f50bfd3 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts @@ -3,8 +3,9 @@ import { SessionMessage } from "@opencode-ai/core/session-message" import { Prompt } from "@opencode-ai/core/session-prompt" import { SessionV2 } from "@/v2/session" import { Schema } from "effect" -import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { Authorization } from "../../middleware/authorization" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { InvalidCursorError, InvalidRequestError } from "../../errors" +import { V2Authorization } from "../../middleware/authorization" import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing" import { QueryBoolean } from "../query" @@ -41,7 +42,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session") next: Schema.String.pipe(Schema.optional), }), }).annotate({ identifier: "V2SessionsResponse" }), - error: HttpApiError.BadRequest, + error: [InvalidCursorError, InvalidRequestError], }).annotateMerge( OpenApi.annotations({ identifier: "v2.session.list", @@ -113,4 +114,4 @@ export const SessionGroup = HttpApiGroup.make("v2.session") description: "Experimental v2 routes.", }), ) - .middleware(Authorization) + .middleware(V2Authorization) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts index fd710ba954a7..c809f6485dfb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts @@ -2,8 +2,9 @@ import { SessionMessage } from "@opencode-ai/core/session-message" import { SessionV2 } from "@/v2/session" import { Effect, Schema } from "effect" import * as DateTime from "effect/DateTime" -import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" +import { InvalidCursorError } from "../../errors" const DefaultMessagesLimit = 50 @@ -34,10 +35,11 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message return handlers.handle( "messages", Effect.fn(function* (ctx) { - if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.cursor && ctx.query.order !== undefined) + return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" }) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined), - catch: () => new HttpApiError.BadRequest({}), + catch: () => new InvalidCursorError({ message: "Invalid cursor" }), }) const order = decoded?.order ?? ctx.query.order ?? "desc" const messages = yield* session.messages({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts index 9bed3b80680c..23b9376c8d01 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts @@ -1,8 +1,9 @@ import { WorkspaceID } from "@/control-plane/schema" import { SessionV2 } from "@/v2/session" -import { DateTime, Effect, Schema } from "effect" -import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi" +import { DateTime, Effect, Option, Schema } from "effect" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../../api" +import { InvalidCursorError, InvalidRequestError } from "../../errors" const DefaultSessionsLimit = 50 @@ -69,6 +70,19 @@ const sessionCursor = { }, } +function decodeWorkspaceID(input: string | undefined) { + if (input === undefined) return Effect.succeed(undefined) + const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(input) + if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value) + return Effect.fail( + new InvalidRequestError({ + message: "Invalid workspace query parameter", + kind: "Query", + field: "workspace", + }), + ) +} + export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) => Effect.gen(function* () { const session = yield* SessionV2.Service @@ -77,17 +91,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session .handle( "sessions", Effect.fn(function* (ctx) { - if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.cursor && hasCursorFilter(ctx.query)) + return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order or filters" }) const decoded = yield* Effect.try({ try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined), - catch: () => new HttpApiError.BadRequest({}), + catch: () => new InvalidCursorError({ message: "Invalid cursor" }), }) - if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({}) + if (hasCursorRoutingMismatch(ctx.query, decoded)) + return yield* new InvalidCursorError({ message: "Cursor does not match requested directory or workspace" }) const order = decoded?.order ?? ctx.query.order ?? "desc" const filters = decoded ?? { directory: ctx.query.directory, path: ctx.query.path, - workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined, + workspaceID: yield* decodeWorkspaceID(ctx.query.workspace), roots: ctx.query.roots, start: ctx.query.start, search: ctx.query.search, diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 73676bd6652e..e25d7e86494a 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -4,6 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi" import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket" import { isPublicUIPath } from "@/server/shared/public-ui" +import { UnauthorizedError } from "../errors" const AUTH_TOKEN_QUERY = "auth_token" const UNAUTHORIZED = 401 @@ -19,6 +20,13 @@ export class Authorization extends HttpApiMiddleware.Service()( }, ) {} +export class V2Authorization extends HttpApiMiddleware.Service()( + "@opencode/ExperimentalHttpApiV2Authorization", + { + error: UnauthorizedError, + }, +) {} + function emptyCredential() { return { username: "", @@ -122,3 +130,27 @@ export const authorizationLayer = Layer.effect( ) }), ) + +export const v2AuthorizationLayer = Layer.effect( + V2Authorization, + Effect.gen(function* () { + const config = yield* ServerAuth.Config + if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect) + return V2Authorization.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* credentialFromRequest(request).pipe( + Effect.flatMap((credential) => + Effect.gen(function* () { + if (ServerAuth.authorized(credential, config)) return yield* effect + yield* HttpEffect.appendPreResponseHandler((_request, response) => + Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)), + ) + return yield* new UnauthorizedError({ message: "Authentication required" }) + }), + ), + ) + }), + ) + }), +) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts index e7d661c5a8ef..18fa4065d7a6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts @@ -2,6 +2,7 @@ import { Effect } from "effect" import { HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Log from "@opencode-ai/core/util/log" +import { InvalidRequestError } from "../errors" const log = Log.create({ service: "server" }) @@ -19,11 +20,22 @@ function truncateReason(reason: string) { // used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`. export class SchemaErrorMiddleware extends HttpApiMiddleware.Service()( "@opencode/HttpApiSchemaError", + { + error: InvalidRequestError, + }, ) {} -export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => { +export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error, context) => { const reason = truncateReason(error.cause.message) log.warn("schema rejection", { kind: error.kind, reason }) + if (context.endpoint.path.startsWith("/api/")) { + return Effect.fail( + new InvalidRequestError({ + message: reason, + kind: error.kind, + }), + ) + } return Effect.succeed( HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts index cd9376f7f9d8..229792b8e281 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts @@ -8,10 +8,11 @@ import * as Fence from "@/server/shared/fence" import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing" import { NotFoundError } from "@/storage/storage" import { Flag } from "@opencode-ai/core/flag/flag" -import { Context, Data, Effect, Layer, Schema } from "effect" +import { Context, Data, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" +import { InvalidRequestError } from "../errors" // Query fields this middleware reads from the URL. Spread into every // endpoint query schema in groups that apply WorkspaceRoutingMiddleware, @@ -28,6 +29,7 @@ export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields) type RemoteTarget = Extract type RequestPlan = Data.TaggedEnum<{ + InvalidWorkspace: {} MissingWorkspace: { readonly workspaceID: WorkspaceID } Local: { readonly directory: string; readonly workspaceID?: WorkspaceID } Remote: { @@ -38,6 +40,7 @@ type RequestPlan = Data.TaggedEnum<{ } }> const RequestPlan = Data.taggedEnum() +const InvalidWorkspaceID = Symbol("InvalidWorkspaceID") export class WorkspaceRouteContext extends Context.Service< WorkspaceRouteContext, @@ -68,6 +71,15 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined) } +function selectedV2WorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | typeof InvalidWorkspaceID | undefined { + if (sessionWorkspaceID) return sessionWorkspaceID + const workspaceParam = url.searchParams.get("workspace") + if (!workspaceParam) return undefined + const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(workspaceParam) + if (Option.isNone(workspaceID)) return InvalidWorkspaceID + return workspaceID.value +} + function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string { return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd() } @@ -149,7 +161,10 @@ function planRequest( return Effect.gen(function* () { const url = requestURL(request) const envWorkspaceID = configuredWorkspaceID() - const workspaceID = selectedWorkspaceID(url, sessionWorkspaceID) + const workspaceID = url.pathname.startsWith("/api/") + ? selectedV2WorkspaceID(url, sessionWorkspaceID) + : selectedWorkspaceID(url, sessionWorkspaceID) + if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace() const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID) if (workspaceID && workspace === undefined && !envWorkspaceID) { @@ -170,6 +185,17 @@ function routeWorkspace( plan: RequestPlan, ): Effect.Effect { return RequestPlan.$match(plan, { + InvalidWorkspace: () => + Effect.succeed( + HttpServerResponse.jsonUnsafe( + new InvalidRequestError({ + message: "Invalid workspace query parameter", + kind: "Query", + field: "workspace", + }), + { status: 400 }, + ), + ), MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)), Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url), Local: ({ directory, workspaceID }) => diff --git a/packages/opencode/src/server/routes/instance/httpapi/public.ts b/packages/opencode/src/server/routes/instance/httpapi/public.ts index 5684171837ea..7eb449716163 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/public.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/public.ts @@ -341,7 +341,7 @@ function rewriteRefs(input: unknown, from: string, to: string): void { } function normalizeLegacyErrorResponses(operation: OpenApiOperation) { - if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) { + if (operation.responses?.["400"] && isLegacyBadRequestResponse(operation.responses["400"])) { operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError") } if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) { @@ -396,6 +396,10 @@ function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" | return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`) } +function isLegacyBadRequestResponse(response: OpenApiResponse) { + return isBuiltInErrorResponse(response, "BadRequest") || isRefResponse(response, "InvalidRequestError") +} + function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse { return { description, diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 94301bd8a4f1..6ccc995c6601 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -59,7 +59,7 @@ import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { PublicApi } from "./public" -import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" +import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization" import { EventApi } from "./groups/event" import { eventHandlers } from "./handlers/event" import { configHandlers } from "./handlers/config" @@ -107,6 +107,7 @@ const cors = (corsOptions?: CorsOptions) => // - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it. const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) +const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( Layer.provide([controlHandlers, globalHandlers]), Layer.provide(schemaErrorLayer), @@ -144,6 +145,7 @@ const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(ins const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ httpApiAuthLayer, + v2HttpApiAuthLayer, workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, schemaErrorLayer, diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index e99a91e1d09a..b08c4a652cf4 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -4,7 +4,12 @@ import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi" import { ServerAuth } from "../../src/server/auth" -import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { + Authorization, + authorizationLayer, + V2Authorization, + v2AuthorizationLayer, +} from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" const Api = HttpApi.make("test-authorization").add( @@ -21,17 +26,36 @@ const Api = HttpApi.make("test-authorization").add( .middleware(Authorization), ) +const V2Api = HttpApi.make("test-v2-authorization").add( + HttpApiGroup.make("test.v2") + .add( + HttpApiEndpoint.get("probe", "/api/probe", { + success: Schema.String, + }), + ) + .middleware(V2Authorization), +) + const handlers = HttpApiBuilder.group(Api, "test", (handlers) => handlers .handle("probe", () => Effect.succeed("ok")) .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))), ) +const v2Handlers = HttpApiBuilder.group(V2Api, "test.v2", (handlers) => + handlers.handle("probe", () => Effect.succeed("ok")), +) + const apiLayer = HttpRouter.serve( HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)), { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) +const v2ApiLayer = HttpRouter.serve( + HttpApiBuilder.layer(V2Api).pipe(Layer.provide(v2Handlers), Layer.provide(v2AuthorizationLayer)), + { disableListenLog: true, disableLogger: true }, +).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) + const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" }) const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" }) const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" }) @@ -39,6 +63,7 @@ const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret") const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer))) const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer))) const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer))) +const itV2Secret = testEffect(v2ApiLayer.pipe(Layer.provide(secretLayer))) const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? "" @@ -135,4 +160,15 @@ describe("HttpApi authorization middleware", () => { expect(response.status).toBe(401) }), ) + + itV2Secret.live("returns bodyful v2 unauthorized errors", () => + Effect.gen(function* () { + const response = yield* HttpClient.get("/api/probe") + const body = yield* response.json + + expect(response.status).toBe(401) + expect(response.headers["www-authenticate"] ?? "").toContain("Basic") + expect(body).toEqual({ _tag: "UnauthorizedError", message: "Authentication required" }) + }), + ) }) diff --git a/packages/opencode/test/server/httpapi-public-openapi.test.ts b/packages/opencode/test/server/httpapi-public-openapi.test.ts index ba415f2abc5b..9a437029f171 100644 --- a/packages/opencode/test/server/httpapi-public-openapi.test.ts +++ b/packages/opencode/test/server/httpapi-public-openapi.test.ts @@ -17,10 +17,7 @@ type OpenApiSpec = { readonly paths: Record } const methods = ["get", "post", "put", "delete", "patch"] as const -const allowedV2BuiltInEndpointErrors = [ - "GET /api/session 400 effect_HttpApiError_BadRequest", - "GET /api/session/{sessionID}/message 400 effect_HttpApiError_BadRequest", -] +const allowedV2BuiltInEndpointErrors: string[] = [] function v2Operations(spec: OpenApiSpec) { return Object.entries(spec.paths).flatMap(([path, item]) => diff --git a/packages/opencode/test/server/httpapi-schema-error-body.test.ts b/packages/opencode/test/server/httpapi-schema-error-body.test.ts index 48ed7b6bfb68..c221bdd19b7d 100644 --- a/packages/opencode/test/server/httpapi-schema-error-body.test.ts +++ b/packages/opencode/test/server/httpapi-schema-error-body.test.ts @@ -105,6 +105,24 @@ describe("schema-rejection wire shape", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "v2 query schema rejection returns InvalidRequestError JSON", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const res = yield* Effect.promise(async () => + Server.Default().app.request("/api/session?limit=0", { + headers: { "x-opencode-directory": test.directory }, + }), + ) + const parsed = JSON.parse(yield* Effect.promise(async () => res.text())) + expect(res.status).toBe(400) + expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" }) + expect(parsed.message).toEqual(expect.any(String)) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "rejected request body never echoes back unbounded — message is capped", // Defense against DoS-amplification + secret-echo: Effect's Issue formatter diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index bfdc42d9960b..d802a9b33523 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -104,7 +104,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri (info) => Workspace.Service.use((svc) => svc.remove(info.id)).pipe(Effect.ignore), ) -const insertLegacyAssistantMessage = (sessionID: SessionIDType) => +const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) => Effect.sync(() => { const message = new SessionMessage.Assistant({ id: SessionMessage.ID.create(), @@ -115,7 +115,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) => providerID: ProviderV2.ID.make("provider"), variant: ModelV2.VariantID.make("default"), }, - time: { created: DateTime.makeUnsafe(1) }, + time: { created: DateTime.makeUnsafe(time) }, content: [], }) Database.use((db) => @@ -126,9 +126,9 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) => id: message.id, session_id: sessionID, type: message.type, - time_created: 1, + time_created: time, data: { - time: { created: 1 }, + time: { created: time }, agent: message.agent, model: message.model, content: message.content, @@ -333,6 +333,73 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) + it.instance( + "returns v2 public request errors for cursor and workspace query failures", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const headers = { "x-opencode-directory": test.directory } + const session = yield* createSession({ title: "v2 cursor" }) + yield* insertLegacyAssistantMessage(session.id, 1) + yield* insertLegacyAssistantMessage(session.id, 2) + + const sessionPage = yield* request(`/api/session?limit=1`, { headers }) + const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next + expect(sessionCursor).toBeTruthy() + + const cursorWithFilter = yield* request(`/api/session?cursor=${sessionCursor}&search=v2`, { headers }) + expect(cursorWithFilter.status).toBe(400) + expect(yield* responseJson(cursorWithFilter)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Cursor cannot be combined with order or filters", + }) + + const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers }) + expect(invalidSessionCursor.status).toBe(400) + expect(yield* responseJson(invalidSessionCursor)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Invalid cursor", + }) + + const mismatchedRouting = yield* request(`/api/session?cursor=${sessionCursor}&directory=/elsewhere`, { headers }) + expect(mismatchedRouting.status).toBe(400) + expect(yield* responseJson(mismatchedRouting)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Cursor does not match requested directory or workspace", + }) + + const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers }) + expect(invalidWorkspace.status).toBe(400) + expect(yield* responseJson(invalidWorkspace)).toMatchObject({ + _tag: "InvalidRequestError", + message: "Invalid workspace query parameter", + field: "workspace", + }) + + const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers }) + const messageCursor = (yield* json<{ cursor: { next?: string } }>(messagePage)).cursor.next + expect(messageCursor).toBeTruthy() + + const messageCursorWithOrder = yield* request( + `/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`, + { headers }, + ) + expect(messageCursorWithOrder.status).toBe(400) + expect(yield* responseJson(messageCursorWithOrder)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Cursor cannot be combined with order", + }) + + const invalidMessageCursor = yield* request(`/api/session/${session.id}/message?cursor=invalid`, { headers }) + expect(invalidMessageCursor.status).toBe(400) + expect(yield* responseJson(invalidMessageCursor)).toMatchObject({ + _tag: "InvalidCursorError", + message: "Invalid cursor", + }) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + it.instance( "serves sessions with migrated summary diffs missing file details", () =>