Skip to content

Commit 250e30b

Browse files
authored
add experimental permission HttpApi slice (#22385)
1 parent e83b221 commit 250e30b

21 files changed

Lines changed: 556 additions & 257 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 27 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,13 @@ Why `question` first:
121121

122122
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
123123

124-
### 4. Run in parallel before replacing
124+
### 4. Build in parallel, do not bridge into Hono
125125

126-
Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare:
126+
The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`.
127127

128-
- handler ergonomics
129-
- OpenAPI output
130-
- auth and middleware integration
131-
- test ergonomics
128+
The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`.
129+
130+
The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes.
132131

133132
### 5. Migrate JSON route groups gradually
134133

@@ -218,17 +217,15 @@ Placement rule:
218217

219218
Suggested file layout for a repeatable spike:
220219

221-
- `src/server/instance/httpapi/question.ts`
222-
- `src/server/instance/httpapi/index.ts`
223-
- `test/server/question-httpapi.test.ts`
224-
- `test/server/question-httpapi-openapi.test.ts`
220+
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
221+
- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups
222+
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
225223

226224
Suggested responsibilities:
227225

228-
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice
229-
- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer
230-
- `question-httpapi.test.ts` proves the route works end-to-end against the real service
231-
- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints
226+
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
227+
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
228+
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
232229

233230
## Example migration shape
234231

@@ -248,11 +245,12 @@ Each route-group spike should follow the same shape.
248245
- keep handler bodies thin
249246
- keep transport mapping at the HTTP boundary only
250247

251-
### 3. Mounting
248+
### 3. Standalone server
252249

253-
- mount under an experimental prefix such as `/experimental/httpapi`
254-
- keep existing Hono routes unchanged
255-
- expose separate OpenAPI output for the experimental slice first
250+
- the Effect HTTP server is self-contained in `httpapi/server.ts`
251+
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
252+
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
253+
- each route group exposes its own OpenAPI doc endpoint
256254

257255
### 4. Verification
258256

@@ -263,53 +261,32 @@ Each route-group spike should follow the same shape.
263261

264262
## Boundary composition
265263

266-
The first slices should keep the existing outer server composition and only replace the route contract and handler layer.
264+
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
267265

268266
### Auth
269267

270-
- keep `AuthMiddleware` at the outer Hono app level
271-
- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices
272-
- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler
273-
274-
Practical rule:
275-
276-
- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack
268+
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
269+
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
270+
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
277271

278272
### Instance and workspace lookup
279273

280-
- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context
281-
- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler
282-
- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them
283-
284-
Practical rule:
285-
286-
- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided
287-
- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself
274+
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
275+
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
276+
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
288277

289278
### Error mapping
290279

291280
- keep domain and service errors typed in the service layer
292281
- declare typed transport errors on the endpoint only when the route can actually return them intentionally
293-
- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior
294-
295-
Practical rule:
296-
297-
- request decoding failures should remain transport-level `400`s
282+
- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically
298283
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors
299-
- unexpected defects can still fall through to the outer error middleware while the slice is experimental
300-
301-
For the current parallel slices, this means:
302-
303-
- auth still composes outside `HttpApi`
304-
- instance selection still composes outside `HttpApi`
305-
- success payloads should be schema-defined from canonical Effect schemas
306-
- known route errors should be modeled at the endpoint boundary incrementally instead of all at once
307284

308285
## Exit criteria for the spike
309286

310287
The first slice is successful if:
311288

312-
- the endpoints run in parallel with the current Hono routes
289+
- the standalone Effect server starts and serves the endpoints independently of the Hono server
313290
- the handlers reuse the existing Effect service
314291
- request decoding and response shapes are schema-defined from canonical Effect schemas
315292
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
@@ -324,8 +301,8 @@ The first parallel `question` spike gave us a concrete pattern to reuse.
324301
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
325302
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
326303
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
327-
- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged.
328-
- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix.
304+
- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged.
305+
- compare generated OpenAPI semantically at the route and schema level.
329306

330307
## Route inventory
331308

packages/opencode/src/agent/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export namespace Agent {
3535
topP: z.number().optional(),
3636
temperature: z.number().optional(),
3737
color: z.string().optional(),
38-
permission: Permission.Ruleset,
38+
permission: Permission.Ruleset.zod,
3939
model: z
4040
.object({
4141
modelID: ModelID.zod,

packages/opencode/src/control-plane/schema.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import { Schema } from "effect"
22
import z from "zod"
33

4-
import { withStatics } from "@/util/schema"
54
import { Identifier } from "@/id/id"
5+
import { ZodOverride } from "@/util/effect-zod"
6+
import { withStatics } from "@/util/schema"
67

7-
const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID"))
8+
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
9+
Schema.brand("WorkspaceID"),
10+
)
811

912
export type WorkspaceID = typeof workspaceIdSchema.Type
1013

packages/opencode/src/permission/index.ts

Lines changed: 83 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,75 +7,84 @@ import { Instance } from "@/project/instance"
77
import { MessageID, SessionID } from "@/session/schema"
88
import { PermissionTable } from "@/session/session.sql"
99
import { Database, eq } from "@/storage/db"
10+
import { zod } from "@/util/effect-zod"
1011
import { Log } from "@/util/log"
12+
import { withStatics } from "@/util/schema"
1113
import { Wildcard } from "@/util/wildcard"
1214
import { Deferred, Effect, Layer, Schema, Context } from "effect"
1315
import os from "os"
14-
import z from "zod"
1516
import { evaluate as evalRule } from "./evaluate"
1617
import { PermissionID } from "./schema"
1718

1819
export namespace Permission {
1920
const log = Log.create({ service: "permission" })
2021

21-
export const Action = z.enum(["allow", "deny", "ask"]).meta({
22-
ref: "PermissionAction",
23-
})
24-
export type Action = z.infer<typeof Action>
25-
26-
export const Rule = z
27-
.object({
28-
permission: z.string(),
29-
pattern: z.string(),
30-
action: Action,
31-
})
32-
.meta({
33-
ref: "PermissionRule",
34-
})
35-
export type Rule = z.infer<typeof Rule>
36-
37-
export const Ruleset = Rule.array().meta({
38-
ref: "PermissionRuleset",
39-
})
40-
export type Ruleset = z.infer<typeof Ruleset>
41-
42-
export const Request = z
43-
.object({
44-
id: PermissionID.zod,
45-
sessionID: SessionID.zod,
46-
permission: z.string(),
47-
patterns: z.string().array(),
48-
metadata: z.record(z.string(), z.any()),
49-
always: z.string().array(),
50-
tool: z
51-
.object({
52-
messageID: MessageID.zod,
53-
callID: z.string(),
54-
})
55-
.optional(),
56-
})
57-
.meta({
58-
ref: "PermissionRequest",
59-
})
60-
export type Request = z.infer<typeof Request>
61-
62-
export const Reply = z.enum(["once", "always", "reject"])
63-
export type Reply = z.infer<typeof Reply>
64-
65-
export const Approval = z.object({
66-
projectID: ProjectID.zod,
67-
patterns: z.string().array(),
68-
})
22+
export const Action = Schema.Literals(["allow", "deny", "ask"])
23+
.annotate({ identifier: "PermissionAction" })
24+
.pipe(withStatics((s) => ({ zod: zod(s) })))
25+
export type Action = Schema.Schema.Type<typeof Action>
26+
27+
export class Rule extends Schema.Class<Rule>("PermissionRule")({
28+
permission: Schema.String,
29+
pattern: Schema.String,
30+
action: Action,
31+
}) {
32+
static readonly zod = zod(this)
33+
}
34+
35+
export const Ruleset = Schema.mutable(Schema.Array(Rule))
36+
.annotate({ identifier: "PermissionRuleset" })
37+
.pipe(withStatics((s) => ({ zod: zod(s) })))
38+
export type Ruleset = Schema.Schema.Type<typeof Ruleset>
39+
40+
export class Request extends Schema.Class<Request>("PermissionRequest")({
41+
id: PermissionID,
42+
sessionID: SessionID,
43+
permission: Schema.String,
44+
patterns: Schema.Array(Schema.String),
45+
metadata: Schema.Record(Schema.String, Schema.Unknown),
46+
always: Schema.Array(Schema.String),
47+
tool: Schema.optional(
48+
Schema.Struct({
49+
messageID: MessageID,
50+
callID: Schema.String,
51+
}),
52+
),
53+
}) {
54+
static readonly zod = zod(this)
55+
}
56+
57+
export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
58+
export type Reply = Schema.Schema.Type<typeof Reply>
59+
60+
const reply = {
61+
reply: Reply,
62+
message: Schema.optional(Schema.String),
63+
}
64+
65+
export const ReplyBody = Schema.Struct(reply)
66+
.annotate({ identifier: "PermissionReplyBody" })
67+
.pipe(withStatics((s) => ({ zod: zod(s) })))
68+
export type ReplyBody = Schema.Schema.Type<typeof ReplyBody>
69+
70+
export class Approval extends Schema.Class<Approval>("PermissionApproval")({
71+
projectID: ProjectID,
72+
patterns: Schema.Array(Schema.String),
73+
}) {
74+
static readonly zod = zod(this)
75+
}
6976

7077
export const Event = {
71-
Asked: BusEvent.define("permission.asked", Request),
78+
Asked: BusEvent.define("permission.asked", Request.zod),
7279
Replied: BusEvent.define(
7380
"permission.replied",
74-
z.object({
75-
sessionID: SessionID.zod,
76-
requestID: PermissionID.zod,
77-
reply: Reply,
78-
}),
81+
zod(
82+
Schema.Struct({
83+
sessionID: SessionID,
84+
requestID: PermissionID,
85+
reply: Reply,
86+
}),
87+
),
7988
),
8089
}
8190

@@ -103,20 +112,27 @@ export namespace Permission {
103112

104113
export type Error = DeniedError | RejectedError | CorrectedError
105114

106-
export const AskInput = Request.partial({ id: true }).extend({
115+
export const AskInput = Schema.Struct({
116+
...Request.fields,
117+
id: Schema.optional(PermissionID),
107118
ruleset: Ruleset,
108119
})
120+
.annotate({ identifier: "PermissionAskInput" })
121+
.pipe(withStatics((s) => ({ zod: zod(s) })))
122+
export type AskInput = Schema.Schema.Type<typeof AskInput>
109123

110-
export const ReplyInput = z.object({
111-
requestID: PermissionID.zod,
112-
reply: Reply,
113-
message: z.string().optional(),
124+
export const ReplyInput = Schema.Struct({
125+
requestID: PermissionID,
126+
...reply,
114127
})
128+
.annotate({ identifier: "PermissionReplyInput" })
129+
.pipe(withStatics((s) => ({ zod: zod(s) })))
130+
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
115131

116132
export interface Interface {
117-
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
118-
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
119-
readonly list: () => Effect.Effect<Request[]>
133+
readonly ask: (input: AskInput) => Effect.Effect<void, Error>
134+
readonly reply: (input: ReplyInput) => Effect.Effect<void>
135+
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
120136
}
121137

122138
interface PendingEntry {
@@ -163,7 +179,7 @@ export namespace Permission {
163179
}),
164180
)
165181

166-
const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
182+
const ask = Effect.fn("Permission.ask")(function* (input: AskInput) {
167183
const { approved, pending } = yield* InstanceState.get(state)
168184
const { ruleset, ...request } = input
169185
let needsAsk = false
@@ -183,10 +199,10 @@ export namespace Permission {
183199
if (!needsAsk) return
184200

185201
const id = request.id ?? PermissionID.ascending()
186-
const info: Request = {
202+
const info = Schema.decodeUnknownSync(Request)({
187203
id,
188204
...request,
189-
}
205+
})
190206
log.info("asking", { id, permission: info.permission, patterns: info.patterns })
191207

192208
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
@@ -200,7 +216,7 @@ export namespace Permission {
200216
)
201217
})
202218

203-
const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
219+
const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) {
204220
const { approved, pending } = yield* InstanceState.get(state)
205221
const existing = pending.get(input.requestID)
206222
if (!existing) return

packages/opencode/src/permission/schema.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ import { Schema } from "effect"
22
import z from "zod"
33

44
import { Identifier } from "@/id/id"
5+
import { ZodOverride } from "@/util/effect-zod"
56
import { Newtype } from "@/util/schema"
67

7-
export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
8+
export class PermissionID extends Newtype<PermissionID>()(
9+
"PermissionID",
10+
Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }),
11+
) {
812
static ascending(id?: string): PermissionID {
913
return this.make(Identifier.ascending("permission", id))
1014
}

packages/opencode/src/pty/schema.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Schema } from "effect"
22
import z from "zod"
33

44
import { Identifier } from "@/id/id"
5+
import { ZodOverride } from "@/util/effect-zod"
56
import { withStatics } from "@/util/schema"
67

7-
const ptyIdSchema = Schema.String.pipe(Schema.brand("PtyID"))
8+
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))
89

910
export type PtyID = typeof ptyIdSchema.Type
1011

0 commit comments

Comments
 (0)