summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-28 16:31:45 -0400
committerGitHub <[email protected]>2026-04-28 16:31:45 -0400
commit58836e75f0a7867cba77b82773a294cdab870d60 (patch)
treed462e87abbdc3ba23839946f74a4ed3507d7f336
parent0acac216aeee334d5c7f9e4aa4557a16ad7c7691 (diff)
downloadopencode-58836e75f0a7867cba77b82773a294cdab870d60.tar.gz
opencode-58836e75f0a7867cba77b82773a294cdab870d60.zip
fix(httpapi): wire global and control handlers (#24835)
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/config.ts10
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/control.ts32
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/experimental.ts38
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/file.ts22
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/global.ts166
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/instance.ts33
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/mcp.ts26
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/permission.ts10
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/project.ts10
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/provider.ts20
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/pty.ts20
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/question.ts10
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts75
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/session.ts68
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/sync.ts8
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/tui.ts34
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/workspace.ts20
-rw-r--r--packages/opencode/test/server/httpapi-bridge.test.ts53
18 files changed, 448 insertions, 207 deletions
diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts
index e659cf74e..eef825967 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/config.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts
@@ -1,7 +1,7 @@
import { Config } from "@/config/config"
import { Provider } from "@/provider/provider"
import * as InstanceState from "@/effect/instance-state"
-import { Effect, Layer } from "effect"
+import { Effect } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
@@ -57,7 +57,7 @@ export const ConfigApi = HttpApi.make("config")
}),
)
-export const configHandlers = Layer.unwrap(
+export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
Effect.gen(function* () {
const providerSvc = yield* Provider.Service
const configSvc = yield* Config.Service
@@ -80,8 +80,6 @@ export const configHandlers = Layer.unwrap(
}
})
- return HttpApiBuilder.group(ConfigApi, "config", (handlers) =>
- handlers.handle("get", get).handle("update", update).handle("providers", providers),
- )
+ return handlers.handle("get", get).handle("update", update).handle("providers", providers)
}),
-).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer))
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/control.ts b/packages/opencode/src/server/routes/instance/httpapi/control.ts
index f850f76e7..718629db7 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/control.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/control.ts
@@ -1,7 +1,8 @@
import { Auth } from "@/auth"
import { ProviderID } from "@/provider/schema"
-import { Schema } from "effect"
-import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import * as Log from "@opencode-ai/core/util/log"
+import { Effect, Schema } from "effect"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
const AuthParams = Schema.Struct({
providerID: ProviderID,
@@ -69,3 +70,30 @@ export const ControlApi = HttpApi.make("control").add(
)
.annotateMerge(OpenApi.annotations({ title: "control", description: "Control plane routes." })),
)
+
+export const controlHandlers = HttpApiBuilder.group(ControlApi, "control", (handlers) =>
+ Effect.gen(function* () {
+ const auth = yield* Auth.Service
+
+ const authSet = Effect.fn("ControlHttpApi.authSet")(function* (ctx: {
+ params: { providerID: ProviderID }
+ payload: Auth.Info
+ }) {
+ yield* auth.set(ctx.params.providerID, ctx.payload).pipe(Effect.orDie)
+ return true
+ })
+
+ const authRemove = Effect.fn("ControlHttpApi.authRemove")(function* (ctx: { params: { providerID: ProviderID } }) {
+ yield* auth.remove(ctx.params.providerID).pipe(Effect.orDie)
+ return true
+ })
+
+ const log = Effect.fn("ControlHttpApi.log")(function* (ctx: { payload: typeof LogInput.Type }) {
+ const logger = Log.create({ service: ctx.payload.service })
+ logger[ctx.payload.level](ctx.payload.message, ctx.payload.extra)
+ return true
+ })
+
+ return handlers.handle("authSet", authSet).handle("authRemove", authRemove).handle("log", log)
+ }),
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
index 7e0aae8f4..cc39c7604 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/experimental.ts
@@ -10,7 +10,7 @@ import { Session } from "@/session/session"
import { ToolRegistry } from "@/tool/registry"
import * as EffectZod from "@/util/effect-zod"
import { Worktree } from "@/worktree"
-import { Effect, Layer, Option, Schema, SchemaGetter } from "effect"
+import { Effect, Option, Schema, SchemaGetter } from "effect"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -210,7 +210,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
}),
)
-export const experimentalHandlers = Layer.unwrap(
+export const experimentalHandlers = HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
Effect.gen(function* () {
const account = yield* Account.Service
const agents = yield* Agent.Service
@@ -335,27 +335,17 @@ export const experimentalHandlers = Layer.unwrap(
return yield* mcp.resources()
})
- return HttpApiBuilder.group(ExperimentalApi, "experimental", (handlers) =>
- handlers
- .handle("console", getConsole)
- .handle("consoleOrgs", listConsoleOrgs)
- .handle("consoleSwitch", switchConsole)
- .handle("tool", tool)
- .handle("toolIDs", toolIDs)
- .handle("worktree", worktree)
- .handle("worktreeCreate", worktreeCreate)
- .handle("worktreeRemove", worktreeRemove)
- .handle("worktreeReset", worktreeReset)
- .handle("session", session)
- .handle("resource", resource),
- )
+ return handlers
+ .handle("console", getConsole)
+ .handle("consoleOrgs", listConsoleOrgs)
+ .handle("consoleSwitch", switchConsole)
+ .handle("tool", tool)
+ .handle("toolIDs", toolIDs)
+ .handle("worktree", worktree)
+ .handle("worktreeCreate", worktreeCreate)
+ .handle("worktreeRemove", worktreeRemove)
+ .handle("worktreeReset", worktreeReset)
+ .handle("session", session)
+ .handle("resource", resource)
}),
-).pipe(
- Layer.provide(Account.defaultLayer),
- Layer.provide(Agent.defaultLayer),
- Layer.provide(Config.defaultLayer),
- Layer.provide(MCP.defaultLayer),
- Layer.provide(Project.defaultLayer),
- Layer.provide(ToolRegistry.defaultLayer),
- Layer.provide(Worktree.defaultLayer),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/file.ts b/packages/opencode/src/server/routes/instance/httpapi/file.ts
index 9f2ab8a3c..df525680a 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/file.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/file.ts
@@ -2,7 +2,7 @@ import { File } from "@/file"
import { Ripgrep } from "@/file/ripgrep"
import * as InstanceState from "@/effect/instance-state"
import { LSP } from "@/lsp/lsp"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -116,7 +116,7 @@ export const FileApi = HttpApi.make("file")
}),
)
-export const fileHandlers = Layer.unwrap(
+export const fileHandlers = HttpApiBuilder.group(FileApi, "file", (handlers) =>
Effect.gen(function* () {
const svc = yield* File.Service
const ripgrep = yield* Ripgrep.Service
@@ -154,14 +154,12 @@ export const fileHandlers = Layer.unwrap(
return yield* svc.status()
})
- return HttpApiBuilder.group(FileApi, "file", (handlers) =>
- handlers
- .handle("findText", findText)
- .handle("findFile", findFile)
- .handle("findSymbol", findSymbol)
- .handle("list", list)
- .handle("content", content)
- .handle("status", status),
- )
+ return handlers
+ .handle("findText", findText)
+ .handle("findFile", findFile)
+ .handle("findSymbol", findSymbol)
+ .handle("list", list)
+ .handle("content", content)
+ .handle("status", status)
}),
-).pipe(Layer.provide(File.defaultLayer), Layer.provide(Ripgrep.defaultLayer))
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/global.ts b/packages/opencode/src/server/routes/instance/httpapi/global.ts
index 215c19ef7..ef7fb331f 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/global.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/global.ts
@@ -1,13 +1,21 @@
import { Config } from "@/config/config"
-import { Schema } from "effect"
-import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { GlobalBus, type GlobalEvent as GlobalBusEvent } from "@/bus/global"
+import { Installation } from "@/installation"
+import { Instance } from "@/project/instance"
+import { InstallationVersion } from "@opencode-ai/core/installation/version"
+import * as Log from "@opencode-ai/core/util/log"
+import { Effect, Schema } from "effect"
+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+
+const log = Log.create({ service: "server" })
const GlobalHealth = Schema.Struct({
healthy: Schema.Literal(true),
version: Schema.String,
}).annotate({ identifier: "GlobalHealth" })
-const GlobalEvent = Schema.Struct({
+const GlobalEventSchema = Schema.Struct({
directory: Schema.String,
project: Schema.optional(Schema.String),
workspace: Schema.optional(Schema.String),
@@ -50,7 +58,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.get("event", GlobalPaths.event, {
- success: GlobalEvent,
+ success: GlobalEventSchema,
}).annotateMerge(
OpenApi.annotations({
identifier: "global.event",
@@ -99,3 +107,153 @@ export const GlobalApi = HttpApi.make("global").add(
)
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
)
+
+function eventData(data: unknown) {
+ return `data: ${JSON.stringify(data)}\n\n`
+}
+
+function parseBody(body: string) {
+ try {
+ return JSON.parse(body || "{}") as unknown
+ } catch {
+ return undefined
+ }
+}
+
+function eventResponse() {
+ const encoder = new TextEncoder()
+ let heartbeat: ReturnType<typeof setInterval> | undefined
+ let unsubscribe = () => {}
+ let done = false
+
+ const cleanup = () => {
+ if (done) return
+ done = true
+ if (heartbeat) clearInterval(heartbeat)
+ unsubscribe()
+ log.info("global event disconnected")
+ }
+
+ log.info("global event connected")
+ return HttpServerResponse.raw(
+ new Response(
+ new ReadableStream<Uint8Array>({
+ start(controller) {
+ const write = (data: unknown) => {
+ if (done) return
+ try {
+ controller.enqueue(encoder.encode(eventData(data)))
+ } catch {
+ cleanup()
+ }
+ }
+ const handler = (event: GlobalBusEvent) => write(event)
+ unsubscribe = () => GlobalBus.off("event", handler)
+ GlobalBus.on("event", handler)
+ write({ payload: { type: "server.connected", properties: {} } })
+ heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000)
+ },
+ cancel: cleanup,
+ }),
+ {
+ headers: {
+ "Cache-Control": "no-cache, no-transform",
+ "Content-Type": "text/event-stream",
+ "X-Accel-Buffering": "no",
+ "X-Content-Type-Options": "nosniff",
+ },
+ },
+ ),
+ )
+}
+
+export const globalHandlers = HttpApiBuilder.group(GlobalApi, "global", (handlers) =>
+ Effect.gen(function* () {
+ const config = yield* Config.Service
+ const installation = yield* Installation.Service
+
+ const health = Effect.fn("GlobalHttpApi.health")(function* () {
+ return { healthy: true as const, version: InstallationVersion }
+ })
+
+ const event = Effect.fn("GlobalHttpApi.event")(function* () {
+ return eventResponse()
+ })
+
+ const configGet = Effect.fn("GlobalHttpApi.configGet")(function* () {
+ return yield* config.getGlobal()
+ })
+
+ const configUpdate = Effect.fn("GlobalHttpApi.configUpdate")(function* (ctx) {
+ return yield* config.updateGlobal(ctx.payload)
+ })
+
+ const dispose = Effect.fn("GlobalHttpApi.dispose")(function* () {
+ yield* Effect.promise(() => Instance.disposeAll())
+ GlobalBus.emit("event", {
+ directory: "global",
+ payload: { type: "global.disposed", properties: {} },
+ })
+ return true
+ })
+
+ const upgrade = Effect.fn("GlobalHttpApi.upgrade")(function* (ctx: { payload: typeof GlobalUpgradeInput.Type }) {
+ const method = yield* installation.method()
+ if (method === "unknown") {
+ return {
+ status: 400,
+ body: { success: false as const, error: "Unknown installation method" },
+ }
+ }
+ const target = ctx.payload.target || (yield* installation.latest(method))
+ const result = yield* installation.upgrade(method, target).pipe(
+ Effect.as({ status: 200, body: { success: true as const, version: target } }),
+ Effect.catch((err) =>
+ Effect.succeed({
+ status: 500,
+ body: {
+ success: false as const,
+ error: err instanceof Error ? err.message : String(err),
+ },
+ }),
+ ),
+ )
+ if (!result.body.success) return result
+ GlobalBus.emit("event", {
+ directory: "global",
+ payload: {
+ type: Installation.Event.Updated.type,
+ properties: { version: target },
+ },
+ })
+ return result
+ })
+
+ const upgradeRaw = Effect.fn("GlobalHttpApi.upgradeRaw")(function* (ctx: {
+ request: HttpServerRequest.HttpServerRequest
+ }) {
+ const body = yield* Effect.orDie(ctx.request.text)
+ const json = parseBody(body)
+ if (json === undefined) {
+ return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
+ }
+ const payload = yield* Schema.decodeUnknownEffect(GlobalUpgradeInput)(json).pipe(
+ Effect.map((payload) => ({ valid: true as const, payload })),
+ Effect.catch(() => Effect.succeed({ valid: false as const })),
+ )
+ if (!payload.valid) {
+ return HttpServerResponse.jsonUnsafe({ success: false, error: "Invalid request body" }, { status: 400 })
+ }
+ const result = yield* upgrade({ payload: payload.payload })
+ return HttpServerResponse.jsonUnsafe(result.body, { status: result.status })
+ })
+
+ return handlers
+ .handle("health", health)
+ .handleRaw("event", event)
+ .handle("configGet", configGet)
+ .handle("configUpdate", configUpdate)
+ .handle("dispose", dispose)
+ .handleRaw("upgrade", upgradeRaw)
+ }),
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/instance.ts
index d36c43c76..8c471c12a 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/instance.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/instance.ts
@@ -6,7 +6,7 @@ import { LSP } from "@/lsp/lsp"
import { Vcs } from "@/project/vcs"
import { Skill } from "@/skill"
import * as InstanceState from "@/effect/instance-state"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForDisposal } from "./lifecycle"
@@ -140,7 +140,7 @@ export const InstanceApi = HttpApi.make("instance")
}),
)
-export const instanceHandlers = Layer.unwrap(
+export const instanceHandlers = HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const command = yield* Command.Service
@@ -194,24 +194,15 @@ export const instanceHandlers = Layer.unwrap(
return yield* format.status()
})
- return HttpApiBuilder.group(InstanceApi, "instance", (handlers) =>
- handlers
- .handle("dispose", dispose)
- .handle("path", getPath)
- .handle("vcs", getVcs)
- .handle("vcsDiff", getVcsDiff)
- .handle("command", getCommand)
- .handle("agent", getAgent)
- .handle("skill", getSkill)
- .handle("lsp", getLsp)
- .handle("formatter", getFormatter),
- )
+ return handlers
+ .handle("dispose", dispose)
+ .handle("path", getPath)
+ .handle("vcs", getVcs)
+ .handle("vcsDiff", getVcsDiff)
+ .handle("command", getCommand)
+ .handle("agent", getAgent)
+ .handle("skill", getSkill)
+ .handle("lsp", getLsp)
+ .handle("formatter", getFormatter)
}),
-).pipe(
- Layer.provide(Agent.defaultLayer),
- Layer.provide(Command.defaultLayer),
- Layer.provide(Format.defaultLayer),
- Layer.provide(LSP.defaultLayer),
- Layer.provide(Skill.defaultLayer),
- Layer.provide(Vcs.defaultLayer),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts
index 8fea8da9f..f5552f6f2 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/mcp.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/mcp.ts
@@ -1,6 +1,6 @@
import { MCP } from "@/mcp"
import { ConfigMCP } from "@/config/mcp"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -137,7 +137,7 @@ export const McpApi = HttpApi.make("mcp")
}),
)
-export const mcpHandlers = Layer.unwrap(
+export const mcpHandlers = HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
Effect.gen(function* () {
const mcp = yield* MCP.Service
@@ -188,16 +188,14 @@ export const mcpHandlers = Layer.unwrap(
return true
})
- return HttpApiBuilder.group(McpApi, "mcp", (handlers) =>
- handlers
- .handle("status", status)
- .handle("add", add)
- .handle("authStart", authStart)
- .handle("authCallback", authCallback)
- .handle("authAuthenticate", authAuthenticate)
- .handle("authRemove", authRemove)
- .handle("connect", connect)
- .handle("disconnect", disconnect),
- )
+ return handlers
+ .handle("status", status)
+ .handle("add", add)
+ .handle("authStart", authStart)
+ .handle("authCallback", authCallback)
+ .handle("authAuthenticate", authAuthenticate)
+ .handle("authRemove", authRemove)
+ .handle("connect", connect)
+ .handle("disconnect", disconnect)
}),
-).pipe(Layer.provide(MCP.defaultLayer))
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/permission.ts b/packages/opencode/src/server/routes/instance/httpapi/permission.ts
index 85dbecd11..357c83299 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/permission.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/permission.ts
@@ -1,6 +1,6 @@
import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -47,7 +47,7 @@ export const PermissionApi = HttpApi.make("permission")
}),
)
-export const permissionHandlers = Layer.unwrap(
+export const permissionHandlers = HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
Effect.gen(function* () {
const svc = yield* Permission.Service
@@ -67,8 +67,6 @@ export const permissionHandlers = Layer.unwrap(
return true
})
- return HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
- handlers.handle("list", list).handle("reply", reply),
- )
+ return handlers.handle("list", list).handle("reply", reply)
}),
-).pipe(Layer.provide(Permission.defaultLayer))
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/project.ts b/packages/opencode/src/server/routes/instance/httpapi/project.ts
index f5a39e39e..276798b0b 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/project.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/project.ts
@@ -3,7 +3,7 @@ import { AppRuntime } from "@/effect/app-runtime"
import { Project } from "@/project/project"
import { InstanceBootstrap } from "@/project/bootstrap"
import { ProjectID } from "@/project/schema"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
import { markInstanceForReload } from "./lifecycle"
@@ -69,7 +69,7 @@ export const ProjectApi = HttpApi.make("project")
}),
)
-export const projectHandlers = Layer.unwrap(
+export const projectHandlers = HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
Effect.gen(function* () {
const svc = yield* Project.Service
@@ -102,8 +102,6 @@ export const projectHandlers = Layer.unwrap(
return yield* svc.update({ ...ctx.payload, projectID: ctx.params.projectID })
})
- return HttpApiBuilder.group(ProjectApi, "project", (handlers) =>
- handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update),
- )
+ return handlers.handle("list", list).handle("current", current).handle("initGit", initGit).handle("update", update)
}),
-).pipe(Layer.provide(Project.defaultLayer))
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts
index 45cb64375..7dbc491e1 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/provider.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/provider.ts
@@ -4,7 +4,7 @@ import { ModelsDev } from "@/provider/models"
import { Provider } from "@/provider/provider"
import { ProviderID } from "@/provider/schema"
import { mapValues } from "remeda"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -74,7 +74,7 @@ export const ProviderApi = HttpApi.make("provider")
}),
)
-export const providerHandlers = Layer.unwrap(
+export const providerHandlers = HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
Effect.gen(function* () {
const cfg = yield* Config.Service
const provider = yield* Provider.Service
@@ -148,16 +148,10 @@ export const providerHandlers = Layer.unwrap(
return true
})
- return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
- handlers
- .handle("list", list)
- .handle("auth", auth)
- .handleRaw("authorize", authorizeRaw)
- .handle("callback", callback),
- )
+ return handlers
+ .handle("list", list)
+ .handle("auth", auth)
+ .handleRaw("authorize", authorizeRaw)
+ .handle("callback", callback)
}),
-).pipe(
- Layer.provide(ProviderAuth.defaultLayer),
- Layer.provide(Provider.defaultLayer),
- Layer.provide(Config.defaultLayer),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/pty.ts
index 217000253..d4e77c9d0 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/pty.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/pty.ts
@@ -2,7 +2,7 @@ import { EffectBridge } from "@/effect/bridge"
import { Pty } from "@/pty"
import { PtyID } from "@/pty/schema"
import { Shell } from "@/shell/shell"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
@@ -131,7 +131,7 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
)
-export const ptyHandlers = Layer.unwrap(
+export const ptyHandlers = HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
@@ -179,15 +179,13 @@ export const ptyHandlers = Layer.unwrap(
return true
})
- return HttpApiBuilder.group(PtyApi, "pty", (handlers) =>
- handlers
- .handle("shells", shells)
- .handle("list", list)
- .handle("create", create)
- .handle("get", get)
- .handle("update", update)
- .handle("remove", remove),
- )
+ return handlers
+ .handle("shells", shells)
+ .handle("list", list)
+ .handle("create", create)
+ .handle("get", get)
+ .handle("update", update)
+ .handle("remove", remove)
}),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/question.ts b/packages/opencode/src/server/routes/instance/httpapi/question.ts
index 526a78ee0..2169e17c5 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/question.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/question.ts
@@ -1,6 +1,6 @@
import { Question } from "@/question"
import { QuestionID } from "@/question/schema"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -57,7 +57,7 @@ export const QuestionApi = HttpApi.make("question")
}),
)
-export const questionHandlers = Layer.unwrap(
+export const questionHandlers = HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
Effect.gen(function* () {
const svc = yield* Question.Service
@@ -81,8 +81,6 @@ export const questionHandlers = Layer.unwrap(
return true
})
- return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
- handlers.handle("list", list).handle("reply", reply).handle("reject", reject),
- )
+ return handlers.handle("list", list).handle("reply", reply).handle("reject", reject)
}),
-).pipe(Layer.provide(Question.defaultLayer))
+)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index 5ab00d6a0..66c4f2dd1 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -1,21 +1,47 @@
import { Context, Effect, Layer, Schema } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpRouter, HttpServer, HttpServerRequest } from "effect/unstable/http"
+import { Account } from "@/account/account"
+import { Agent } from "@/agent/agent"
+import { Auth } from "@/auth"
import { Bus } from "@/bus"
+import { Config } from "@/config/config"
+import { Command } from "@/command"
import { AppRuntime } from "@/effect/app-runtime"
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import * as Observability from "@opencode-ai/core/effect/observability"
+import { File } from "@/file"
+import { Ripgrep } from "@/file/ripgrep"
+import { Format } from "@/format"
+import { LSP } from "@/lsp/lsp"
+import { MCP } from "@/mcp"
+import { Permission } from "@/permission"
import { InstanceBootstrap } from "@/project/bootstrap"
import { Instance } from "@/project/instance"
+import { Installation } from "@/installation"
+import { Project } from "@/project/project"
+import { ProviderAuth } from "@/provider/auth"
+import { Provider } from "@/provider/provider"
import { Pty } from "@/pty"
+import { Question } from "@/question"
import { Session } from "@/session/session"
+import { SessionRunState } from "@/session/run-state"
+import { SessionStatus } from "@/session/status"
+import { SessionSummary } from "@/session/summary"
+import { Todo } from "@/session/todo"
+import { Skill } from "@/skill"
+import { ToolRegistry } from "@/tool/registry"
import { lazy } from "@/util/lazy"
import { Filesystem } from "@/util/filesystem"
+import { Vcs } from "@/project/vcs"
+import { Worktree } from "@/worktree"
import { authorizationLayer } from "./auth"
import { ConfigApi, configHandlers } from "./config"
+import { ControlApi, controlHandlers } from "./control"
import { eventRoute } from "./event"
import { FileApi, fileHandlers } from "./file"
import { ExperimentalApi, experimentalHandlers } from "./experimental"
+import { GlobalApi, globalHandlers } from "./global"
import { InstanceApi, instanceHandlers } from "./instance"
import { McpApi, mcpHandlers } from "./mcp"
import { PermissionApi, permissionHandlers } from "./permission"
@@ -73,30 +99,59 @@ const instance = HttpRouter.middleware()(
}),
).layer
-export const routes = Layer.mergeAll(
- eventRoute,
- ptyConnectRoute,
+const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers))
+const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers))
+const instanceApiRoutes = Layer.mergeAll(
HttpApiBuilder.layer(ConfigApi).pipe(Layer.provide(configHandlers)),
HttpApiBuilder.layer(ExperimentalApi).pipe(Layer.provide(experimentalHandlers)),
HttpApiBuilder.layer(FileApi).pipe(Layer.provide(fileHandlers)),
HttpApiBuilder.layer(InstanceApi).pipe(Layer.provide(instanceHandlers)),
HttpApiBuilder.layer(McpApi).pipe(Layer.provide(mcpHandlers)),
HttpApiBuilder.layer(ProjectApi).pipe(Layer.provide(projectHandlers)),
- HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers), Layer.provide(Pty.defaultLayer)),
+ HttpApiBuilder.layer(PtyApi).pipe(Layer.provide(ptyHandlers)),
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
- HttpApiBuilder.layer(TuiApi).pipe(
- Layer.provide(tuiHandlers),
- Layer.provide(Session.defaultLayer),
- Layer.provide(Bus.layer),
- ),
+ HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)),
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
-).pipe(
+)
+
+const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRoutes).pipe(
Layer.provide(authorizationLayer),
Layer.provide(instance),
+)
+
+export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes).pipe(
+ Layer.provide(Account.defaultLayer),
+ Layer.provide(Agent.defaultLayer),
+ Layer.provide(Auth.defaultLayer),
+ Layer.provide(Command.defaultLayer),
+ Layer.provide(Config.defaultLayer),
+ Layer.provide(File.defaultLayer),
+ Layer.provide(Format.defaultLayer),
+ Layer.provide(LSP.defaultLayer),
+ Layer.provide(Installation.defaultLayer),
+ Layer.provide(MCP.defaultLayer),
+ Layer.provide(Permission.defaultLayer),
+ Layer.provide(Project.defaultLayer),
+ Layer.provide(ProviderAuth.defaultLayer),
+ Layer.provide(Provider.defaultLayer),
+ Layer.provide(Pty.defaultLayer),
+ Layer.provide(Question.defaultLayer),
+ Layer.provide(Ripgrep.defaultLayer),
+ Layer.provide(Session.defaultLayer),
+).pipe(
+ Layer.provide(SessionRunState.defaultLayer),
+ Layer.provide(SessionStatus.defaultLayer),
+ Layer.provide(SessionSummary.defaultLayer),
+ Layer.provide(Skill.defaultLayer),
+ Layer.provide(Todo.defaultLayer),
+ Layer.provide(ToolRegistry.defaultLayer),
+ Layer.provide(Vcs.defaultLayer),
+ Layer.provide(Worktree.defaultLayer),
+ Layer.provide(Bus.layer),
Layer.provide(HttpServer.layerServices),
Layer.provideMerge(Observability.layer),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts
index 449d70e17..9001ae49d 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/session.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts
@@ -21,7 +21,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
import * as Log from "@opencode-ai/core/util/log"
import { NamedError } from "@opencode-ai/core/util/error"
-import { Effect, Layer, Schema, SchemaGetter, Struct } from "effect"
+import { Effect, Schema, SchemaGetter, Struct } from "effect"
import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
@@ -431,7 +431,7 @@ export const SessionApi = HttpApi.make("session")
}),
)
-export const sessionHandlers = Layer.unwrap(
+export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const statusSvc = yield* SessionStatus.Service
@@ -908,41 +908,33 @@ export const sessionHandlers = Layer.unwrap(
)
})
- return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
- handlers
- .handle("list", list)
- .handle("status", status)
- .handle("get", get)
- .handle("children", children)
- .handle("todo", todo)
- .handle("diff", diff)
- .handle("messages", messages)
- .handle("message", message)
- .handleRaw("create", createRaw)
- .handle("remove", remove)
- .handle("update", update)
- .handle("fork", fork)
- .handle("abort", abort)
- .handle("init", init)
- .handle("share", share)
- .handle("unshare", unshare)
- .handle("summarize", summarize)
- .handle("prompt", prompt)
- .handle("promptAsync", promptAsync)
- .handle("command", command)
- .handle("shell", shell)
- .handle("revert", revert)
- .handle("unrevert", unrevert)
- .handle("permissionRespond", permissionRespond)
- .handle("deleteMessage", deleteMessage)
- .handle("deletePart", deletePart)
- .handle("updatePart", updatePart),
- )
+ return handlers
+ .handle("list", list)
+ .handle("status", status)
+ .handle("get", get)
+ .handle("children", children)
+ .handle("todo", todo)
+ .handle("diff", diff)
+ .handle("messages", messages)
+ .handle("message", message)
+ .handleRaw("create", createRaw)
+ .handle("remove", remove)
+ .handle("update", update)
+ .handle("fork", fork)
+ .handle("abort", abort)
+ .handle("init", init)
+ .handle("share", share)
+ .handle("unshare", unshare)
+ .handle("summarize", summarize)
+ .handle("prompt", prompt)
+ .handle("promptAsync", promptAsync)
+ .handle("command", command)
+ .handle("shell", shell)
+ .handle("revert", revert)
+ .handle("unrevert", unrevert)
+ .handle("permissionRespond", permissionRespond)
+ .handle("deleteMessage", deleteMessage)
+ .handle("deletePart", deletePart)
+ .handle("updatePart", updatePart)
}),
-).pipe(
- Layer.provide(Session.defaultLayer),
- Layer.provide(SessionRunState.defaultLayer),
- Layer.provide(SessionStatus.defaultLayer),
- Layer.provide(Todo.defaultLayer),
- Layer.provide(SessionSummary.defaultLayer),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/sync.ts
index 1374518c6..67fcede2f 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/sync.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/sync.ts
@@ -10,7 +10,7 @@ import { or } from "drizzle-orm"
import { SyncEvent } from "@/sync"
import { EventTable } from "@/sync/event.sql"
import { NonNegativeInt } from "@/util/schema"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -97,7 +97,7 @@ export const SyncApi = HttpApi.make("sync")
}),
)
-export const syncHandlers = Layer.unwrap(
+export const syncHandlers = HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
Effect.gen(function* () {
const start = Effect.fn("SyncHttpApi.start")(function* () {
startWorkspaceSyncing((yield* InstanceState.context).project.id)
@@ -132,8 +132,6 @@ export const syncHandlers = Layer.unwrap(
)
})
- return HttpApiBuilder.group(SyncApi, "sync", (handlers) =>
- handlers.handle("start", start).handle("replay", replay).handle("history", history),
- )
+ return handlers.handle("start", start).handle("replay", replay).handle("history", history)
}),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/tui.ts b/packages/opencode/src/server/routes/instance/httpapi/tui.ts
index 36004ea25..2bcc740dd 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/tui.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/tui.ts
@@ -4,7 +4,7 @@ import { SessionID } from "@/session/schema"
import { SessionTable } from "@/session/session.sql"
import * as Database from "@/storage/db"
import { eq } from "drizzle-orm"
-import { Effect, Layer, Schema } from "effect"
+import { Effect, Schema } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { nextTuiRequest, submitTuiResponse } from "../tui"
import { Authorization } from "./auth"
@@ -183,7 +183,7 @@ export const TuiApi = HttpApi.make("tui")
}),
)
-export const tuiHandlers = Layer.unwrap(
+export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
Effect.gen(function* () {
const bus = yield* Bus.Service
const publishCommand = (command: typeof TuiEvent.CommandExecute.properties.Type.command) =>
@@ -273,21 +273,19 @@ export const tuiHandlers = Layer.unwrap(
return true
})
- return HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
- handlers
- .handle("appendPrompt", appendPrompt)
- .handle("openHelp", openHelp)
- .handle("openSessions", openSessions)
- .handle("openThemes", openThemes)
- .handle("openModels", openModels)
- .handle("submitPrompt", submitPrompt)
- .handle("clearPrompt", clearPrompt)
- .handle("executeCommand", executeCommand)
- .handle("showToast", showToast)
- .handle("publish", publish)
- .handle("selectSession", selectSession)
- .handle("controlNext", controlNext)
- .handle("controlResponse", controlResponse),
- )
+ return handlers
+ .handle("appendPrompt", appendPrompt)
+ .handle("openHelp", openHelp)
+ .handle("openSessions", openSessions)
+ .handle("openThemes", openThemes)
+ .handle("openModels", openModels)
+ .handle("submitPrompt", submitPrompt)
+ .handle("clearPrompt", clearPrompt)
+ .handle("executeCommand", executeCommand)
+ .handle("showToast", showToast)
+ .handle("publish", publish)
+ .handle("selectSession", selectSession)
+ .handle("controlNext", controlNext)
+ .handle("controlResponse", controlResponse)
}),
)
diff --git a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts
index c26959601..1c5b4f87d 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/workspace.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/workspace.ts
@@ -3,7 +3,7 @@ import { Workspace } from "@/control-plane/workspace"
import { WorkspaceAdaptorEntry } from "@/control-plane/types"
import * as InstanceState from "@/effect/instance-state"
import { Instance } from "@/project/instance"
-import { Effect, Layer, Schema, Struct } from "effect"
+import { Effect, Schema, Struct } from "effect"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
@@ -107,7 +107,7 @@ export const WorkspaceApi = HttpApi.make("workspace")
}),
)
-export const workspaceHandlers = Layer.unwrap(
+export const workspaceHandlers = HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
Effect.gen(function* () {
const adaptors = Effect.fn("WorkspaceHttpApi.adaptors")(function* () {
const ctx = yield* InstanceState.context
@@ -155,14 +155,12 @@ export const workspaceHandlers = Layer.unwrap(
)
})
- return HttpApiBuilder.group(WorkspaceApi, "workspace", (handlers) =>
- handlers
- .handle("adaptors", adaptors)
- .handle("list", list)
- .handle("create", create)
- .handle("status", status)
- .handle("remove", remove)
- .handle("sessionRestore", sessionRestore),
- )
+ return handlers
+ .handle("adaptors", adaptors)
+ .handle("list", list)
+ .handle("create", create)
+ .handle("status", status)
+ .handle("remove", remove)
+ .handle("sessionRestore", sessionRestore)
}),
)
diff --git a/packages/opencode/test/server/httpapi-bridge.test.ts b/packages/opencode/test/server/httpapi-bridge.test.ts
index 165d0a6c6..7a7105dfa 100644
--- a/packages/opencode/test/server/httpapi-bridge.test.ts
+++ b/packages/opencode/test/server/httpapi-bridge.test.ts
@@ -1,7 +1,9 @@
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/control"
import { FileApi, FilePaths } from "../../src/server/routes/instance/httpapi/file"
+import { GlobalPaths } from "../../src/server/routes/instance/httpapi/global"
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
import { Server } from "../../src/server/server"
import * as Log from "@opencode-ai/core/util/log"
@@ -293,4 +295,55 @@ describe("HttpApi server", () => {
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ content: "query" })
})
+
+ test("serves global health from Effect HttpApi", async () => {
+ const response = await app().request(`${GlobalPaths.health}?directory=/does/not/exist/opencode-test`)
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toMatchObject({ healthy: true })
+ })
+
+ test("serves global event stream from Effect HttpApi", async () => {
+ const response = await app().request(GlobalPaths.event)
+ if (!response.body) throw new Error("missing event stream body")
+ const reader = response.body.getReader()
+ const chunk = await reader.read()
+ await reader.cancel()
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get("content-type")).toContain("text/event-stream")
+ expect(new TextDecoder().decode(chunk.value)).toContain("server.connected")
+ })
+
+ test("serves control log from Effect HttpApi", async () => {
+ const response = await app().request(ControlPaths.log, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ service: "httpapi-test", level: "info", message: "hello" }),
+ })
+
+ expect(response.status).toBe(200)
+ expect(await response.json()).toBe(true)
+ })
+
+ test("validates control auth without falling through to 404", async () => {
+ const response = await app().request(ControlPaths.auth.replace(":providerID", "test"), {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ type: "api" }),
+ })
+
+ expect(response.status).toBe(400)
+ })
+
+ test("validates global upgrade without invoking installers", async () => {
+ const response = await app().request(GlobalPaths.upgrade, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: "not-json",
+ })
+
+ expect(response.status).toBe(400)
+ expect(await response.json()).toMatchObject({ success: false })
+ })
})