fix(httpapi): expose v2 request errors (#28495)

This commit is contained in:
Shoubhit Dash
2026-05-20 23:23:23 +05:30
committed by GitHub
parent 9559e2425b
commit 40e73c4910
15 changed files with 249 additions and 35 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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({

View File

@@ -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,

View File

@@ -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<Authorization>()(
},
) {}
export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
"@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" })
}),
),
)
}),
)
}),
)

View File

@@ -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<SchemaErrorMiddleware>()(
"@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 }),
)

View File

@@ -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<Target, { type: "remote" }>
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<RequestPlan>()
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<E>(
plan: RequestPlan,
): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
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 }) =>

View File

@@ -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,

View File

@@ -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,