summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-28 09:22:49 -0400
committerGitHub <[email protected]>2026-04-28 09:22:49 -0400
commit2a4f2bf527050a2ff89ccf53b65ab605caf883c4 (patch)
tree0f020f306065db4b05a9b9fd2227e68373c9674b
parentaa07f38b0708f306a25d55db8d2123498958f578 (diff)
downloadopencode-2a4f2bf527050a2ff89ccf53b65ab605caf883c4.tar.gz
opencode-2a4f2bf527050a2ff89ccf53b65ab605caf883c4.zip
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.
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/sync.ts9
-rw-r--r--packages/opencode/test/server/httpapi-sync.test.ts48
2 files changed, 52 insertions, 5 deletions
diff --git a/packages/opencode/src/server/routes/instance/httpapi/sync.ts b/packages/opencode/src/server/routes/instance/httpapi/sync.ts
index 8e19cdccd..1374518c6 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/sync.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/sync.ts
@@ -9,15 +9,16 @@ import { not } from "drizzle-orm"
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 { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "./auth"
const root = "/sync"
const ReplayEvent = Schema.Struct({
id: Schema.String,
aggregateID: Schema.String,
- seq: Schema.Number,
+ seq: NonNegativeInt,
type: Schema.String,
data: Schema.Record(Schema.String, Schema.Unknown),
}).annotate({ identifier: "SyncReplayEvent" })
@@ -28,7 +29,7 @@ const ReplayPayload = Schema.Struct({
const ReplayResponse = Schema.Struct({
sessionID: Schema.String,
}).annotate({ identifier: "SyncReplayResponse" })
-const HistoryPayload = Schema.Record(Schema.String, Schema.Number)
+const HistoryPayload = Schema.Record(Schema.String, NonNegativeInt)
const HistoryEvent = Schema.Struct({
id: Schema.String,
aggregate_id: Schema.String,
@@ -59,6 +60,7 @@ export const SyncApi = HttpApi.make("sync")
HttpApiEndpoint.post("replay", SyncPaths.replay, {
payload: ReplayPayload,
success: ReplayResponse,
+ error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.replay",
@@ -69,6 +71,7 @@ export const SyncApi = HttpApi.make("sync")
HttpApiEndpoint.post("history", SyncPaths.history, {
payload: HistoryPayload,
success: Schema.Array(HistoryEvent),
+ error: HttpApiError.BadRequest,
}).annotateMerge(
OpenApi.annotations({
identifier: "sync.history.list",
diff --git a/packages/opencode/test/server/httpapi-sync.test.ts b/packages/opencode/test/server/httpapi-sync.test.ts
index 75e67db46..692dee002 100644
--- a/packages/opencode/test/server/httpapi-sync.test.ts
+++ b/packages/opencode/test/server/httpapi-sync.test.ts
@@ -16,8 +16,8 @@ const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
-function app() {
- Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+function app(httpapi = true) {
+ Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = httpapi
return InstanceRoutes(websocket)
}
@@ -81,4 +81,48 @@ describe("sync HttpApi", () => {
expect(replayed.status).toBe(200)
expect(await replayed.json()).toEqual({ sessionID: session.id })
})
+
+ test("matches legacy seq validation", async () => {
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+ const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+ const cases = [
+ {
+ path: SyncPaths.history,
+ body: { aggregate: -1 },
+ },
+ {
+ path: SyncPaths.history,
+ body: { aggregate: 1.5 },
+ },
+ {
+ path: SyncPaths.replay,
+ body: {
+ directory: tmp.path,
+ events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
+ },
+ },
+ {
+ path: SyncPaths.replay,
+ body: {
+ directory: tmp.path,
+ events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
+ },
+ },
+ ]
+
+ for (const item of cases) {
+ const legacy = await app(false).request(item.path, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(item.body),
+ })
+ const httpapi = await app(true).request(item.path, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(item.body),
+ })
+ expect(httpapi.status).toBe(legacy.status)
+ expect(httpapi.status).toBe(400)
+ }
+ })
})