add experimental permission HttpApi slice (#22385)
This commit is contained in:
135
packages/opencode/src/server/instance/httpapi/server.ts
Normal file
135
packages/opencode/src/server/instance/httpapi/server.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Effect, Layer, Redacted, Schema } from "effect"
|
||||
import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Permission } from "@/permission"
|
||||
import { Question } from "@/question"
|
||||
import { PermissionApi, PermissionLive } from "./permission"
|
||||
import { QuestionApi, QuestionLive } from "./question"
|
||||
|
||||
const Query = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
auth_token: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const Headers = Schema.Struct({
|
||||
authorization: Schema.optional(Schema.String),
|
||||
"x-opencode-directory": Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export namespace ExperimentalHttpApiServer {
|
||||
function text(input: string, status: number, headers?: Record<string, string>) {
|
||||
return HttpServerResponse.text(input, { status, headers })
|
||||
}
|
||||
|
||||
function decode(input: string) {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
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) =>
|
||||
Effect.gen(function* () {
|
||||
const query = yield* HttpServerRequest.schemaSearchParams(Query)
|
||||
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
|
||||
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
|
||||
const workspace = query.workspace || undefined
|
||||
const ctx = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: Filesystem.resolve(decode(raw)),
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => Instance.current,
|
||||
}),
|
||||
)
|
||||
|
||||
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
|
||||
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
})
|
||||
}),
|
||||
).layer
|
||||
|
||||
const QuestionSecured = QuestionApi.middleware(Authorization)
|
||||
const PermissionSecured = PermissionApi.middleware(Authorization)
|
||||
|
||||
export const routes = Layer.mergeAll(
|
||||
HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
|
||||
Layer.provide(QuestionLive),
|
||||
),
|
||||
HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
|
||||
Layer.provide(PermissionLive),
|
||||
),
|
||||
).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance))
|
||||
|
||||
export const layer = (opts: { hostname: string; port: number }) =>
|
||||
HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })),
|
||||
)
|
||||
|
||||
export const layerTest = HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(Question.defaultLayer),
|
||||
Layer.provideMerge(Permission.defaultLayer),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user