Skip to content

Commit 2a4f2bf

Browse files
authored
fix(httpapi): align sync seq validation
Reject negative and fractional sync sequence values in Effect HttpApi schemas so replay/history validation matches the legacy Hono routes.
1 parent aa07f38 commit 2a4f2bf

2 files changed

Lines changed: 52 additions & 5 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/sync.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@ import { not } from "drizzle-orm"
99
import { or } from "drizzle-orm"
1010
import { SyncEvent } from "@/sync"
1111
import { EventTable } from "@/sync/event.sql"
12+
import { NonNegativeInt } from "@/util/schema"
1213
import { Effect, Layer, Schema } from "effect"
13-
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
14+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
1415
import { Authorization } from "./auth"
1516

1617
const root = "/sync"
1718
const ReplayEvent = Schema.Struct({
1819
id: Schema.String,
1920
aggregateID: Schema.String,
20-
seq: Schema.Number,
21+
seq: NonNegativeInt,
2122
type: Schema.String,
2223
data: Schema.Record(Schema.String, Schema.Unknown),
2324
}).annotate({ identifier: "SyncReplayEvent" })
@@ -28,7 +29,7 @@ const ReplayPayload = Schema.Struct({
2829
const ReplayResponse = Schema.Struct({
2930
sessionID: Schema.String,
3031
}).annotate({ identifier: "SyncReplayResponse" })
31-
const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
32+
const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
3233
const HistoryEvent = Schema.Struct({
3334
id: Schema.String,
3435
aggregate_id: Schema.String,
@@ -59,6 +60,7 @@ export const SyncApi = HttpApi.make("sync")
5960
HttpApiEndpoint.post("replay", SyncPaths.replay, {
6061
payload: ReplayPayload,
6162
success: ReplayResponse,
63+
error: HttpApiError.BadRequest,
6264
}).annotateMerge(
6365
OpenApi.annotations({
6466
identifier: "sync.replay",
@@ -69,6 +71,7 @@ export const SyncApi = HttpApi.make("sync")
6971
HttpApiEndpoint.post("history", SyncPaths.history, {
7072
payload: HistoryPayload,
7173
success: Schema.Array(HistoryEvent),
74+
error: HttpApiError.BadRequest,
7275
}).annotateMerge(
7376
OpenApi.annotations({
7477
identifier: "sync.history.list",

packages/opencode/test/server/httpapi-sync.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
1616
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
1717
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
1818

19-
function app() {
20-
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
19+
function app(httpapi = true) {
20+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
2121
return InstanceRoutes(websocket)
2222
}
2323

@@ -81,4 +81,48 @@ describe("sync HttpApi", () => {
8181
expect(replayed.status).toBe(200)
8282
expect(await replayed.json()).toEqual({ sessionID: session.id })
8383
})
84+
85+
test("matches legacy seq validation", async () => {
86+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
87+
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
88+
const cases = [
89+
{
90+
path: SyncPaths.history,
91+
body: { aggregate: -1 },
92+
},
93+
{
94+
path: SyncPaths.history,
95+
body: { aggregate: 1.5 },
96+
},
97+
{
98+
path: SyncPaths.replay,
99+
body: {
100+
directory: tmp.path,
101+
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
102+
},
103+
},
104+
{
105+
path: SyncPaths.replay,
106+
body: {
107+
directory: tmp.path,
108+
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
109+
},
110+
},
111+
]
112+
113+
for (const item of cases) {
114+
const legacy = await app(false).request(item.path, {
115+
method: "POST",
116+
headers,
117+
body: JSON.stringify(item.body),
118+
})
119+
const httpapi = await app(true).request(item.path, {
120+
method: "POST",
121+
headers,
122+
body: JSON.stringify(item.body),
123+
})
124+
expect(httpapi.status).toBe(legacy.status)
125+
expect(httpapi.status).toBe(400)
126+
}
127+
})
84128
})

0 commit comments

Comments
 (0)