fix(httpapi): preserve provider oauth authorize parity (#24703)

This commit is contained in:
Kit Langton
2026-04-27 21:48:50 -04:00
committed by GitHub
parent 0eaa47d857
commit 892fd85ba7
6 changed files with 195 additions and 14 deletions

View File

@@ -37,7 +37,15 @@ const ConsoleSwitchBody = z.object({
orgID: z.string(),
})
const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
const QueryBoolean = z.union([
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
z.enum(["true", "false"]),
])
function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
if (value === undefined) return
return value === true || value === "true"
}
export const ExperimentalRoutes = lazy(() =>
new Hono()
@@ -368,12 +376,12 @@ export const ExperimentalRoutes = lazy(() =>
const sessions: Session.GlobalInfo[] = []
for await (const session of Session.listGlobal({
directory: query.directory,
roots: query.roots,
roots: queryBoolean(query.roots),
start: query.start,
cursor: query.cursor,
search: query.search,
limit: limit + 1,
archived: query.archived,
archived: queryBoolean(query.archived),
})) {
sessions.push(session)
}

View File

@@ -5,6 +5,7 @@ import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -35,7 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
params: { providerID: ProviderID },
payload: ProviderAuth.AuthorizeInput,
success: ProviderAuth.Authorization,
success: Schema.UndefinedOr(ProviderAuth.Authorization),
}).annotateMerge(
OpenApi.annotations({
identifier: "provider.oauth.authorize",
@@ -115,10 +116,22 @@ export const providerHandlers = Layer.unwrap(
inputs: ctx.payload.inputs,
})
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
if (!result) return yield* new HttpApiError.BadRequest({})
return result
})
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
params: { providerID: ProviderID }
request: HttpServerRequest.HttpServerRequest
}) {
const body = yield* Effect.orDie(ctx.request.text)
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
Effect.mapError(() => new HttpApiError.BadRequest({})),
)
const result = yield* authorize({ params: ctx.params, payload })
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
return HttpServerResponse.jsonUnsafe(result)
})
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
params: { providerID: ProviderID }
payload: ProviderAuth.CallbackInput
@@ -134,7 +147,7 @@ export const providerHandlers = Layer.unwrap(
})
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
handlers.handle("list", list).handle("auth", auth).handle("authorize", authorize).handle("callback", callback),
handlers.handle("list", list).handle("auth", auth).handleRaw("authorize", authorizeRaw).handle("callback", callback),
)
}),
).pipe(

View File

@@ -30,7 +30,15 @@ import { jsonRequest, runRequest } from "./trace"
const log = Log.create({ service: "server" })
const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
const QueryBoolean = z.union([
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
z.enum(["true", "false"]),
])
function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
if (value === undefined) return
return value === true || value === "true"
}
export const SessionRoutes = lazy(() =>
new Hono()
@@ -69,7 +77,7 @@ export const SessionRoutes = lazy(() =>
const sessions: Session.Info[] = []
for await (const session of Session.list({
directory: query.directory,
roots: query.roots,
roots: queryBoolean(query.roots),
start: query.start,
search: query.search,
limit: query.limit,

View File

@@ -0,0 +1,152 @@
import { afterEach, describe, expect } from "bun:test"
import type { UpgradeWebSocket } from "hono/ws"
import { Effect, FileSystem, Layer, Path } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { provideInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
const providerID = "test-oauth-parity"
const oauthURL = "https://example.com/oauth"
const oauthInstructions = "Finish OAuth"
function app(experimental: boolean) {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
return InstanceRoutes(websocket)
}
function requestAuthorize(input: {
app: ReturnType<typeof InstanceRoutes>
providerID: string
method: number
headers: HeadersInit
}) {
return Effect.promise(async () => {
const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, {
method: "POST",
headers: input.headers,
body: JSON.stringify({ method: input.method }),
})
return {
status: response.status,
body: await response.text(),
}
})
}
function writeProviderAuthPlugin(dir: string) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true })
yield* fs.writeFileString(
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
[
"export default {",
' id: "test.provider-oauth-parity",',
" server: async () => ({",
" auth: {",
` provider: "${providerID}",`,
" methods: [",
' { type: "api", label: "API key" },',
" {",
' type: "oauth",',
' label: "OAuth",',
" authorize: async () => ({",
` url: "${oauthURL}",`,
' method: "code",',
` instructions: "${oauthInstructions}",`,
" callback: async () => ({ type: 'success', key: 'token' }),",
" }),",
" },",
" ],",
" },",
" }),",
"}",
"",
].join("\n"),
)
})
}
function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
yield* fs.writeFileString(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }),
)
yield* writeProviderAuthPlugin(dir)
yield* Effect.addFinalizer(() =>
Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore),
)
return yield* self(dir).pipe(provideInstance(dir))
})
}
afterEach(async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
await Instance.disposeAll()
await resetDatabase()
})
describe("provider HttpApi", () => {
it.live(
"matches legacy OAuth authorize response shapes",
withProviderProject((dir) =>
Effect.gen(function* () {
const headers = { "x-opencode-directory": dir, "content-type": "application/json" }
const legacy = app(false)
const httpapi = app(true)
const apiLegacy = yield* requestAuthorize({
app: legacy,
providerID,
method: 0,
headers,
})
const apiHttpApi = yield* requestAuthorize({
app: httpapi,
providerID,
method: 0,
headers,
})
expect(apiLegacy).toEqual({ status: 200, body: "" })
expect(apiHttpApi).toEqual(apiLegacy)
const oauthLegacy = yield* requestAuthorize({
app: legacy,
providerID,
method: 1,
headers,
})
const oauthHttpApi = yield* requestAuthorize({
app: httpapi,
providerID,
method: 1,
headers,
})
expect(oauthHttpApi).toEqual(oauthLegacy)
expect(JSON.parse(oauthHttpApi.body)).toEqual({
url: oauthURL,
method: "code",
instructions: oauthInstructions,
})
}),
),
)
})

View File

@@ -848,12 +848,12 @@ export class Session extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
roots?: "true" | "false"
roots?: boolean | "true" | "false"
start?: number
cursor?: number
search?: string
limit?: number
archived?: "true" | "false"
archived?: boolean | "true" | "false"
},
options?: Options<never, ThrowOnError>,
) {
@@ -1647,7 +1647,7 @@ export class Session2 extends HeyApiClient {
parameters?: {
directory?: string
workspace?: string
roots?: "true" | "false"
roots?: boolean | "true" | "false"
start?: number
search?: string
limit?: number

View File

@@ -3217,7 +3217,7 @@ export type ExperimentalSessionListData = {
/**
* Only return root sessions (no parentID)
*/
roots?: "true" | "false"
roots?: boolean | "true" | "false"
/**
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
*/
@@ -3237,7 +3237,7 @@ export type ExperimentalSessionListData = {
/**
* Include archived sessions (default false)
*/
archived?: "true" | "false"
archived?: boolean | "true" | "false"
}
url: "/experimental/session"
}
@@ -3285,7 +3285,7 @@ export type SessionListData = {
/**
* Only return root sessions (no parentID)
*/
roots?: "true" | "false"
roots?: boolean | "true" | "false"
/**
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
*/