From cee9610d2674da2b876198ec097106ce591cc09e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 29 Apr 2026 22:22:32 -0400 Subject: [PATCH] refactor: use Effect config for HttpApi authorization (#25035) --- .../opencode/src/effect/config-service.ts | 67 +++++++++++++++++++ .../httpapi/middleware/authorization.ts | 60 +++++++++-------- .../server/routes/instance/httpapi/server.ts | 6 +- .../test/server/httpapi-authorization.test.ts | 66 +++++------------- .../test/server/httpapi-bridge.test.ts | 43 +++++++++--- .../opencode/test/server/httpapi-sdk.test.ts | 28 +++++++- 6 files changed, 178 insertions(+), 92 deletions(-) create mode 100644 packages/opencode/src/effect/config-service.ts diff --git a/packages/opencode/src/effect/config-service.ts b/packages/opencode/src/effect/config-service.ts new file mode 100644 index 0000000000..634673199f --- /dev/null +++ b/packages/opencode/src/effect/config-service.ts @@ -0,0 +1,67 @@ +import { Config, Context, Effect, Layer } from "effect" + +type ConfigMap = Record> + +/** + * The service shape inferred from an object of Effect `Config` definitions. + */ +export type Shape = { + readonly [Key in keyof Fields]: Config.Success +} + +/** + * A Context service class with generated layers for config-backed services. + */ +export type ServiceClass = Context.ServiceClass & { + /** Provide already-parsed config, useful in tests. */ + readonly layer: (input: Service) => Layer.Layer + /** Parse config once from the active Effect ConfigProvider and provide the service. */ + readonly defaultLayer: Layer.Layer +} + +/** + * Create a Context service whose implementation is derived from Effect `Config`. + * + * This keeps Effect `Config` as the source of truth for env names, defaults, and + * validation while generating a typed service plus convenient production/test + * layers. + * + * ```ts + * class ServerAuthConfig extends ConfigService.Service()( + * "@opencode/ServerAuthConfig", + * { + * password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option), + * username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")), + * }, + * ) {} + * + * const live = ServerAuthConfig.defaultLayer + * const test = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) + * ``` + */ +export const Service = + () => + (id: Id, fields: Fields) => { + class ConfigTag extends Context.Service>()(id) { + static layer(input: Shape) { + return Layer.succeed(this, this.of(input)) + } + + static get defaultLayer() { + return Layer.effect( + this, + Config.all(fields) + .asEffect() + .pipe( + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Config.all preserves the field shape, but its conditional return type also supports iterable inputs. + Effect.map((config) => this.of(config as Shape)), + ), + ) + } + } + + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers. + return ConfigTag as ServiceClass> + } + +export * as ConfigService from "./config-service" diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts index 2fe196b561..b246140a00 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts @@ -1,17 +1,11 @@ -import { Effect, Encoding, Layer, Redacted, Schema } from "effect" -import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" -import { Flag } from "@opencode-ai/core/flag/flag" - -class Unauthorized extends Schema.TaggedErrorClass()( - "Unauthorized", - { message: Schema.String }, - { httpApiStatus: 401 }, -) {} +import { ConfigService } from "@/effect/config-service" +import { Config, Context, Effect, Encoding, Layer, Option, Redacted } from "effect" +import { HttpApiError, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" export class Authorization extends HttpApiMiddleware.Service()( "@opencode/ExperimentalHttpApiAuthorization", { - error: Unauthorized, + error: HttpApiError.UnauthorizedNoContent, security: { basic: HttpApiSecurity.basic, authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }), @@ -19,29 +13,38 @@ export class Authorization extends HttpApiMiddleware.Service()( }, ) {} -const emptyCredential = { - username: "", - password: Redacted.make(""), -} +export class ServerAuthConfig extends ConfigService.Service()( + "@opencode/ExperimentalHttpApiServerAuthConfig", + { + password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option), + username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")), + }, +) {} function validateCredential( effect: Effect.Effect, - credential: { readonly username: string; readonly password: typeof emptyCredential.password }, + credential: { readonly username: string; readonly password: Redacted.Redacted }, + config: Context.Service.Shape, ) { return Effect.gen(function* () { - if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect + if (Option.isNone(config.password) || config.password.value === "") return yield* effect - if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) { - return yield* new Unauthorized({ message: "Unauthorized" }) + if (credential.username !== config.username) { + return yield* new HttpApiError.Unauthorized({}) } - if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) { - return yield* new Unauthorized({ message: "Unauthorized" }) + if (Redacted.value(credential.password) !== config.password.value) { + return yield* new HttpApiError.Unauthorized({}) } return yield* effect }) } function decodeCredential(input: string) { + const emptyCredential = { + username: "", + password: Redacted.make(""), + } + return Encoding.decodeBase64String(input) .asEffect() .pipe( @@ -59,13 +62,16 @@ function decodeCredential(input: string) { ) } -export const authorizationLayer = Layer.succeed( +export const authorizationLayer = Layer.effect( Authorization, - Authorization.of({ - basic: (effect, { credential }) => validateCredential(effect, credential), - authToken: (effect, { credential }) => - Effect.gen(function* () { - return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential))) - }), + Effect.gen(function* () { + const config = yield* ServerAuthConfig + return Authorization.of({ + basic: (effect, { credential }) => validateCredential(effect, credential, config), + authToken: (effect, { credential }) => + decodeCredential(Redacted.value(credential)).pipe( + Effect.flatMap((decoded) => validateCredential(effect, decoded, config)), + ), + }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index c0fb5a20a0..86b7182c73 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -32,7 +32,7 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { InstanceHttpApi, RootHttpApi } from "./api" -import { authorizationLayer } from "./middleware/authorization" +import { ServerAuthConfig, authorizationLayer } from "./middleware/authorization" import { eventRoute } from "./event" import { configHandlers } from "./handlers/config" import { controlHandlers } from "./handlers/control" @@ -56,7 +56,7 @@ import { disposeMiddleware } from "./lifecycle" import { memoMap } from "@opencode-ai/core/effect/memo-map" import * as ServerBackend from "@/server/backend" -export const context = Context.empty() as Context.Context +export const context = Context.makeUnsafe(new Map()) const runtime = HttpRouter.middleware()( Effect.succeed((effect) => @@ -97,7 +97,7 @@ const rawInstanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute).pipe( ) const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( Layer.provide([ - authorizationLayer, + authorizationLayer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), instanceContextLayer, ]), diff --git a/packages/opencode/test/server/httpapi-authorization.test.ts b/packages/opencode/test/server/httpapi-authorization.test.ts index 7dec899164..c3bab23ac7 100644 --- a/packages/opencode/test/server/httpapi-authorization.test.ts +++ b/packages/opencode/test/server/httpapi-authorization.test.ts @@ -1,10 +1,13 @@ import { NodeHttpServer } from "@effect/platform-node" -import { Flag } from "@opencode-ai/core/flag/flag" import { describe, expect } from "bun:test" -import { Effect, Layer, Schema } from "effect" +import { Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi" -import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization" +import { + Authorization, + ServerAuthConfig, + authorizationLayer, +} from "../../src/server/routes/instance/httpapi/middleware/authorization" import { testEffect } from "../lib/effect" const Api = HttpApi.make("test-authorization").add( @@ -24,48 +27,19 @@ const apiLayer = HttpRouter.serve( { disableListenLog: true, disableLogger: true }, ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) -const testStateLayer = Layer.effectDiscard( - Effect.gen(function* () { - const original = { - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, - } - Flag.OPENCODE_SERVER_PASSWORD = undefined - Flag.OPENCODE_SERVER_USERNAME = undefined - yield* Effect.addFinalizer(() => - Effect.sync(() => { - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - }), - ) - }), -) +const noAuthLayer = ServerAuthConfig.layer({ password: Option.none(), username: "opencode" }) +const secretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "opencode" }) +const kitSecretLayer = ServerAuthConfig.layer({ password: Option.some("secret"), username: "kit" }) -const it = testEffect(apiLayer.pipe(Layer.provideMerge(testStateLayer))) +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 basic = (username: string, password: string) => `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` const token = (username: string, password: string) => Buffer.from(`${username}:${password}`).toString("base64") -const useAuth = (input: { password: string; username?: string }) => - Effect.acquireRelease( - Effect.sync(() => { - const original = { - OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD, - OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, - } - Flag.OPENCODE_SERVER_PASSWORD = input.password - Flag.OPENCODE_SERVER_USERNAME = input.username - return original - }), - (original) => - Effect.sync(() => { - Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD - Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME - }), - ) - const getProbe = (headers?: Record) => HttpClientRequest.get("/probe").pipe( headers ? HttpClientRequest.setHeaders(headers) : (request) => request, @@ -82,10 +56,8 @@ describe("HttpApi authorization middleware", () => { }), ) - it.live("requires configured password for basic auth", () => + itSecret.live("requires configured password for basic auth", () => Effect.gen(function* () { - yield* useAuth({ password: "secret" }) - const [missing, badPassword, good] = yield* Effect.all( [ getProbe(), @@ -101,10 +73,8 @@ describe("HttpApi authorization middleware", () => { }), ) - it.live("respects configured basic auth username", () => + itKitSecret.live("respects configured basic auth username", () => Effect.gen(function* () { - yield* useAuth({ username: "kit", password: "secret" }) - const [defaultUser, configuredUser] = yield* Effect.all( [getProbe({ authorization: basic("opencode", "secret") }), getProbe({ authorization: basic("kit", "secret") })], { concurrency: "unbounded" }, @@ -115,20 +85,16 @@ describe("HttpApi authorization middleware", () => { }), ) - it.live("accepts auth token query credentials", () => + itSecret.live("accepts auth token query credentials", () => Effect.gen(function* () { - yield* useAuth({ password: "secret" }) - const response = yield* HttpClient.get(`/probe?auth_token=${encodeURIComponent(token("opencode", "secret"))}`) expect(response.status).toBe(200) }), ) - it.live("rejects malformed auth token query credentials", () => + itSecret.live("rejects malformed auth token query credentials", () => Effect.gen(function* () { - yield* useAuth({ password: "secret" }) - const response = yield* HttpClient.get("/probe?auth_token=not-base64") expect(response.status).toBe(401) diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts index 5847192cb6..9343326738 100644 --- a/packages/opencode/test/server/httpapi-bridge.test.ts +++ b/packages/opencode/test/server/httpapi-bridge.test.ts @@ -2,11 +2,14 @@ import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" import { Instance } from "../../src/project/instance" import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control" -import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" +import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file" import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global" import { PublicApi } from "../../src/server/routes/instance/httpapi/public" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import * as Log from "@opencode-ai/core/util/log" +import { ConfigProvider, Layer } from "effect" +import { HttpRouter } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" import { resetDatabase } from "../fixture/db" import { tmpdir } from "../fixture/fixture" @@ -30,7 +33,26 @@ function app(input?: { password?: string; username?: string }) { Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - return Server.Default().app + + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } } function openApiRouteKeys(spec: { paths: Record>> }) { @@ -94,9 +116,9 @@ type RequestBody = { required?: boolean } -function parameterKey(param: unknown) { - if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return - if (typeof param.in !== "string" || typeof param.name !== "string") return +function parameterKey(param: unknown): string | undefined { + if (!param || typeof param !== "object" || !("in" in param) || !("name" in param)) return undefined + if (typeof param.in !== "string" || typeof param.name !== "string") return undefined return `${param.in}:${param.name}:${"required" in param && param.required === true}` } @@ -105,27 +127,29 @@ function parameterSchema(input: { path: string method: (typeof methods)[number] name: string -}) { +}): unknown { const param = input.spec.paths[input.path]?.[input.method]?.parameters?.find( (param) => !!param && typeof param === "object" && "name" in param && param.name === input.name, ) - if (!param || typeof param !== "object" || !("schema" in param)) return + if (!param || typeof param !== "object" || !("schema" in param)) return undefined return param.schema } function requestBodyKey(spec: OpenApiSpec, body: unknown) { if (!body || typeof body !== "object" || !("content" in body)) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset. const requestBody = body as RequestBody return JSON.stringify({ required: requestBody.required === true, content: Object.entries(requestBody.content ?? {}) - .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)]) - .sort(), + .map(([type, value]) => [type, requestBodySchemaKind(spec, value.schema)] as const) + .sort(([left], [right]) => left.localeCompare(right)), }) } function requestBodySchemaKind(spec: OpenApiSpec, schema: OpenApiSchema | undefined) { if (!schema) return "" + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper. const resolved = ( schema.$ref ? spec.components?.schemas?.[schema.$ref.replace("#/components/schemas/", "")] : schema ) as OpenApiSchema | undefined @@ -142,6 +166,7 @@ function responseContentTypes(input: { }) { const responses = input.spec.paths[input.path]?.[input.method]?.responses if (!responses || typeof responses !== "object" || !(input.status in responses)) return [] + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup. const response = (responses as Record)[input.status] if (!response || typeof response !== "object" || !("content" in response)) return [] const content = (response as { content?: unknown }).content diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 66f48455a0..6f3a0cb1cb 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -1,9 +1,11 @@ import { afterEach, describe, expect } from "bun:test" -import { Effect } from "effect" +import { ConfigProvider, Effect, Layer } from "effect" import type * as Scope from "effect/Scope" +import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Instance } from "../../src/project/instance" +import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { Server } from "../../src/server/server" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { MessageV2 } from "../../src/session/message-v2" @@ -33,7 +35,27 @@ function app(backend: Backend, input?: { password?: string; username?: string }) Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "httpapi" Flag.OPENCODE_SERVER_PASSWORD = input?.password Flag.OPENCODE_SERVER_USERNAME = input?.username - return backend === "httpapi" ? Server.Default().app : Server.Legacy().app + if (backend === "legacy") return Server.Legacy().app + + const handler = HttpRouter.toWebHandler( + ExperimentalHttpApiServer.routes.pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + OPENCODE_SERVER_PASSWORD: input?.password, + OPENCODE_SERVER_USERNAME: input?.username, + }), + ), + ), + ), + { disableLogger: true }, + ).handler + return { + fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), + request(input: string | URL | Request, init?: RequestInit) { + return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init)) + }, + } } function client( @@ -123,7 +145,7 @@ function firstEvent(open: () => Promise<{ stream: AsyncIterator }>) { } function record(value: unknown) { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} + return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {} } function array(value: unknown) {