Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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",
Expand All @@ -51,4 +52,4 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
description: "Experimental v2 message routes.",
}),
)
.middleware(Authorization)
.middleware(V2Authorization)
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -26,4 +26,4 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
}),
)
.middleware(V2LocationMiddleware)
.middleware(Authorization)
.middleware(V2Authorization)
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -44,4 +44,4 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
}),
)
.middleware(V2LocationMiddleware)
.middleware(Authorization)
.middleware(V2Authorization)
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -113,4 +114,4 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
description: "Experimental v2 routes.",
}),
)
.middleware(Authorization)
.middleware(V2Authorization)
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: "",
Expand Down Expand Up @@ -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" })
}),
),
)
}),
)
}),
)
Original file line number Diff line number Diff line change
Expand Up @@ -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" })

Expand All @@ -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 }),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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: {
Expand All @@ -38,6 +40,7 @@ type RequestPlan = Data.TaggedEnum<{
}
}>
const RequestPlan = Data.taggedEnum<RequestPlan>()
const InvalidWorkspaceID = Symbol("InvalidWorkspaceID")

export class WorkspaceRouteContext extends Context.Service<
WorkspaceRouteContext,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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 }) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading