fix(httpapi): preserve provider oauth authorize parity (#24703)
This commit is contained in:
@@ -37,7 +37,15 @@ const ConsoleSwitchBody = z.object({
|
|||||||
orgID: z.string(),
|
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(() =>
|
export const ExperimentalRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
@@ -368,12 +376,12 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
const sessions: Session.GlobalInfo[] = []
|
const sessions: Session.GlobalInfo[] = []
|
||||||
for await (const session of Session.listGlobal({
|
for await (const session of Session.listGlobal({
|
||||||
directory: query.directory,
|
directory: query.directory,
|
||||||
roots: query.roots,
|
roots: queryBoolean(query.roots),
|
||||||
start: query.start,
|
start: query.start,
|
||||||
cursor: query.cursor,
|
cursor: query.cursor,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: limit + 1,
|
limit: limit + 1,
|
||||||
archived: query.archived,
|
archived: queryBoolean(query.archived),
|
||||||
})) {
|
})) {
|
||||||
sessions.push(session)
|
sessions.push(session)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Provider } from "@/provider/provider"
|
|||||||
import { ProviderID } from "@/provider/schema"
|
import { ProviderID } from "@/provider/schema"
|
||||||
import { mapValues } from "remeda"
|
import { mapValues } from "remeda"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
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 { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||||
import { Authorization } from "./auth"
|
import { Authorization } from "./auth"
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
|
|||||||
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
||||||
params: { providerID: ProviderID },
|
params: { providerID: ProviderID },
|
||||||
payload: ProviderAuth.AuthorizeInput,
|
payload: ProviderAuth.AuthorizeInput,
|
||||||
success: ProviderAuth.Authorization,
|
success: Schema.UndefinedOr(ProviderAuth.Authorization),
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "provider.oauth.authorize",
|
identifier: "provider.oauth.authorize",
|
||||||
@@ -115,10 +116,22 @@ export const providerHandlers = Layer.unwrap(
|
|||||||
inputs: ctx.payload.inputs,
|
inputs: ctx.payload.inputs,
|
||||||
})
|
})
|
||||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||||
if (!result) return yield* new HttpApiError.BadRequest({})
|
|
||||||
return result
|
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: {
|
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
|
||||||
params: { providerID: ProviderID }
|
params: { providerID: ProviderID }
|
||||||
payload: ProviderAuth.CallbackInput
|
payload: ProviderAuth.CallbackInput
|
||||||
@@ -134,7 +147,7 @@ export const providerHandlers = Layer.unwrap(
|
|||||||
})
|
})
|
||||||
|
|
||||||
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
|
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(
|
).pipe(
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ import { jsonRequest, runRequest } from "./trace"
|
|||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
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(() =>
|
export const SessionRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
@@ -69,7 +77,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
const sessions: Session.Info[] = []
|
const sessions: Session.Info[] = []
|
||||||
for await (const session of Session.list({
|
for await (const session of Session.list({
|
||||||
directory: query.directory,
|
directory: query.directory,
|
||||||
roots: query.roots,
|
roots: queryBoolean(query.roots),
|
||||||
start: query.start,
|
start: query.start,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
|
|||||||
152
packages/opencode/test/server/httpapi-provider.test.ts
Normal file
152
packages/opencode/test/server/httpapi-provider.test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -848,12 +848,12 @@ export class Session extends HeyApiClient {
|
|||||||
parameters?: {
|
parameters?: {
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
roots?: "true" | "false"
|
roots?: boolean | "true" | "false"
|
||||||
start?: number
|
start?: number
|
||||||
cursor?: number
|
cursor?: number
|
||||||
search?: string
|
search?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
archived?: "true" | "false"
|
archived?: boolean | "true" | "false"
|
||||||
},
|
},
|
||||||
options?: Options<never, ThrowOnError>,
|
options?: Options<never, ThrowOnError>,
|
||||||
) {
|
) {
|
||||||
@@ -1647,7 +1647,7 @@ export class Session2 extends HeyApiClient {
|
|||||||
parameters?: {
|
parameters?: {
|
||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
roots?: "true" | "false"
|
roots?: boolean | "true" | "false"
|
||||||
start?: number
|
start?: number
|
||||||
search?: string
|
search?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
|
|||||||
@@ -3217,7 +3217,7 @@ export type ExperimentalSessionListData = {
|
|||||||
/**
|
/**
|
||||||
* Only return root sessions (no parentID)
|
* Only return root sessions (no parentID)
|
||||||
*/
|
*/
|
||||||
roots?: "true" | "false"
|
roots?: boolean | "true" | "false"
|
||||||
/**
|
/**
|
||||||
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
|
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
|
||||||
*/
|
*/
|
||||||
@@ -3237,7 +3237,7 @@ export type ExperimentalSessionListData = {
|
|||||||
/**
|
/**
|
||||||
* Include archived sessions (default false)
|
* Include archived sessions (default false)
|
||||||
*/
|
*/
|
||||||
archived?: "true" | "false"
|
archived?: boolean | "true" | "false"
|
||||||
}
|
}
|
||||||
url: "/experimental/session"
|
url: "/experimental/session"
|
||||||
}
|
}
|
||||||
@@ -3285,7 +3285,7 @@ export type SessionListData = {
|
|||||||
/**
|
/**
|
||||||
* Only return root sessions (no parentID)
|
* Only return root sessions (no parentID)
|
||||||
*/
|
*/
|
||||||
roots?: "true" | "false"
|
roots?: boolean | "true" | "false"
|
||||||
/**
|
/**
|
||||||
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
|
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user