Sfoglia il codice sorgente

refactor: use Effect config for HttpApi authorization (#25035)

Kit Langton 3 mesi fa
parent
commit
cee9610d26

+ 67 - 0
packages/opencode/src/effect/config-service.ts

@@ -0,0 +1,67 @@
+import { Config, Context, Effect, Layer } from "effect"
+
+type ConfigMap = Record<string, Config.Config<unknown>>
+
+/**
+ * The service shape inferred from an object of Effect `Config` definitions.
+ */
+export type Shape<Fields extends ConfigMap> = {
+  readonly [Key in keyof Fields]: Config.Success<Fields[Key]>
+}
+
+/**
+ * A Context service class with generated layers for config-backed services.
+ */
+export type ServiceClass<Self, Id extends string, Service> = Context.ServiceClass<Self, Id, Service> & {
+  /** Provide already-parsed config, useful in tests. */
+  readonly layer: (input: Service) => Layer.Layer<Self>
+  /** Parse config once from the active Effect ConfigProvider and provide the service. */
+  readonly defaultLayer: Layer.Layer<Self, Config.ConfigError>
+}
+
+/**
+ * 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<ServerAuthConfig>()(
+ *   "@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 =
+  <Self>() =>
+  <const Id extends string, const Fields extends ConfigMap>(id: Id, fields: Fields) => {
+    class ConfigTag extends Context.Service<Self, Shape<Fields>>()(id) {
+      static layer(input: Shape<Fields>) {
+        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<Fields>)),
+            ),
+        )
+      }
+    }
+
+    // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The generated class carries typed static helpers.
+    return ConfigTag as ServiceClass<Self, Id, Shape<Fields>>
+  }
+
+export * as ConfigService from "./config-service"

+ 33 - 27
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>()(
-  "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<Authorization>()(
   "@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<Authorization>()(
   },
 ) {}
 
-const emptyCredential = {
-  username: "",
-  password: Redacted.make(""),
-}
+export class ServerAuthConfig extends ConfigService.Service<ServerAuthConfig>()(
+  "@opencode/ExperimentalHttpApiServerAuthConfig",
+  {
+    password: Config.string("OPENCODE_SERVER_PASSWORD").pipe(Config.option),
+    username: Config.string("OPENCODE_SERVER_USERNAME").pipe(Config.withDefault("opencode")),
+  },
+) {}
 
 function validateCredential<A, E, R>(
   effect: Effect.Effect<A, E, R>,
-  credential: { readonly username: string; readonly password: typeof emptyCredential.password },
+  credential: { readonly username: string; readonly password: Redacted.Redacted },
+  config: Context.Service.Shape<typeof ServerAuthConfig>,
 ) {
   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)),
+        ),
+    })
   }),
 )

+ 3 - 3
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<unknown>
+export const context = Context.makeUnsafe<unknown>(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,
   ]),

+ 16 - 50
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<string, string>) =>
   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)

+ 34 - 9
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<string, Partial<Record<(typeof methods)[number], unknown>>> }) {
@@ -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<string, unknown>)[input.status]
   if (!response || typeof response !== "object" || !("content" in response)) return []
   const content = (response as { content?: unknown }).content

+ 25 - 3
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<unknown> }>) {
 }
 
 function record(value: unknown) {
-  return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
+  return value && typeof value === "object" && !Array.isArray(value) ? Object.fromEntries(Object.entries(value)) : {}
 }
 
 function array(value: unknown) {