refactor(server): clarify HttpApi route auth layers (#26372)

This commit is contained in:
Kit Langton
2026-05-08 13:06:00 -04:00
committed by GitHub
parent 13b3117ca9
commit 3052a79b32
3 changed files with 20 additions and 5 deletions
@@ -20,13 +20,17 @@ import { SyncApi } from "./groups/sync"
import { TuiApi } from "./groups/tui" import { TuiApi } from "./groups/tui"
import { WorkspaceApi } from "./groups/workspace" import { WorkspaceApi } from "./groups/workspace"
import { V2Api } from "./groups/v2" import { V2Api } from "./groups/v2"
import { Authorization } from "./middleware/authorization"
// SSE event schemas built from the same BusEvent/SyncEvent registries that // SSE event schemas built from the same BusEvent/SyncEvent registries that
// the Hono spec uses, so both specs emit identical Event/SyncEvent components. // the Hono spec uses, so both specs emit identical Event/SyncEvent components.
const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" }) const EventSchema = Schema.Union(BusEvent.effectPayloads()).annotate({ identifier: "Event" })
const SyncEventSchemas = SyncEvent.effectPayloads() const SyncEventSchemas = SyncEvent.effectPayloads()
export const RootHttpApi = HttpApi.make("opencode-root").addHttpApi(ControlApi).addHttpApi(GlobalApi) export const RootHttpApi = HttpApi.make("opencode-root")
.addHttpApi(ControlApi)
.addHttpApi(GlobalApi)
.middleware(Authorization)
export const InstanceHttpApi = HttpApi.make("opencode-instance") export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ConfigApi) .addHttpApi(ConfigApi)
@@ -96,9 +96,16 @@ const cors = (corsOptions?: CorsOptions) =>
{ global: true }, { global: true },
) )
// Route tree:
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware.
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe( const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]), Layer.provide([controlHandlers, globalHandlers]),
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), Layer.provide(httpApiAuthLayer),
) )
const instanceRouterLayer = authorizationRouterMiddleware const instanceRouterLayer = authorizationRouterMiddleware
.combine(instanceRouterMiddleware) .combine(instanceRouterMiddleware)
@@ -131,7 +138,7 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer)) const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe( const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([ Layer.provide([
authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)), httpApiAuthLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)), workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer, instanceContextLayer,
]), ]),
@@ -143,7 +150,7 @@ const uiRoute = HttpRouter.use((router) =>
const client = yield* HttpClient.HttpClient const client = yield* HttpClient.HttpClient
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}), }),
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer)))) ).pipe(Layer.provide(authOnlyRouterLayer))
export function createRoutes(corsOptions?: CorsOptions) { export function createRoutes(corsOptions?: CorsOptions) {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
@@ -363,12 +363,16 @@ describe("HttpApi server", () => {
const auth = { authorization: authorization("opencode", "secret") } const auth = { authorization: authorization("opencode", "secret") }
const wrongAuth = { authorization: authorization("opencode", "wrong") } const wrongAuth = { authorization: authorization("opencode", "wrong") }
const [missingConfig, wrongConfig, goodConfig] = await Promise.all([ const [missingHealth, goodHealth, missingConfig, wrongConfig, goodConfig] = await Promise.all([
server.request(GlobalPaths.health),
server.request(GlobalPaths.health, { headers: auth }),
server.request(GlobalPaths.config), server.request(GlobalPaths.config),
server.request(GlobalPaths.config, { headers: wrongAuth }), server.request(GlobalPaths.config, { headers: wrongAuth }),
server.request(GlobalPaths.config, { headers: auth }), server.request(GlobalPaths.config, { headers: auth }),
]) ])
expect(missingHealth.status).toBe(401)
expect(goodHealth.status).toBe(200)
expect(missingConfig.status).toBe(401) expect(missingConfig.status).toBe(401)
expect(wrongConfig.status).toBe(401) expect(wrongConfig.status).toBe(401)
expect(goodConfig.status).toBe(200) expect(goodConfig.status).toBe(200)