From 2a4f2bf527050a2ff89ccf53b65ab605caf883c4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 28 Apr 2026 09:22:49 -0400 Subject: [PATCH] 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. --- .../server/routes/instance/httpapi/sync.ts | 9 ++-- .../opencode/test/server/httpapi-sync.test.ts | 48 ++++++++++++++++++- 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 8e19cdccde..1374518c61 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 75e67db468..692dee002c 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) + } + }) })