mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 11:25:15 +00:00
fix(httpapi): expose v2 request errors (#28495)
This commit is contained in:
parent
9559e2425b
commit
40e73c4910
15 changed files with 249 additions and 35 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,10 +17,7 @@ type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
|
|||
|
||||
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]) =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
() =>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue