Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { Effect, Encoding, Layer, Redacted, Schema } from "effect"
import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { Flag } from "@/flag/flag"

class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}

export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
error: Unauthorized,
security: {
basic: HttpApiSecurity.basic,
authToken: HttpApiSecurity.apiKey({ in: "query", key: "auth_token" }),
},
}) {}

const emptyCredential = {
username: "",
password: Redacted.make(""),
}

function validateCredential<A, E, R>(
effect: Effect.Effect<A, E, R>,
credential: { readonly username: string; readonly password: typeof emptyCredential.password },
) {
return Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect

if (credential.username !== (Flag.OPENCODE_SERVER_USERNAME ?? "opencode")) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
return yield* effect
})
}

function decodeCredential(input: string) {
return Encoding.decodeBase64String(input).asEffect().pipe(
Effect.match({
onFailure: () => emptyCredential,
onSuccess: (header) => {
const parts = header.split(":")
if (parts.length !== 2) return emptyCredential
return {
username: parts[0],
password: Redacted.make(parts[1]),
}
},
}),
)
}

export const authorizationLayer = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect, { credential }) => validateCredential(effect, credential),
authToken: (effect, { credential }) =>
Effect.gen(function* () {
return yield* validateCredential(effect, yield* decodeCredential(Redacted.value(credential)))
}),
}),
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Config } from "@/config"
import { Provider } from "@/provider"
import { Effect, Layer } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

const root = "/config"

Expand Down Expand Up @@ -33,7 +34,8 @@ export const ConfigApi = HttpApi.make("config")
title: "config",
description: "Experimental HttpApi config routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/server/routes/instance/httpapi/file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { File } from "@/file"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

const FileQuery = Schema.Struct({
path: Schema.String,
Expand Down Expand Up @@ -51,7 +52,8 @@ export const FileApi = HttpApi.make("file")
title: "file",
description: "Experimental HttpApi file routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/server/routes/instance/httpapi/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MCP } from "@/mcp"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

export const McpPaths = {
status: "/mcp",
Expand All @@ -25,7 +26,8 @@ export const McpApi = HttpApi.make("mcp")
title: "mcp",
description: "Experimental HttpApi MCP routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

const root = "/permission"

Expand Down Expand Up @@ -35,7 +36,8 @@ export const PermissionApi = HttpApi.make("permission")
title: "permission",
description: "Experimental HttpApi permission routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Instance } from "@/project/instance"
import { Project } from "@/project"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

const root = "/project"

Expand Down Expand Up @@ -33,7 +34,8 @@ export const ProjectApi = HttpApi.make("project")
title: "project",
description: "Experimental HttpApi project routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

const root = "/provider"

Expand Down Expand Up @@ -59,7 +60,8 @@ export const ProviderApi = HttpApi.make("provider")
title: "provider",
description: "Experimental HttpApi provider routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

const root = "/question"

Expand Down Expand Up @@ -45,7 +46,8 @@ export const QuestionApi = HttpApi.make("question")
title: "question",
description: "Question routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
84 changes: 12 additions & 72 deletions packages/opencode/src/server/routes/instance/httpapi/server.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Effect, Layer, Redacted, Schema } from "effect"
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
import { Effect, Layer, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { Observability } from "@/effect"
import { Flag } from "@/flag/flag"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util"
import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
import { FileApi, fileHandlers } from "./file"
import { McpApi, mcpHandlers } from "./mcp"
Expand Down Expand Up @@ -38,56 +38,6 @@ function decode(input: string) {
}
}

class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
"Unauthorized",
{ message: Schema.String },
{ httpApiStatus: 401 },
) {}

class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/ExperimentalHttpApiAuthorization", {
error: Unauthorized,
security: {
basic: HttpApiSecurity.basic,
},
}) {}

const normalize = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Effect.gen(function* () {
const query = yield* HttpServerRequest.schemaSearchParams(Query)
if (!query.auth_token) return yield* effect
const req = yield* HttpServerRequest.HttpServerRequest
const next = req.modify({
headers: {
...req.headers,
authorization: `Basic ${query.auth_token}`,
},
})
return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next))
})
}),
).layer

const auth = Layer.succeed(
Authorization,
Authorization.of({
basic: (effect, { credential }) =>
Effect.gen(function* () {
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect

const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
if (credential.username !== user) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) {
return yield* new Unauthorized({ message: "Unauthorized" })
}
return yield* effect
}),
}),
)

const instance = HttpRouter.middleware()(
Effect.gen(function* () {
return (effect) =>
Expand All @@ -110,27 +60,17 @@ const instance = HttpRouter.middleware()(
}),
).layer

const QuestionSecured = QuestionApi.middleware(Authorization)
const PermissionSecured = PermissionApi.middleware(Authorization)
const ProjectSecured = ProjectApi.middleware(Authorization)
const ProviderSecured = ProviderApi.middleware(Authorization)
const ConfigSecured = ConfigApi.middleware(Authorization)
const WorkspaceSecured = WorkspaceApi.middleware(Authorization)
const FileSecured = FileApi.middleware(Authorization)
const McpSecured = McpApi.middleware(Authorization)

export const routes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigSecured).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(FileSecured).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(McpSecured).pipe(Layer.provide(mcpHandlers)),
HttpApiBuilder.layer(ProjectSecured).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(WorkspaceSecured).pipe(Layer.provide(workspaceHandlers)),
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
).pipe(
Layer.provide(auth),
Layer.provide(normalize),
Layer.provide(authorizationLayer),
Layer.provide(instance),
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Observability.layer),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import * as InstanceState from "@/effect/instance-state"
import { Effect, Layer, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"

const root = "/experimental/workspace"
export const WorkspacePaths = {
Expand Down Expand Up @@ -49,7 +50,8 @@ export const WorkspaceApi = HttpApi.make("workspace")
title: "workspace",
description: "Experimental HttpApi workspace routes.",
}),
),
)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
Expand Down
Loading