fix(httpapi): expose v2 request errors (#28495)
This commit is contained in:
@@ -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" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
|
||||
+28
-2
@@ -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 }) =>
|
||||
|
||||
Reference in New Issue
Block a user