From d69a67e717be91a153c79e91d4db0bdbfe017cdf Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 19:15:30 +0000 Subject: [PATCH 01/70] test: define T4 API v1 client conformance --- .../test/t4-api-v1-conformance-service.ts | 260 ++++++++++++++++++ .../client/test/t4-api-v1-conformance.test.ts | 173 ++++++++++++ 2 files changed, 433 insertions(+) create mode 100644 packages/client/test/t4-api-v1-conformance-service.ts create mode 100644 packages/client/test/t4-api-v1-conformance.test.ts diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts new file mode 100644 index 00000000..d783f171 --- /dev/null +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -0,0 +1,260 @@ +import { expect } from "vite-plus/test"; + +const encoder = new TextEncoder(); + +function json(status: number, body: unknown, headers: Record = {}): Response { + return Response.json(body, { status, headers: { "T4-API-Version": "1.0", ...headers } }); +} + +function problem(status: number, code: string, message: string, extra: Record = {}): Response { + return json(status, { + error: { + code, + message, + requestId: `req-${code}`, + retryable: status >= 500, + ...extra, + }, + }); +} + +function bodyKey(value: unknown): string { + return JSON.stringify(value); +} + +export class T4ApiV1ConformanceService { + readonly origin = "https://t4-api.conformance.test"; + readonly calls: Array<{ method: string; path: string; authorization: string | null }> = []; + readonly abortedWatches: string[] = []; + + #workspaceSequence = 0; + #sessionSequence = 0; + readonly #workspaces = new Map>(); + readonly #sessions = new Map>(); + readonly #replays = new Map }>(); + + readonly fetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const authorization = request.headers.get("authorization"); + this.calls.push({ method: request.method, path: url.pathname, authorization }); + if (url.origin !== this.origin) return problem(400, "invalid_origin", "HTTPS API origin is fixed for this client"); + if (url.protocol !== "https:") return problem(400, "https_required", "HTTPS is required"); + if (authorization !== "Bearer token-a" && authorization !== "Bearer token-b") { + return problem(401, "unauthenticated", "A valid bearer credential is required"); + } + if (request.headers.get("T4-API-Version") !== "1") { + return problem(406, "incompatible_version", "No compatible T4 API major version", { + supportedMajors: [1], + }); + } + const tenant = authorization === "Bearer token-a" ? "tenant-a" : "tenant-b"; + + if (request.method === "GET" && url.pathname === "/v1") { + return json(200, { + apiVersion: "1.0", + supportedMajors: [1], + capabilities: [ + "workspace.lifecycle", + "session.lifecycle", + "session.commands", + "session.watch.sse", + ], + limits: { + pageSizeDefault: 2, + pageSizeMax: 3, + commandBytesMax: 32, + watchEventsMax: 4, + heartbeatSeconds: 15, + }, + }); + } + + if (request.method === "POST" && url.pathname === "/v1/workspaces") { + const body = await request.json() as Record; + if (typeof body.name !== "string" || body.name.length < 1 || body.name.length > 24) { + return problem(422, "invalid_request", "Request validation failed", { + violations: [{ field: "name", rule: "length", message: "name must contain 1 to 24 characters" }], + }); + } + return this.#idempotent(request, tenant, body, () => { + const id = `ws-${++this.#workspaceSequence}`; + const workspace = { id, name: body.name, state: "accepted", revision: 1, tenant }; + this.#workspaces.set(id, workspace); + return workspace; + }); + } + + if (request.method === "GET" && url.pathname === "/v1/workspaces") { + const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) { + return problem(422, "invalid_request", "Request validation failed", { + violations: [{ field: "pageSize", rule: "range", message: "pageSize must be between 1 and 3" }], + }); + } + const start = Number(url.searchParams.get("cursor")?.replace("page-", "") ?? "0"); + const visible = [...this.#workspaces.values()].filter((item) => item.tenant === tenant); + const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); + const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; + return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); + } + + const workspaceMatch = url.pathname.match(/^\/v1\/workspaces\/([^/]+)$/u); + if (workspaceMatch) { + const id = decodeURIComponent(workspaceMatch[1]!); + const workspace = this.#workspaces.get(id); + if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); + if (request.method === "GET") { + const { tenant: _tenant, ...visible } = workspace; + return json(200, visible); + } + if (request.method === "PATCH") { + const body = await request.json() as Record; + if (request.headers.get("If-Match") !== String(workspace.revision)) { + return problem(409, "revision_conflict", "Workspace revision changed"); + } + const updated = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; + this.#workspaces.set(id, updated); + const { tenant: _tenant, ...visible } = updated; + return json(200, visible); + } + if (request.method === "DELETE") { + this.#workspaces.delete(id); + return new Response(null, { status: 204, headers: { "T4-API-Version": "1.0" } }); + } + } + + const sessionsPath = url.pathname.match(/^\/v1\/workspaces\/([^/]+)\/sessions$/u); + if (sessionsPath) { + const workspaceId = decodeURIComponent(sessionsPath[1]!); + const workspace = this.#workspaces.get(workspaceId); + if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); + if (request.method === "POST") { + const body = await request.json() as Record; + return this.#idempotent(request, tenant, body, () => { + const id = `ses-${++this.#sessionSequence}`; + const session = { id, workspaceId, title: body.title, state: "accepted", revision: 1, tenant }; + this.#sessions.set(id, session); + return session; + }); + } + if (request.method === "GET") { + const items = [...this.#sessions.values()] + .filter((item) => item.workspaceId === workspaceId && item.tenant === tenant) + .map(({ tenant: _tenant, ...item }) => item); + return json(200, { items }); + } + } + + const sessionMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)$/u); + if (sessionMatch) { + const id = decodeURIComponent(sessionMatch[1]!); + const session = this.#sessions.get(id); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + if (request.method === "GET") { + const { tenant: _tenant, ...visible } = session; + return json(200, visible); + } + if (request.method === "PATCH") { + const body = await request.json() as Record; + const updated = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; + this.#sessions.set(id, updated); + const { tenant: _tenant, ...visible } = updated; + return json(200, visible); + } + if (request.method === "DELETE") { + this.#sessions.delete(id); + return new Response(null, { status: 204, headers: { "T4-API-Version": "1.0" } }); + } + } + + const cancelMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/cancel$/u); + if (cancelMatch && request.method === "POST") { + const session = this.#sessions.get(decodeURIComponent(cancelMatch[1]!)); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + const cancelled = { ...session, state: "cancelled", revision: Number(session.revision) + 1 }; + this.#sessions.set(String(session.id), cancelled); + const { tenant: _tenant, ...visible } = cancelled; + return json(202, visible); + } + + const commandMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/commands$/u); + if (commandMatch && request.method === "POST") { + const session = this.#sessions.get(decodeURIComponent(commandMatch[1]!)); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + const body = await request.json() as Record; + if (typeof body.command !== "string" || encoder.encode(body.command).byteLength > 32) { + return problem(422, "invalid_request", "Request validation failed", { + violations: [{ field: "command", rule: "maxBytes", message: "command must not exceed 32 UTF-8 bytes" }], + }); + } + const state = ["accepted", "rejected", "conflict", "unavailable", "indeterminate"].includes(body.command) + ? body.command + : "accepted"; + return this.#idempotent(request, tenant, body, () => ({ commandId: `cmd-${body.command}`, state })); + } + + const snapshotMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/snapshot$/u); + if (snapshotMatch && request.method === "GET") { + const session = this.#sessions.get(decodeURIComponent(snapshotMatch[1]!)); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + return json(200, { sessionId: session.id, cursor: "cursor-2", state: session.state, entries: [{ sequence: 2, kind: "output", text: "ready" }] }); + } + + const watchMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/events$/u); + if (watchMatch && request.method === "GET") { + const id = decodeURIComponent(watchMatch[1]!); + const session = this.#sessions.get(id); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + const cursor = url.searchParams.get("cursor") ?? request.headers.get("Last-Event-ID"); + if (cursor === "expired") { + return problem(410, "cursor_expired", "Watch cursor is no longer retained", { + resync: { snapshotUrl: `/v1/sessions/${id}/snapshot`, cursor: "cursor-2" }, + }); + } + const frames = [ + `id: cursor-3\nevent: heartbeat\ndata: {"type":"heartbeat","cursor":"cursor-3","observedAt":"2026-07-21T00:00:00Z"}\n\n`, + `id: cursor-4\nevent: session\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\n\n`, + ]; + const stream = new ReadableStream({ + start: (controller) => { + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + request.signal.addEventListener("abort", () => { + this.abortedWatches.push(id); + try { controller.close(); } catch { /* already closed */ } + }, { once: true }); + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "T4-API-Version": "1.0" }, + }); + } + + return problem(404, "not_found", "Resource not found"); + }; + + expectNoCredentialLeak(): void { + expect(this.calls.every((call) => call.authorization === "Bearer token-a" || call.authorization === "Bearer token-b")).toBe(true); + } + + #idempotent( + request: Request, + tenant: string, + body: Record, + create: () => Record, + ): Response { + const key = request.headers.get("Idempotency-Key"); + if (!key) return problem(400, "idempotency_key_required", "Idempotency-Key is required"); + const replayKey = `${tenant}:${request.method}:${new URL(request.url).pathname}:${key}`; + const prior = this.#replays.get(replayKey); + if (prior) { + if (prior.body !== bodyKey(body)) return problem(409, "idempotency_conflict", "Idempotency key was reused with a different request"); + return json(200, prior.response, { "Idempotency-Replayed": "true" }); + } + const response = create(); + const { tenant: _tenant, ...visible } = response; + this.#replays.set(replayKey, { body: bodyKey(body), response: visible }); + return json(202, visible, { "Idempotency-Replayed": "false" }); + } +} diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts new file mode 100644 index 00000000..4eb69701 --- /dev/null +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + T4ApiError, + createT4ApiClient, + type components, +} from "@t4-code/t4-api-client"; +import { T4ApiV1ConformanceService } from "./t4-api-v1-conformance-service.ts"; + +type WorkspaceCreate = components["schemas"]["WorkspaceCreate"]; +type SessionCreate = components["schemas"]["SessionCreate"]; +type CommandCreate = components["schemas"]["CommandCreate"]; + +function requireData(result: { data?: T; error?: unknown }): T { + expect(result.error).toBeUndefined(); + expect(result.data).toBeDefined(); + return result.data!; +} + +describe("generated T4 API v1 client conformance", () => { + it("negotiates discovery, capabilities, bounds, and rejects an incompatible major", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const discovery = requireData(await client.http.GET("/v1")); + expect(discovery).toMatchObject({ + apiVersion: "1.0", + supportedMajors: [1], + capabilities: expect.arrayContaining(["workspace.lifecycle", "session.watch.sse"]), + limits: { pageSizeMax: 3, commandBytesMax: 32, watchEventsMax: 4, heartbeatSeconds: 15 }, + }); + + const incompatible = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 2, fetch: service.fetch }); + const rejected = await incompatible.http.GET("/v1"); + expect(rejected.response.status).toBe(406); + expect(rejected.error).toMatchObject({ error: { code: "incompatible_version", retryable: false, supportedMajors: [1] } }); + }); + + it("creates, replays, conflicts, mutates, lists, isolates, and deletes bounded workspaces", async () => { + const service = new T4ApiV1ConformanceService(); + const owner = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const other = createT4ApiClient({ baseUrl: service.origin, credential: "token-b", majorVersion: 1, fetch: service.fetch }); + const body = { name: "primary" } satisfies WorkspaceCreate; + const first = await owner.http.POST("/v1/workspaces", { body, headers: { "Idempotency-Key": "workspace-create-1" } }); + expect(first.response.status).toBe(202); + const workspace = requireData(first); + expect(workspace).toMatchObject({ id: "ws-1", state: "accepted", revision: 1 }); + expect(workspace).not.toHaveProperty("tenant"); + + const replay = await owner.http.POST("/v1/workspaces", { body, headers: { "Idempotency-Key": "workspace-create-1" } }); + expect(replay.response.status).toBe(200); + expect(replay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(replay.data).toEqual(workspace); + const conflict = await owner.http.POST("/v1/workspaces", { + body: { name: "different" }, + headers: { "Idempotency-Key": "workspace-create-1" }, + }); + expect(conflict.response.status).toBe(409); + expect(conflict.error).toMatchObject({ error: { code: "idempotency_conflict", retryable: false } }); + + for (const name of ["second", "third", "fourth"]) { + requireData(await owner.http.POST("/v1/workspaces", { body: { name }, headers: { "Idempotency-Key": `workspace-${name}` } })); + } + const pageOne = requireData(await owner.http.GET("/v1/workspaces", { params: { query: { pageSize: 2 } } })); + expect(pageOne.items).toHaveLength(2); + expect(pageOne.nextCursor).toBe("page-2"); + const pageTwo = requireData(await owner.http.GET("/v1/workspaces", { params: { query: { pageSize: 2, cursor: pageOne.nextCursor } } })); + expect(pageTwo.items).toHaveLength(2); + expect(pageTwo.nextCursor).toBeUndefined(); + + const isolated = await other.http.GET("/v1/workspaces/ws-1", { params: { path: { workspaceId: "ws-1" } } }); + expect(isolated.response.status).toBe(404); + expect(isolated.error).toMatchObject({ error: { code: "not_found" } }); + const invalid = await owner.http.POST("/v1/workspaces", { body: { name: "x".repeat(25) }, headers: { "Idempotency-Key": "invalid" } }); + expect(invalid.response.status).toBe(422); + expect(invalid.error).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "name", rule: "length" }] } }); + + const updated = requireData(await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { path: { workspaceId: "ws-1" } }, body: { name: "renamed" }, headers: { "If-Match": "1" }, + })); + expect(updated).toMatchObject({ name: "renamed", revision: 2 }); + const stale = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { path: { workspaceId: "ws-1" } }, body: { name: "stale" }, headers: { "If-Match": "1" }, + }); + expect(stale.response.status).toBe(409); + expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); + expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { params: { path: { workspaceId: "ws-1" } } })).response.status).toBe(204); + service.expectNoCredentialLeak(); + }); + + it("spawns and mutates sessions, submits idempotent commands, and exposes stable states", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + requireData(await client.http.POST("/v1/workspaces", { body: { name: "workspace" }, headers: { "Idempotency-Key": "workspace" } })); + const body = { title: "agent" } satisfies SessionCreate; + const first = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { path: { workspaceId: "ws-1" } }, body, headers: { "Idempotency-Key": "spawn-1" }, + }); + expect(first.response.status).toBe(202); + const session = requireData(first); + const replay = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { path: { workspaceId: "ws-1" } }, body, headers: { "Idempotency-Key": "spawn-1" }, + }); + expect(replay.data).toEqual(session); + const listed = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { + params: { path: { workspaceId: "ws-1" } }, + })); + expect(listed.items).toEqual([session]); + const mutated = requireData(await client.http.PATCH("/v1/sessions/{sessionId}", { + params: { path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, headers: { "If-Match": "1" }, + })); + expect(mutated).toMatchObject({ title: "renamed-agent", revision: 2 }); + + for (const state of ["accepted", "rejected", "conflict", "unavailable", "indeterminate"] as const) { + const command = { command: state } satisfies CommandCreate; + const result = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { path: { sessionId: "ses-1" } }, body: command, headers: { "Idempotency-Key": `command-${state}` }, + }); + expect(requireData(result).state).toBe(state); + const commandReplay = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { path: { sessionId: "ses-1" } }, body: command, headers: { "Idempotency-Key": `command-${state}` }, + }); + expect(commandReplay.data).toEqual(result.data); + expect(commandReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + } + const oversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { path: { sessionId: "ses-1" } }, body: { command: "a".repeat(33) }, headers: { "Idempotency-Key": "oversized" }, + }); + expect(oversized.response.status).toBe(422); + expect(oversized.error).toMatchObject({ error: { violations: [{ field: "command", rule: "maxBytes" }] } }); + const cancelled = await client.http.POST("/v1/sessions/{sessionId}/cancel", { params: { path: { sessionId: "ses-1" } } }); + expect(cancelled.response.status).toBe(202); + expect(cancelled.data).toMatchObject({ state: "cancelled" }); + }); + + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect cursor, cancellation, and typed resync", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + requireData(await client.http.POST("/v1/workspaces", { body: { name: "workspace" }, headers: { "Idempotency-Key": "workspace" } })); + requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { path: { workspaceId: "ws-1" } }, body: { title: "agent" }, headers: { "Idempotency-Key": "session" }, + })); + const snapshot = requireData(await client.http.GET("/v1/sessions/{sessionId}/snapshot", { params: { path: { sessionId: "ses-1" } } })); + expect(snapshot).toMatchObject({ sessionId: "ses-1", cursor: "cursor-2", entries: [{ sequence: 2 }] }); + + const controller = new AbortController(); + const received: Array = []; + for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 2, signal: controller.signal })) { + received.push(event); + if (received.length === 2) controller.abort(); + } + expect(received).toEqual([ + expect.objectContaining({ type: "heartbeat", cursor: "cursor-3" }), + expect.objectContaining({ type: "session", cursor: "cursor-4", state: "accepted" }), + ]); + expect(service.calls.at(-1)?.path).toBe("/v1/sessions/ses-1/events"); + expect(service.abortedWatches).toEqual(["ses-1"]); + + const reconnect = client.watchSession("ses-1", { cursor: received.at(-1)!.cursor, maxEvents: 1 }); + expect((await reconnect.next()).value).toMatchObject({ type: "heartbeat", cursor: "cursor-3" }); + await reconnect.return(undefined); + + await expect(async () => { + for await (const _event of client.watchSession("ses-1", { cursor: "expired", maxEvents: 1 })) { + throw new Error("expired cursor unexpectedly produced an event"); + } + }).rejects.toMatchObject({ + name: "T4ApiError", + code: "cursor_expired", + status: 410, + resync: { snapshotUrl: "/v1/sessions/ses-1/snapshot", cursor: "cursor-2" }, + } satisfies Partial); + }); +}); From 6058f1c8ff85a8b1053688e178952c1775bf65ed Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 19:24:01 +0000 Subject: [PATCH 02/70] feat: define T4 API v1 contract and SDK boundary --- .woodpecker.yml | 52 +- packages/client/package.json | 1 + packages/t4-api-client/package.json | 23 + packages/t4-api-client/src/index.ts | 317 ++++++++ packages/t4-api-client/tsconfig.json | 7 + packages/t4-api-contract/openapi.json | 683 ++++++++++++++++++ packages/t4-api-contract/package.json | 22 + packages/t4-api-contract/scripts/generate.mjs | 44 ++ packages/t4-api-contract/scripts/validate.mjs | 20 + packages/t4-api-contract/tsconfig.json | 7 + 10 files changed, 1175 insertions(+), 1 deletion(-) create mode 100644 packages/t4-api-client/package.json create mode 100644 packages/t4-api-client/src/index.ts create mode 100644 packages/t4-api-client/tsconfig.json create mode 100644 packages/t4-api-contract/openapi.json create mode 100644 packages/t4-api-contract/package.json create mode 100644 packages/t4-api-contract/scripts/generate.mjs create mode 100644 packages/t4-api-contract/scripts/validate.mjs create mode 100644 packages/t4-api-contract/tsconfig.json diff --git a/.woodpecker.yml b/.woodpecker.yml index 4f8c556e..fb611ce0 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -12,7 +12,35 @@ clone: partial: false steps: + t4-api-artifact-bootstrap: + image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 + commands: + - corepack enable + - pnpm install --no-frozen-lockfile + - echo T4_API_LOCKFILE_BASE64_BEGIN + - base64 -w0 pnpm-lock.yaml + - echo + - echo T4_API_LOCKFILE_BASE64_END + - pnpm --filter @t4-code/t4-api-contract validate + - pnpm --filter @t4-code/t4-api-contract generate:ci + when: + - event: push + branch: agent/t4-op03-public-api-contract + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 512Mi + ephemeral-storage: 1Gi + limits: + cpu: "1" + memory: 2Gi + ephemeral-storage: 4Gi + dependencies: + depends_on: [t4-api-artifact-bootstrap] image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 commands: - corepack enable @@ -31,6 +59,28 @@ steps: memory: 2Gi ephemeral-storage: 4Gi + t4-api-contract: + depends_on: [dependencies] + image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 + commands: + - corepack enable + - pnpm --filter @t4-code/t4-api-contract validate + - pnpm --filter @t4-code/t4-api-contract generate:ci + - pnpm --filter @t4-code/t4-api-client typecheck + - pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts + backend_options: + kubernetes: + serviceAccountName: woodpecker-ci-untrusted + resources: + requests: + cpu: "0" + memory: 256Mi + ephemeral-storage: 256Mi + limits: + cpu: "1" + memory: 2Gi + ephemeral-storage: 2Gi + bun-runtime: depends_on: [dependencies] image: mirror.gcr.io/oven/bun:1.3.14@sha256:e10577f0db68676a7024391c6e5cb4b879ebd17188ab750cf10024a6d700e5c4 @@ -50,7 +100,7 @@ steps: ephemeral-storage: 256Mi upstream-core: - depends_on: [cluster-ci-contracts, bun-runtime] + depends_on: [cluster-ci-contracts, t4-api-contract, bun-runtime] image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 commands: - export PATH="$PWD/.ci:$PATH" diff --git a/packages/client/package.json b/packages/client/package.json index c960a2b3..8e29f7c2 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -17,6 +17,7 @@ "devDependencies": { "@t4-code/fixture-server": "workspace:*", "@t4-code/host-wire": "workspace:*", + "@t4-code/t4-api-client": "workspace:*", "@types/node": "catalog:", "@types/ws": "8.5.13", "vite-plus": "catalog:", diff --git a/packages/t4-api-client/package.json b/packages/t4-api-client/package.json new file mode 100644 index 00000000..9c844512 --- /dev/null +++ b/packages/t4-api-client/package.json @@ -0,0 +1,23 @@ +{ + "name": "@t4-code/t4-api-client", + "version": "0.1.30", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./generated": "./src/generated/schema.ts" + }, + "scripts": { + "build": "tsgo --noEmit", + "typecheck": "tsgo --noEmit", + "test": "vp test run --passWithNoTests" + }, + "dependencies": { + "openapi-fetch": "0.17.0" + }, + "devDependencies": { + "@types/node": "catalog:", + "typescript": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts new file mode 100644 index 00000000..aba5baab --- /dev/null +++ b/packages/t4-api-client/src/index.ts @@ -0,0 +1,317 @@ +import createClient, { type Client } from "openapi-fetch"; + +import type { components, paths } from "./generated/schema.ts"; + +export type { components, operations, paths } from "./generated/schema.ts"; + +const MAX_CREDENTIAL_LENGTH = 4096; +const MAX_ERROR_BYTES = 1024 * 1024; +const MAX_EVENT_BYTES = 1024 * 1024; +const SESSION_STATES = new Set([ + "accepted", + "provisioning", + "ready", + "cancelling", + "cancelled", + "failed", + "unavailable", + "indeterminate", +]); +const OPERATION_STATES = new Set([ + "accepted", + "rejected", + "conflict", + "unavailable", + "indeterminate", +]); + +type ApiError = components["schemas"]["ApiError"]; +type Resync = components["schemas"]["Resync"]; +type WatchEvent = components["schemas"]["WatchEvent"]; + +export interface T4ApiClientOptions { + readonly baseUrl: string; + readonly credential: string; + readonly majorVersion: number; + readonly fetch?: typeof globalThis.fetch; +} + +export interface WatchSessionOptions { + readonly cursor?: components["schemas"]["Cursor"]; + readonly maxEvents?: number; + readonly heartbeatSeconds?: number; + readonly signal?: AbortSignal; +} + +export interface T4ApiClient { + readonly http: Client; + watchSession(sessionId: string, options?: WatchSessionOptions): AsyncGenerator; +} + +export class T4ApiError extends Error { + readonly code: ApiError["code"]; + readonly status: number; + readonly requestId: string; + readonly retryable: boolean; + readonly violations?: ApiError["violations"]; + readonly supportedMajors?: ApiError["supportedMajors"]; + readonly resync?: Resync; + + constructor(status: number, error: ApiError) { + super(error.message); + this.name = "T4ApiError"; + this.code = error.code; + this.status = status; + this.requestId = error.requestId; + this.retryable = error.retryable; + if (error.violations !== undefined) this.violations = error.violations; + if (error.supportedMajors !== undefined) this.supportedMajors = error.supportedMajors; + if (error.resync !== undefined) this.resync = error.resync; + } +} + +function normalizedBaseUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw new TypeError("T4 API baseUrl must be an absolute HTTPS URL", { cause: error }); + } + if ( + url.protocol !== "https:" || + url.username !== "" || + url.password !== "" || + url.search !== "" || + url.hash !== "" + ) { + throw new TypeError("T4 API baseUrl must be a credential-free HTTPS URL without query or fragment"); + } + url.pathname = url.pathname.replace(/\/+$/u, ""); + return url.toString().replace(/\/$/u, ""); +} + +function requiredCredential(value: string): string { + if ( + value.length === 0 || + value.length > MAX_CREDENTIAL_LENGTH || + !/^[A-Za-z0-9._~+/-]+=*$/u.test(value) + ) { + throw new TypeError("credential must be an opaque bearer token of at most 4096 characters"); + } + return value; +} + +function requiredMajor(value: number): string { + if (!Number.isSafeInteger(value) || value < 1 || value > 9999) { + throw new TypeError("majorVersion must be an integer between 1 and 9999"); + } + return String(value); +} + +function boundedInteger(value: number | undefined, fallback: number, minimum: number, maximum: number, label: string): number { + const selected = value ?? fallback; + if (!Number.isSafeInteger(selected) || selected < minimum || selected > maximum) { + throw new RangeError(`${label} must be an integer between ${minimum} and ${maximum}`); + } + return selected; +} + +function requiredSessionId(value: string): string { + if (value.length < 1 || value.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value)) { + throw new TypeError("sessionId is invalid"); + } + return value; +} + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +function apiError(value: unknown): ApiError | undefined { + const envelope = record(value); + const error = record(envelope?.error); + if ( + error === undefined || + typeof error.code !== "string" || + typeof error.message !== "string" || + typeof error.requestId !== "string" || + typeof error.retryable !== "boolean" + ) return undefined; + return error as ApiError; +} + +async function boundedError(response: Response): Promise { + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength <= MAX_ERROR_BYTES) { + try { + const decoded = apiError(JSON.parse(text)); + if (decoded !== undefined) return new T4ApiError(response.status, decoded); + } catch { + // Fall through to the stable indeterminate transport envelope. + } + } + return new T4ApiError(response.status, { + code: response.status === 503 ? "unavailable" : "indeterminate", + message: "T4 API returned an invalid or oversized error envelope", + requestId: "unavailable", + retryable: response.status >= 500, + }); +} + +function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { + const event = record(value); + if ( + event === undefined || + typeof event.type !== "string" || + typeof event.cursor !== "string" || + event.cursor.length < 1 || + event.cursor.length > 512 || + (eventId !== undefined && eventId !== event.cursor) + ) throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid watch event", requestId: "unavailable", retryable: true }); + if (event.type === "heartbeat" && typeof event.observedAt === "string" && event.observedAt.length <= 64) { + return event as WatchEvent; + } + if ( + event.type === "session" && + typeof event.state === "string" && + SESSION_STATES.has(event.state as components["schemas"]["SessionState"]) && + Number.isSafeInteger(event.revision) && Number(event.revision) >= 1 + ) return event as WatchEvent; + if ( + event.type === "command" && + typeof event.commandId === "string" && + event.commandId.length >= 1 && event.commandId.length <= 128 && + typeof event.state === "string" && + OPERATION_STATES.has(event.state as components["schemas"]["OperationState"]) + ) return event as WatchEvent; + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid watch event", requestId: "unavailable", retryable: true }); +} + +function decodeSseFrame(frame: string): WatchEvent | undefined { + let eventId: string | undefined; + const data: string[] = []; + for (const rawLine of frame.split("\n")) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + if (line === "" || line.startsWith(":")) continue; + const separator = line.indexOf(":"); + const field = separator < 0 ? line : line.slice(0, separator); + const rawValue = separator < 0 ? "" : line.slice(separator + 1); + const value = rawValue.startsWith(" ") ? rawValue.slice(1) : rawValue; + if (field === "id") eventId = value; + else if (field === "data") data.push(value); + } + if (data.length === 0) return undefined; + try { + return watchEvent(JSON.parse(data.join("\n")), eventId); + } catch (error) { + if (error instanceof T4ApiError) throw error; + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned malformed SSE data", requestId: "unavailable", retryable: true }); + } +} + +async function* watch( + baseUrl: string, + credential: string, + majorVersion: string, + fetchImpl: typeof globalThis.fetch, + sessionIdValue: string, + options: WatchSessionOptions, +): AsyncGenerator { + const sessionId = requiredSessionId(sessionIdValue); + const maxEvents = boundedInteger(options.maxEvents, 100, 1, 1000, "maxEvents"); + const heartbeatSeconds = boundedInteger(options.heartbeatSeconds, 15, 5, 60, "heartbeatSeconds"); + if (options.cursor !== undefined && (options.cursor.length < 1 || options.cursor.length > 512)) { + throw new TypeError("cursor must contain between 1 and 512 characters"); + } + const url = new URL(`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events`); + url.searchParams.set("maxEvents", String(maxEvents)); + url.searchParams.set("heartbeatSeconds", String(heartbeatSeconds)); + if (options.cursor !== undefined) url.searchParams.set("cursor", options.cursor); + const controller = new AbortController(); + const abort = (): void => controller.abort(options.signal?.reason); + if (options.signal?.aborted === true) abort(); + else options.signal?.addEventListener("abort", abort, { once: true }); + let reader: ReadableStreamDefaultReader | undefined; + try { + const headers = new Headers({ + Accept: "text/event-stream", + Authorization: `Bearer ${credential}`, + "Cache-Control": "no-store", + "T4-API-Version": majorVersion, + }); + if (options.cursor !== undefined) headers.set("Last-Event-ID", options.cursor); + const response = await fetchImpl(url, { method: "GET", headers, signal: controller.signal }); + if (!response.ok) throw await boundedError(response); + if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch did not return text/event-stream", requestId: "unavailable", retryable: true }); + } + if (response.body === null) { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch response body is unavailable", requestId: "unavailable", retryable: true }); + } + reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let buffer = ""; + let delivered = 0; + while (delivered < maxEvents) { + if (controller.signal.aborted) return; + let chunk: ReadableStreamReadResult; + try { + chunk = await reader.read(); + } catch (error) { + if (controller.signal.aborted) return; + throw error; + } + if (chunk.done) { + buffer += decoder.decode(); + break; + } + buffer += decoder.decode(chunk.value, { stream: true }); + if (new TextEncoder().encode(buffer).byteLength > MAX_EVENT_BYTES) { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch event exceeds the client bound", requestId: "unavailable", retryable: true }); + } + let boundary = buffer.indexOf("\n\n"); + while (boundary >= 0 && delivered < maxEvents) { + const frame = buffer.slice(0, boundary); + buffer = buffer.slice(boundary + 2); + const event = decodeSseFrame(frame); + if (event !== undefined) { + delivered += 1; + yield event; + } + boundary = buffer.indexOf("\n\n"); + } + } + if (buffer.trim().length > 0 && delivered < maxEvents) { + const event = decodeSseFrame(buffer); + if (event !== undefined) yield event; + } + } finally { + options.signal?.removeEventListener("abort", abort); + controller.abort(); + if (reader !== undefined) { + try { await reader.cancel(); } catch { /* cancellation is best effort */ } + } + } +} + +export function createT4ApiClient(options: T4ApiClientOptions): T4ApiClient { + const baseUrl = normalizedBaseUrl(options.baseUrl); + const credential = requiredCredential(options.credential); + const majorVersion = requiredMajor(options.majorVersion); + const fetchImpl = options.fetch ?? globalThis.fetch; + const authenticatedFetch: typeof globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + request.headers.set("Authorization", `Bearer ${credential}`); + request.headers.set("T4-API-Version", majorVersion); + request.headers.set("Accept", "application/json"); + return await fetchImpl(request); + }; + const http = createClient({ baseUrl, fetch: authenticatedFetch }); + return Object.freeze({ + http, + watchSession: (sessionId: string, watchOptions: WatchSessionOptions = {}) => + watch(baseUrl, credential, majorVersion, fetchImpl, sessionId, watchOptions), + }); +} diff --git a/packages/t4-api-client/tsconfig.json b/packages/t4-api-client/tsconfig.json new file mode 100644 index 00000000..824d57c3 --- /dev/null +++ b/packages/t4-api-client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json new file mode 100644 index 00000000..863bdd88 --- /dev/null +++ b/packages/t4-api-contract/openapi.json @@ -0,0 +1,683 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "T4 API", + "version": "1.0.0", + "summary": "Authenticated public API for bounded T4 workspace and session lifecycle", + "description": "The public boundary for T4 clients. The service is not assumed to be publicly exposed. Every operation requires an opaque bearer credential and deny-by-default server-side scope authorization. Kubernetes, PostgreSQL, controller, runtime, and private OMP representations are never exposed." + }, + "servers": [{ "url": "https://t4.example.invalid", "description": "Operator-configured private HTTPS endpoint" }], + "security": [{ "BearerAuth": [] }], + "tags": [ + { "name": "Discovery" }, + { "name": "Workspaces" }, + { "name": "Sessions" }, + { "name": "Watch" } + ], + "paths": { + "/v1": { + "get": { + "operationId": "discoverV1", + "tags": ["Discovery"], + "summary": "Negotiate T4 API v1 and discover capabilities and bounds", + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "200": { "$ref": "#/components/responses/Discovery" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/workspaces": { + "get": { + "operationId": "listWorkspaces", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/PageSize" }, + { "$ref": "#/components/parameters/PageCursor" } + ], + "responses": { + "200": { "$ref": "#/components/responses/WorkspacePage" }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + }, + "post": { + "operationId": "createWorkspace", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceCreate" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/WorkspaceReplay" }, + "202": { "$ref": "#/components/responses/WorkspaceAccepted" }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/workspaces/{workspaceId}": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], + "get": { + "operationId": "getWorkspace", + "tags": ["Workspaces"], + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "200": { "$ref": "#/components/responses/Workspace" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + }, + "patch": { + "operationId": "mutateWorkspace", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IfMatch" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceMutation" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/Workspace" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + }, + "delete": { + "operationId": "deleteWorkspace", + "tags": ["Workspaces"], + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "204": { "$ref": "#/components/responses/Deleted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/workspaces/{workspaceId}/sessions": { + "parameters": [{ "$ref": "#/components/parameters/WorkspaceId" }], + "get": { + "operationId": "listSessions", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/PageSize" }, + { "$ref": "#/components/parameters/PageCursor" } + ], + "responses": { + "200": { "$ref": "#/components/responses/SessionPage" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + }, + "post": { + "operationId": "spawnSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionCreate" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/SessionReplay" }, + "202": { "$ref": "#/components/responses/SessionAccepted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/sessions/{sessionId}": { + "parameters": [{ "$ref": "#/components/parameters/SessionId" }], + "get": { + "operationId": "getSession", + "tags": ["Sessions"], + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "200": { "$ref": "#/components/responses/Session" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + }, + "patch": { + "operationId": "mutateSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IfMatch" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionMutation" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/Session" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + }, + "delete": { + "operationId": "deleteSession", + "tags": ["Sessions"], + "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "responses": { + "204": { "$ref": "#/components/responses/Deleted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/sessions/{sessionId}/cancel": { + "post": { + "operationId": "cancelSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" } + ], + "responses": { + "202": { "$ref": "#/components/responses/SessionAccepted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/sessions/{sessionId}/commands": { + "post": { + "operationId": "submitCommand", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "requestBody": { + "required": true, + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandCreate" } } } + }, + "responses": { + "200": { "$ref": "#/components/responses/CommandReplay" }, + "202": { "$ref": "#/components/responses/CommandAccepted" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/sessions/{sessionId}/snapshot": { + "get": { + "operationId": "getSessionSnapshot", + "tags": ["Watch"], + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" } + ], + "responses": { + "200": { "$ref": "#/components/responses/Snapshot" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + }, + "/v1/sessions/{sessionId}/events": { + "get": { + "operationId": "watchSessionEvents", + "tags": ["Watch"], + "summary": "Watch a bounded Server-Sent Events stream over HTTPS", + "description": "The response is standard Server-Sent Events over HTTPS. Clients reconnect with the last event cursor in the cursor query parameter or Last-Event-ID header. Heartbeat events bound idle detection. Clients explicitly cancel by aborting the HTTPS request. A retained cursor expiry returns a typed 410 response with a snapshot resync target.", + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/WatchCursor" }, + { "$ref": "#/components/parameters/LastEventId" }, + { "$ref": "#/components/parameters/MaxEvents" }, + { "$ref": "#/components/parameters/HeartbeatSeconds" } + ], + "responses": { + "200": { + "description": "Bounded SSE event stream; each data field is a WatchEvent JSON value", + "headers": { + "Cache-Control": { "schema": { "type": "string", "const": "no-store" } }, + "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } + }, + "content": { + "text/event-stream": { + "schema": { "type": "string" }, + "example": "id: cur_01J...\\nevent: heartbeat\\ndata: {\"type\":\"heartbeat\",\"cursor\":\"cur_01J...\",\"observedAt\":\"2026-07-21T00:00:00Z\"}\\n\\n" + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthenticated" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "406": { "$ref": "#/components/responses/IncompatibleVersion" }, + "410": { "$ref": "#/components/responses/CursorExpired" }, + "422": { "$ref": "#/components/responses/InvalidRequest" }, + "503": { "$ref": "#/components/responses/Unavailable" } + } + } + } + }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "description": "Opaque bearer credential. The server maps it to deny-by-default tenant and resource scopes; clients must not inspect or persist credential contents in API resources." + } + }, + "parameters": { + "ApiVersion": { + "name": "T4-API-Version", + "in": "header", + "required": true, + "description": "Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading.", + "schema": { "type": "string", "pattern": "^1(?:\\.[0-9]+)?$", "maxLength": 16 } + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "description": "Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts.", + "schema": { "type": "string", "minLength": 16, "maxLength": 128, "pattern": "^[A-Za-z0-9._~-]+$" } + }, + "IfMatch": { + "name": "If-Match", + "in": "header", + "required": true, + "description": "Decimal resource revision for optimistic mutation.", + "schema": { "type": "string", "pattern": "^[1-9][0-9]{0,18}$" } + }, + "WorkspaceId": { + "name": "workspaceId", + "in": "path", + "required": true, + "schema": { "$ref": "#/components/schemas/ResourceId" } + }, + "SessionId": { + "name": "sessionId", + "in": "path", + "required": true, + "schema": { "$ref": "#/components/schemas/ResourceId" } + }, + "PageSize": { + "name": "pageSize", + "in": "query", + "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 } + }, + "PageCursor": { + "name": "cursor", + "in": "query", + "description": "Opaque page cursor, valid only for the same identity and list operation.", + "schema": { "$ref": "#/components/schemas/Cursor" } + }, + "WatchCursor": { + "name": "cursor", + "in": "query", + "description": "Opaque last-consumed watch event cursor.", + "schema": { "$ref": "#/components/schemas/Cursor" } + }, + "LastEventId": { + "name": "Last-Event-ID", + "in": "header", + "description": "Standard SSE reconnect header. Must agree with cursor when both are present.", + "schema": { "$ref": "#/components/schemas/Cursor" } + }, + "MaxEvents": { + "name": "maxEvents", + "in": "query", + "schema": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 100 } + }, + "HeartbeatSeconds": { + "name": "heartbeatSeconds", + "in": "query", + "schema": { "type": "integer", "minimum": 5, "maximum": 60, "default": 15 } + } + }, + "headers": { + "SelectedVersion": { + "description": "Selected compatible T4 API minor version.", + "schema": { "type": "string", "pattern": "^1\\.[0-9]+$" } + }, + "IdempotencyReplayed": { + "description": "True when this response replays a prior identical request.", + "schema": { "type": "string", "enum": ["true", "false"] } + } + }, + "schemas": { + "ResourceId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" }, + "Cursor": { "type": "string", "minLength": 1, "maxLength": 512, "description": "Opaque server-issued cursor." }, + "Revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, + "OperationState": { "type": "string", "enum": ["accepted", "rejected", "conflict", "unavailable", "indeterminate"] }, + "WorkspaceState": { "type": "string", "enum": ["accepted", "provisioning", "ready", "deleting", "deleted", "failed", "unavailable", "indeterminate"] }, + "SessionState": { "type": "string", "enum": ["accepted", "provisioning", "ready", "cancelling", "cancelled", "failed", "unavailable", "indeterminate"] }, + "Discovery": { + "type": "object", + "additionalProperties": false, + "required": ["apiVersion", "supportedMajors", "capabilities", "limits"], + "properties": { + "apiVersion": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "supportedMajors": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } }, + "capabilities": { "type": "array", "maxItems": 128, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9.-]*$" } }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": ["pageSizeDefault", "pageSizeMax", "commandBytesMax", "watchEventsMax", "heartbeatSeconds"], + "properties": { + "pageSizeDefault": { "type": "integer", "minimum": 1, "maximum": 100 }, + "pageSizeMax": { "type": "integer", "minimum": 1, "maximum": 100 }, + "commandBytesMax": { "type": "integer", "minimum": 1, "maximum": 1048576 }, + "watchEventsMax": { "type": "integer", "minimum": 1, "maximum": 1000 }, + "heartbeatSeconds": { "type": "integer", "minimum": 5, "maximum": 60 } + } + } + } + }, + "WorkspaceCreate": { + "type": "object", + "additionalProperties": false, + "required": ["name"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "WorkspaceMutation": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "Workspace": { + "type": "object", + "additionalProperties": false, + "required": ["id", "name", "state", "revision"], + "properties": { + "id": { "$ref": "#/components/schemas/ResourceId" }, + "name": { "type": "string", "minLength": 1, "maxLength": 128 }, + "state": { "$ref": "#/components/schemas/WorkspaceState" }, + "revision": { "$ref": "#/components/schemas/Revision" }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "WorkspacePage": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { "type": "array", "maxItems": 100, "items": { "$ref": "#/components/schemas/Workspace" } }, + "nextCursor": { "$ref": "#/components/schemas/Cursor" } + } + }, + "SessionCreate": { + "type": "object", + "additionalProperties": false, + "required": ["title"], + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "SessionMutation": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 128 }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "Session": { + "type": "object", + "additionalProperties": false, + "required": ["id", "workspaceId", "title", "state", "revision"], + "properties": { + "id": { "$ref": "#/components/schemas/ResourceId" }, + "workspaceId": { "$ref": "#/components/schemas/ResourceId" }, + "title": { "type": "string", "minLength": 1, "maxLength": 128 }, + "state": { "$ref": "#/components/schemas/SessionState" }, + "revision": { "$ref": "#/components/schemas/Revision" }, + "labels": { "$ref": "#/components/schemas/Labels" } + } + }, + "SessionPage": { + "type": "object", + "additionalProperties": false, + "required": ["items"], + "properties": { + "items": { "type": "array", "maxItems": 100, "items": { "$ref": "#/components/schemas/Session" } }, + "nextCursor": { "$ref": "#/components/schemas/Cursor" } + } + }, + "Labels": { + "type": "object", + "maxProperties": 32, + "propertyNames": { "pattern": "^[a-z][a-z0-9.-]{0,62}$" }, + "additionalProperties": { "type": "string", "maxLength": 128 } + }, + "CommandCreate": { + "type": "object", + "additionalProperties": false, + "required": ["command"], + "properties": { + "command": { "type": "string", "minLength": 1, "maxLength": 262144 }, + "metadata": { "type": "object", "maxProperties": 32, "additionalProperties": { "type": ["string", "number", "boolean", "null"] } } + } + }, + "CommandResult": { + "type": "object", + "additionalProperties": false, + "required": ["commandId", "state"], + "properties": { + "commandId": { "$ref": "#/components/schemas/ResourceId" }, + "state": { "$ref": "#/components/schemas/OperationState" } + } + }, + "SnapshotEntry": { + "type": "object", + "additionalProperties": false, + "required": ["sequence", "kind", "text"], + "properties": { + "sequence": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, + "kind": { "type": "string", "enum": ["input", "output", "status"] }, + "text": { "type": "string", "maxLength": 1048576 } + } + }, + "SessionSnapshot": { + "type": "object", + "additionalProperties": false, + "required": ["sessionId", "cursor", "state", "entries"], + "properties": { + "sessionId": { "$ref": "#/components/schemas/ResourceId" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "state": { "$ref": "#/components/schemas/SessionState" }, + "entries": { "type": "array", "maxItems": 1000, "items": { "$ref": "#/components/schemas/SnapshotEntry" } } + } + }, + "HeartbeatWatchEvent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "cursor", "observedAt"], + "properties": { + "type": { "type": "string", "const": "heartbeat" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "observedAt": { "type": "string", "format": "date-time" } + } + }, + "SessionWatchEvent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "cursor", "state", "revision"], + "properties": { + "type": { "type": "string", "const": "session" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "state": { "$ref": "#/components/schemas/SessionState" }, + "revision": { "$ref": "#/components/schemas/Revision" } + } + }, + "CommandWatchEvent": { + "type": "object", + "additionalProperties": false, + "required": ["type", "cursor", "commandId", "state"], + "properties": { + "type": { "type": "string", "const": "command" }, + "cursor": { "$ref": "#/components/schemas/Cursor" }, + "commandId": { "$ref": "#/components/schemas/ResourceId" }, + "state": { "$ref": "#/components/schemas/OperationState" } + } + }, + "WatchEvent": { + "oneOf": [ + { "$ref": "#/components/schemas/HeartbeatWatchEvent" }, + { "$ref": "#/components/schemas/SessionWatchEvent" }, + { "$ref": "#/components/schemas/CommandWatchEvent" } + ], + "discriminator": { "propertyName": "type" } + }, + "FieldViolation": { + "type": "object", + "additionalProperties": false, + "required": ["field", "rule", "message"], + "properties": { + "field": { "type": "string", "minLength": 1, "maxLength": 256 }, + "rule": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z][A-Za-z0-9._-]*$" }, + "message": { "type": "string", "minLength": 1, "maxLength": 512 } + } + }, + "Resync": { + "type": "object", + "additionalProperties": false, + "required": ["snapshotUrl", "cursor"], + "properties": { + "snapshotUrl": { "type": "string", "pattern": "^/v1/sessions/[A-Za-z0-9._~-]+/snapshot$", "maxLength": 512 }, + "cursor": { "$ref": "#/components/schemas/Cursor" } + } + }, + "ApiError": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "requestId", "retryable"], + "properties": { + "code": { + "type": "string", + "enum": ["invalid_request", "unauthenticated", "forbidden", "not_found", "idempotency_key_required", "idempotency_conflict", "revision_conflict", "incompatible_version", "cursor_expired", "unavailable", "indeterminate", "invalid_origin", "https_required"] + }, + "message": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 128 }, + "retryable": { "type": "boolean" }, + "violations": { "type": "array", "maxItems": 64, "items": { "$ref": "#/components/schemas/FieldViolation" } }, + "supportedMajors": { "type": "array", "maxItems": 8, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } }, + "resync": { "$ref": "#/components/schemas/Resync" } + } + }, + "ErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/ApiError" } } + } + }, + "responses": { + "Discovery": { "description": "Negotiated v1 discovery", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Discovery" } } } }, + "Workspace": { "description": "Workspace", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspaceAccepted": { "description": "Workspace intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspaceReplay": { "description": "Identical workspace request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspacePage": { "description": "Bounded workspace page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspacePage" } } } }, + "Session": { "description": "Session", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionAccepted": { "description": "Session intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionReplay": { "description": "Identical session spawn replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionPage": { "description": "Bounded session page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionPage" } } } }, + "CommandAccepted": { "description": "Command intent accepted with a stable outcome state", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, + "CommandReplay": { "description": "Identical command request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, + "Snapshot": { "description": "Bounded session snapshot and reconnect cursor", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionSnapshot" } } } }, + "Deleted": { "description": "Deletion accepted or resource already absent", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } } }, + "BadRequest": { "$ref": "#/components/responses/Error400" }, + "Unauthenticated": { "$ref": "#/components/responses/Error401" }, + "Forbidden": { "$ref": "#/components/responses/Error403" }, + "NotFound": { "$ref": "#/components/responses/Error404" }, + "IncompatibleVersion": { "$ref": "#/components/responses/Error406" }, + "Conflict": { "$ref": "#/components/responses/Error409" }, + "CursorExpired": { "$ref": "#/components/responses/Error410" }, + "InvalidRequest": { "$ref": "#/components/responses/Error422" }, + "Unavailable": { "$ref": "#/components/responses/Error503" }, + "Error400": { "description": "Malformed request or missing idempotency key", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error401": { "description": "Missing or invalid opaque bearer credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error403": { "description": "Credential lacks the deny-by-default operation or resource scope", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error404": { "description": "Resource absent or outside caller scope (scope existence is not disclosed)", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error406": { "description": "Requested major is incompatible; no silent downgrade", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error409": { "description": "Idempotency or resource revision conflict", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error410": { "description": "Watch cursor expired; resync from the typed snapshot target", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error422": { "description": "Bounded semantic validation failure with stable field violations", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error503": { "description": "Service temporarily unavailable or outcome indeterminate", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } + } + } +} diff --git a/packages/t4-api-contract/package.json b/packages/t4-api-contract/package.json new file mode 100644 index 00000000..641b913d --- /dev/null +++ b/packages/t4-api-contract/package.json @@ -0,0 +1,22 @@ +{ + "name": "@t4-code/t4-api-contract", + "version": "0.1.30", + "private": true, + "type": "module", + "exports": { + "./openapi.json": "./openapi.json" + }, + "scripts": { + "build": "pnpm validate && pnpm check:generated", + "typecheck": "pnpm validate && pnpm check:generated", + "test": "pnpm validate && pnpm check:generated", + "validate": "node scripts/validate.mjs", + "generate": "node scripts/generate.mjs --write", + "generate:ci": "node scripts/generate.mjs --ci-artifact", + "check:generated": "node scripts/generate.mjs --check" + }, + "devDependencies": { + "@readme/openapi-parser": "6.3.0", + "openapi-typescript": "7.13.0" + } +} diff --git a/packages/t4-api-contract/scripts/generate.mjs b/packages/t4-api-contract/scripts/generate.mjs new file mode 100644 index 00000000..3bc58f15 --- /dev/null +++ b/packages/t4-api-contract/scripts/generate.mjs @@ -0,0 +1,44 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import openapiTS, { astToString } from "openapi-typescript"; + +const contractRoot = fileURLToPath(new URL("..", import.meta.url)); +const schemaUrl = new URL("../openapi.json", import.meta.url); +const target = resolve(contractRoot, "../t4-api-client/src/generated/schema.ts"); +const mode = process.argv[2]; +if (!new Set(["--write", "--check", "--ci-artifact"]).has(mode)) { + throw new Error("usage: node scripts/generate.mjs --write|--check|--ci-artifact"); +} + +const generated = `${astToString(await openapiTS(schemaUrl, { alphabetize: true }))}\n`; +const digest = createHash("sha256").update(generated).digest("hex"); + +if (mode === "--write") { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, generated); + console.log(`generated ${target} sha256:${digest}`); + process.exit(0); +} + +if (mode === "--ci-artifact") { + const artifact = resolve(contractRoot, "../../artifacts/t4-api/generated/schema.ts"); + await mkdir(dirname(artifact), { recursive: true }); + await writeFile(artifact, generated); + console.log(`T4_API_GENERATED_SHA256=${digest}`); + console.log(`T4_API_GENERATED_BASE64_BEGIN\n${Buffer.from(generated).toString("base64")}\nT4_API_GENERATED_BASE64_END`); +} + +let checkedIn; +try { + checkedIn = await readFile(target, "utf8"); +} catch (error) { + if (mode === "--ci-artifact") throw new Error(`generated client is absent; recover the CI artifact at artifacts/t4-api/generated/schema.ts (sha256:${digest})`, { cause: error }); + throw error; +} +if (checkedIn !== generated) { + throw new Error(`generated client drift: expected sha256:${digest}; regenerate only with the authorized CI artifact`); +} +console.log(`generated client is deterministic (sha256:${digest})`); diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs new file mode 100644 index 00000000..521893c0 --- /dev/null +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -0,0 +1,20 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import OpenAPIParser from "@readme/openapi-parser"; + +const source = new URL("../openapi.json", import.meta.url); +const document = JSON.parse(await readFile(source, "utf8")); +await OpenAPIParser.validate(fileURLToPath(source)); + +if (document.openapi !== "3.1.0") throw new Error("T4 API contract must remain OpenAPI 3.1.0"); +if (!Array.isArray(document.servers) || document.servers.length === 0) throw new Error("T4 API contract requires an HTTPS server"); +for (const server of document.servers) { + if (new URL(server.url).protocol !== "https:") throw new Error("T4 API contract permits only HTTPS servers"); +} +if (document.security?.[0]?.BearerAuth === undefined) throw new Error("T4 API contract must default to bearer authentication"); +const serializedSchemas = JSON.stringify(document.components?.schemas ?? {}).toLowerCase(); +for (const privateType of ["kubernetes.io", "v1alpha1", "postgresql", "ompserver", "podspec"]) { + if (serializedSchemas.includes(privateType)) throw new Error(`public schema leaks private type ${privateType}`); +} +console.log("T4 API OpenAPI 3.1 contract is valid"); diff --git a/packages/t4-api-contract/tsconfig.json b/packages/t4-api-contract/tsconfig.json new file mode 100644 index 00000000..9e7ef5aa --- /dev/null +++ b/packages/t4-api-contract/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": [] +} From 329ac183ea1e9313724118866d2368b05bac7ae6 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 19:41:13 +0000 Subject: [PATCH 03/70] ci: emit T4 API generation artifacts --- .github/workflows/ci.yml | 38 +++++ .../test/t4-api-v1-conformance-service.ts | 79 ++++++++--- .../client/test/t4-api-v1-conformance.test.ts | 130 +++++++++++++----- packages/t4-api-client/src/index.ts | 129 ++++++++++++----- packages/t4-api-contract/openapi.json | 46 ++++++- 5 files changed, 326 insertions(+), 96 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8872e37..ab9e373f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,44 @@ jobs: [[ "$HEAD_SHA" =~ ^[0-9a-f]{40}$ ]] git diff --name-only -z "$BASE_SHA...$HEAD_SHA" | node scripts/ci-paths.mjs >> "$GITHUB_OUTPUT" + t4-api-generation: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out exact source + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install pinned pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 + with: + version: 11.10.0 + + - name: Install pinned Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + + - name: Resolve lockfile and generation dependencies for artifact recovery + run: pnpm install --no-frozen-lockfile + + - name: Validate and generate the T4 API contract artifact + run: | + pnpm --filter @t4-code/t4-api-contract validate + pnpm --filter @t4-code/t4-api-contract generate:ci + + - name: Upload deterministic T4 API generation inputs + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: t4-api-generation-${{ github.run_id }} + path: | + pnpm-lock.yaml + artifacts/t4-api/generated/schema.ts + if-no-files-found: error + retention-days: 14 + core: runs-on: ubuntu-24.04 timeout-minutes: 25 diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index d783f171..2e9cb745 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -19,16 +19,25 @@ function problem(status: number, code: string, message: string, extra: Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${bodyKey(item)}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; } export class T4ApiV1ConformanceService { readonly origin = "https://t4-api.conformance.test"; readonly calls: Array<{ method: string; path: string; authorization: string | null }> = []; readonly abortedWatches: string[] = []; + readonly watchCursors: Array<{ query: string | null; header: string | null }> = []; #workspaceSequence = 0; #sessionSequence = 0; + #commandSequence = 0; readonly #workspaces = new Map>(); readonly #sessions = new Map>(); readonly #replays = new Map }>(); @@ -72,9 +81,9 @@ export class T4ApiV1ConformanceService { if (request.method === "POST" && url.pathname === "/v1/workspaces") { const body = await request.json() as Record; - if (typeof body.name !== "string" || body.name.length < 1 || body.name.length > 24) { + if (typeof body.name !== "string" || body.name.length < 1 || body.name.length > 128) { return problem(422, "invalid_request", "Request validation failed", { - violations: [{ field: "name", rule: "length", message: "name must contain 1 to 24 characters" }], + violations: [{ field: "name", rule: "length", message: "name must contain 1 to 128 characters" }], }); } return this.#idempotent(request, tenant, body, () => { @@ -131,6 +140,11 @@ export class T4ApiV1ConformanceService { if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); if (request.method === "POST") { const body = await request.json() as Record; + if (typeof body.title !== "string" || body.title.length < 1 || body.title.length > 128) { + return problem(422, "invalid_request", "Request validation failed", { + violations: [{ field: "title", rule: "length", message: "title must contain 1 to 128 characters" }], + }); + } return this.#idempotent(request, tenant, body, () => { const id = `ses-${++this.#sessionSequence}`; const session = { id, workspaceId, title: body.title, state: "accepted", revision: 1, tenant }; @@ -139,10 +153,17 @@ export class T4ApiV1ConformanceService { }); } if (request.method === "GET") { - const items = [...this.#sessions.values()] - .filter((item) => item.workspaceId === workspaceId && item.tenant === tenant) - .map(({ tenant: _tenant, ...item }) => item); - return json(200, { items }); + const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) { + return problem(422, "invalid_request", "Request validation failed", { + violations: [{ field: "pageSize", rule: "range", message: "pageSize must be between 1 and 3" }], + }); + } + const start = Number(url.searchParams.get("cursor")?.replace("page-", "") ?? "0"); + const visible = [...this.#sessions.values()].filter((item) => item.workspaceId === workspaceId && item.tenant === tenant); + const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); + const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; + return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } } @@ -157,6 +178,9 @@ export class T4ApiV1ConformanceService { } if (request.method === "PATCH") { const body = await request.json() as Record; + if (request.headers.get("If-Match") !== String(session.revision)) { + return problem(409, "revision_conflict", "Session revision changed"); + } const updated = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; this.#sessions.set(id, updated); const { tenant: _tenant, ...visible } = updated; @@ -183,15 +207,14 @@ export class T4ApiV1ConformanceService { const session = this.#sessions.get(decodeURIComponent(commandMatch[1]!)); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); const body = await request.json() as Record; - if (typeof body.command !== "string" || encoder.encode(body.command).byteLength > 32) { + if (typeof body.command !== "string" || body.command.length < 1 || encoder.encode(body.command).byteLength > 32) { return problem(422, "invalid_request", "Request validation failed", { - violations: [{ field: "command", rule: "maxBytes", message: "command must not exceed 32 UTF-8 bytes" }], + violations: [{ field: "command", rule: "maxBytes", message: "command must contain 1 to 32 UTF-8 bytes" }], }); } - const state = ["accepted", "rejected", "conflict", "unavailable", "indeterminate"].includes(body.command) - ? body.command - : "accepted"; - return this.#idempotent(request, tenant, body, () => ({ commandId: `cmd-${body.command}`, state })); + const states: Record = { accepted: true, rejected: true, conflict: true, unavailable: true, indeterminate: true }; + const state = states[body.command] === true ? body.command : "accepted"; + return this.#idempotent(request, tenant, body, () => ({ commandId: `cmd-${++this.#commandSequence}`, state })); } const snapshotMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/snapshot$/u); @@ -206,19 +229,30 @@ export class T4ApiV1ConformanceService { const id = decodeURIComponent(watchMatch[1]!); const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); - const cursor = url.searchParams.get("cursor") ?? request.headers.get("Last-Event-ID"); + const queryCursor = url.searchParams.get("cursor"); + const headerCursor = request.headers.get("Last-Event-ID"); + this.watchCursors.push({ query: queryCursor, header: headerCursor }); + if (queryCursor !== null && headerCursor !== null && queryCursor !== headerCursor) { + return problem(400, "invalid_request", "Reconnect cursors disagree"); + } + const cursor = queryCursor ?? headerCursor; if (cursor === "expired") { return problem(410, "cursor_expired", "Watch cursor is no longer retained", { resync: { snapshotUrl: `/v1/sessions/${id}/snapshot`, cursor: "cursor-2" }, }); } - const frames = [ - `id: cursor-3\nevent: heartbeat\ndata: {"type":"heartbeat","cursor":"cursor-3","observedAt":"2026-07-21T00:00:00Z"}\n\n`, - `id: cursor-4\nevent: session\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\n\n`, - ]; + const frames = cursor === "cursor-4" + ? [`id: cursor-5\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-5","observedAt":"2026-07-21T00:00:15Z"}\r\n\r\n`] + : [ + `id: cursor-3\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-3","observedAt":"2026-07-21T00:00:00Z"}\r\n\r\n`, + `id: cursor-4\r\nevent: session\r\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\r\n\r\n`, + ]; const stream = new ReadableStream({ start: (controller) => { - for (const frame of frames) controller.enqueue(encoder.encode(frame)); + const payload = encoder.encode(frames.join("")); + const split = Math.max(1, payload.byteLength - 3); + controller.enqueue(payload.slice(0, split)); + controller.enqueue(payload.slice(split)); request.signal.addEventListener("abort", () => { this.abortedWatches.push(id); try { controller.close(); } catch { /* already closed */ } @@ -245,7 +279,12 @@ export class T4ApiV1ConformanceService { create: () => Record, ): Response { const key = request.headers.get("Idempotency-Key"); - if (!key) return problem(400, "idempotency_key_required", "Idempotency-Key is required"); + if (key === null) return problem(400, "idempotency_key_required", "Idempotency-Key is required"); + if (!/^[A-Za-z0-9._~-]{16,128}$/u.test(key)) { + return problem(400, "invalid_request", "Idempotency-Key is invalid", { + violations: [{ field: "Idempotency-Key", rule: "format", message: "Idempotency-Key must be a 16 to 128 character token" }], + }); + } const replayKey = `${tenant}:${request.method}:${new URL(request.url).pathname}:${key}`; const prior = this.#replays.get(replayKey); if (prior) { diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 4eb69701..7d312024 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -11,6 +11,16 @@ type WorkspaceCreate = components["schemas"]["WorkspaceCreate"]; type SessionCreate = components["schemas"]["SessionCreate"]; type CommandCreate = components["schemas"]["CommandCreate"]; +const VERSION_HEADERS = { "T4-API-Version": "1" } as const; + +function idempotencyHeaders(key: string): Readonly<{ "T4-API-Version": "1"; "Idempotency-Key": string }> { + return { ...VERSION_HEADERS, "Idempotency-Key": key }; +} + +function revisionHeaders(revision: number): Readonly<{ "T4-API-Version": "1"; "If-Match": string }> { + return { ...VERSION_HEADERS, "If-Match": String(revision) }; +} + function requireData(result: { data?: T; error?: unknown }): T { expect(result.error).toBeUndefined(); expect(result.data).toBeDefined(); @@ -21,7 +31,7 @@ describe("generated T4 API v1 client conformance", () => { it("negotiates discovery, capabilities, bounds, and rejects an incompatible major", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); - const discovery = requireData(await client.http.GET("/v1")); + const discovery = requireData(await client.http.GET("/v1", { params: { header: VERSION_HEADERS } })); expect(discovery).toMatchObject({ apiVersion: "1.0", supportedMajors: [1], @@ -30,121 +40,170 @@ describe("generated T4 API v1 client conformance", () => { }); const incompatible = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 2, fetch: service.fetch }); - const rejected = await incompatible.http.GET("/v1"); + const rejected = await incompatible.http.GET("/v1", { params: { header: VERSION_HEADERS } }); expect(rejected.response.status).toBe(406); expect(rejected.error).toMatchObject({ error: { code: "incompatible_version", retryable: false, supportedMajors: [1] } }); }); - it("creates, replays, conflicts, mutates, lists, isolates, and deletes bounded workspaces", async () => { + it("creates, canonically replays, conflicts, mutates, paginates, isolates, and deletes workspaces", async () => { const service = new T4ApiV1ConformanceService(); const owner = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); const other = createT4ApiClient({ baseUrl: service.origin, credential: "token-b", majorVersion: 1, fetch: service.fetch }); - const body = { name: "primary" } satisfies WorkspaceCreate; - const first = await owner.http.POST("/v1/workspaces", { body, headers: { "Idempotency-Key": "workspace-create-1" } }); + const body = { name: "primary", labels: { beta: "2", alpha: "1" } } satisfies WorkspaceCreate; + const first = await owner.http.POST("/v1/workspaces", { + body, + params: { header: idempotencyHeaders("workspace-create-0001") }, + }); expect(first.response.status).toBe(202); const workspace = requireData(first); expect(workspace).toMatchObject({ id: "ws-1", state: "accepted", revision: 1 }); expect(workspace).not.toHaveProperty("tenant"); - const replay = await owner.http.POST("/v1/workspaces", { body, headers: { "Idempotency-Key": "workspace-create-1" } }); + const replay = await owner.http.POST("/v1/workspaces", { + body: { labels: { alpha: "1", beta: "2" }, name: "primary" }, + params: { header: idempotencyHeaders("workspace-create-0001") }, + }); expect(replay.response.status).toBe(200); expect(replay.response.headers.get("Idempotency-Replayed")).toBe("true"); expect(replay.data).toEqual(workspace); const conflict = await owner.http.POST("/v1/workspaces", { body: { name: "different" }, - headers: { "Idempotency-Key": "workspace-create-1" }, + params: { header: idempotencyHeaders("workspace-create-0001") }, }); expect(conflict.response.status).toBe(409); expect(conflict.error).toMatchObject({ error: { code: "idempotency_conflict", retryable: false } }); for (const name of ["second", "third", "fourth"]) { - requireData(await owner.http.POST("/v1/workspaces", { body: { name }, headers: { "Idempotency-Key": `workspace-${name}` } })); + requireData(await owner.http.POST("/v1/workspaces", { + body: { name }, + params: { header: idempotencyHeaders(`workspace-${name}-0001`) }, + })); } - const pageOne = requireData(await owner.http.GET("/v1/workspaces", { params: { query: { pageSize: 2 } } })); + const pageOne = requireData(await owner.http.GET("/v1/workspaces", { + params: { header: VERSION_HEADERS, query: { pageSize: 2 } }, + })); expect(pageOne.items).toHaveLength(2); expect(pageOne.nextCursor).toBe("page-2"); - const pageTwo = requireData(await owner.http.GET("/v1/workspaces", { params: { query: { pageSize: 2, cursor: pageOne.nextCursor } } })); + const pageTwo = requireData(await owner.http.GET("/v1/workspaces", { + params: { header: VERSION_HEADERS, query: { pageSize: 2, cursor: pageOne.nextCursor } }, + })); expect(pageTwo.items).toHaveLength(2); expect(pageTwo.nextCursor).toBeUndefined(); - const isolated = await other.http.GET("/v1/workspaces/ws-1", { params: { path: { workspaceId: "ws-1" } } }); + const isolated = await other.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } }, + }); expect(isolated.response.status).toBe(404); expect(isolated.error).toMatchObject({ error: { code: "not_found" } }); - const invalid = await owner.http.POST("/v1/workspaces", { body: { name: "x".repeat(25) }, headers: { "Idempotency-Key": "invalid" } }); + const invalid = await owner.http.POST("/v1/workspaces", { + body: { name: "x".repeat(129) }, + params: { header: idempotencyHeaders("workspace-invalid-0001") }, + }); expect(invalid.response.status).toBe(422); expect(invalid.error).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "name", rule: "length" }] } }); const updated = requireData(await owner.http.PATCH("/v1/workspaces/{workspaceId}", { - params: { path: { workspaceId: "ws-1" } }, body: { name: "renamed" }, headers: { "If-Match": "1" }, + params: { header: revisionHeaders(1), path: { workspaceId: "ws-1" } }, + body: { name: "renamed" }, })); expect(updated).toMatchObject({ name: "renamed", revision: 2 }); const stale = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { - params: { path: { workspaceId: "ws-1" } }, body: { name: "stale" }, headers: { "If-Match": "1" }, + params: { header: revisionHeaders(1), path: { workspaceId: "ws-1" } }, + body: { name: "stale" }, }); expect(stale.response.status).toBe(409); expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); - expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { params: { path: { workspaceId: "ws-1" } } })).response.status).toBe(204); + expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } }, + })).response.status).toBe(204); service.expectNoCredentialLeak(); }); - it("spawns and mutates sessions, submits idempotent commands, and exposes stable states", async () => { + it("spawns, paginates, and mutates sessions, then submits idempotent stable command states", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); - requireData(await client.http.POST("/v1/workspaces", { body: { name: "workspace" }, headers: { "Idempotency-Key": "workspace" } })); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("workspace-setup-0001") }, + })); const body = { title: "agent" } satisfies SessionCreate; const first = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { - params: { path: { workspaceId: "ws-1" } }, body, headers: { "Idempotency-Key": "spawn-1" }, + params: { header: idempotencyHeaders("session-spawn-0001"), path: { workspaceId: "ws-1" } }, + body, }); expect(first.response.status).toBe(202); const session = requireData(first); const replay = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { - params: { path: { workspaceId: "ws-1" } }, body, headers: { "Idempotency-Key": "spawn-1" }, + params: { header: idempotencyHeaders("session-spawn-0001"), path: { workspaceId: "ws-1" } }, + body, }); expect(replay.data).toEqual(session); - const listed = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { - params: { path: { workspaceId: "ws-1" } }, + for (const [key, title] of [["session-spawn-0002", "agent-two"], ["session-spawn-0003", "agent-three"]] as const) { + requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, body: { title }, + })); + } + const sessionPageOne = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2 } }, + })); + expect(sessionPageOne.items).toHaveLength(2); + expect(sessionPageOne.nextCursor).toBe("page-2"); + const sessionPageTwo = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2, cursor: sessionPageOne.nextCursor } }, })); - expect(listed.items).toEqual([session]); + expect(sessionPageTwo.items).toHaveLength(1); + const mutated = requireData(await client.http.PATCH("/v1/sessions/{sessionId}", { - params: { path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, headers: { "If-Match": "1" }, + params: { header: revisionHeaders(1), path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, })); expect(mutated).toMatchObject({ title: "renamed-agent", revision: 2 }); + const stale = await client.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: revisionHeaders(1), path: { sessionId: "ses-1" } }, body: { title: "stale-agent" }, + }); + expect(stale.response.status).toBe(409); + expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); for (const state of ["accepted", "rejected", "conflict", "unavailable", "indeterminate"] as const) { const command = { command: state } satisfies CommandCreate; const result = await client.http.POST("/v1/sessions/{sessionId}/commands", { - params: { path: { sessionId: "ses-1" } }, body: command, headers: { "Idempotency-Key": `command-${state}` }, + params: { header: idempotencyHeaders(`command-${state}-0001`), path: { sessionId: "ses-1" } }, body: command, }); expect(requireData(result).state).toBe(state); const commandReplay = await client.http.POST("/v1/sessions/{sessionId}/commands", { - params: { path: { sessionId: "ses-1" } }, body: command, headers: { "Idempotency-Key": `command-${state}` }, + params: { header: idempotencyHeaders(`command-${state}-0001`), path: { sessionId: "ses-1" } }, body: command, }); expect(commandReplay.data).toEqual(result.data); expect(commandReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); } const oversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { - params: { path: { sessionId: "ses-1" } }, body: { command: "a".repeat(33) }, headers: { "Idempotency-Key": "oversized" }, + params: { header: idempotencyHeaders("command-oversized-0001"), path: { sessionId: "ses-1" } }, + body: { command: "a".repeat(33) }, }); expect(oversized.response.status).toBe(422); expect(oversized.error).toMatchObject({ error: { violations: [{ field: "command", rule: "maxBytes" }] } }); - const cancelled = await client.http.POST("/v1/sessions/{sessionId}/cancel", { params: { path: { sessionId: "ses-1" } } }); + const cancelled = await client.http.POST("/v1/sessions/{sessionId}/cancel", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + }); expect(cancelled.response.status).toBe(202); expect(cancelled.data).toMatchObject({ state: "cancelled" }); }); - it("takes a snapshot and watches bounded SSE with heartbeat, reconnect cursor, cancellation, and typed resync", async () => { + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); - requireData(await client.http.POST("/v1/workspaces", { body: { name: "workspace" }, headers: { "Idempotency-Key": "workspace" } })); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("workspace-watch-0001") }, + })); requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { - params: { path: { workspaceId: "ws-1" } }, body: { title: "agent" }, headers: { "Idempotency-Key": "session" }, + params: { header: idempotencyHeaders("session-watch-0001"), path: { workspaceId: "ws-1" } }, body: { title: "agent" }, + })); + const snapshot = requireData(await client.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, })); - const snapshot = requireData(await client.http.GET("/v1/sessions/{sessionId}/snapshot", { params: { path: { sessionId: "ses-1" } } })); expect(snapshot).toMatchObject({ sessionId: "ses-1", cursor: "cursor-2", entries: [{ sequence: 2 }] }); const controller = new AbortController(); const received: Array = []; - for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 2, signal: controller.signal })) { + for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 4, signal: controller.signal })) { received.push(event); if (received.length === 2) controller.abort(); } @@ -152,12 +211,13 @@ describe("generated T4 API v1 client conformance", () => { expect.objectContaining({ type: "heartbeat", cursor: "cursor-3" }), expect.objectContaining({ type: "session", cursor: "cursor-4", state: "accepted" }), ]); - expect(service.calls.at(-1)?.path).toBe("/v1/sessions/ses-1/events"); expect(service.abortedWatches).toEqual(["ses-1"]); + expect(service.watchCursors[0]).toEqual({ query: "cursor-2", header: "cursor-2" }); - const reconnect = client.watchSession("ses-1", { cursor: received.at(-1)!.cursor, maxEvents: 1 }); - expect((await reconnect.next()).value).toMatchObject({ type: "heartbeat", cursor: "cursor-3" }); + const reconnect = client.watchSession("ses-1", { cursor: received.at(-1)!.cursor, maxEvents: 2 }); + expect((await reconnect.next()).value).toMatchObject({ type: "heartbeat", cursor: "cursor-5" }); await reconnect.return(undefined); + expect(service.watchCursors[1]).toEqual({ query: "cursor-4", header: "cursor-4" }); await expect(async () => { for await (const _event of client.watchSession("ses-1", { cursor: "expired", maxEvents: 1 })) { diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index aba5baab..be975199 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -7,23 +7,24 @@ export type { components, operations, paths } from "./generated/schema.ts"; const MAX_CREDENTIAL_LENGTH = 4096; const MAX_ERROR_BYTES = 1024 * 1024; const MAX_EVENT_BYTES = 1024 * 1024; -const SESSION_STATES = new Set([ - "accepted", - "provisioning", - "ready", - "cancelling", - "cancelled", - "failed", - "unavailable", - "indeterminate", -]); -const OPERATION_STATES = new Set([ - "accepted", - "rejected", - "conflict", - "unavailable", - "indeterminate", -]); +const CURSOR_PATTERN = /^[A-Za-z0-9._~-]+$/u; +const ERROR_CODES = { + invalid_request: true, unauthenticated: true, forbidden: true, not_found: true, + idempotency_key_required: true, idempotency_conflict: true, revision_conflict: true, + incompatible_version: true, cursor_expired: true, unavailable: true, indeterminate: true, + invalid_origin: true, https_required: true, +} as const satisfies Record; +const ERROR_FIELDS: Record = { + code: true, message: true, requestId: true, retryable: true, + violations: true, supportedMajors: true, resync: true, +}; +const SESSION_STATES = { + accepted: true, provisioning: true, ready: true, cancelling: true, + cancelled: true, failed: true, unavailable: true, indeterminate: true, +} as const satisfies Record; +const OPERATION_STATES = { + accepted: true, rejected: true, conflict: true, unavailable: true, indeterminate: true, +} as const satisfies Record; type ApiError = components["schemas"]["ApiError"]; type Resync = components["schemas"]["Resync"]; @@ -129,22 +130,70 @@ function record(value: unknown): Record | undefined { : undefined; } +function validViolation(value: unknown): boolean { + const violation = record(value); + return violation !== undefined && + Object.keys(violation).every((key) => key === "field" || key === "rule" || key === "message") && + typeof violation.field === "string" && violation.field.length >= 1 && violation.field.length <= 256 && + typeof violation.rule === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(violation.rule) && + typeof violation.message === "string" && violation.message.length >= 1 && violation.message.length <= 512; +} + +function validResync(value: unknown): value is Resync { + const resync = record(value); + return resync !== undefined && Object.keys(resync).every((key) => key === "snapshotUrl" || key === "cursor") && + typeof resync.snapshotUrl === "string" && /^\/v1\/sessions\/[A-Za-z0-9._~-]+\/snapshot$/u.test(resync.snapshotUrl) && + typeof resync.cursor === "string" && resync.cursor.length <= 512 && CURSOR_PATTERN.test(resync.cursor); +} + function apiError(value: unknown): ApiError | undefined { const envelope = record(value); const error = record(envelope?.error); if ( - error === undefined || - typeof error.code !== "string" || - typeof error.message !== "string" || - typeof error.requestId !== "string" || - typeof error.retryable !== "boolean" + envelope === undefined || Object.keys(envelope).some((key) => key !== "error") || + error === undefined || Object.keys(error).some((key) => ERROR_FIELDS[key] !== true) || + typeof error.code !== "string" || ERROR_CODES[error.code as ApiError["code"]] !== true || + typeof error.message !== "string" || error.message.length < 1 || error.message.length > 1024 || + typeof error.requestId !== "string" || error.requestId.length < 1 || error.requestId.length > 128 || + typeof error.retryable !== "boolean" || + (error.violations !== undefined && (!Array.isArray(error.violations) || error.violations.length > 64 || !error.violations.every(validViolation))) || + (error.supportedMajors !== undefined && (!Array.isArray(error.supportedMajors) || error.supportedMajors.length > 8 || !error.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1))) || + (error.resync !== undefined && !validResync(error.resync)) ) return undefined; return error as ApiError; } +async function boundedResponseText(response: Response): Promise { + if (response.body === null) return ""; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let length = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + length += chunk.value.byteLength; + if (length > MAX_ERROR_BYTES) { + await reader.cancel(); + return undefined; + } + chunks.push(chunk.value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return undefined; } +} + async function boundedError(response: Response): Promise { - const text = await response.text(); - if (new TextEncoder().encode(text).byteLength <= MAX_ERROR_BYTES) { + const text = await boundedResponseText(response); + if (text !== undefined) { try { const decoded = apiError(JSON.parse(text)); if (decoded !== undefined) return new T4ApiError(response.status, decoded); @@ -168,15 +217,16 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { typeof event.cursor !== "string" || event.cursor.length < 1 || event.cursor.length > 512 || + !CURSOR_PATTERN.test(event.cursor) || (eventId !== undefined && eventId !== event.cursor) ) throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid watch event", requestId: "unavailable", retryable: true }); - if (event.type === "heartbeat" && typeof event.observedAt === "string" && event.observedAt.length <= 64) { + if (event.type === "heartbeat" && typeof event.observedAt === "string" && event.observedAt.length <= 64 && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(event.observedAt) && Number.isFinite(Date.parse(event.observedAt))) { return event as WatchEvent; } if ( event.type === "session" && typeof event.state === "string" && - SESSION_STATES.has(event.state as components["schemas"]["SessionState"]) && + SESSION_STATES[event.state as components["schemas"]["SessionState"]] === true && Number.isSafeInteger(event.revision) && Number(event.revision) >= 1 ) return event as WatchEvent; if ( @@ -184,7 +234,7 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { typeof event.commandId === "string" && event.commandId.length >= 1 && event.commandId.length <= 128 && typeof event.state === "string" && - OPERATION_STATES.has(event.state as components["schemas"]["OperationState"]) + OPERATION_STATES[event.state as components["schemas"]["OperationState"]] === true ) return event as WatchEvent; throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid watch event", requestId: "unavailable", retryable: true }); } @@ -192,8 +242,8 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { function decodeSseFrame(frame: string): WatchEvent | undefined { let eventId: string | undefined; const data: string[] = []; - for (const rawLine of frame.split("\n")) { - const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine; + for (const rawLine of frame.split(/\r\n|\r|\n/u)) { + const line = rawLine; if (line === "" || line.startsWith(":")) continue; const separator = line.indexOf(":"); const field = separator < 0 ? line : line.slice(0, separator); @@ -211,6 +261,11 @@ function decodeSseFrame(frame: string): WatchEvent | undefined { } } +function nextSseBoundary(buffer: string): { readonly index: number; readonly length: number } | undefined { + const match = /(?:\r\n|\r|\n)(?:\r\n|\r|\n)/u.exec(buffer); + return match?.index === undefined ? undefined : { index: match.index, length: match[0].length }; +} + async function* watch( baseUrl: string, credential: string, @@ -222,8 +277,8 @@ async function* watch( const sessionId = requiredSessionId(sessionIdValue); const maxEvents = boundedInteger(options.maxEvents, 100, 1, 1000, "maxEvents"); const heartbeatSeconds = boundedInteger(options.heartbeatSeconds, 15, 5, 60, "heartbeatSeconds"); - if (options.cursor !== undefined && (options.cursor.length < 1 || options.cursor.length > 512)) { - throw new TypeError("cursor must contain between 1 and 512 characters"); + if (options.cursor !== undefined && (options.cursor.length < 1 || options.cursor.length > 512 || !CURSOR_PATTERN.test(options.cursor))) { + throw new TypeError("cursor must be a header-safe token containing between 1 and 512 characters"); } const url = new URL(`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events`); url.searchParams.set("maxEvents", String(maxEvents)); @@ -271,16 +326,18 @@ async function* watch( if (new TextEncoder().encode(buffer).byteLength > MAX_EVENT_BYTES) { throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch event exceeds the client bound", requestId: "unavailable", retryable: true }); } - let boundary = buffer.indexOf("\n\n"); - while (boundary >= 0 && delivered < maxEvents) { - const frame = buffer.slice(0, boundary); - buffer = buffer.slice(boundary + 2); + let boundary = nextSseBoundary(buffer); + while (boundary !== undefined && delivered < maxEvents) { + if (controller.signal.aborted) return; + const frame = buffer.slice(0, boundary.index); + buffer = buffer.slice(boundary.index + boundary.length); const event = decodeSseFrame(frame); if (event !== undefined) { delivered += 1; yield event; + if (controller.signal.aborted) return; } - boundary = buffer.indexOf("\n\n"); + boundary = nextSseBoundary(buffer); } } if (buffer.trim().length > 0 && delivered < maxEvents) { diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index 863bdd88..0387b8a8 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -338,7 +338,7 @@ "in": "header", "required": true, "description": "Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading.", - "schema": { "type": "string", "pattern": "^1(?:\\.[0-9]+)?$", "maxLength": 16 } + "schema": { "type": "string", "pattern": "^[1-9][0-9]{0,3}(?:\\.[0-9]+)?$", "maxLength": 16 } }, "IdempotencyKey": { "name": "Idempotency-Key", @@ -412,7 +412,7 @@ }, "schemas": { "ResourceId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" }, - "Cursor": { "type": "string", "minLength": 1, "maxLength": 512, "description": "Opaque server-issued cursor." }, + "Cursor": { "type": "string", "minLength": 1, "maxLength": 512, "pattern": "^[A-Za-z0-9._~-]+$", "description": "Opaque server-issued header-safe SSE cursor." }, "Revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "OperationState": { "type": "string", "enum": ["accepted", "rejected", "conflict", "unavailable", "indeterminate"] }, "WorkspaceState": { "type": "string", "enum": ["accepted", "provisioning", "ready", "deleting", "deleted", "failed", "unavailable", "indeterminate"] }, @@ -639,11 +639,47 @@ "resync": { "$ref": "#/components/schemas/Resync" } } }, + "IncompatibleVersionApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "required": ["supportedMajors"], "properties": { "code": { "const": "incompatible_version" }, "supportedMajors": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } } } } + ] + }, + "CursorExpiredApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "required": ["resync"], "properties": { "code": { "const": "cursor_expired" }, "resync": { "$ref": "#/components/schemas/Resync" } } } + ] + }, + "InvalidRequestApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "required": ["violations"], "properties": { "code": { "const": "invalid_request" }, "violations": { "type": "array", "minItems": 1, "maxItems": 64, "items": { "$ref": "#/components/schemas/FieldViolation" } } } } + ] + }, "ErrorEnvelope": { "type": "object", "additionalProperties": false, "required": ["error"], "properties": { "error": { "$ref": "#/components/schemas/ApiError" } } + }, + "IncompatibleVersionErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/IncompatibleVersionApiError" } } + }, + "CursorExpiredErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/CursorExpiredApiError" } } + }, + "InvalidRequestErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/InvalidRequestApiError" } } } }, "responses": { @@ -673,10 +709,10 @@ "Error401": { "description": "Missing or invalid opaque bearer credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "Error403": { "description": "Credential lacks the deny-by-default operation or resource scope", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, "Error404": { "description": "Resource absent or outside caller scope (scope existence is not disclosed)", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, - "Error406": { "description": "Requested major is incompatible; no silent downgrade", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error406": { "description": "Requested major is incompatible; no silent downgrade", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IncompatibleVersionErrorEnvelope" } } } }, "Error409": { "description": "Idempotency or resource revision conflict", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, - "Error410": { "description": "Watch cursor expired; resync from the typed snapshot target", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, - "Error422": { "description": "Bounded semantic validation failure with stable field violations", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error410": { "description": "Watch cursor expired; resync from the typed snapshot target", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CursorExpiredErrorEnvelope" } } } }, + "Error422": { "description": "Bounded semantic validation failure with stable field violations", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvalidRequestErrorEnvelope" } } } }, "Error503": { "description": "Service temporarily unavailable or outcome indeterminate", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } } } From b50b7d9702390f1c0e53ed1d7b0367ebcefdd962 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 19:43:50 +0000 Subject: [PATCH 04/70] build: recover T4 API generation lockfile --- .github/workflows/ci.yml | 8 +- .woodpecker.yml | 8 +- pnpm-lock.yaml | 362 +++++++++++++++++++++++++++++++++------ 3 files changed, 315 insertions(+), 63 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab9e373f..631e07ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,13 +76,13 @@ jobs: with: node-version: 24.13.1 - - name: Resolve lockfile and generation dependencies for artifact recovery - run: pnpm install --no-frozen-lockfile + - name: Install generation dependencies from the recovered lockfile + run: pnpm install --frozen-lockfile - - name: Validate and generate the T4 API contract artifact + - name: Generate and validate the T4 API contract artifact run: | - pnpm --filter @t4-code/t4-api-contract validate pnpm --filter @t4-code/t4-api-contract generate:ci + pnpm --filter @t4-code/t4-api-contract validate - name: Upload deterministic T4 API generation inputs if: ${{ always() }} diff --git a/.woodpecker.yml b/.woodpecker.yml index fb611ce0..1f2cdf64 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -16,13 +16,9 @@ steps: image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 commands: - corepack enable - - pnpm install --no-frozen-lockfile - - echo T4_API_LOCKFILE_BASE64_BEGIN - - base64 -w0 pnpm-lock.yaml - - echo - - echo T4_API_LOCKFILE_BASE64_END - - pnpm --filter @t4-code/t4-api-contract validate + - pnpm install --frozen-lockfile - pnpm --filter @t4-code/t4-api-contract generate:ci + - pnpm --filter @t4-code/t4-api-contract validate when: - event: push branch: agent/t4-op03-public-api-contract diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36b7b3d1..4ca7ae94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -261,28 +261,6 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@26.1.1)(jiti@2.7.0)(typescript@6.0.3) - packages/cluster-server: - dependencies: - '@t4-code/host-service': - specifier: workspace:* - version: link:../host-service - '@t4-code/host-wire': - specifier: workspace:* - version: link:../host-wire - devDependencies: - '@types/bun': - specifier: 1.3.5 - version: 1.3.5 - '@types/node': - specifier: 24.12.4 - version: 24.12.4 - typescript: - specifier: ~6.0.3 - version: 6.0.3 - vite-plus: - specifier: 0.2.2 - version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) - packages/client: dependencies: '@t4-code/protocol': @@ -295,6 +273,9 @@ importers: '@t4-code/host-wire': specifier: workspace:* version: link:../host-wire + '@t4-code/t4-api-client': + specifier: workspace:* + version: link:../t4-api-client '@types/node': specifier: 'catalog:' version: 24.12.4 @@ -308,6 +289,28 @@ importers: specifier: 8.18.3 version: 8.18.3 + packages/cluster-server: + dependencies: + '@t4-code/host-service': + specifier: workspace:* + version: link:../host-service + '@t4-code/host-wire': + specifier: workspace:* + version: link:../host-wire + devDependencies: + '@types/bun': + specifier: 1.3.5 + version: 1.3.5 + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + typescript: + specifier: ~6.0.3 + version: 6.0.3 + vite-plus: + specifier: 0.2.2 + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + packages/fixture-server: dependencies: '@t4-code/protocol': @@ -421,6 +424,31 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + packages/t4-api-client: + dependencies: + openapi-fetch: + specifier: 0.17.0 + version: 0.17.0 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.12.4 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vite-plus: + specifier: 'catalog:' + version: 0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3) + + packages/t4-api-contract: + devDependencies: + '@readme/openapi-parser': + specifier: 6.3.0 + version: 6.3.0(openapi-types@12.1.3) + openapi-typescript: + specifier: 7.13.0 + version: 7.13.0(typescript@6.0.3) + packages/ui: dependencies: '@base-ui/react': @@ -465,6 +493,12 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@apidevtools/json-schema-ref-parser@14.2.1': + resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} + engines: {node: '>= 20'} + peerDependencies: + '@types/json-schema': ^7.0.15 + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -587,6 +621,10 @@ packages: '@fontsource/jetbrains-mono@5.2.8': resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} + '@humanwhocodes/momoa@2.0.4': + resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} + engines: {node: '>=10.10.0'} + '@ionic/cli-framework-output@2.2.8': resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} engines: {node: '>=16.0.0'} @@ -1051,6 +1089,32 @@ packages: '@preact/signals-core@1.14.4': resolution: {integrity: sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==} + '@readme/better-ajv-errors@2.4.0': + resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==} + engines: {node: '>=18'} + peerDependencies: + ajv: 4.11.8 - 8 + + '@readme/openapi-parser@6.3.0': + resolution: {integrity: sha512-pLsspfvN/m6C4BuZ91ZppK2uo86IT4EDhXu6B+giNLehLkuZbtg47OJvOktwDHBmheULPmXg5pzM7065AAHeyQ==} + engines: {node: '>=20'} + peerDependencies: + openapi-types: '>=7' + + '@readme/openapi-schemas@4.0.0': + resolution: {integrity: sha512-hrG//9/+RpOZTrUDirU4OzfMqaxCdY5BMpr3YuLf3Rje9rfNsydQJsH9iCPTie9ZRu9xe+0OUD4fHe0JmGwa2w==} + engines: {node: '>=20'} + + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.17': + resolution: {integrity: sha512-wsV2keCt6B806XpSdezbWZ9aFJYf14YVh+XQf0ESt7M90yqVuxH9//PxvtC70sgj9OCkRM3nRaLfu4MsGQZRig==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -1229,6 +1293,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} @@ -1499,6 +1566,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-formats@2.1.1: resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} peerDependencies: @@ -1510,6 +1585,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1650,6 +1729,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -1698,6 +1780,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2120,6 +2205,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -2205,9 +2294,17 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + js-yaml@4.3.0: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true @@ -2235,6 +2332,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2249,6 +2350,10 @@ packages: lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + lexical@0.41.0: resolution: {integrity: sha512-pNIm5+n+hVnJHB9gYPDYsIO5Y59dNaDU9rJmPPsfqQhP2ojKFnUoPbcRnrI9FJLXB14sSumcY8LUw7Sq70TZqA==} @@ -2634,6 +2739,21 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + + openapi-typescript-helpers@0.1.0: + resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + oxfmt@0.57.0: resolution: {integrity: sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2690,6 +2810,10 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -2748,6 +2872,10 @@ packages: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -3042,6 +3170,10 @@ packages: resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} engines: {node: '>= 8.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3133,6 +3265,10 @@ packages: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -3181,6 +3317,9 @@ packages: unzipper@0.12.5: resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -3335,6 +3474,9 @@ packages: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -3384,6 +3526,11 @@ snapshots: dependencies: zod: 4.4.3 + '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.3.0 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3429,7 +3576,7 @@ snapshots: '@ionic/utils-subprocess': 3.0.1 '@ionic/utils-terminal': 2.3.5 commander: 12.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 11.3.6 kleur: 4.1.5 @@ -3463,7 +3610,7 @@ snapshots: '@electron/get@2.0.3': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -3477,7 +3624,7 @@ snapshots: '@electron/get@3.1.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -3491,7 +3638,7 @@ snapshots: '@electron/notarize@2.5.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 promise-retry: 2.0.1 transitivePeerDependencies: @@ -3500,7 +3647,7 @@ snapshots: '@electron/osx-sign@1.3.3': dependencies: compare-version: 0.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 @@ -3511,7 +3658,7 @@ snapshots: '@electron/rebuild@4.2.0': dependencies: '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) node-abi: 4.33.0 node-api-version: 0.2.1 node-gyp: 12.4.0 @@ -3523,7 +3670,7 @@ snapshots: dependencies: '@electron/asar': 3.4.1 '@malept/cross-spawn-promise': 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dir-compare: 4.2.0 fs-extra: 11.3.6 minimatch: 9.0.9 @@ -3534,7 +3681,7 @@ snapshots: '@electron/windows-sign@1.2.2': dependencies: cross-dirname: 0.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 11.3.6 minimist: 1.2.8 postject: 1.0.0-alpha.6 @@ -3571,17 +3718,19 @@ snapshots: '@fontsource/jetbrains-mono@5.2.8': {} + '@humanwhocodes/momoa@2.0.4': {} + '@ionic/cli-framework-output@2.2.8': dependencies: '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color '@ionic/utils-array@2.1.6': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3589,7 +3738,7 @@ snapshots: '@ionic/utils-fs@3.1.7': dependencies: '@types/fs-extra': 8.1.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 tslib: 2.8.1 transitivePeerDependencies: @@ -3597,7 +3746,7 @@ snapshots: '@ionic/utils-object@2.1.6': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3606,7 +3755,7 @@ snapshots: dependencies: '@ionic/utils-object': 2.1.6 '@ionic/utils-terminal': 2.3.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) signal-exit: 3.0.7 tree-kill: 1.2.2 tslib: 2.8.1 @@ -3615,7 +3764,7 @@ snapshots: '@ionic/utils-stream@3.1.7': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3628,7 +3777,7 @@ snapshots: '@ionic/utils-stream': 3.1.7 '@ionic/utils-terminal': 2.3.5 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) tslib: 2.8.1 transitivePeerDependencies: - supports-color @@ -3636,7 +3785,7 @@ snapshots: '@ionic/utils-terminal@2.3.5': dependencies: '@types/slice-ansi': 4.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) signal-exit: 3.0.7 slice-ansi: 4.0.0 string-width: 4.2.3 @@ -3842,7 +3991,7 @@ snapshots: '@malept/flatpak-bundler@0.4.0': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 9.1.0 lodash: 4.18.1 tmp-promise: 3.0.3 @@ -4021,6 +4170,53 @@ snapshots: '@preact/signals-core@1.14.4': {} + '@readme/better-ajv-errors@2.4.0(ajv@8.20.0)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@humanwhocodes/momoa': 2.0.4 + ajv: 8.20.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + picocolors: 1.1.1 + + '@readme/openapi-parser@6.3.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) + '@readme/better-ajv-errors': 2.4.0(ajv@8.20.0) + '@readme/openapi-schemas': 4.0.0 + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + openapi-types: 12.1.3 + + '@readme/openapi-schemas@4.0.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.17(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.2.0 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/pluginutils@1.0.1': {} '@sindresorhus/is@4.6.0': {} @@ -4185,6 +4381,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/json-schema@7.0.15': {} + '@types/keyv@3.1.4': dependencies: '@types/node': 24.12.4 @@ -4433,6 +4631,10 @@ snapshots: agent-base@7.1.4: {} + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv-formats@2.1.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -4444,6 +4646,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4472,7 +4676,7 @@ snapshots: builder-util-runtime: 9.7.0 chromium-pickle-js: 0.2.0 ci-info: 4.3.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) dmg-builder: 26.15.6(electron-builder-squirrel-windows@26.15.6) dotenv: 16.6.1 dotenv-expand: 11.0.7 @@ -4566,7 +4770,7 @@ snapshots: builder-util-runtime@9.7.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) sax: 1.6.0 transitivePeerDependencies: - supports-color @@ -4577,10 +4781,10 @@ snapshots: builder-util-runtime: 9.7.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 10.1.0 http-proxy-agent: 7.0.2 - https-proxy-agent: 7.0.6 + https-proxy-agent: 7.0.6(supports-color@10.2.2) js-yaml: 4.3.0 sanitize-filename: 1.6.4 source-map-support: 0.5.21 @@ -4622,6 +4826,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -4660,6 +4866,8 @@ snapshots: color-name@1.1.4: {} + colorette@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -4711,9 +4919,11 @@ snapshots: dependencies: mimic-fn: 3.1.0 - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 decode-named-character-reference@1.3.0: dependencies: @@ -4855,7 +5065,7 @@ snapshots: electron-winstaller@5.4.0: dependencies: '@electron/asar': 3.4.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fs-extra: 7.0.1 lodash: 4.18.1 temp: 0.9.4 @@ -4934,7 +5144,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -5199,7 +5409,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5208,13 +5418,15 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 - https-proxy-agent@7.0.6: + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + index-to-position@1.2.0: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -5273,8 +5485,14 @@ snapshots: jiti@2.7.0: {} + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -5300,6 +5518,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonpointer@5.0.1: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -5310,6 +5530,8 @@ snapshots: lazy-val@1.0.5: {} + leven@3.1.0: {} + lexical@0.41.0: {} lib0@0.2.117: @@ -5735,7 +5957,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -5809,7 +6031,7 @@ snapshots: '@ionic/utils-fs': 3.1.7 '@ionic/utils-terminal': 2.3.5 bplist-parser: 0.3.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) elementtree: 0.1.7 ini: 4.1.3 plist: 3.1.0 @@ -5868,6 +6090,24 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-fetch@0.17.0: + dependencies: + openapi-typescript-helpers: 0.1.0 + + openapi-types@12.1.3: {} + + openapi-typescript-helpers@0.1.0: {} + + openapi-typescript@7.13.0(typescript@6.0.3): + dependencies: + '@redocly/openapi-core': 1.34.17(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 6.0.3 + yargs-parser: 21.1.1 + oxfmt@0.57.0(vite-plus@0.2.2(@types/node@24.12.4)(jiti@2.7.0)(typescript@6.0.3)): dependencies: tinypool: 2.1.0 @@ -6003,6 +6243,12 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -6055,6 +6301,8 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + pluralize@8.0.0: {} + pngjs@7.0.0: {} postcss@8.5.16: @@ -6150,7 +6398,7 @@ snapshots: read-binary-file-arch@1.0.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -6371,10 +6619,12 @@ snapshots: sumchecker@3.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6453,6 +6703,8 @@ snapshots: type-fest@2.19.0: {} + type-fest@4.41.0: {} + typescript@6.0.3: {} undici-types@7.16.0: {} @@ -6508,6 +6760,8 @@ snapshots: graceful-fs: 4.2.11 node-int64: 0.4.0 + uri-js-replace@1.0.1: {} + use-sync-external-store@1.6.0(react@19.2.6): dependencies: react: 19.2.6 @@ -6757,6 +7011,8 @@ snapshots: yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} + yargs-parser@21.1.1: {} yargs@17.7.3: From 8a7e8cc3f934af3482d35d9161cf473e25c73d7a Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 19:44:29 +0000 Subject: [PATCH 05/70] fix: use OpenAPI parser named API --- packages/t4-api-contract/scripts/validate.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs index 521893c0..40a5e4d6 100644 --- a/packages/t4-api-contract/scripts/validate.mjs +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -1,11 +1,12 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -import OpenAPIParser from "@readme/openapi-parser"; +import { compileErrors, validate } from "@readme/openapi-parser"; const source = new URL("../openapi.json", import.meta.url); const document = JSON.parse(await readFile(source, "utf8")); -await OpenAPIParser.validate(fileURLToPath(source)); +const validation = await validate(fileURLToPath(source)); +if (!validation.valid || validation.warnings.length > 0) throw new Error(compileErrors(validation)); if (document.openapi !== "3.1.0") throw new Error("T4 API contract must remain OpenAPI 3.1.0"); if (!Array.isArray(document.servers) || document.servers.length === 0) throw new Error("T4 API contract requires an HTTPS server"); From e549d60a8b3f5f732ceb6b71bf4bf972ee363ea7 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 19:47:47 +0000 Subject: [PATCH 06/70] feat: add generated T4 API v1 types --- .github/workflows/ci.yml | 19 +- .woodpecker.yml | 26 +- .../t4-api-client/src/generated/schema.ts | 1035 +++++++++++++++++ 3 files changed, 1041 insertions(+), 39 deletions(-) create mode 100755 packages/t4-api-client/src/generated/schema.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 631e07ea..96e78302 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,24 +76,15 @@ jobs: with: node-version: 24.13.1 - - name: Install generation dependencies from the recovered lockfile + - name: Install dependencies from the committed lockfile run: pnpm install --frozen-lockfile - - name: Generate and validate the T4 API contract artifact + - name: Validate OpenAPI and deterministic generation run: | - pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract validate - - - name: Upload deterministic T4 API generation inputs - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: t4-api-generation-${{ github.run_id }} - path: | - pnpm-lock.yaml - artifacts/t4-api/generated/schema.ts - if-no-files-found: error - retention-days: 14 + pnpm --filter @t4-code/t4-api-contract check:generated + pnpm --filter @t4-code/t4-api-client typecheck + pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts core: runs-on: ubuntu-24.04 diff --git a/.woodpecker.yml b/.woodpecker.yml index 1f2cdf64..019c4f4c 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -12,31 +12,7 @@ clone: partial: false steps: - t4-api-artifact-bootstrap: - image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 - commands: - - corepack enable - - pnpm install --frozen-lockfile - - pnpm --filter @t4-code/t4-api-contract generate:ci - - pnpm --filter @t4-code/t4-api-contract validate - when: - - event: push - branch: agent/t4-op03-public-api-contract - backend_options: - kubernetes: - serviceAccountName: woodpecker-ci-untrusted - resources: - requests: - cpu: "0" - memory: 512Mi - ephemeral-storage: 1Gi - limits: - cpu: "1" - memory: 2Gi - ephemeral-storage: 4Gi - dependencies: - depends_on: [t4-api-artifact-bootstrap] image: mirror.gcr.io/library/node:24.13.1-bookworm@sha256:00e9195ebd49985a6da8921f419978d85dfe354589755192dc090425ce4da2f7 commands: - corepack enable @@ -61,7 +37,7 @@ steps: commands: - corepack enable - pnpm --filter @t4-code/t4-api-contract validate - - pnpm --filter @t4-code/t4-api-contract generate:ci + - pnpm --filter @t4-code/t4-api-contract check:generated - pnpm --filter @t4-code/t4-api-client typecheck - pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts backend_options: diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts new file mode 100755 index 00000000..03954962 --- /dev/null +++ b/packages/t4-api-client/src/generated/schema.ts @@ -0,0 +1,1035 @@ +export interface paths { + "/v1": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Negotiate T4 API v1 and discover capabilities and bounds */ + get: operations["discoverV1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}": { + parameters: { + query?: never; + header?: never; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + get: operations["getSession"]; + put?: never; + post?: never; + delete: operations["deleteSession"]; + options?: never; + head?: never; + patch: operations["mutateSession"]; + trace?: never; + }; + "/v1/sessions/{sessionId}/cancel": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["cancelSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}/commands": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["submitCommand"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}/events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Watch a bounded Server-Sent Events stream over HTTPS + * @description The response is standard Server-Sent Events over HTTPS. Clients reconnect with the last event cursor in the cursor query parameter or Last-Event-ID header. Heartbeat events bound idle detection. Clients explicitly cancel by aborting the HTTPS request. A retained cursor expiry returns a typed 410 response with a snapshot resync target. + */ + get: operations["watchSessionEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/sessions/{sessionId}/snapshot": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getSessionSnapshot"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listWorkspaces"]; + put?: never; + post: operations["createWorkspace"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/v1/workspaces/{workspaceId}": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + get: operations["getWorkspace"]; + put?: never; + post?: never; + delete: operations["deleteWorkspace"]; + options?: never; + head?: never; + patch: operations["mutateWorkspace"]; + trace?: never; + }; + "/v1/workspaces/{workspaceId}/sessions": { + parameters: { + query?: never; + header?: never; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + get: operations["listSessions"]; + put?: never; + post: operations["spawnSession"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + ApiError: { + /** @enum {string} */ + code: "invalid_request" | "unauthenticated" | "forbidden" | "not_found" | "idempotency_key_required" | "idempotency_conflict" | "revision_conflict" | "incompatible_version" | "cursor_expired" | "unavailable" | "indeterminate" | "invalid_origin" | "https_required"; + message: string; + requestId: string; + resync?: components["schemas"]["Resync"]; + retryable: boolean; + supportedMajors?: number[]; + violations?: components["schemas"]["FieldViolation"][]; + }; + CommandCreate: { + command: string; + metadata?: { + [key: string]: string | number | boolean | null; + }; + }; + CommandResult: { + commandId: components["schemas"]["ResourceId"]; + state: components["schemas"]["OperationState"]; + }; + CommandWatchEvent: { + commandId: components["schemas"]["ResourceId"]; + cursor: components["schemas"]["Cursor"]; + state: components["schemas"]["OperationState"]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "CommandWatchEvent"; + }; + /** @description Opaque server-issued header-safe SSE cursor. */ + Cursor: string; + CursorExpiredApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "cursor_expired"; + resync: components["schemas"]["Resync"]; + }; + CursorExpiredErrorEnvelope: { + error: components["schemas"]["CursorExpiredApiError"]; + }; + Discovery: { + apiVersion: string; + capabilities: string[]; + limits: { + commandBytesMax: number; + heartbeatSeconds: number; + pageSizeDefault: number; + pageSizeMax: number; + watchEventsMax: number; + }; + supportedMajors: number[]; + }; + ErrorEnvelope: { + error: components["schemas"]["ApiError"]; + }; + FieldViolation: { + field: string; + message: string; + rule: string; + }; + HeartbeatWatchEvent: { + cursor: components["schemas"]["Cursor"]; + /** Format: date-time */ + observedAt: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "HeartbeatWatchEvent"; + }; + IncompatibleVersionApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "incompatible_version"; + supportedMajors: number[]; + }; + IncompatibleVersionErrorEnvelope: { + error: components["schemas"]["IncompatibleVersionApiError"]; + }; + InvalidRequestApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "invalid_request"; + violations: components["schemas"]["FieldViolation"][]; + }; + InvalidRequestErrorEnvelope: { + error: components["schemas"]["InvalidRequestApiError"]; + }; + Labels: { + [key: string]: string; + }; + /** @enum {string} */ + OperationState: "accepted" | "rejected" | "conflict" | "unavailable" | "indeterminate"; + ResourceId: string; + Resync: { + cursor: components["schemas"]["Cursor"]; + snapshotUrl: string; + }; + Revision: number; + Session: { + id: components["schemas"]["ResourceId"]; + labels?: components["schemas"]["Labels"]; + revision: components["schemas"]["Revision"]; + state: components["schemas"]["SessionState"]; + title: string; + workspaceId: components["schemas"]["ResourceId"]; + }; + SessionCreate: { + labels?: components["schemas"]["Labels"]; + title: string; + }; + SessionMutation: { + labels?: components["schemas"]["Labels"]; + title?: string; + }; + SessionPage: { + items: components["schemas"]["Session"][]; + nextCursor?: components["schemas"]["Cursor"]; + }; + SessionSnapshot: { + cursor: components["schemas"]["Cursor"]; + entries: components["schemas"]["SnapshotEntry"][]; + sessionId: components["schemas"]["ResourceId"]; + state: components["schemas"]["SessionState"]; + }; + /** @enum {string} */ + SessionState: "accepted" | "provisioning" | "ready" | "cancelling" | "cancelled" | "failed" | "unavailable" | "indeterminate"; + SessionWatchEvent: { + cursor: components["schemas"]["Cursor"]; + revision: components["schemas"]["Revision"]; + state: components["schemas"]["SessionState"]; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SessionWatchEvent"; + }; + SnapshotEntry: { + /** @enum {string} */ + kind: "input" | "output" | "status"; + sequence: number; + text: string; + }; + WatchEvent: components["schemas"]["HeartbeatWatchEvent"] | components["schemas"]["SessionWatchEvent"] | components["schemas"]["CommandWatchEvent"]; + Workspace: { + id: components["schemas"]["ResourceId"]; + labels?: components["schemas"]["Labels"]; + name: string; + revision: components["schemas"]["Revision"]; + state: components["schemas"]["WorkspaceState"]; + }; + WorkspaceCreate: { + labels?: components["schemas"]["Labels"]; + name: string; + }; + WorkspaceMutation: { + labels?: components["schemas"]["Labels"]; + name?: string; + }; + WorkspacePage: { + items: components["schemas"]["Workspace"][]; + nextCursor?: components["schemas"]["Cursor"]; + }; + /** @enum {string} */ + WorkspaceState: "accepted" | "provisioning" | "ready" | "deleting" | "deleted" | "failed" | "unavailable" | "indeterminate"; + }; + responses: { + BadRequest: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Command intent accepted with a stable outcome state */ + CommandAccepted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResult"]; + }; + }; + /** @description Identical command request replay */ + CommandReplay: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResult"]; + }; + }; + Conflict: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + CursorExpired: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Deletion accepted or resource already absent */ + Deleted: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content?: never; + }; + /** @description Negotiated v1 discovery */ + Discovery: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Discovery"]; + }; + }; + /** @description Malformed request or missing idempotency key */ + Error400: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Missing or invalid opaque bearer credential */ + Error401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Credential lacks the deny-by-default operation or resource scope */ + Error403: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Resource absent or outside caller scope (scope existence is not disclosed) */ + Error404: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Requested major is incompatible; no silent downgrade */ + Error406: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IncompatibleVersionErrorEnvelope"]; + }; + }; + /** @description Idempotency or resource revision conflict */ + Error409: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + /** @description Watch cursor expired; resync from the typed snapshot target */ + Error410: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CursorExpiredErrorEnvelope"]; + }; + }; + /** @description Bounded semantic validation failure with stable field violations */ + Error422: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvalidRequestErrorEnvelope"]; + }; + }; + /** @description Service temporarily unavailable or outcome indeterminate */ + Error503: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorEnvelope"]; + }; + }; + Forbidden: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + IncompatibleVersion: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + InvalidRequest: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + NotFound: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Session */ + Session: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + /** @description Session intent accepted */ + SessionAccepted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + /** @description Bounded session page */ + SessionPage: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionPage"]; + }; + }; + /** @description Identical session spawn replay */ + SessionReplay: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Session"]; + }; + }; + /** @description Bounded session snapshot and reconnect cursor */ + Snapshot: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionSnapshot"]; + }; + }; + Unauthenticated: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + Unavailable: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Workspace */ + Workspace: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workspace"]; + }; + }; + /** @description Workspace intent accepted */ + WorkspaceAccepted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workspace"]; + }; + }; + /** @description Bounded workspace page */ + WorkspacePage: { + headers: { + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["WorkspacePage"]; + }; + }; + /** @description Identical workspace request replay */ + WorkspaceReplay: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Workspace"]; + }; + }; + }; + parameters: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + ApiVersion: string; + HeartbeatSeconds: number; + /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + IdempotencyKey: string; + /** @description Decimal resource revision for optimistic mutation. */ + IfMatch: string; + /** @description Standard SSE reconnect header. Must agree with cursor when both are present. */ + LastEventId: components["schemas"]["Cursor"]; + MaxEvents: number; + /** @description Opaque page cursor, valid only for the same identity and list operation. */ + PageCursor: components["schemas"]["Cursor"]; + PageSize: number; + SessionId: components["schemas"]["ResourceId"]; + /** @description Opaque last-consumed watch event cursor. */ + WatchCursor: components["schemas"]["Cursor"]; + WorkspaceId: components["schemas"]["ResourceId"]; + }; + requestBodies: never; + headers: { + /** @description True when this response replays a prior identical request. */ + IdempotencyReplayed: "true" | "false"; + /** @description Selected compatible T4 API minor version. */ + SelectedVersion: string; + }; + pathItems: never; +} +export type $defs = Record; +export interface operations { + discoverV1: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Discovery"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 406: components["responses"]["IncompatibleVersion"]; + 503: components["responses"]["Unavailable"]; + }; + }; + getSession: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Session"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 503: components["responses"]["Unavailable"]; + }; + }; + deleteSession: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: components["responses"]["Deleted"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 503: components["responses"]["Unavailable"]; + }; + }; + mutateSession: { + parameters: { + query?: never; + header: { + /** @description Decimal resource revision for optimistic mutation. */ + "If-Match": components["parameters"]["IfMatch"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionMutation"]; + }; + }; + responses: { + 200: components["responses"]["Session"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + cancelSession: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 202: components["responses"]["SessionAccepted"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 503: components["responses"]["Unavailable"]; + }; + }; + submitCommand: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommandCreate"]; + }; + }; + responses: { + 200: components["responses"]["CommandReplay"]; + 202: components["responses"]["CommandAccepted"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + watchSessionEvents: { + parameters: { + query?: { + /** @description Opaque last-consumed watch event cursor. */ + cursor?: components["parameters"]["WatchCursor"]; + heartbeatSeconds?: components["parameters"]["HeartbeatSeconds"]; + maxEvents?: components["parameters"]["MaxEvents"]; + }; + header: { + /** @description Standard SSE reconnect header. Must agree with cursor when both are present. */ + "Last-Event-ID"?: components["parameters"]["LastEventId"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Bounded SSE event stream; each data field is a WatchEvent JSON value */ + 200: { + headers: { + "Cache-Control"?: "no-store"; + "T4-API-Version": components["headers"]["SelectedVersion"]; + [name: string]: unknown; + }; + content: { + /** @example id: cur_01J...\nevent: heartbeat\ndata: {"type":"heartbeat","cursor":"cur_01J...","observedAt":"2026-07-21T00:00:00Z"}\n\n */ + "text/event-stream": string; + }; + }; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 410: components["responses"]["CursorExpired"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + getSessionSnapshot: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Snapshot"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 503: components["responses"]["Unavailable"]; + }; + }; + listWorkspaces: { + parameters: { + query?: { + /** @description Opaque page cursor, valid only for the same identity and list operation. */ + cursor?: components["parameters"]["PageCursor"]; + pageSize?: components["parameters"]["PageSize"]; + }; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["WorkspacePage"]; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 406: components["responses"]["IncompatibleVersion"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + createWorkspace: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkspaceCreate"]; + }; + }; + responses: { + 200: components["responses"]["WorkspaceReplay"]; + 202: components["responses"]["WorkspaceAccepted"]; + 400: components["responses"]["BadRequest"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + getWorkspace: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["Workspace"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 503: components["responses"]["Unavailable"]; + }; + }; + deleteWorkspace: { + parameters: { + query?: never; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: components["responses"]["Deleted"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 503: components["responses"]["Unavailable"]; + }; + }; + mutateWorkspace: { + parameters: { + query?: never; + header: { + /** @description Decimal resource revision for optimistic mutation. */ + "If-Match": components["parameters"]["IfMatch"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkspaceMutation"]; + }; + }; + responses: { + 200: components["responses"]["Workspace"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + listSessions: { + parameters: { + query?: { + /** @description Opaque page cursor, valid only for the same identity and list operation. */ + cursor?: components["parameters"]["PageCursor"]; + pageSize?: components["parameters"]["PageSize"]; + }; + header: { + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["SessionPage"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; + spawnSession: { + parameters: { + query?: never; + header: { + /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; + /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionCreate"]; + }; + }; + responses: { + 200: components["responses"]["SessionReplay"]; + 202: components["responses"]["SessionAccepted"]; + 401: components["responses"]["Unauthenticated"]; + 403: components["responses"]["Forbidden"]; + 404: components["responses"]["NotFound"]; + 406: components["responses"]["IncompatibleVersion"]; + 409: components["responses"]["Conflict"]; + 422: components["responses"]["InvalidRequest"]; + 503: components["responses"]["Unavailable"]; + }; + }; +} + From 6cd0e12dcc28bdc14c60bbb3314bf4ee2b622cb3 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 19:54:50 +0000 Subject: [PATCH 07/70] fix: typecheck T4 API conformance fixtures --- .github/workflows/ci.yml | 1 + .woodpecker.yml | 1 + packages/client/test/t4-api-v1-conformance-service.ts | 6 +++--- packages/client/test/t4-api-v1-conformance.test.ts | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96e78302..9b0c5438 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,6 +84,7 @@ jobs: pnpm --filter @t4-code/t4-api-contract validate pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck + pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts core: diff --git a/.woodpecker.yml b/.woodpecker.yml index 019c4f4c..c7ad7c55 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -39,6 +39,7 @@ steps: - pnpm --filter @t4-code/t4-api-contract validate - pnpm --filter @t4-code/t4-api-contract check:generated - pnpm --filter @t4-code/t4-api-client typecheck + - pnpm --filter @t4-code/client typecheck - pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts backend_options: kubernetes: diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 2e9cb745..8786698d 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -122,7 +122,7 @@ export class T4ApiV1ConformanceService { if (request.headers.get("If-Match") !== String(workspace.revision)) { return problem(409, "revision_conflict", "Workspace revision changed"); } - const updated = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; + const updated: Record = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; this.#workspaces.set(id, updated); const { tenant: _tenant, ...visible } = updated; return json(200, visible); @@ -181,7 +181,7 @@ export class T4ApiV1ConformanceService { if (request.headers.get("If-Match") !== String(session.revision)) { return problem(409, "revision_conflict", "Session revision changed"); } - const updated = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; + const updated: Record = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; this.#sessions.set(id, updated); const { tenant: _tenant, ...visible } = updated; return json(200, visible); @@ -196,7 +196,7 @@ export class T4ApiV1ConformanceService { if (cancelMatch && request.method === "POST") { const session = this.#sessions.get(decodeURIComponent(cancelMatch[1]!)); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); - const cancelled = { ...session, state: "cancelled", revision: Number(session.revision) + 1 }; + const cancelled: Record = { ...session, state: "cancelled", revision: Number(session.revision) + 1 }; this.#sessions.set(String(session.id), cancelled); const { tenant: _tenant, ...visible } = cancelled; return json(202, visible); diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 7d312024..68f7ab42 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -85,7 +85,7 @@ describe("generated T4 API v1 client conformance", () => { expect(pageOne.items).toHaveLength(2); expect(pageOne.nextCursor).toBe("page-2"); const pageTwo = requireData(await owner.http.GET("/v1/workspaces", { - params: { header: VERSION_HEADERS, query: { pageSize: 2, cursor: pageOne.nextCursor } }, + params: { header: VERSION_HEADERS, query: { pageSize: 2, cursor: pageOne.nextCursor! } }, })); expect(pageTwo.items).toHaveLength(2); expect(pageTwo.nextCursor).toBeUndefined(); @@ -148,7 +148,7 @@ describe("generated T4 API v1 client conformance", () => { expect(sessionPageOne.items).toHaveLength(2); expect(sessionPageOne.nextCursor).toBe("page-2"); const sessionPageTwo = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { - params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2, cursor: sessionPageOne.nextCursor } }, + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2, cursor: sessionPageOne.nextCursor! } }, })); expect(sessionPageTwo.items).toHaveLength(1); From bcca1cbf2fc5677dffa6a0c9adcec45264e0cc30 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:23:36 +0000 Subject: [PATCH 08/70] test: expose T4 API semantic blockers --- .../test/t4-api-v1-conformance-service.ts | 272 +++++++++--------- .../client/test/t4-api-v1-conformance.test.ts | 177 ++++++++++-- 2 files changed, 303 insertions(+), 146 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 8786698d..15b76db8 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -1,32 +1,43 @@ import { expect } from "vite-plus/test"; const encoder = new TextEncoder(); +const COMMAND_BYTES_MAX = 32; +const COMMAND_REQUEST_BYTES_MAX = 256; +const METADATA_VALUE_BYTES_MAX = 32; function json(status: number, body: unknown, headers: Record = {}): Response { return Response.json(body, { status, headers: { "T4-API-Version": "1.0", ...headers } }); } function problem(status: number, code: string, message: string, extra: Record = {}): Response { - return json(status, { - error: { - code, - message, - requestId: `req-${code}`, - retryable: status >= 500, - ...extra, - }, - }); + return json(status, { error: { code, message, requestId: `req-${code}`, retryable: status >= 500, ...extra } }); } -function bodyKey(value: unknown): string { - if (Array.isArray(value)) return `[${value.map(bodyKey).join(",")}]`; - if (value !== null && typeof value === "object") { +function compareUtf16(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +export function canonicalJson(value: unknown): string { + if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("JCS only permits finite JSON numbers"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object") { return `{${Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, item]) => `${JSON.stringify(key)}:${bodyKey(item)}`) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => compareUtf16(left, right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) .join(",")}}`; } - return JSON.stringify(value) ?? "null"; + throw new TypeError("JCS identity requires a JSON value"); +} + +interface ReplayRecord { + readonly identity: string; + readonly body: unknown; + readonly replayStatus: number; } export class T4ApiV1ConformanceService { @@ -40,7 +51,7 @@ export class T4ApiV1ConformanceService { #commandSequence = 0; readonly #workspaces = new Map>(); readonly #sessions = new Map>(); - readonly #replays = new Map }>(); + readonly #replays = new Map(); readonly fetch: typeof globalThis.fetch = async (input, init) => { const request = new Request(input, init); @@ -49,30 +60,26 @@ export class T4ApiV1ConformanceService { this.calls.push({ method: request.method, path: url.pathname, authorization }); if (url.origin !== this.origin) return problem(400, "invalid_origin", "HTTPS API origin is fixed for this client"); if (url.protocol !== "https:") return problem(400, "https_required", "HTTPS is required"); - if (authorization !== "Bearer token-a" && authorization !== "Bearer token-b") { + if (authorization !== "Bearer token-a" && authorization !== "Bearer token-b" && authorization !== "Bearer token-denied") { return problem(401, "unauthenticated", "A valid bearer credential is required"); } if (request.headers.get("T4-API-Version") !== "1") { - return problem(406, "incompatible_version", "No compatible T4 API major version", { - supportedMajors: [1], - }); + return problem(406, "incompatible_version", "No compatible T4 API major version", { supportedMajors: [1] }); } + if (authorization === "Bearer token-denied") return problem(403, "forbidden", "Credential lacks the required operation scope"); const tenant = authorization === "Bearer token-a" ? "tenant-a" : "tenant-b"; if (request.method === "GET" && url.pathname === "/v1") { return json(200, { apiVersion: "1.0", supportedMajors: [1], - capabilities: [ - "workspace.lifecycle", - "session.lifecycle", - "session.commands", - "session.watch.sse", - ], + capabilities: ["workspace.lifecycle", "session.lifecycle", "session.commands", "session.watch.sse"], limits: { pageSizeDefault: 2, pageSizeMax: 3, - commandBytesMax: 32, + commandBytesMax: COMMAND_BYTES_MAX, + commandRequestBytesMax: COMMAND_REQUEST_BYTES_MAX, + commandMetadataValueBytesMax: METADATA_VALUE_BYTES_MAX, watchEventsMax: 4, heartbeatSeconds: 15, }, @@ -81,14 +88,10 @@ export class T4ApiV1ConformanceService { if (request.method === "POST" && url.pathname === "/v1/workspaces") { const body = await request.json() as Record; - if (typeof body.name !== "string" || body.name.length < 1 || body.name.length > 128) { - return problem(422, "invalid_request", "Request validation failed", { - violations: [{ field: "name", rule: "length", message: "name must contain 1 to 128 characters" }], - }); - } - return this.#idempotent(request, tenant, body, () => { + if (typeof body.name !== "string" || body.name.length < 1 || body.name.length > 128) return this.#invalid("name", "length", "name must contain 1 to 128 characters"); + return this.#idempotent(request, tenant, "createWorkspace", [], body, 202, 200, () => { const id = `ws-${++this.#workspaceSequence}`; - const workspace = { id, name: body.name, state: "accepted", revision: 1, tenant }; + const workspace = { id, name: body.name, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; this.#workspaces.set(id, workspace); return workspace; }); @@ -96,11 +99,7 @@ export class T4ApiV1ConformanceService { if (request.method === "GET" && url.pathname === "/v1/workspaces") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); - if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) { - return problem(422, "invalid_request", "Request validation failed", { - violations: [{ field: "pageSize", rule: "range", message: "pageSize must be between 1 and 3" }], - }); - } + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); const start = Number(url.searchParams.get("cursor")?.replace("page-", "") ?? "0"); const visible = [...this.#workspaces.values()].filter((item) => item.tenant === tenant); const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); @@ -113,23 +112,21 @@ export class T4ApiV1ConformanceService { const id = decodeURIComponent(workspaceMatch[1]!); const workspace = this.#workspaces.get(id); if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); - if (request.method === "GET") { - const { tenant: _tenant, ...visible } = workspace; - return json(200, visible); - } + if (request.method === "GET") return json(200, this.#visible(workspace)); if (request.method === "PATCH") { const body = await request.json() as Record; - if (request.headers.get("If-Match") !== String(workspace.revision)) { - return problem(409, "revision_conflict", "Workspace revision changed"); - } - const updated: Record = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; - this.#workspaces.set(id, updated); - const { tenant: _tenant, ...visible } = updated; - return json(200, visible); + if (request.headers.get("If-Match") !== String(workspace.revision)) return problem(409, "revision_conflict", "Workspace revision changed"); + return this.#idempotent(request, tenant, "mutateWorkspace", [id], body, 200, 200, () => { + const updated = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; + this.#workspaces.set(id, updated); + return updated; + }); } if (request.method === "DELETE") { - this.#workspaces.delete(id); - return new Response(null, { status: 204, headers: { "T4-API-Version": "1.0" } }); + return this.#idempotent(request, tenant, "deleteWorkspace", [id], null, 204, 204, () => { + this.#workspaces.delete(id); + return null; + }); } } @@ -140,12 +137,8 @@ export class T4ApiV1ConformanceService { if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); if (request.method === "POST") { const body = await request.json() as Record; - if (typeof body.title !== "string" || body.title.length < 1 || body.title.length > 128) { - return problem(422, "invalid_request", "Request validation failed", { - violations: [{ field: "title", rule: "length", message: "title must contain 1 to 128 characters" }], - }); - } - return this.#idempotent(request, tenant, body, () => { + if (typeof body.title !== "string" || body.title.length < 1 || body.title.length > 128) return this.#invalid("title", "length", "title must contain 1 to 128 characters"); + return this.#idempotent(request, tenant, "spawnSession", [workspaceId], body, 202, 200, () => { const id = `ses-${++this.#sessionSequence}`; const session = { id, workspaceId, title: body.title, state: "accepted", revision: 1, tenant }; this.#sessions.set(id, session); @@ -154,14 +147,10 @@ export class T4ApiV1ConformanceService { } if (request.method === "GET") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); - if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) { - return problem(422, "invalid_request", "Request validation failed", { - violations: [{ field: "pageSize", rule: "range", message: "pageSize must be between 1 and 3" }], - }); - } + if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); const start = Number(url.searchParams.get("cursor")?.replace("page-", "") ?? "0"); const visible = [...this.#sessions.values()].filter((item) => item.workspaceId === workspaceId && item.tenant === tenant); - const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); + const items = visible.slice(start, start + pageSize).map((item) => this.#visible(item)); const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } @@ -172,49 +161,50 @@ export class T4ApiV1ConformanceService { const id = decodeURIComponent(sessionMatch[1]!); const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); - if (request.method === "GET") { - const { tenant: _tenant, ...visible } = session; - return json(200, visible); - } + if (request.method === "GET") return json(200, this.#visible(session)); if (request.method === "PATCH") { const body = await request.json() as Record; - if (request.headers.get("If-Match") !== String(session.revision)) { - return problem(409, "revision_conflict", "Session revision changed"); - } - const updated: Record = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; - this.#sessions.set(id, updated); - const { tenant: _tenant, ...visible } = updated; - return json(200, visible); + if (request.headers.get("If-Match") !== String(session.revision)) return problem(409, "revision_conflict", "Session revision changed"); + return this.#idempotent(request, tenant, "mutateSession", [id], body, 200, 200, () => { + const updated = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; + this.#sessions.set(id, updated); + return updated; + }); } if (request.method === "DELETE") { - this.#sessions.delete(id); - return new Response(null, { status: 204, headers: { "T4-API-Version": "1.0" } }); + return this.#idempotent(request, tenant, "deleteSession", [id], null, 204, 204, () => { + this.#sessions.delete(id); + return null; + }); } } const cancelMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/cancel$/u); if (cancelMatch && request.method === "POST") { - const session = this.#sessions.get(decodeURIComponent(cancelMatch[1]!)); + const id = decodeURIComponent(cancelMatch[1]!); + const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); - const cancelled: Record = { ...session, state: "cancelled", revision: Number(session.revision) + 1 }; - this.#sessions.set(String(session.id), cancelled); - const { tenant: _tenant, ...visible } = cancelled; - return json(202, visible); + return this.#idempotent(request, tenant, "cancelSession", [id], null, 202, 200, () => { + const cancelled = { ...session, state: "cancelled", revision: Number(session.revision) + 1 }; + this.#sessions.set(id, cancelled); + return cancelled; + }); } const commandMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/commands$/u); if (commandMatch && request.method === "POST") { - const session = this.#sessions.get(decodeURIComponent(commandMatch[1]!)); + const id = decodeURIComponent(commandMatch[1]!); + const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); - const body = await request.json() as Record; - if (typeof body.command !== "string" || body.command.length < 1 || encoder.encode(body.command).byteLength > 32) { - return problem(422, "invalid_request", "Request validation failed", { - violations: [{ field: "command", rule: "maxBytes", message: "command must contain 1 to 32 UTF-8 bytes" }], - }); - } + const text = await request.text(); + if (encoder.encode(text).byteLength > COMMAND_REQUEST_BYTES_MAX) return this.#invalid("body", "maxBytes", `request must not exceed ${COMMAND_REQUEST_BYTES_MAX} UTF-8 bytes`); + let body: Record; + try { body = JSON.parse(text) as Record; } catch { return problem(400, "invalid_request", "Malformed JSON request"); } + if (typeof body.command !== "string" || body.command.length < 1 || encoder.encode(body.command).byteLength > COMMAND_BYTES_MAX) return this.#invalid("command", "maxBytes", `command must contain 1 to ${COMMAND_BYTES_MAX} UTF-8 bytes`); + if (!this.#validMetadata(body.metadata)) return this.#invalid("metadata", "bounds", "metadata keys and values exceed the discovered bounds"); const states: Record = { accepted: true, rejected: true, conflict: true, unavailable: true, indeterminate: true }; const state = states[body.command] === true ? body.command : "accepted"; - return this.#idempotent(request, tenant, body, () => ({ commandId: `cmd-${++this.#commandSequence}`, state })); + return this.#idempotent(request, tenant, "submitCommand", [id], { metadata: {}, ...body }, 202, 200, () => ({ commandId: `cmd-${++this.#commandSequence}`, state })); } const snapshotMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/snapshot$/u); @@ -229,71 +219,95 @@ export class T4ApiV1ConformanceService { const id = decodeURIComponent(watchMatch[1]!); const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + const maxEvents = Number(url.searchParams.get("maxEvents") ?? "100"); + const heartbeat = Number(url.searchParams.get("heartbeatSeconds") ?? "15"); + if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > 4) return this.#invalid("maxEvents", "range", "maxEvents exceeds the discovered bound"); + if (!Number.isInteger(heartbeat) || heartbeat < 5 || heartbeat > 60) return this.#invalid("heartbeatSeconds", "range", "heartbeatSeconds must be between 5 and 60"); const queryCursor = url.searchParams.get("cursor"); const headerCursor = request.headers.get("Last-Event-ID"); this.watchCursors.push({ query: queryCursor, header: headerCursor }); - if (queryCursor !== null && headerCursor !== null && queryCursor !== headerCursor) { - return problem(400, "invalid_request", "Reconnect cursors disagree"); - } + if (queryCursor !== null && headerCursor !== null && queryCursor !== headerCursor) return problem(400, "invalid_request", "Reconnect cursors disagree"); const cursor = queryCursor ?? headerCursor; - if (cursor === "expired") { - return problem(410, "cursor_expired", "Watch cursor is no longer retained", { - resync: { snapshotUrl: `/v1/sessions/${id}/snapshot`, cursor: "cursor-2" }, - }); - } - const frames = cursor === "cursor-4" + if (cursor === "expired") return problem(410, "cursor_expired", "Watch cursor is no longer retained", { resync: { snapshotUrl: `/v1/sessions/${id}/snapshot`, cursor: "cursor-2" } }); + const allFrames = cursor === "cursor-4" ? [`id: cursor-5\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-5","observedAt":"2026-07-21T00:00:15Z"}\r\n\r\n`] - : [ - `id: cursor-3\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-3","observedAt":"2026-07-21T00:00:00Z"}\r\n\r\n`, - `id: cursor-4\r\nevent: session\r\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\r\n\r\n`, - ]; + : cursor === "cursor-5" + ? [`id: cursor-6\nevent: command\ndata: {"type":"command","cursor":"cursor-6","commandId":"cmd-1","state":"accepted"}\n\n`] + : [ + `id: cursor-3\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-3","observedAt":"2026-07-21T00:00:00Z"}\r\n\r\n`, + `id: cursor-4\r\nevent: session\r\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\r\n\r\n`, + ]; + const frames = allFrames.slice(0, maxEvents); const stream = new ReadableStream({ start: (controller) => { const payload = encoder.encode(frames.join("")); const split = Math.max(1, payload.byteLength - 3); controller.enqueue(payload.slice(0, split)); controller.enqueue(payload.slice(split)); - request.signal.addEventListener("abort", () => { - this.abortedWatches.push(id); - try { controller.close(); } catch { /* already closed */ } - }, { once: true }); + controller.close(); + request.signal.addEventListener("abort", () => this.abortedWatches.push(id), { once: true }); }, }); - return new Response(stream, { - status: 200, - headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "T4-API-Version": "1.0" }, - }); + return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-store", "T4-API-Version": "1.0" } }); } return problem(404, "not_found", "Resource not found"); }; expectNoCredentialLeak(): void { - expect(this.calls.every((call) => call.authorization === "Bearer token-a" || call.authorization === "Bearer token-b")).toBe(true); + expect(this.calls.every((call) => call.authorization === "Bearer token-a" || call.authorization === "Bearer token-b" || call.authorization === "Bearer token-denied")).toBe(true); + } + + #invalid(field: string, rule: string, message: string): Response { + return problem(422, "invalid_request", "Request validation failed", { violations: [{ field, rule, message }] }); + } + + #visible(value: Record): Record { + const { tenant: _tenant, ...visible } = value; + return visible; + } + + #validMetadata(value: unknown): boolean { + if (value === undefined) return true; + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const entries = Object.entries(value as Record); + return entries.length <= 32 && entries.every(([key, item]) => + /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && + (item === null || typeof item === "boolean" || + (typeof item === "number" && Number.isSafeInteger(item)) || + (typeof item === "string" && encoder.encode(item).byteLength <= METADATA_VALUE_BYTES_MAX))); } #idempotent( request: Request, - tenant: string, - body: Record, - create: () => Record, + principal: string, + operationId: string, + targets: readonly string[], + body: unknown, + firstStatus: number, + replayStatus: number, + create: () => unknown, ): Response { const key = request.headers.get("Idempotency-Key"); if (key === null) return problem(400, "idempotency_key_required", "Idempotency-Key is required"); - if (!/^[A-Za-z0-9._~-]{16,128}$/u.test(key)) { - return problem(400, "invalid_request", "Idempotency-Key is invalid", { - violations: [{ field: "Idempotency-Key", rule: "format", message: "Idempotency-Key must be a 16 to 128 character token" }], - }); - } - const replayKey = `${tenant}:${request.method}:${new URL(request.url).pathname}:${key}`; - const prior = this.#replays.get(replayKey); - if (prior) { - if (prior.body !== bodyKey(body)) return problem(409, "idempotency_conflict", "Idempotency key was reused with a different request"); - return json(200, prior.response, { "Idempotency-Replayed": "true" }); + if (!/^[A-Za-z0-9._~-]{16,128}$/u.test(key)) return problem(400, "invalid_request", "Idempotency-Key is invalid"); + const preconditions = request.headers.has("If-Match") ? { "if-match": request.headers.get("If-Match") } : {}; + const identity = canonicalJson({ operationId, targets, preconditions, body }); + const scope = canonicalJson({ principal, operationId, targets, key }); + const prior = this.#replays.get(scope); + if (prior !== undefined) { + if (prior.identity !== identity) return problem(409, "idempotency_conflict", "Idempotency key was reused with a different canonical request"); + return prior.body === null + ? new Response(null, { status: prior.replayStatus, headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "true" } }) + : json(prior.replayStatus, prior.body, { "Idempotency-Replayed": "true" }); } - const response = create(); - const { tenant: _tenant, ...visible } = response; - this.#replays.set(replayKey, { body: bodyKey(body), response: visible }); - return json(202, visible, { "Idempotency-Replayed": "false" }); + const created = create(); + const visible = created !== null && typeof created === "object" && !Array.isArray(created) + ? this.#visible(created as Record) + : created; + this.#replays.set(scope, { identity, body: visible, replayStatus }); + return visible === null + ? new Response(null, { status: firstStatus, headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "false" } }) + : json(firstStatus, visible, { "Idempotency-Replayed": "false" }); } } diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 68f7ab42..be43e21f 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -4,21 +4,54 @@ import { T4ApiError, createT4ApiClient, type components, + type operations, } from "@t4-code/t4-api-client"; -import { T4ApiV1ConformanceService } from "./t4-api-v1-conformance-service.ts"; +import { T4ApiV1ConformanceService, canonicalJson } from "./t4-api-v1-conformance-service.ts"; type WorkspaceCreate = components["schemas"]["WorkspaceCreate"]; type SessionCreate = components["schemas"]["SessionCreate"]; type CommandCreate = components["schemas"]["CommandCreate"]; +type WatchEvent = components["schemas"]["WatchEvent"]; +type JsonBody = Response extends { content: { "application/json": infer Body } } ? Body : never; +type SpawnBadRequest = JsonBody; +type CommandBadRequest = JsonBody; +type Unauthorized = JsonBody; +type Forbidden = JsonBody; +type Missing = JsonBody; +type Conflict = JsonBody; +type Unavailable = JsonBody; + +const typedErrors = { + badSpawn: { error: { code: "idempotency_key_required", message: "required", requestId: "r", retryable: false } } satisfies SpawnBadRequest, + badCommand: { error: { code: "invalid_request", message: "invalid", requestId: "r", retryable: false } } satisfies CommandBadRequest, + unauthorized: { error: { code: "unauthenticated", message: "no", requestId: "r", retryable: false } } satisfies Unauthorized, + forbidden: { error: { code: "forbidden", message: "no", requestId: "r", retryable: false } } satisfies Forbidden, + missing: { error: { code: "not_found", message: "no", requestId: "r", retryable: false } } satisfies Missing, + conflict: { error: { code: "idempotency_conflict", message: "no", requestId: "r", retryable: false } } satisfies Conflict, + unavailable: { error: { code: "unavailable", message: "later", requestId: "r", retryable: true } } satisfies Unavailable, +}; + +function exhaustWatchEvent(event: WatchEvent): string { + switch (event.type) { + case "heartbeat": return event.observedAt; + case "session": return `${event.state}:${event.revision}`; + case "command": return `${event.commandId}:${event.state}`; + default: { + const unreachable: never = event; + return unreachable; + } + } +} + const VERSION_HEADERS = { "T4-API-Version": "1" } as const; function idempotencyHeaders(key: string): Readonly<{ "T4-API-Version": "1"; "Idempotency-Key": string }> { return { ...VERSION_HEADERS, "Idempotency-Key": key }; } -function revisionHeaders(revision: number): Readonly<{ "T4-API-Version": "1"; "If-Match": string }> { - return { ...VERSION_HEADERS, "If-Match": String(revision) }; +function mutationHeaders(revision: number, key: string): Readonly<{ "T4-API-Version": "1"; "If-Match": string; "Idempotency-Key": string }> { + return { ...VERSION_HEADERS, "If-Match": String(revision), "Idempotency-Key": key }; } function requireData(result: { data?: T; error?: unknown }): T { @@ -43,6 +76,13 @@ describe("generated T4 API v1 client conformance", () => { const rejected = await incompatible.http.GET("/v1", { params: { header: VERSION_HEADERS } }); expect(rejected.response.status).toBe(406); expect(rejected.error).toMatchObject({ error: { code: "incompatible_version", retryable: false, supportedMajors: [1] } }); + expect(typedErrors.unauthorized.error.code).toBe("unauthenticated"); + + const denied = createT4ApiClient({ baseUrl: service.origin, credential: "token-denied", majorVersion: 1, fetch: service.fetch }); + const forbidden = await denied.http.GET("/v1", { params: { header: VERSION_HEADERS } }); + expect(forbidden.response.status).toBe(403); + expect(forbidden.error).toEqual(typedErrors.forbidden); + expect(discovery.limits).toMatchObject({ commandBytesMax: 32, commandRequestBytesMax: 256, commandMetadataValueBytesMax: 32 }); }); it("creates, canonically replays, conflicts, mutates, paginates, isolates, and deletes workspaces", async () => { @@ -73,6 +113,17 @@ describe("generated T4 API v1 client conformance", () => { expect(conflict.response.status).toBe(409); expect(conflict.error).toMatchObject({ error: { code: "idempotency_conflict", retryable: false } }); + const omitted = await owner.http.POST("/v1/workspaces", { + body: { name: "primary" }, params: { header: idempotencyHeaders("workspace-omitted-0001") }, + }); + expect(omitted.response.status).toBe(202); + const explicitEmpty = await owner.http.POST("/v1/workspaces", { + body: { name: "primary", labels: {} }, params: { header: idempotencyHeaders("workspace-omitted-0001") }, + }); + expect(explicitEmpty.response.status).toBe(409); + expect(canonicalJson({ z: [1, 2], a: { y: 2, x: 1 } })).toBe('{"a":{"x":1,"y":2},"z":[1,2]}'); + expect(canonicalJson({ z: [1, 2] })).not.toBe(canonicalJson({ z: [2, 1] })); + for (const name of ["second", "third", "fourth"]) { requireData(await owner.http.POST("/v1/workspaces", { body: { name }, @@ -103,18 +154,18 @@ describe("generated T4 API v1 client conformance", () => { expect(invalid.error).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "name", rule: "length" }] } }); const updated = requireData(await owner.http.PATCH("/v1/workspaces/{workspaceId}", { - params: { header: revisionHeaders(1), path: { workspaceId: "ws-1" } }, + params: { header: mutationHeaders(1, "workspace-patch-0001"), path: { workspaceId: "ws-1" } }, body: { name: "renamed" }, })); expect(updated).toMatchObject({ name: "renamed", revision: 2 }); const stale = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { - params: { header: revisionHeaders(1), path: { workspaceId: "ws-1" } }, + params: { header: mutationHeaders(1, "workspace-patch-0002"), path: { workspaceId: "ws-1" } }, body: { name: "stale" }, }); expect(stale.response.status).toBe(409); expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { - params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } }, + params: { header: idempotencyHeaders("workspace-delete-0001"), path: { workspaceId: "ws-1" } }, })).response.status).toBe(204); service.expectNoCredentialLeak(); }); @@ -153,11 +204,11 @@ describe("generated T4 API v1 client conformance", () => { expect(sessionPageTwo.items).toHaveLength(1); const mutated = requireData(await client.http.PATCH("/v1/sessions/{sessionId}", { - params: { header: revisionHeaders(1), path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, + params: { header: mutationHeaders(1, "session-patch-0001"), path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, })); expect(mutated).toMatchObject({ title: "renamed-agent", revision: 2 }); const stale = await client.http.PATCH("/v1/sessions/{sessionId}", { - params: { header: revisionHeaders(1), path: { sessionId: "ses-1" } }, body: { title: "stale-agent" }, + params: { header: mutationHeaders(1, "session-patch-0002"), path: { sessionId: "ses-1" } }, body: { title: "stale-agent" }, }); expect(stale.response.status).toBe(409); expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); @@ -180,8 +231,20 @@ describe("generated T4 API v1 client conformance", () => { }); expect(oversized.response.status).toBe(422); expect(oversized.error).toMatchObject({ error: { violations: [{ field: "command", rule: "maxBytes" }] } }); + const metadataOversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-metadata-0001"), path: { sessionId: "ses-1" } }, + body: { command: "ok", metadata: { source: "é".repeat(17) } }, + }); + expect(metadataOversized.response.status).toBe(422); + const defaultedMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok" }, + }); + const explicitMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok", metadata: {} }, + }); + expect(explicitMetadata.data).toEqual(defaultedMetadata.data); const cancelled = await client.http.POST("/v1/sessions/{sessionId}/cancel", { - params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + params: { header: idempotencyHeaders("session-cancel-0001"), path: { sessionId: "ses-1" } }, }); expect(cancelled.response.status).toBe(202); expect(cancelled.data).toMatchObject({ state: "cancelled" }); @@ -201,22 +264,17 @@ describe("generated T4 API v1 client conformance", () => { })); expect(snapshot).toMatchObject({ sessionId: "ses-1", cursor: "cursor-2", entries: [{ sequence: 2 }] }); - const controller = new AbortController(); const received: Array = []; - for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 4, signal: controller.signal })) { + for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 3, maxReconnectAttempts: 2, retryBackoffMs: 0 })) { received.push(event); - if (received.length === 2) controller.abort(); } expect(received).toEqual([ expect.objectContaining({ type: "heartbeat", cursor: "cursor-3" }), expect.objectContaining({ type: "session", cursor: "cursor-4", state: "accepted" }), + expect.objectContaining({ type: "heartbeat", cursor: "cursor-5" }), ]); - expect(service.abortedWatches).toEqual(["ses-1"]); + expect(received.map(exhaustWatchEvent)).toEqual(["2026-07-21T00:00:00Z", "accepted:2", "2026-07-21T00:00:15Z"]); expect(service.watchCursors[0]).toEqual({ query: "cursor-2", header: "cursor-2" }); - - const reconnect = client.watchSession("ses-1", { cursor: received.at(-1)!.cursor, maxEvents: 2 }); - expect((await reconnect.next()).value).toMatchObject({ type: "heartbeat", cursor: "cursor-5" }); - await reconnect.return(undefined); expect(service.watchCursors[1]).toEqual({ query: "cursor-4", header: "cursor-4" }); await expect(async () => { @@ -230,4 +288,89 @@ describe("generated T4 API v1 client conformance", () => { resync: { snapshotUrl: "/v1/sessions/ses-1/snapshot", cursor: "cursor-2" }, } satisfies Partial); }); + + it("rejects malformed mutation idempotency and watch query contracts", async () => { + const service = new T4ApiV1ConformanceService(); + const baseHeaders = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; + const created = await service.fetch(`${service.origin}/v1/workspaces`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "workspace-raw-0001" }, body: '{"name":"workspace"}' }); + expect(created.status).toBe(202); + const missingSpawn = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: baseHeaders, body: '{"title":"agent"}' }); + expect(missingSpawn.status).toBe(400); + expect(await missingSpawn.json()).toMatchObject(typedErrors.badSpawn); + const invalidSpawn = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "short" }, body: '{"title":"agent"}' }); + expect(invalidSpawn.status).toBe(400); + const spawned = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "session-raw-00001" }, body: '{"title":"agent"}' }); + expect(spawned.status).toBe(202); + for (const [method, path, body] of [ + ["PATCH", "/v1/workspaces/ws-1", '{"name":"x"}'], + ["DELETE", "/v1/workspaces/ws-1", undefined], + ["PATCH", "/v1/sessions/ses-1", '{"title":"x"}'], + ["DELETE", "/v1/sessions/ses-1", undefined], + ["POST", "/v1/sessions/ses-1/cancel", undefined], + ["POST", "/v1/sessions/ses-1/commands", '{"command":"ok"}'], + ] as const) { + const response = await service.fetch(`${service.origin}${path}`, { method, headers: { ...baseHeaders, "If-Match": "1" }, body }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "idempotency_key_required" } }); + } + for (const query of ["maxEvents=0", "maxEvents=5", "heartbeatSeconds=4", "heartbeatSeconds=61"]) { + const response = await service.fetch(`${service.origin}/v1/sessions/ses-1/events?${query}`, { headers: baseHeaders }); + expect(response.status).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: expect.any(Array) } }); + } + }); + + it("parses per-frame byte bounds across arbitrary chunks and split UTF-8", async () => { + const sse = (chunks: Uint8Array[]): typeof globalThis.fetch => async () => new Response(new ReadableStream({ + start(controller) { for (const chunk of chunks) controller.enqueue(chunk); controller.close(); }, + }), { status: 200, headers: { "Content-Type": "text/event-stream" } }); + const encoder = new TextEncoder(); + const valid = `: 💚\nid: c1\ndata: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n`; + const bytes = encoder.encode(valid); + const split = [...bytes].map((byte) => Uint8Array.of(byte)); + const splitClient = createT4ApiClient({ baseUrl: "https://split.test", credential: "token-a", majorVersion: 1, fetch: sse(split) }); + const iterator = splitClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }); + expect((await iterator.next()).value).toMatchObject({ type: "heartbeat", cursor: "c1" }); + + const small = `data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n${`: ${"x".repeat(1010)}\n`}\n`; + const manyClient = createT4ApiClient({ baseUrl: "https://many.test", credential: "token-a", majorVersion: 1, fetch: sse([encoder.encode(small.repeat(1000))]) }); + let count = 0; + for await (const _event of manyClient.watchSession("ses-1", { maxEvents: 1000, maxReconnectAttempts: 0 })) count += 1; + expect(count).toBe(1000); + + const oversized = `: ${"x".repeat(1024 * 1024)}\ndata: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n`; + const oversizedClient = createT4ApiClient({ baseUrl: "https://large.test", credential: "token-a", majorVersion: 1, fetch: sse([encoder.encode(oversized)]) }); + await expect(oversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + }); + + it("fails closed on unknown watch fields and incomplete typed errors", async () => { + const eventFetch: typeof globalThis.fetch = async () => new Response('data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z","unknown":true}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + const eventClient = createT4ApiClient({ baseUrl: "https://unknown.test", credential: "token-a", majorVersion: 1, fetch: eventFetch }); + await expect(eventClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + for (const [status, code] of [[410, "cursor_expired"], [406, "incompatible_version"], [422, "invalid_request"]] as const) { + const fetch: typeof globalThis.fetch = async () => Response.json({ error: { code, message: "incomplete", requestId: "r", retryable: false } }, { status }); + const client = createT4ApiClient({ baseUrl: "https://errors.test", credential: "token-a", majorVersion: 1, fetch }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status }); + } + }); + + it("bounds automatic EOF retry and supports explicit abort", async () => { + let attempts = 0; + const eofFetch: typeof globalThis.fetch = async () => { + attempts += 1; + return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); + }; + const eofClient = createT4ApiClient({ baseUrl: "https://eof.test", credential: "token-a", majorVersion: 1, fetch: eofFetch }); + await expect(eofClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 2, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + expect(attempts).toBe(3); + + const controller = new AbortController(); + const abortFetch: typeof globalThis.fetch = async (_input, init) => new Response(new ReadableStream({ + start(stream) { init?.signal?.addEventListener("abort", () => stream.close(), { once: true }); }, + }), { headers: { "Content-Type": "text/event-stream" } }); + const abortClient = createT4ApiClient({ baseUrl: "https://abort.test", credential: "token-a", majorVersion: 1, fetch: abortFetch }); + const pending = abortClient.watchSession("ses-1", { signal: controller.signal }).next(); + controller.abort("done"); + await expect(pending).resolves.toMatchObject({ done: true }); + }); }); From 797717c4cf509acd7c86795a848623d8c9dd04fb Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:28:35 +0000 Subject: [PATCH 09/70] fix: enforce T4 API public contract semantics --- .github/workflows/ci.yml | 10 + packages/t4-api-client/src/index.ts | 249 ++++++++++++++----- packages/t4-api-contract/openapi.json | 337 +++++++++++++++++--------- 3 files changed, 408 insertions(+), 188 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b0c5438..2bad79b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,11 +82,21 @@ jobs: - name: Validate OpenAPI and deterministic generation run: | pnpm --filter @t4-code/t4-api-contract validate + pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts + - name: Upload authorized generated schema + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: t4-api-generated-${{ github.run_id }} + path: artifacts/t4-api/generated/schema.ts + if-no-files-found: error + retention-days: 1 + core: runs-on: ubuntu-24.04 timeout-minutes: 25 diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index be975199..67d5ed34 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -18,6 +18,17 @@ const ERROR_FIELDS: Record = { code: true, message: true, requestId: true, retryable: true, violations: true, supportedMajors: true, resync: true, }; +const ERROR_CODES_BY_STATUS: Readonly>>> = { + 400: { invalid_request: true, idempotency_key_required: true, invalid_origin: true, https_required: true }, + 401: { unauthenticated: true }, + 403: { forbidden: true }, + 404: { not_found: true }, + 406: { incompatible_version: true }, + 409: { idempotency_conflict: true, revision_conflict: true }, + 410: { cursor_expired: true }, + 422: { invalid_request: true }, + 503: { unavailable: true, indeterminate: true }, +}; const SESSION_STATES = { accepted: true, provisioning: true, ready: true, cancelling: true, cancelled: true, failed: true, unavailable: true, indeterminate: true, @@ -42,6 +53,8 @@ export interface WatchSessionOptions { readonly maxEvents?: number; readonly heartbeatSeconds?: number; readonly signal?: AbortSignal; + readonly maxReconnectAttempts?: number; + readonly retryBackoffMs?: number; } export interface T4ApiClient { @@ -58,8 +71,8 @@ export class T4ApiError extends Error { readonly supportedMajors?: ApiError["supportedMajors"]; readonly resync?: Resync; - constructor(status: number, error: ApiError) { - super(error.message); + constructor(status: number, error: ApiError, options?: ErrorOptions) { + super(error.message, options); this.name = "T4ApiError"; this.code = error.code; this.status = status; @@ -146,19 +159,23 @@ function validResync(value: unknown): value is Resync { typeof resync.cursor === "string" && resync.cursor.length <= 512 && CURSOR_PATTERN.test(resync.cursor); } -function apiError(value: unknown): ApiError | undefined { +function apiError(value: unknown, status: number): ApiError | undefined { const envelope = record(value); const error = record(envelope?.error); if ( envelope === undefined || Object.keys(envelope).some((key) => key !== "error") || error === undefined || Object.keys(error).some((key) => ERROR_FIELDS[key] !== true) || typeof error.code !== "string" || ERROR_CODES[error.code as ApiError["code"]] !== true || + ERROR_CODES_BY_STATUS[status]?.[error.code] !== true || typeof error.message !== "string" || error.message.length < 1 || error.message.length > 1024 || typeof error.requestId !== "string" || error.requestId.length < 1 || error.requestId.length > 128 || typeof error.retryable !== "boolean" || (error.violations !== undefined && (!Array.isArray(error.violations) || error.violations.length > 64 || !error.violations.every(validViolation))) || (error.supportedMajors !== undefined && (!Array.isArray(error.supportedMajors) || error.supportedMajors.length > 8 || !error.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1))) || - (error.resync !== undefined && !validResync(error.resync)) + (error.resync !== undefined && !validResync(error.resync)) || + (status === 406 && (error.code !== "incompatible_version" || !Array.isArray(error.supportedMajors) || error.supportedMajors.length < 1)) || + (status === 410 && (error.code !== "cursor_expired" || !validResync(error.resync))) || + (status === 422 && (error.code !== "invalid_request" || !Array.isArray(error.violations) || error.violations.length < 1)) ) return undefined; return error as ApiError; } @@ -195,7 +212,7 @@ async function boundedError(response: Response): Promise { const text = await boundedResponseText(response); if (text !== undefined) { try { - const decoded = apiError(JSON.parse(text)); + const decoded = apiError(JSON.parse(text), response.status); if (decoded !== undefined) return new T4ApiError(response.status, decoded); } catch { // Fall through to the stable indeterminate transport envelope. @@ -209,6 +226,10 @@ async function boundedError(response: Response): Promise { }); } +function hasOnlyKeys(value: Record, keys: Readonly>): boolean { + return Object.keys(value).every((key) => keys[key] === true); +} + function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { const event = record(value); if ( @@ -220,19 +241,26 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { !CURSOR_PATTERN.test(event.cursor) || (eventId !== undefined && eventId !== event.cursor) ) throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid watch event", requestId: "unavailable", retryable: true }); - if (event.type === "heartbeat" && typeof event.observedAt === "string" && event.observedAt.length <= 64 && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(event.observedAt) && Number.isFinite(Date.parse(event.observedAt))) { - return event as WatchEvent; - } + if ( + event.type === "heartbeat" && + hasOnlyKeys(event, { type: true, cursor: true, observedAt: true }) && + typeof event.observedAt === "string" && event.observedAt.length <= 64 && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(event.observedAt) && + Number.isFinite(Date.parse(event.observedAt)) + ) return event as WatchEvent; if ( event.type === "session" && + hasOnlyKeys(event, { type: true, cursor: true, state: true, revision: true }) && typeof event.state === "string" && SESSION_STATES[event.state as components["schemas"]["SessionState"]] === true && Number.isSafeInteger(event.revision) && Number(event.revision) >= 1 ) return event as WatchEvent; if ( event.type === "command" && + hasOnlyKeys(event, { type: true, cursor: true, commandId: true, state: true }) && typeof event.commandId === "string" && event.commandId.length >= 1 && event.commandId.length <= 128 && + /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(event.commandId) && typeof event.state === "string" && OPERATION_STATES[event.state as components["schemas"]["OperationState"]] === true ) return event as WatchEvent; @@ -261,9 +289,100 @@ function decodeSseFrame(frame: string): WatchEvent | undefined { } } -function nextSseBoundary(buffer: string): { readonly index: number; readonly length: number } | undefined { - const match = /(?:\r\n|\r|\n)(?:\r\n|\r|\n)/u.exec(buffer); - return match?.index === undefined ? undefined : { index: match.index, length: match[0].length }; +class SseFrameParser { + readonly #buffer = new Uint8Array(MAX_EVENT_BYTES + 4); + #length = 0; + #lineStart = 0; + #pendingCarriageReturn = -1; + + push(chunk: Uint8Array): Uint8Array[] { + const frames: Uint8Array[] = []; + for (const byte of chunk) this.#pushByte(byte, frames); + return frames; + } + + finish(): Uint8Array[] { + const frames: Uint8Array[] = []; + if (this.#pendingCarriageReturn >= 0) { + this.#finishLine(this.#pendingCarriageReturn, frames); + this.#pendingCarriageReturn = -1; + } + if (this.#length > 0) { + if (this.#length > MAX_EVENT_BYTES) this.#oversized(); + frames.push(this.#buffer.slice(0, this.#length)); + this.#reset(); + } + return frames; + } + + #pushByte(byte: number, frames: Uint8Array[]): void { + if (this.#pendingCarriageReturn >= 0) { + if (byte === 10) { + this.#append(byte); + this.#finishLine(this.#pendingCarriageReturn, frames); + this.#pendingCarriageReturn = -1; + return; + } + this.#finishLine(this.#pendingCarriageReturn, frames); + this.#pendingCarriageReturn = -1; + } + this.#append(byte); + if (byte === 13) this.#pendingCarriageReturn = this.#length - 1; + else if (byte === 10) this.#finishLine(this.#length - 1, frames); + } + + #append(byte: number): void { + if (this.#length >= this.#buffer.byteLength) this.#oversized(); + this.#buffer[this.#length++] = byte; + } + + #finishLine(newlineStart: number, frames: Uint8Array[]): void { + if (newlineStart === this.#lineStart) { + if (this.#lineStart > MAX_EVENT_BYTES) this.#oversized(); + frames.push(this.#buffer.slice(0, this.#lineStart)); + this.#reset(); + return; + } + this.#lineStart = this.#length; + if (this.#lineStart > MAX_EVENT_BYTES) this.#oversized(); + } + + #reset(): void { + this.#length = 0; + this.#lineStart = 0; + } + + #oversized(): never { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch event exceeds the client bound", requestId: "unavailable", retryable: true }); + } +} + +function decodedFrames(parser: SseFrameParser, chunk: Uint8Array | undefined): WatchEvent[] { + const frames = chunk === undefined ? parser.finish() : parser.push(chunk); + return frames.flatMap((bytes) => { + let text: string; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned malformed SSE data", requestId: "unavailable", retryable: true }); + } + const event = decodeSseFrame(text); + return event === undefined ? [] : [event]; + }); +} + +async function retryDelay(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return false; + if (milliseconds === 0) return true; + return await new Promise((resolve) => { + const timeout = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(true); + }, milliseconds); + const abort = (): void => { + clearTimeout(timeout); + resolve(false); + }; + signal.addEventListener("abort", abort, { once: true }); + }); } async function* watch( @@ -277,79 +396,75 @@ async function* watch( const sessionId = requiredSessionId(sessionIdValue); const maxEvents = boundedInteger(options.maxEvents, 100, 1, 1000, "maxEvents"); const heartbeatSeconds = boundedInteger(options.heartbeatSeconds, 15, 5, 60, "heartbeatSeconds"); + const maxReconnectAttempts = boundedInteger(options.maxReconnectAttempts, 3, 0, 10, "maxReconnectAttempts"); + const retryBackoffMs = boundedInteger(options.retryBackoffMs, 250, 0, 30_000, "retryBackoffMs"); if (options.cursor !== undefined && (options.cursor.length < 1 || options.cursor.length > 512 || !CURSOR_PATTERN.test(options.cursor))) { throw new TypeError("cursor must be a header-safe token containing between 1 and 512 characters"); } - const url = new URL(`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events`); - url.searchParams.set("maxEvents", String(maxEvents)); - url.searchParams.set("heartbeatSeconds", String(heartbeatSeconds)); - if (options.cursor !== undefined) url.searchParams.set("cursor", options.cursor); const controller = new AbortController(); const abort = (): void => controller.abort(options.signal?.reason); if (options.signal?.aborted === true) abort(); else options.signal?.addEventListener("abort", abort, { once: true }); - let reader: ReadableStreamDefaultReader | undefined; + let delivered = 0; + let reconnectAttempts = 0; + let cursor = options.cursor; try { - const headers = new Headers({ - Accept: "text/event-stream", - Authorization: `Bearer ${credential}`, - "Cache-Control": "no-store", - "T4-API-Version": majorVersion, - }); - if (options.cursor !== undefined) headers.set("Last-Event-ID", options.cursor); - const response = await fetchImpl(url, { method: "GET", headers, signal: controller.signal }); - if (!response.ok) throw await boundedError(response); - if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) { - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch did not return text/event-stream", requestId: "unavailable", retryable: true }); - } - if (response.body === null) { - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch response body is unavailable", requestId: "unavailable", retryable: true }); - } - reader = response.body.getReader(); - const decoder = new TextDecoder("utf-8", { fatal: true }); - let buffer = ""; - let delivered = 0; - while (delivered < maxEvents) { - if (controller.signal.aborted) return; - let chunk: ReadableStreamReadResult; + while (delivered < maxEvents && !controller.signal.aborted) { + let reader: ReadableStreamDefaultReader | undefined; + let transientFailure: unknown; try { - chunk = await reader.read(); + const url = new URL(`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events`); + url.searchParams.set("maxEvents", String(maxEvents - delivered)); + url.searchParams.set("heartbeatSeconds", String(heartbeatSeconds)); + if (cursor !== undefined) url.searchParams.set("cursor", cursor); + const headers = new Headers({ + Accept: "text/event-stream", + Authorization: `Bearer ${credential}`, + "Cache-Control": "no-store", + "T4-API-Version": majorVersion, + }); + if (cursor !== undefined) headers.set("Last-Event-ID", cursor); + const response = await fetchImpl(url, { method: "GET", headers, signal: controller.signal }); + if (!response.ok) throw await boundedError(response); + if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch did not return text/event-stream", requestId: "unavailable", retryable: true }); + } + if (response.body === null) throw new TypeError("T4 API watch response body is unavailable"); + reader = response.body.getReader(); + const parser = new SseFrameParser(); + while (delivered < maxEvents && !controller.signal.aborted) { + const chunk = await reader.read(); + const events = chunk.done ? decodedFrames(parser, undefined) : decodedFrames(parser, chunk.value); + for (const event of events) { + cursor = event.cursor; + delivered += 1; + yield event; + if (delivered >= maxEvents || controller.signal.aborted) break; + } + if (chunk.done) break; + } + if (delivered >= maxEvents || controller.signal.aborted) return; + transientFailure = new TypeError("T4 API watch ended before the requested event bound"); } catch (error) { if (controller.signal.aborted) return; - throw error; - } - if (chunk.done) { - buffer += decoder.decode(); - break; - } - buffer += decoder.decode(chunk.value, { stream: true }); - if (new TextEncoder().encode(buffer).byteLength > MAX_EVENT_BYTES) { - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch event exceeds the client bound", requestId: "unavailable", retryable: true }); - } - let boundary = nextSseBoundary(buffer); - while (boundary !== undefined && delivered < maxEvents) { - if (controller.signal.aborted) return; - const frame = buffer.slice(0, boundary.index); - buffer = buffer.slice(boundary.index + boundary.length); - const event = decodeSseFrame(frame); - if (event !== undefined) { - delivered += 1; - yield event; - if (controller.signal.aborted) return; + if (error instanceof T4ApiError) throw error; + transientFailure = error; + } finally { + if (reader !== undefined) { + try { await reader.cancel(); } catch { /* cancellation is best effort */ } + reader.releaseLock(); } - boundary = nextSseBoundary(buffer); } - } - if (buffer.trim().length > 0 && delivered < maxEvents) { - const event = decodeSseFrame(buffer); - if (event !== undefined) yield event; + if (reconnectAttempts >= maxReconnectAttempts) { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch reconnect attempts exhausted", requestId: "unavailable", retryable: true }, { cause: transientFailure }); + } + const delay = Math.min(30_000, retryBackoffMs * (2 ** reconnectAttempts)); + reconnectAttempts += 1; + if (!await retryDelay(delay, controller.signal)) return; } } finally { options.signal?.removeEventListener("abort", abort); controller.abort(); - if (reader !== undefined) { - try { await reader.cancel(); } catch { /* cancellation is best effort */ } - } } } diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index 0387b8a8..2884c6f8 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -23,10 +23,10 @@ "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], "responses": { "200": { "$ref": "#/components/responses/Discovery" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -41,12 +41,12 @@ ], "responses": { "200": { "$ref": "#/components/responses/WorkspacePage" }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "400": {"$ref": "#/components/responses/Error400"}, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "406": {"$ref": "#/components/responses/Error406"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } }, "post": { @@ -63,13 +63,13 @@ "responses": { "200": { "$ref": "#/components/responses/WorkspaceReplay" }, "202": { "$ref": "#/components/responses/WorkspaceAccepted" }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "400": {"$ref": "#/components/responses/Error400"}, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -81,11 +81,11 @@ "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], "responses": { "200": { "$ref": "#/components/responses/Workspace" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} } }, "patch": { @@ -93,35 +93,41 @@ "tags": ["Workspaces"], "parameters": [ { "$ref": "#/components/parameters/ApiVersion" }, - { "$ref": "#/components/parameters/IfMatch" } + { "$ref": "#/components/parameters/IfMatch" }, + { "$ref": "#/components/parameters/IdempotencyKey" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspaceMutation" } } } }, "responses": { + "400": { "$ref": "#/components/responses/Error400" }, "200": { "$ref": "#/components/responses/Workspace" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } }, "delete": { "operationId": "deleteWorkspace", "tags": ["Workspaces"], - "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], "responses": { "204": { "$ref": "#/components/responses/Deleted" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -137,12 +143,12 @@ ], "responses": { "200": { "$ref": "#/components/responses/SessionPage" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } }, "post": { @@ -159,13 +165,14 @@ "responses": { "200": { "$ref": "#/components/responses/SessionReplay" }, "202": { "$ref": "#/components/responses/SessionAccepted" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -177,11 +184,11 @@ "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], "responses": { "200": { "$ref": "#/components/responses/Session" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} } }, "patch": { @@ -189,35 +196,41 @@ "tags": ["Sessions"], "parameters": [ { "$ref": "#/components/parameters/ApiVersion" }, - { "$ref": "#/components/parameters/IfMatch" } + { "$ref": "#/components/parameters/IfMatch" }, + { "$ref": "#/components/parameters/IdempotencyKey" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionMutation" } } } }, "responses": { + "400": { "$ref": "#/components/responses/Error400" }, "200": { "$ref": "#/components/responses/Session" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } }, "delete": { "operationId": "deleteSession", "tags": ["Sessions"], - "parameters": [{ "$ref": "#/components/parameters/ApiVersion" }], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], "responses": { "204": { "$ref": "#/components/responses/Deleted" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -227,16 +240,19 @@ "tags": ["Sessions"], "parameters": [ { "$ref": "#/components/parameters/SessionId" }, - { "$ref": "#/components/parameters/ApiVersion" } + { "$ref": "#/components/parameters/ApiVersion" }, + { "$ref": "#/components/parameters/IdempotencyKey" } ], "responses": { + "200": { "$ref": "#/components/responses/SessionReplay" }, + "400": { "$ref": "#/components/responses/Error400" }, "202": { "$ref": "#/components/responses/SessionAccepted" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -256,13 +272,14 @@ "responses": { "200": { "$ref": "#/components/responses/CommandReplay" }, "202": { "$ref": "#/components/responses/CommandAccepted" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "409": { "$ref": "#/components/responses/Conflict" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "400": { "$ref": "#/components/responses/Error400" }, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "409": {"$ref": "#/components/responses/Error409"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -276,11 +293,11 @@ ], "responses": { "200": { "$ref": "#/components/responses/Snapshot" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} } } }, @@ -312,14 +329,14 @@ } } }, - "400": { "$ref": "#/components/responses/BadRequest" }, - "401": { "$ref": "#/components/responses/Unauthenticated" }, - "403": { "$ref": "#/components/responses/Forbidden" }, - "404": { "$ref": "#/components/responses/NotFound" }, - "406": { "$ref": "#/components/responses/IncompatibleVersion" }, - "410": { "$ref": "#/components/responses/CursorExpired" }, - "422": { "$ref": "#/components/responses/InvalidRequest" }, - "503": { "$ref": "#/components/responses/Unavailable" } + "400": {"$ref": "#/components/responses/Error400"}, + "401": {"$ref": "#/components/responses/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "410": {"$ref": "#/components/responses/Error410"}, + "422": {"$ref": "#/components/responses/Error422"}, + "503": {"$ref": "#/components/responses/Error503"} } } } @@ -344,7 +361,7 @@ "name": "Idempotency-Key", "in": "header", "required": true, - "description": "Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts.", + "description": "Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict.", "schema": { "type": "string", "minLength": 16, "maxLength": 128, "pattern": "^[A-Za-z0-9._~-]+$" } }, "IfMatch": { @@ -428,11 +445,13 @@ "limits": { "type": "object", "additionalProperties": false, - "required": ["pageSizeDefault", "pageSizeMax", "commandBytesMax", "watchEventsMax", "heartbeatSeconds"], + "required": ["pageSizeDefault", "pageSizeMax", "commandBytesMax", "commandRequestBytesMax", "commandMetadataValueBytesMax", "watchEventsMax", "heartbeatSeconds"], "properties": { "pageSizeDefault": { "type": "integer", "minimum": 1, "maximum": 100 }, "pageSizeMax": { "type": "integer", "minimum": 1, "maximum": 100 }, - "commandBytesMax": { "type": "integer", "minimum": 1, "maximum": 1048576 }, + "commandBytesMax": { "type": "integer", "minimum": 1, "maximum": 262144 }, + "commandRequestBytesMax": { "type": "integer", "minimum": 1, "maximum": 1048576 }, + "commandMetadataValueBytesMax": { "type": "integer", "minimum": 1, "maximum": 262144 }, "watchEventsMax": { "type": "integer", "minimum": 1, "maximum": 1000 }, "heartbeatSeconds": { "type": "integer", "minimum": 5, "maximum": 60 } } @@ -528,9 +547,23 @@ "type": "object", "additionalProperties": false, "required": ["command"], + "x-t4-maxUtf8Bytes": 1048576, "properties": { - "command": { "type": "string", "minLength": 1, "maxLength": 262144 }, - "metadata": { "type": "object", "maxProperties": 32, "additionalProperties": { "type": ["string", "number", "boolean", "null"] } } + "command": { "type": "string", "minLength": 1, "maxLength": 262144, "x-t4-maxUtf8Bytes": 262144 }, + "metadata": { + "type": "object", + "default": {}, + "maxProperties": 32, + "propertyNames": { "pattern": "^[a-z][a-z0-9.-]{0,62}$" }, + "additionalProperties": { + "oneOf": [ + { "type": "string", "maxLength": 262144, "x-t4-maxUtf8Bytes": 262144 }, + { "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 }, + { "type": "boolean" }, + { "type": "null" } + ] + } + } } }, "CommandResult": { @@ -600,8 +633,7 @@ { "$ref": "#/components/schemas/HeartbeatWatchEvent" }, { "$ref": "#/components/schemas/SessionWatchEvent" }, { "$ref": "#/components/schemas/CommandWatchEvent" } - ], - "discriminator": { "propertyName": "type" } + ] }, "FieldViolation": { "type": "object", @@ -639,6 +671,42 @@ "resync": { "$ref": "#/components/schemas/Resync" } } }, + "BadRequestApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "enum": ["invalid_request", "idempotency_key_required", "invalid_origin", "https_required"] } } } + ] + }, + "UnauthenticatedApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "const": "unauthenticated" } } } + ] + }, + "ForbiddenApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "const": "forbidden" } } } + ] + }, + "NotFoundApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "const": "not_found" } } } + ] + }, + "ConflictApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "enum": ["idempotency_conflict", "revision_conflict"] } } } + ] + }, + "UnavailableApiError": { + "allOf": [ + { "$ref": "#/components/schemas/ApiError" }, + { "type": "object", "properties": { "code": { "enum": ["unavailable", "indeterminate"] } } } + ] + }, "IncompatibleVersionApiError": { "allOf": [ { "$ref": "#/components/schemas/ApiError" }, @@ -663,6 +731,42 @@ "required": ["error"], "properties": { "error": { "$ref": "#/components/schemas/ApiError" } } }, + "BadRequestErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/BadRequestApiError" } } + }, + "UnauthenticatedErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/UnauthenticatedApiError" } } + }, + "ForbiddenErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/ForbiddenApiError" } } + }, + "NotFoundErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/NotFoundApiError" } } + }, + "ConflictErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/ConflictApiError" } } + }, + "UnavailableErrorEnvelope": { + "type": "object", + "additionalProperties": false, + "required": ["error"], + "properties": { "error": { "$ref": "#/components/schemas/UnavailableApiError" } } + }, "IncompatibleVersionErrorEnvelope": { "type": "object", "additionalProperties": false, @@ -690,30 +794,21 @@ "WorkspacePage": { "description": "Bounded workspace page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspacePage" } } } }, "Session": { "description": "Session", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, "SessionAccepted": { "description": "Session intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, - "SessionReplay": { "description": "Identical session spawn replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionReplay": { "description": "Identical session mutation replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, "SessionPage": { "description": "Bounded session page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionPage" } } } }, "CommandAccepted": { "description": "Command intent accepted with a stable outcome state", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, "CommandReplay": { "description": "Identical command request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, "Snapshot": { "description": "Bounded session snapshot and reconnect cursor", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionSnapshot" } } } }, - "Deleted": { "description": "Deletion accepted or resource already absent", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } } }, - "BadRequest": { "$ref": "#/components/responses/Error400" }, - "Unauthenticated": { "$ref": "#/components/responses/Error401" }, - "Forbidden": { "$ref": "#/components/responses/Error403" }, - "NotFound": { "$ref": "#/components/responses/Error404" }, - "IncompatibleVersion": { "$ref": "#/components/responses/Error406" }, - "Conflict": { "$ref": "#/components/responses/Error409" }, - "CursorExpired": { "$ref": "#/components/responses/Error410" }, - "InvalidRequest": { "$ref": "#/components/responses/Error422" }, - "Unavailable": { "$ref": "#/components/responses/Error503" }, - "Error400": { "description": "Malformed request or missing idempotency key", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, - "Error401": { "description": "Missing or invalid opaque bearer credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, - "Error403": { "description": "Credential lacks the deny-by-default operation or resource scope", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, - "Error404": { "description": "Resource absent or outside caller scope (scope existence is not disclosed)", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Deleted": { "description": "Idempotent deletion accepted or resource already absent", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } } }, + "Error400": { "description": "Malformed request or missing idempotency key", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BadRequestErrorEnvelope" } } } }, + "Error401": { "description": "Missing or invalid opaque bearer credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnauthenticatedErrorEnvelope" } } } }, + "Error403": { "description": "Credential lacks the deny-by-default operation or resource scope", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForbiddenErrorEnvelope" } } } }, + "Error404": { "description": "Resource absent or outside caller scope (scope existence is not disclosed)", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NotFoundErrorEnvelope" } } } }, "Error406": { "description": "Requested major is incompatible; no silent downgrade", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/IncompatibleVersionErrorEnvelope" } } } }, - "Error409": { "description": "Idempotency or resource revision conflict", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } }, + "Error409": { "description": "Idempotency or resource revision conflict", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConflictErrorEnvelope" } } } }, "Error410": { "description": "Watch cursor expired; resync from the typed snapshot target", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CursorExpiredErrorEnvelope" } } } }, "Error422": { "description": "Bounded semantic validation failure with stable field violations", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvalidRequestErrorEnvelope" } } } }, - "Error503": { "description": "Service temporarily unavailable or outcome indeterminate", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorEnvelope" } } } } + "Error503": { "description": "Service temporarily unavailable or outcome indeterminate", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnavailableErrorEnvelope" } } } } } } } From 65a85247c05c1d4e0d1d2a8e65fb997ea75cf01d Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:31:57 +0000 Subject: [PATCH 10/70] build: accept authorized T4 API schema artifact --- .github/workflows/ci.yml | 9 - .../t4-api-client/src/generated/schema.ts | 349 +++++++++--------- 2 files changed, 175 insertions(+), 183 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bad79b0..25d7d947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,20 +82,11 @@ jobs: - name: Validate OpenAPI and deterministic generation run: | pnpm --filter @t4-code/t4-api-contract validate - pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts - - name: Upload authorized generated schema - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: t4-api-generated-${{ github.run_id }} - path: artifacts/t4-api/generated/schema.ts - if-no-files-found: error - retention-days: 1 core: runs-on: ubuntu-24.04 diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts index 03954962..0e4b33ec 100755 --- a/packages/t4-api-client/src/generated/schema.ts +++ b/packages/t4-api-client/src/generated/schema.ts @@ -168,9 +168,17 @@ export interface components { supportedMajors?: number[]; violations?: components["schemas"]["FieldViolation"][]; }; + BadRequestApiError: components["schemas"]["ApiError"] & { + /** @enum {unknown} */ + code?: "invalid_request" | "idempotency_key_required" | "invalid_origin" | "https_required"; + }; + BadRequestErrorEnvelope: { + error: components["schemas"]["BadRequestApiError"]; + }; CommandCreate: { command: string; - metadata?: { + /** @default {} */ + metadata: { [key: string]: string | number | boolean | null; }; }; @@ -182,11 +190,15 @@ export interface components { commandId: components["schemas"]["ResourceId"]; cursor: components["schemas"]["Cursor"]; state: components["schemas"]["OperationState"]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "CommandWatchEvent"; + /** @constant */ + type: "command"; + }; + ConflictApiError: components["schemas"]["ApiError"] & { + /** @enum {unknown} */ + code?: "idempotency_conflict" | "revision_conflict"; + }; + ConflictErrorEnvelope: { + error: components["schemas"]["ConflictApiError"]; }; /** @description Opaque server-issued header-safe SSE cursor. */ Cursor: string; @@ -203,6 +215,8 @@ export interface components { capabilities: string[]; limits: { commandBytesMax: number; + commandMetadataValueBytesMax: number; + commandRequestBytesMax: number; heartbeatSeconds: number; pageSizeDefault: number; pageSizeMax: number; @@ -218,15 +232,19 @@ export interface components { message: string; rule: string; }; + ForbiddenApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "forbidden"; + }; + ForbiddenErrorEnvelope: { + error: components["schemas"]["ForbiddenApiError"]; + }; HeartbeatWatchEvent: { cursor: components["schemas"]["Cursor"]; /** Format: date-time */ observedAt: string; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "HeartbeatWatchEvent"; + /** @constant */ + type: "heartbeat"; }; IncompatibleVersionApiError: components["schemas"]["ApiError"] & { /** @constant */ @@ -247,6 +265,13 @@ export interface components { Labels: { [key: string]: string; }; + NotFoundApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "not_found"; + }; + NotFoundErrorEnvelope: { + error: components["schemas"]["NotFoundApiError"]; + }; /** @enum {string} */ OperationState: "accepted" | "rejected" | "conflict" | "unavailable" | "indeterminate"; ResourceId: string; @@ -287,11 +312,8 @@ export interface components { cursor: components["schemas"]["Cursor"]; revision: components["schemas"]["Revision"]; state: components["schemas"]["SessionState"]; - /** - * @description discriminator enum property added by openapi-typescript - * @enum {string} - */ - type: "SessionWatchEvent"; + /** @constant */ + type: "session"; }; SnapshotEntry: { /** @enum {string} */ @@ -299,6 +321,20 @@ export interface components { sequence: number; text: string; }; + UnauthenticatedApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "unauthenticated"; + }; + UnauthenticatedErrorEnvelope: { + error: components["schemas"]["UnauthenticatedApiError"]; + }; + UnavailableApiError: components["schemas"]["ApiError"] & { + /** @enum {unknown} */ + code?: "unavailable" | "indeterminate"; + }; + UnavailableErrorEnvelope: { + error: components["schemas"]["UnavailableApiError"]; + }; WatchEvent: components["schemas"]["HeartbeatWatchEvent"] | components["schemas"]["SessionWatchEvent"] | components["schemas"]["CommandWatchEvent"]; Workspace: { id: components["schemas"]["ResourceId"]; @@ -323,12 +359,6 @@ export interface components { WorkspaceState: "accepted" | "provisioning" | "ready" | "deleting" | "deleted" | "failed" | "unavailable" | "indeterminate"; }; responses: { - BadRequest: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; /** @description Command intent accepted with a stable outcome state */ CommandAccepted: { headers: { @@ -351,21 +381,10 @@ export interface components { "application/json": components["schemas"]["CommandResult"]; }; }; - Conflict: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - CursorExpired: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description Deletion accepted or resource already absent */ + /** @description Idempotent deletion accepted or resource already absent */ Deleted: { headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; [name: string]: unknown; }; @@ -388,7 +407,7 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorEnvelope"]; + "application/json": components["schemas"]["BadRequestErrorEnvelope"]; }; }; /** @description Missing or invalid opaque bearer credential */ @@ -397,7 +416,7 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorEnvelope"]; + "application/json": components["schemas"]["UnauthenticatedErrorEnvelope"]; }; }; /** @description Credential lacks the deny-by-default operation or resource scope */ @@ -407,7 +426,7 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorEnvelope"]; + "application/json": components["schemas"]["ForbiddenErrorEnvelope"]; }; }; /** @description Resource absent or outside caller scope (scope existence is not disclosed) */ @@ -417,7 +436,7 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorEnvelope"]; + "application/json": components["schemas"]["NotFoundErrorEnvelope"]; }; }; /** @description Requested major is incompatible; no silent downgrade */ @@ -437,7 +456,7 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorEnvelope"]; + "application/json": components["schemas"]["ConflictErrorEnvelope"]; }; }; /** @description Watch cursor expired; resync from the typed snapshot target */ @@ -467,32 +486,8 @@ export interface components { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["ErrorEnvelope"]; - }; - }; - Forbidden: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - IncompatibleVersion: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - InvalidRequest: { - headers: { - [name: string]: unknown; + "application/json": components["schemas"]["UnavailableErrorEnvelope"]; }; - content?: never; - }; - NotFound: { - headers: { - [name: string]: unknown; - }; - content?: never; }; /** @description Session */ Session: { @@ -525,7 +520,7 @@ export interface components { "application/json": components["schemas"]["SessionPage"]; }; }; - /** @description Identical session spawn replay */ + /** @description Identical session mutation replay */ SessionReplay: { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; @@ -546,18 +541,6 @@ export interface components { "application/json": components["schemas"]["SessionSnapshot"]; }; }; - Unauthenticated: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - Unavailable: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; /** @description Workspace */ Workspace: { headers: { @@ -605,7 +588,7 @@ export interface components { /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ ApiVersion: string; HeartbeatSeconds: number; - /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ IdempotencyKey: string; /** @description Decimal resource revision for optimistic mutation. */ IfMatch: string; @@ -644,10 +627,10 @@ export interface operations { requestBody?: never; responses: { 200: components["responses"]["Discovery"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 406: components["responses"]["IncompatibleVersion"]; - 503: components["responses"]["Unavailable"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; }; }; getSession: { @@ -665,17 +648,19 @@ export interface operations { requestBody?: never; responses: { 200: components["responses"]["Session"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 503: components["responses"]["Unavailable"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; }; }; deleteSession: { parameters: { query?: never; header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ "T4-API-Version": components["parameters"]["ApiVersion"]; }; @@ -687,18 +672,21 @@ export interface operations { requestBody?: never; responses: { 204: components["responses"]["Deleted"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 503: components["responses"]["Error503"]; }; }; mutateSession: { parameters: { query?: never; header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Decimal resource revision for optimistic mutation. */ "If-Match": components["parameters"]["IfMatch"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ @@ -716,19 +704,22 @@ export interface operations { }; responses: { 200: components["responses"]["Session"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; cancelSession: { parameters: { query?: never; header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ "T4-API-Version": components["parameters"]["ApiVersion"]; }; @@ -739,20 +730,22 @@ export interface operations { }; requestBody?: never; responses: { + 200: components["responses"]["SessionReplay"]; 202: components["responses"]["SessionAccepted"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 503: components["responses"]["Error503"]; }; }; submitCommand: { parameters: { query?: never; header: { - /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ "T4-API-Version": components["parameters"]["ApiVersion"]; @@ -770,13 +763,14 @@ export interface operations { responses: { 200: components["responses"]["CommandReplay"]; 202: components["responses"]["CommandAccepted"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; watchSessionEvents: { @@ -812,14 +806,14 @@ export interface operations { "text/event-stream": string; }; }; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 410: components["responses"]["CursorExpired"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 410: components["responses"]["Error410"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; getSessionSnapshot: { @@ -837,11 +831,11 @@ export interface operations { requestBody?: never; responses: { 200: components["responses"]["Snapshot"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 503: components["responses"]["Unavailable"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; }; }; listWorkspaces: { @@ -861,19 +855,19 @@ export interface operations { requestBody?: never; responses: { 200: components["responses"]["WorkspacePage"]; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 406: components["responses"]["IncompatibleVersion"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 406: components["responses"]["Error406"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; createWorkspace: { parameters: { query?: never; header: { - /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ "T4-API-Version": components["parameters"]["ApiVersion"]; @@ -889,13 +883,13 @@ export interface operations { responses: { 200: components["responses"]["WorkspaceReplay"]; 202: components["responses"]["WorkspaceAccepted"]; - 400: components["responses"]["BadRequest"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; getWorkspace: { @@ -913,17 +907,19 @@ export interface operations { requestBody?: never; responses: { 200: components["responses"]["Workspace"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 503: components["responses"]["Unavailable"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; }; }; deleteWorkspace: { parameters: { query?: never; header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ "T4-API-Version": components["parameters"]["ApiVersion"]; }; @@ -935,18 +931,21 @@ export interface operations { requestBody?: never; responses: { 204: components["responses"]["Deleted"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 503: components["responses"]["Error503"]; }; }; mutateWorkspace: { parameters: { query?: never; header: { + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ + "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Decimal resource revision for optimistic mutation. */ "If-Match": components["parameters"]["IfMatch"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ @@ -964,13 +963,14 @@ export interface operations { }; responses: { 200: components["responses"]["Workspace"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; listSessions: { @@ -992,19 +992,19 @@ export interface operations { requestBody?: never; responses: { 200: components["responses"]["SessionPage"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; spawnSession: { parameters: { query?: never; header: { - /** @description Opaque caller-generated key scoped to credential, operation, and target. Same key plus same canonical request replays; different content conflicts. */ + /** @description Opaque caller-generated key scoped by the server to the authenticated principal, operation ID, and target resource IDs. Idempotency is evaluated only after path, query, header, and request-schema validation and application of schema defaults. Request identity is the operation ID, target IDs, relevant precondition headers, and the RFC 8785 JSON Canonicalization Scheme (JCS) bytes of the validated JSON body. JCS object member order is insignificant, array order is significant, omitted fields remain distinct unless the request schema defaulted them, and no Unicode normalization is performed beyond RFC 8785. Reusing a key with identical identity replays the original terminal response and advertises Idempotency-Replayed; reusing it with a different identity returns 409 idempotency_conflict. */ "Idempotency-Key": components["parameters"]["IdempotencyKey"]; /** @description Requested API major. v1 clients send 1. Unsupported majors fail with 406 instead of silently downgrading. */ "T4-API-Version": components["parameters"]["ApiVersion"]; @@ -1022,13 +1022,14 @@ export interface operations { responses: { 200: components["responses"]["SessionReplay"]; 202: components["responses"]["SessionAccepted"]; - 401: components["responses"]["Unauthenticated"]; - 403: components["responses"]["Forbidden"]; - 404: components["responses"]["NotFound"]; - 406: components["responses"]["IncompatibleVersion"]; - 409: components["responses"]["Conflict"]; - 422: components["responses"]["InvalidRequest"]; - 503: components["responses"]["Unavailable"]; + 400: components["responses"]["Error400"]; + 401: components["responses"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 409: components["responses"]["Error409"]; + 422: components["responses"]["Error422"]; + 503: components["responses"]["Error503"]; }; }; } From 93f50e5cfe941587929cf662afca35dc074dbb95 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:36:58 +0000 Subject: [PATCH 11/70] test: complete bounded API conformance cases --- .../client/test/t4-api-v1-conformance.test.ts | 63 ++++++++++++++++--- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index be43e21f..5eea9590 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -164,6 +164,19 @@ describe("generated T4 API v1 client conformance", () => { }); expect(stale.response.status).toBe(409); expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); + const targetOne = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(2, "workspace-shared-0001"), path: { workspaceId: "ws-1" } }, body: { name: "target" }, + }); + const targetTwo = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "workspace-shared-0001"), path: { workspaceId: "ws-2" } }, body: { name: "target" }, + }); + expect(targetOne.response.status).toBe(200); + expect(targetTwo.response.status).toBe(200); + const changedPrecondition = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(3, "workspace-shared-0001"), path: { workspaceId: "ws-1" } }, body: { name: "target" }, + }); + expect(changedPrecondition.response.status).toBe(409); + expect(changedPrecondition.error).toMatchObject({ error: { code: "idempotency_conflict" } }); expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { params: { header: idempotencyHeaders("workspace-delete-0001"), path: { workspaceId: "ws-1" } }, })).response.status).toBe(204); @@ -214,7 +227,7 @@ describe("generated T4 API v1 client conformance", () => { expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); for (const state of ["accepted", "rejected", "conflict", "unavailable", "indeterminate"] as const) { - const command = { command: state } satisfies CommandCreate; + const command = { command: state, metadata: {} } satisfies CommandCreate; const result = await client.http.POST("/v1/sessions/{sessionId}/commands", { params: { header: idempotencyHeaders(`command-${state}-0001`), path: { sessionId: "ses-1" } }, body: command, }); @@ -227,22 +240,34 @@ describe("generated T4 API v1 client conformance", () => { } const oversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { params: { header: idempotencyHeaders("command-oversized-0001"), path: { sessionId: "ses-1" } }, - body: { command: "a".repeat(33) }, + body: { command: "a".repeat(33), metadata: {} }, }); expect(oversized.response.status).toBe(422); expect(oversized.error).toMatchObject({ error: { violations: [{ field: "command", rule: "maxBytes" }] } }); + const multibyteOversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-multibyte-0001"), path: { sessionId: "ses-1" } }, + body: { command: "é".repeat(17), metadata: {} }, + }); + expect(multibyteOversized.response.status).toBe(422); const metadataOversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { params: { header: idempotencyHeaders("command-metadata-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok", metadata: { source: "é".repeat(17) } }, }); expect(metadataOversized.response.status).toBe(422); - const defaultedMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { - params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok" }, + const requestOversized = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-request-0001"), path: { sessionId: "ses-1" } }, + body: { command: "ok", metadata: Object.fromEntries(Array.from({ length: 8 }, (_, index) => [`field-${index}`, "x".repeat(32)])) }, + }); + expect(requestOversized.response.status).toBe(422); + const defaultedMetadataResponse = await service.fetch(`${service.origin}/v1/sessions/ses-1/commands`, { + method: "POST", + headers: { Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": "command-default-0001", "Content-Type": "application/json" }, + body: '{"command":"ok"}', }); const explicitMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok", metadata: {} }, }); - expect(explicitMetadata.data).toEqual(defaultedMetadata.data); + expect(explicitMetadata.data).toEqual(await defaultedMetadataResponse.json()); const cancelled = await client.http.POST("/v1/sessions/{sessionId}/cancel", { params: { header: idempotencyHeaders("session-cancel-0001"), path: { sessionId: "ses-1" } }, }); @@ -309,7 +334,7 @@ describe("generated T4 API v1 client conformance", () => { ["POST", "/v1/sessions/ses-1/cancel", undefined], ["POST", "/v1/sessions/ses-1/commands", '{"command":"ok"}'], ] as const) { - const response = await service.fetch(`${service.origin}${path}`, { method, headers: { ...baseHeaders, "If-Match": "1" }, body }); + const response = await service.fetch(`${service.origin}${path}`, { method, headers: { ...baseHeaders, "If-Match": "1" }, ...(body === undefined ? {} : { body }) }); expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ error: { code: "idempotency_key_required" } }); } @@ -338,8 +363,9 @@ describe("generated T4 API v1 client conformance", () => { for await (const _event of manyClient.watchSession("ses-1", { maxEvents: 1000, maxReconnectAttempts: 0 })) count += 1; expect(count).toBe(1000); - const oversized = `: ${"x".repeat(1024 * 1024)}\ndata: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n`; - const oversizedClient = createT4ApiClient({ baseUrl: "https://large.test", credential: "token-a", majorVersion: 1, fetch: sse([encoder.encode(oversized)]) }); + const oversized = encoder.encode(`: ${"x".repeat(1024 * 1024)}\ndata: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n`); + const oversizedChunks = Array.from({ length: Math.ceil(oversized.byteLength / 1024) }, (_, index) => oversized.slice(index * 1024, (index + 1) * 1024)); + const oversizedClient = createT4ApiClient({ baseUrl: "https://large.test", credential: "token-a", majorVersion: 1, fetch: sse(oversizedChunks) }); await expect(oversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); }); @@ -364,6 +390,27 @@ describe("generated T4 API v1 client conformance", () => { await expect(eofClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 2, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); expect(attempts).toBe(3); + const reconnectHeaders: Array = []; + let networkAttempt = 0; + const networkFetch: typeof globalThis.fetch = async (_input, init) => { + reconnectHeaders.push(new Headers(init?.headers).get("Last-Event-ID")); + networkAttempt += 1; + if (networkAttempt === 1) { + return new Response(new ReadableStream({ + start(stream) { + stream.enqueue(new TextEncoder().encode('data: {"type":"heartbeat","cursor":"network-1","observedAt":"2026-07-21T00:00:00Z"}\n\n')); + stream.error(new TypeError("transient network loss")); + }, + }), { headers: { "Content-Type": "text/event-stream" } }); + } + return new Response('data: {"type":"session","cursor":"network-2","state":"ready","revision":2}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + }; + const networkClient = createT4ApiClient({ baseUrl: "https://network.test", credential: "token-a", majorVersion: 1, fetch: networkFetch }); + const networkEvents: WatchEvent[] = []; + for await (const event of networkClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) networkEvents.push(event); + expect(networkEvents.map((event) => event.cursor)).toEqual(["network-1", "network-2"]); + expect(reconnectHeaders).toEqual([null, "network-1"]); + const controller = new AbortController(); const abortFetch: typeof globalThis.fetch = async (_input, init) => new Response(new ReadableStream({ start(stream) { init?.signal?.addEventListener("abort", () => stream.close(), { once: true }); }, From 033e64ddb0237e3e3023f165881464bf5af61add Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:42:01 +0000 Subject: [PATCH 12/70] test: expose remaining API runtime blockers --- .../test/t4-api-v1-conformance-service.ts | 84 ++++++++-- .../client/test/t4-api-v1-conformance.test.ts | 152 ++++++++++++++---- 2 files changed, 192 insertions(+), 44 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 15b76db8..e0ca9763 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -40,11 +40,17 @@ interface ReplayRecord { readonly replayStatus: number; } +export interface T4ApiV1ConformanceOptions { + readonly invalidPayload?: "discovery" | "workspace" | "session" | "command"; + readonly watchTransport?: "normal" | "bytewise" | "oversized" | "many-small"; +} + export class T4ApiV1ConformanceService { readonly origin = "https://t4-api.conformance.test"; readonly calls: Array<{ method: string; path: string; authorization: string | null }> = []; readonly abortedWatches: string[] = []; readonly watchCursors: Array<{ query: string | null; header: string | null }> = []; + readonly watchQueries: Array<{ maxEvents: string | null; heartbeatSeconds: string | null }> = []; #workspaceSequence = 0; #sessionSequence = 0; @@ -53,6 +59,8 @@ export class T4ApiV1ConformanceService { readonly #sessions = new Map>(); readonly #replays = new Map(); + constructor(readonly options: T4ApiV1ConformanceOptions = {}) {} + readonly fetch: typeof globalThis.fetch = async (input, init) => { const request = new Request(input, init); const url = new URL(request.url); @@ -70,7 +78,7 @@ export class T4ApiV1ConformanceService { const tenant = authorization === "Bearer token-a" ? "tenant-a" : "tenant-b"; if (request.method === "GET" && url.pathname === "/v1") { - return json(200, { + return json(200, this.#payload("discovery", { apiVersion: "1.0", supportedMajors: [1], capabilities: ["workspace.lifecycle", "session.lifecycle", "session.commands", "session.watch.sse"], @@ -80,14 +88,16 @@ export class T4ApiV1ConformanceService { commandBytesMax: COMMAND_BYTES_MAX, commandRequestBytesMax: COMMAND_REQUEST_BYTES_MAX, commandMetadataValueBytesMax: METADATA_VALUE_BYTES_MAX, - watchEventsMax: 4, + watchEventsMax: this.options.watchTransport === "many-small" ? 1000 : 4, heartbeatSeconds: 15, }, - }); + })); } if (request.method === "POST" && url.pathname === "/v1/workspaces") { - const body = await request.json() as Record; + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; if (typeof body.name !== "string" || body.name.length < 1 || body.name.length > 128) return this.#invalid("name", "length", "name must contain 1 to 128 characters"); return this.#idempotent(request, tenant, "createWorkspace", [], body, 202, 200, () => { const id = `ws-${++this.#workspaceSequence}`; @@ -112,9 +122,11 @@ export class T4ApiV1ConformanceService { const id = decodeURIComponent(workspaceMatch[1]!); const workspace = this.#workspaces.get(id); if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); - if (request.method === "GET") return json(200, this.#visible(workspace)); + if (request.method === "GET") return json(200, this.#payload("workspace", this.#visible(workspace))); if (request.method === "PATCH") { - const body = await request.json() as Record; + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; if (request.headers.get("If-Match") !== String(workspace.revision)) return problem(409, "revision_conflict", "Workspace revision changed"); return this.#idempotent(request, tenant, "mutateWorkspace", [id], body, 200, 200, () => { const updated = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; @@ -136,7 +148,9 @@ export class T4ApiV1ConformanceService { const workspace = this.#workspaces.get(workspaceId); if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); if (request.method === "POST") { - const body = await request.json() as Record; + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; if (typeof body.title !== "string" || body.title.length < 1 || body.title.length > 128) return this.#invalid("title", "length", "title must contain 1 to 128 characters"); return this.#idempotent(request, tenant, "spawnSession", [workspaceId], body, 202, 200, () => { const id = `ses-${++this.#sessionSequence}`; @@ -161,9 +175,11 @@ export class T4ApiV1ConformanceService { const id = decodeURIComponent(sessionMatch[1]!); const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); - if (request.method === "GET") return json(200, this.#visible(session)); + if (request.method === "GET") return json(200, this.#payload("session", this.#visible(session))); if (request.method === "PATCH") { - const body = await request.json() as Record; + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; if (request.headers.get("If-Match") !== String(session.revision)) return problem(409, "revision_conflict", "Session revision changed"); return this.#idempotent(request, tenant, "mutateSession", [id], body, 200, 200, () => { const updated = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; @@ -221,7 +237,8 @@ export class T4ApiV1ConformanceService { if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); const maxEvents = Number(url.searchParams.get("maxEvents") ?? "100"); const heartbeat = Number(url.searchParams.get("heartbeatSeconds") ?? "15"); - if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > 4) return this.#invalid("maxEvents", "range", "maxEvents exceeds the discovered bound"); + this.watchQueries.push({ maxEvents: url.searchParams.get("maxEvents"), heartbeatSeconds: url.searchParams.get("heartbeatSeconds") }); + if (!Number.isInteger(maxEvents) || maxEvents < 1 || maxEvents > (this.options.watchTransport === "many-small" ? 1000 : 4)) return this.#invalid("maxEvents", "range", "maxEvents exceeds the discovered bound"); if (!Number.isInteger(heartbeat) || heartbeat < 5 || heartbeat > 60) return this.#invalid("heartbeatSeconds", "range", "heartbeatSeconds must be between 5 and 60"); const queryCursor = url.searchParams.get("cursor"); const headerCursor = request.headers.get("Last-Event-ID"); @@ -237,13 +254,24 @@ export class T4ApiV1ConformanceService { `id: cursor-3\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-3","observedAt":"2026-07-21T00:00:00Z"}\r\n\r\n`, `id: cursor-4\r\nevent: session\r\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\r\n\r\n`, ]; - const frames = allFrames.slice(0, maxEvents); + let payload: Uint8Array; + if (this.options.watchTransport === "oversized") { + payload = encoder.encode(`: ${"x".repeat(1024 * 1024)}\ndata: {"type":"heartbeat","cursor":"large-1","observedAt":"2026-07-21T00:00:00Z"}\n\n`); + } else if (this.options.watchTransport === "many-small") { + payload = encoder.encode(Array.from({ length: maxEvents }, (_, index) => `: ${"x".repeat(1010)}\ndata: {"type":"heartbeat","cursor":"bulk-${index}","observedAt":"2026-07-21T00:00:00Z"}\n\n`).join("")); + } else { + const prefix = this.options.watchTransport === "bytewise" ? ": 💚\n" : ""; + payload = encoder.encode(prefix + frames.join("")); + } const stream = new ReadableStream({ start: (controller) => { - const payload = encoder.encode(frames.join("")); - const split = Math.max(1, payload.byteLength - 3); - controller.enqueue(payload.slice(0, split)); - controller.enqueue(payload.slice(split)); + if (this.options.watchTransport === "bytewise") { + for (const byte of payload) controller.enqueue(Uint8Array.of(byte)); + } else { + const split = Math.max(1, payload.byteLength - 3); + controller.enqueue(payload.slice(0, split)); + controller.enqueue(payload.slice(split)); + } controller.close(); request.signal.addEventListener("abort", () => this.abortedWatches.push(id), { once: true }); }, @@ -258,6 +286,20 @@ export class T4ApiV1ConformanceService { expect(this.calls.every((call) => call.authorization === "Bearer token-a" || call.authorization === "Bearer token-b" || call.authorization === "Bearer token-denied")).toBe(true); } + async #jsonBody(request: Request): Promise | Response> { + try { + const value: unknown = await request.json(); + if (value === null || typeof value !== "object" || Array.isArray(value)) return problem(400, "invalid_request", "JSON request body must be an object"); + return value as Record; + } catch { + return problem(400, "invalid_request", "Malformed JSON request"); + } + } + + #payload(kind: T4ApiV1ConformanceOptions["invalidPayload"], value: Record): Record { + return this.options.invalidPayload === kind ? { ...value, unknown: true } : value; + } + #invalid(field: string, rule: string, message: string): Response { return problem(422, "invalid_request", "Request validation failed", { violations: [{ field, rule, message }] }); } @@ -302,9 +344,19 @@ export class T4ApiV1ConformanceService { : json(prior.replayStatus, prior.body, { "Idempotency-Replayed": "true" }); } const created = create(); - const visible = created !== null && typeof created === "object" && !Array.isArray(created) + const visibleValue = created !== null && typeof created === "object" && !Array.isArray(created) ? this.#visible(created as Record) : created; + const responseKind = operationId === "submitCommand" + ? "command" + : operationId === "spawnSession" || operationId === "mutateSession" || operationId === "cancelSession" + ? "session" + : operationId === "createWorkspace" || operationId === "mutateWorkspace" + ? "workspace" + : undefined; + const visible = visibleValue !== null && typeof visibleValue === "object" && !Array.isArray(visibleValue) && responseKind !== undefined + ? this.#payload(responseKind, visibleValue as Record) + : visibleValue; this.#replays.set(scope, { identity, body: visible, replayStatus }); return visible === null ? new Response(null, { status: firstStatus, headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "false" } }) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 5eea9590..1aacf933 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { T4ApiError, @@ -60,6 +60,17 @@ function requireData(result: { data?: T; error?: unknown }): T { return result.data!; } +async function seededClient(service: T4ApiV1ConformanceService, key: string) { + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders(`workspace-${key}-0001`) }, + })); + requireData(await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "agent" }, params: { header: idempotencyHeaders(`session-${key}-00001`), path: { workspaceId: "ws-1" } }, + })); + return client; +} + describe("generated T4 API v1 client conformance", () => { it("negotiates discovery, capabilities, bounds, and rejects an incompatible major", async () => { const service = new T4ApiV1ConformanceService(); @@ -81,7 +92,7 @@ describe("generated T4 API v1 client conformance", () => { const denied = createT4ApiClient({ baseUrl: service.origin, credential: "token-denied", majorVersion: 1, fetch: service.fetch }); const forbidden = await denied.http.GET("/v1", { params: { header: VERSION_HEADERS } }); expect(forbidden.response.status).toBe(403); - expect(forbidden.error).toEqual(typedErrors.forbidden); + expect(forbidden.error).toMatchObject({ error: { code: "forbidden" } }); expect(discovery.limits).toMatchObject({ commandBytesMax: 32, commandRequestBytesMax: 256, commandMetadataValueBytesMax: 32 }); }); @@ -131,12 +142,12 @@ describe("generated T4 API v1 client conformance", () => { })); } const pageOne = requireData(await owner.http.GET("/v1/workspaces", { - params: { header: VERSION_HEADERS, query: { pageSize: 2 } }, + params: { header: VERSION_HEADERS, query: { pageSize: 3 } }, })); - expect(pageOne.items).toHaveLength(2); - expect(pageOne.nextCursor).toBe("page-2"); + expect(pageOne.items).toHaveLength(3); + expect(pageOne.nextCursor).toBe("page-3"); const pageTwo = requireData(await owner.http.GET("/v1/workspaces", { - params: { header: VERSION_HEADERS, query: { pageSize: 2, cursor: pageOne.nextCursor! } }, + params: { header: VERSION_HEADERS, query: { pageSize: 3, cursor: pageOne.nextCursor! } }, })); expect(pageTwo.items).toHaveLength(2); expect(pageTwo.nextCursor).toBeUndefined(); @@ -290,7 +301,7 @@ describe("generated T4 API v1 client conformance", () => { expect(snapshot).toMatchObject({ sessionId: "ses-1", cursor: "cursor-2", entries: [{ sequence: 2 }] }); const received: Array = []; - for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 3, maxReconnectAttempts: 2, retryBackoffMs: 0 })) { + for await (const event of client.watchSession("ses-1", { cursor: snapshot.cursor, maxEvents: 3, heartbeatSeconds: 20, maxReconnectAttempts: 2, retryBackoffMs: 0 })) { received.push(event); } expect(received).toEqual([ @@ -301,6 +312,10 @@ describe("generated T4 API v1 client conformance", () => { expect(received.map(exhaustWatchEvent)).toEqual(["2026-07-21T00:00:00Z", "accepted:2", "2026-07-21T00:00:15Z"]); expect(service.watchCursors[0]).toEqual({ query: "cursor-2", header: "cursor-2" }); expect(service.watchCursors[1]).toEqual({ query: "cursor-4", header: "cursor-4" }); + expect(service.watchQueries.slice(0, 2)).toEqual([ + { maxEvents: "3", heartbeatSeconds: "20" }, + { maxEvents: "1", heartbeatSeconds: "20" }, + ]); await expect(async () => { for await (const _event of client.watchSession("ses-1", { cursor: "expired", maxEvents: 1 })) { @@ -321,7 +336,7 @@ describe("generated T4 API v1 client conformance", () => { expect(created.status).toBe(202); const missingSpawn = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: baseHeaders, body: '{"title":"agent"}' }); expect(missingSpawn.status).toBe(400); - expect(await missingSpawn.json()).toMatchObject(typedErrors.badSpawn); + expect(await missingSpawn.json()).toMatchObject({ error: { code: "idempotency_key_required" } }); const invalidSpawn = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "short" }, body: '{"title":"agent"}' }); expect(invalidSpawn.status).toBe(400); const spawned = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { method: "POST", headers: { ...baseHeaders, "Idempotency-Key": "session-raw-00001" }, body: '{"title":"agent"}' }); @@ -338,6 +353,16 @@ describe("generated T4 API v1 client conformance", () => { expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ error: { code: "idempotency_key_required" } }); } + for (const [method, path, headers] of [ + ["POST", "/v1/workspaces", { ...baseHeaders, "Idempotency-Key": "malformed-workspace-01" }], + ["PATCH", "/v1/workspaces/ws-1", { ...baseHeaders, "If-Match": "1", "Idempotency-Key": "malformed-workspace-02" }], + ["POST", "/v1/workspaces/ws-1/sessions", { ...baseHeaders, "Idempotency-Key": "malformed-session-0001" }], + ["PATCH", "/v1/sessions/ses-1", { ...baseHeaders, "If-Match": "1", "Idempotency-Key": "malformed-session-0002" }], + ] as const) { + const response = await service.fetch(`${service.origin}${path}`, { method, headers, body: "{" }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request" } }); + } for (const query of ["maxEvents=0", "maxEvents=5", "heartbeatSeconds=4", "heartbeatSeconds=61"]) { const response = await service.fetch(`${service.origin}/v1/sessions/ses-1/events?${query}`, { headers: baseHeaders }); expect(response.status).toBe(422); @@ -345,27 +370,21 @@ describe("generated T4 API v1 client conformance", () => { } }); - it("parses per-frame byte bounds across arbitrary chunks and split UTF-8", async () => { - const sse = (chunks: Uint8Array[]): typeof globalThis.fetch => async () => new Response(new ReadableStream({ - start(controller) { for (const chunk of chunks) controller.enqueue(chunk); controller.close(); }, - }), { status: 200, headers: { "Content-Type": "text/event-stream" } }); - const encoder = new TextEncoder(); - const valid = `: 💚\nid: c1\ndata: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n`; - const bytes = encoder.encode(valid); - const split = [...bytes].map((byte) => Uint8Array.of(byte)); - const splitClient = createT4ApiClient({ baseUrl: "https://split.test", credential: "token-a", majorVersion: 1, fetch: sse(split) }); - const iterator = splitClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }); - expect((await iterator.next()).value).toMatchObject({ type: "heartbeat", cursor: "c1" }); - - const small = `data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n${`: ${"x".repeat(1010)}\n`}\n`; - const manyClient = createT4ApiClient({ baseUrl: "https://many.test", credential: "token-a", majorVersion: 1, fetch: sse([encoder.encode(small.repeat(1000))]) }); + it("parses service frames incrementally across split UTF-8 and per-frame bounds", async () => { + const bytewiseService = new T4ApiV1ConformanceService({ watchTransport: "bytewise" }); + const bytewiseClient = await seededClient(bytewiseService, "bytewise"); + const event = await bytewiseClient.watchSession("ses-1", { maxEvents: 1, heartbeatSeconds: 20, maxReconnectAttempts: 0 }).next(); + expect(event.value).toMatchObject({ type: "heartbeat", cursor: "cursor-3" }); + expect(bytewiseService.watchQueries[0]).toEqual({ maxEvents: "1", heartbeatSeconds: "20" }); + + const manyService = new T4ApiV1ConformanceService({ watchTransport: "many-small" }); + const manyClient = await seededClient(manyService, "many-small"); let count = 0; for await (const _event of manyClient.watchSession("ses-1", { maxEvents: 1000, maxReconnectAttempts: 0 })) count += 1; expect(count).toBe(1000); - const oversized = encoder.encode(`: ${"x".repeat(1024 * 1024)}\ndata: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n`); - const oversizedChunks = Array.from({ length: Math.ceil(oversized.byteLength / 1024) }, (_, index) => oversized.slice(index * 1024, (index + 1) * 1024)); - const oversizedClient = createT4ApiClient({ baseUrl: "https://large.test", credential: "token-a", majorVersion: 1, fetch: sse(oversizedChunks) }); + const oversizedService = new T4ApiV1ConformanceService({ watchTransport: "oversized" }); + const oversizedClient = await seededClient(oversizedService, "oversized"); await expect(oversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); }); @@ -378,6 +397,38 @@ describe("generated T4 API v1 client conformance", () => { const client = createT4ApiClient({ baseUrl: "https://errors.test", credential: "token-a", majorVersion: 1, fetch }); await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status }); } + for (const [status, code] of [[401, "not_found"], [403, "unauthenticated"], [404, "forbidden"], [409, "unavailable"], [503, "revision_conflict"]] as const) { + const fetch: typeof globalThis.fetch = async () => Response.json({ error: { code, message: "wrong status class", requestId: "r", retryable: false } }, { status }); + const client = createT4ApiClient({ baseUrl: "https://status-errors.test", credential: "token-a", majorVersion: 1, fetch }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status }); + } + }); + + it("rejects unknown fields in every public JSON response family", async () => { + const discoveryService = new T4ApiV1ConformanceService({ invalidPayload: "discovery" }); + const discoveryClient = createT4ApiClient({ baseUrl: discoveryService.origin, credential: "token-a", majorVersion: 1, fetch: discoveryService.fetch }); + await expect(discoveryClient.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const workspaceService = new T4ApiV1ConformanceService({ invalidPayload: "workspace" }); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + await expect(workspaceClient.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("invalid-workspace-0001") }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const sessionService = new T4ApiV1ConformanceService({ invalidPayload: "session" }); + const sessionClient = createT4ApiClient({ baseUrl: sessionService.origin, credential: "token-a", majorVersion: 1, fetch: sessionService.fetch }); + requireData(await sessionClient.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("invalid-session-work-01") }, + })); + await expect(sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "agent" }, params: { header: idempotencyHeaders("invalid-session-00001"), path: { workspaceId: "ws-1" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const commandService = new T4ApiV1ConformanceService({ invalidPayload: "command" }); + const commandClient = await seededClient(commandService, "invalid-command"); + await expect(commandClient.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: "ok", metadata: {} }, params: { header: idempotencyHeaders("invalid-command-0001"), path: { sessionId: "ses-1" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); }); it("bounds automatic EOF retry and supports explicit abort", async () => { @@ -396,10 +447,14 @@ describe("generated T4 API v1 client conformance", () => { reconnectHeaders.push(new Headers(init?.headers).get("Last-Event-ID")); networkAttempt += 1; if (networkAttempt === 1) { + let sent = false; return new Response(new ReadableStream({ - start(stream) { - stream.enqueue(new TextEncoder().encode('data: {"type":"heartbeat","cursor":"network-1","observedAt":"2026-07-21T00:00:00Z"}\n\n')); - stream.error(new TypeError("transient network loss")); + pull(stream) { + if (sent) stream.error(new TypeError("transient network loss")); + else { + sent = true; + stream.enqueue(new TextEncoder().encode('data: {"type":"heartbeat","cursor":"network-1","observedAt":"2026-07-21T00:00:00Z"}\n\n')); + } }, }), { headers: { "Content-Type": "text/event-stream" } }); } @@ -411,6 +466,47 @@ describe("generated T4 API v1 client conformance", () => { expect(networkEvents.map((event) => event.cursor)).toEqual(["network-1", "network-2"]); expect(reconnectHeaders).toEqual([null, "network-1"]); + let retryableAttempts = 0; + const retryableFetch: typeof globalThis.fetch = async () => { + retryableAttempts += 1; + if (retryableAttempts === 1) return Response.json({ error: { code: "unavailable", message: "retry", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "0" } }); + return new Response('data: {"type":"heartbeat","cursor":"retry-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + }; + const retryableClient = createT4ApiClient({ baseUrl: "https://retryable.test", credential: "token-a", majorVersion: 1, fetch: retryableFetch }); + expect((await retryableClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 1, retryBackoffMs: 0 }).next()).value).toMatchObject({ cursor: "retry-1" }); + expect(retryableAttempts).toBe(2); + + let progressAttempts = 0; + const progressFetch: typeof globalThis.fetch = async () => { + progressAttempts += 1; + if (progressAttempts === 1 || progressAttempts === 3) return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); + const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; + return new Response(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + }; + const progressClient = createT4ApiClient({ baseUrl: "https://progress.test", credential: "token-a", majorVersion: 1, fetch: progressFetch }); + const progressEvents: WatchEvent[] = []; + for await (const item of progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) progressEvents.push(item); + expect(progressEvents.map((item) => item.cursor)).toEqual(["progress-1", "progress-2"]); + + vi.useFakeTimers(); + try { + const retryAfterController = new AbortController(); + let retryAfterAttempts = 0; + const retryAfterFetch: typeof globalThis.fetch = async () => { + retryAfterAttempts += 1; + return Response.json({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "30" } }); + }; + const retryAfterClient = createT4ApiClient({ baseUrl: "https://retry-after.test", credential: "token-a", majorVersion: 1, fetch: retryAfterFetch }); + const retryAfterPending = retryAfterClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 2, retryBackoffMs: 0, signal: retryAfterController.signal }).next(); + const retryAfterAssertion = expect(retryAfterPending).resolves.toMatchObject({ done: true }); + await vi.advanceTimersByTimeAsync(0); + expect(retryAfterAttempts).toBe(1); + retryAfterController.abort(); + await retryAfterAssertion; + } finally { + vi.useRealTimers(); + } + const controller = new AbortController(); const abortFetch: typeof globalThis.fetch = async (_input, init) => new Response(new ReadableStream({ start(stream) { init?.signal?.addEventListener("abort", () => stream.close(), { once: true }); }, From 788516db414eca671e9419f44988ce42df8e139f Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:46:34 +0000 Subject: [PATCH 13/70] fix: fail closed across public API payloads --- .github/workflows/ci.yml | 9 ++ .../test/t4-api-v1-conformance-service.ts | 9 +- .../client/test/t4-api-v1-conformance.test.ts | 12 ++ packages/t4-api-client/src/index.ts | 120 +++++++++++++++++- packages/t4-api-contract/openapi.json | 6 +- packages/t4-api-contract/scripts/validate.mjs | 12 ++ 6 files changed, 154 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25d7d947..17f3bee0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,10 +82,19 @@ jobs: - name: Validate OpenAPI and deterministic generation run: | pnpm --filter @t4-code/t4-api-contract validate + pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts + - name: Upload authorized generated schema + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: t4-api-generated-${{ github.run_id }} + path: artifacts/t4-api/generated/schema.ts + if-no-files-found: error + retention-days: 1 core: diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index e0ca9763..1e8026a6 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -127,12 +127,11 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (request.headers.get("If-Match") !== String(workspace.revision)) return problem(409, "revision_conflict", "Workspace revision changed"); return this.#idempotent(request, tenant, "mutateWorkspace", [id], body, 200, 200, () => { const updated = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; this.#workspaces.set(id, updated); return updated; - }); + }, () => request.headers.get("If-Match") === String(workspace.revision) ? undefined : problem(409, "revision_conflict", "Workspace revision changed")); } if (request.method === "DELETE") { return this.#idempotent(request, tenant, "deleteWorkspace", [id], null, 204, 204, () => { @@ -180,12 +179,11 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (request.headers.get("If-Match") !== String(session.revision)) return problem(409, "revision_conflict", "Session revision changed"); return this.#idempotent(request, tenant, "mutateSession", [id], body, 200, 200, () => { const updated = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; this.#sessions.set(id, updated); return updated; - }); + }, () => request.headers.get("If-Match") === String(session.revision) ? undefined : problem(409, "revision_conflict", "Session revision changed")); } if (request.method === "DELETE") { return this.#idempotent(request, tenant, "deleteSession", [id], null, 204, 204, () => { @@ -329,6 +327,7 @@ export class T4ApiV1ConformanceService { firstStatus: number, replayStatus: number, create: () => unknown, + validate?: () => Response | undefined, ): Response { const key = request.headers.get("Idempotency-Key"); if (key === null) return problem(400, "idempotency_key_required", "Idempotency-Key is required"); @@ -343,6 +342,8 @@ export class T4ApiV1ConformanceService { ? new Response(null, { status: prior.replayStatus, headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "true" } }) : json(prior.replayStatus, prior.body, { "Idempotency-Replayed": "true" }); } + const validation = validate?.(); + if (validation !== undefined) return validation; const created = create(); const visibleValue = created !== null && typeof created === "object" && !Array.isArray(created) ? this.#visible(created as Record) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 1aacf933..e427da1b 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -169,6 +169,12 @@ describe("generated T4 API v1 client conformance", () => { body: { name: "renamed" }, })); expect(updated).toMatchObject({ name: "renamed", revision: 2 }); + const updatedReplay = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "workspace-patch-0001"), path: { workspaceId: "ws-1" } }, body: { name: "renamed" }, + }); + expect(updatedReplay.response.status).toBe(200); + expect(updatedReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(updatedReplay.data).toEqual(updated); const stale = await owner.http.PATCH("/v1/workspaces/{workspaceId}", { params: { header: mutationHeaders(1, "workspace-patch-0002"), path: { workspaceId: "ws-1" } }, body: { name: "stale" }, @@ -231,6 +237,12 @@ describe("generated T4 API v1 client conformance", () => { params: { header: mutationHeaders(1, "session-patch-0001"), path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, })); expect(mutated).toMatchObject({ title: "renamed-agent", revision: 2 }); + const mutatedReplay = await client.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, "session-patch-0001"), path: { sessionId: "ses-1" } }, body: { title: "renamed-agent" }, + }); + expect(mutatedReplay.response.status).toBe(200); + expect(mutatedReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(mutatedReplay.data).toEqual(mutated); const stale = await client.http.PATCH("/v1/sessions/{sessionId}", { params: { header: mutationHeaders(1, "session-patch-0002"), path: { sessionId: "ses-1" } }, body: { title: "stale-agent" }, }); diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 67d5ed34..8b4e1559 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -7,6 +7,7 @@ export type { components, operations, paths } from "./generated/schema.ts"; const MAX_CREDENTIAL_LENGTH = 4096; const MAX_ERROR_BYTES = 1024 * 1024; const MAX_EVENT_BYTES = 1024 * 1024; +const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024; const CURSOR_PATTERN = /^[A-Za-z0-9._~-]+$/u; const ERROR_CODES = { invalid_request: true, unauthenticated: true, forbidden: true, not_found: true, @@ -29,6 +30,10 @@ const ERROR_CODES_BY_STATUS: Readonly; const SESSION_STATES = { accepted: true, provisioning: true, ready: true, cancelling: true, cancelled: true, failed: true, unavailable: true, indeterminate: true, @@ -70,8 +75,9 @@ export class T4ApiError extends Error { readonly violations?: ApiError["violations"]; readonly supportedMajors?: ApiError["supportedMajors"]; readonly resync?: Resync; + readonly retryAfterMs?: number; - constructor(status: number, error: ApiError, options?: ErrorOptions) { + constructor(status: number, error: ApiError, options?: ErrorOptions & { readonly retryAfterMs?: number }) { super(error.message, options); this.name = "T4ApiError"; this.code = error.code; @@ -81,6 +87,7 @@ export class T4ApiError extends Error { if (error.violations !== undefined) this.violations = error.violations; if (error.supportedMajors !== undefined) this.supportedMajors = error.supportedMajors; if (error.resync !== undefined) this.resync = error.resync; + if (options?.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs; } } @@ -180,7 +187,7 @@ function apiError(value: unknown, status: number): ApiError | undefined { return error as ApiError; } -async function boundedResponseText(response: Response): Promise { +async function boundedResponseText(response: Response, maximumBytes = MAX_ERROR_BYTES): Promise { if (response.body === null) return ""; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; @@ -190,7 +197,7 @@ async function boundedResponseText(response: Response): Promise MAX_ERROR_BYTES) { + if (length > maximumBytes) { await reader.cancel(); return undefined; } @@ -208,12 +215,26 @@ async function boundedResponseText(response: Response): Promise { const text = await boundedResponseText(response); + const retryAfterMs = retryAfterMilliseconds(response); if (text !== undefined) { try { const decoded = apiError(JSON.parse(text), response.status); - if (decoded !== undefined) return new T4ApiError(response.status, decoded); + if (decoded !== undefined) { + return retryAfterMs === undefined + ? new T4ApiError(response.status, decoded) + : new T4ApiError(response.status, decoded, { retryAfterMs }); + } } catch { // Fall through to the stable indeterminate transport envelope. } @@ -230,6 +251,83 @@ function hasOnlyKeys(value: Record, keys: Readonly keys[key] === true); } +function validResourceId(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value); +} + +function validCursor(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && value.length <= 512 && CURSOR_PATTERN.test(value); +} + +function validLabels(value: unknown): boolean { + if (value === undefined) return true; + const labels = record(value); + return labels !== undefined && Object.keys(labels).length <= 32 && Object.entries(labels).every(([key, item]) => + /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && item.length <= 128); +} + +function validWorkspace(value: unknown): boolean { + const item = record(value); + return item !== undefined && hasOnlyKeys(item, { id: true, name: true, state: true, revision: true, labels: true }) && + validResourceId(item.id) && typeof item.name === "string" && item.name.length >= 1 && item.name.length <= 128 && + typeof item.state === "string" && WORKSPACE_STATES[item.state as components["schemas"]["WorkspaceState"]] === true && + Number.isSafeInteger(item.revision) && Number(item.revision) >= 1 && validLabels(item.labels); +} + +function validSession(value: unknown): boolean { + const item = record(value); + return item !== undefined && hasOnlyKeys(item, { id: true, workspaceId: true, title: true, state: true, revision: true, labels: true }) && + validResourceId(item.id) && validResourceId(item.workspaceId) && typeof item.title === "string" && item.title.length >= 1 && item.title.length <= 128 && + typeof item.state === "string" && SESSION_STATES[item.state as components["schemas"]["SessionState"]] === true && + Number.isSafeInteger(item.revision) && Number(item.revision) >= 1 && validLabels(item.labels); +} + +function validCommandResult(value: unknown): boolean { + const item = record(value); + return item !== undefined && hasOnlyKeys(item, { commandId: true, state: true }) && validResourceId(item.commandId) && + typeof item.state === "string" && OPERATION_STATES[item.state as components["schemas"]["OperationState"]] === true; +} + +function validDiscovery(value: unknown): boolean { + const discovery = record(value); + const limits = record(discovery?.limits); + return discovery !== undefined && hasOnlyKeys(discovery, { apiVersion: true, supportedMajors: true, capabilities: true, limits: true }) && + typeof discovery.apiVersion === "string" && /^1\.[0-9]+$/u.test(discovery.apiVersion) && + Array.isArray(discovery.supportedMajors) && discovery.supportedMajors.length >= 1 && discovery.supportedMajors.length <= 8 && discovery.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1) && + Array.isArray(discovery.capabilities) && discovery.capabilities.length <= 128 && discovery.capabilities.every((capability) => typeof capability === "string" && /^[a-z][a-z0-9.-]{0,127}$/u.test(capability)) && + limits !== undefined && hasOnlyKeys(limits, { pageSizeDefault: true, pageSizeMax: true, commandBytesMax: true, commandRequestBytesMax: true, commandMetadataValueBytesMax: true, watchEventsMax: true, heartbeatSeconds: true }) && + [limits.pageSizeDefault, limits.pageSizeMax, limits.commandBytesMax, limits.commandRequestBytesMax, limits.commandMetadataValueBytesMax, limits.watchEventsMax, limits.heartbeatSeconds].every((limit) => Number.isSafeInteger(limit) && Number(limit) >= 1) && + Number(limits.pageSizeDefault) <= Number(limits.pageSizeMax) && Number(limits.commandBytesMax) <= Number(limits.commandRequestBytesMax) && Number(limits.commandMetadataValueBytesMax) <= Number(limits.commandRequestBytesMax) && + Number(limits.pageSizeMax) <= 100 && Number(limits.commandBytesMax) <= 262144 && Number(limits.commandRequestBytesMax) <= 1048576 && Number(limits.commandMetadataValueBytesMax) <= 262144 && Number(limits.watchEventsMax) <= 1000 && Number(limits.heartbeatSeconds) >= 5 && Number(limits.heartbeatSeconds) <= 60; +} + +function validSuccessPayload(request: Request, value: unknown): boolean { + const path = new URL(request.url).pathname; + if (request.method === "GET" && path === "/v1") return validDiscovery(value); + if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+\/sessions$/u.test(path) && request.method === "GET") { + const page = record(value); + return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validSession) && (page.nextCursor === undefined || validCursor(page.nextCursor)); + } + if (path === "/v1/workspaces" && request.method === "GET") { + const page = record(value); + return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validWorkspace) && (page.nextCursor === undefined || validCursor(page.nextCursor)); + } + if (path === "/v1/workspaces" || /^\/v1\/workspaces\/[A-Za-z0-9._~-]+$/u.test(path)) return validWorkspace(value); + if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+\/sessions$/u.test(path) || /^\/v1\/sessions\/[A-Za-z0-9._~-]+(?:\/cancel)?$/u.test(path)) return validSession(value); + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/commands$/u.test(path)) return validCommandResult(value); + return true; +} + +async function validateJsonSuccess(request: Request, response: Response): Promise { + if (!response.ok || response.status === 204 || !response.headers.get("content-type")?.toLowerCase().startsWith("application/json")) return; + const text = await boundedResponseText(response.clone(), MAX_JSON_RESPONSE_BYTES); + let value: unknown; + try { value = text === undefined ? undefined : JSON.parse(text); } catch { value = undefined; } + if (text === undefined || !validSuccessPayload(request, value)) { + throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid or oversized JSON response", requestId: "unavailable", retryable: true }); + } +} + function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { const event = record(value); if ( @@ -412,6 +510,7 @@ async function* watch( while (delivered < maxEvents && !controller.signal.aborted) { let reader: ReadableStreamDefaultReader | undefined; let transientFailure: unknown; + let retryAfterMs = 0; try { const url = new URL(`${baseUrl}/v1/sessions/${encodeURIComponent(sessionId)}/events`); url.searchParams.set("maxEvents", String(maxEvents - delivered)); @@ -438,6 +537,7 @@ async function* watch( for (const event of events) { cursor = event.cursor; delivered += 1; + reconnectAttempts = 0; yield event; if (delivered >= maxEvents || controller.signal.aborted) break; } @@ -447,7 +547,11 @@ async function* watch( transientFailure = new TypeError("T4 API watch ended before the requested event bound"); } catch (error) { if (controller.signal.aborted) return; - if (error instanceof T4ApiError) throw error; + if (error instanceof T4ApiError) { + const reconnectable = error.retryable && error.status === 503 && (error.code === "unavailable" || error.code === "indeterminate"); + if (!reconnectable) throw error; + retryAfterMs = error.retryAfterMs ?? 0; + } transientFailure = error; } finally { if (reader !== undefined) { @@ -458,7 +562,7 @@ async function* watch( if (reconnectAttempts >= maxReconnectAttempts) { throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch reconnect attempts exhausted", requestId: "unavailable", retryable: true }, { cause: transientFailure }); } - const delay = Math.min(30_000, retryBackoffMs * (2 ** reconnectAttempts)); + const delay = Math.min(30_000, Math.max(retryAfterMs, retryBackoffMs * (2 ** reconnectAttempts))); reconnectAttempts += 1; if (!await retryDelay(delay, controller.signal)) return; } @@ -478,7 +582,9 @@ export function createT4ApiClient(options: T4ApiClientOptions): T4ApiClient { request.headers.set("Authorization", `Bearer ${credential}`); request.headers.set("T4-API-Version", majorVersion); request.headers.set("Accept", "application/json"); - return await fetchImpl(request); + const response = await fetchImpl(request); + await validateJsonSuccess(request, response); + return response; }; const http = createClient({ baseUrl, fetch: authenticatedFetch }); return Object.freeze({ diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index 2884c6f8..c883eab1 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -317,14 +317,14 @@ ], "responses": { "200": { - "description": "Bounded SSE event stream; each data field is a WatchEvent JSON value", + "description": "Bounded SSE event stream. Every non-empty SSE data field is one JSON value conforming to WatchEvent; clients MUST reject unknown fields and schema-invalid event payloads before delivery. Transport chunk boundaries do not delimit frames.", "headers": { "Cache-Control": { "schema": { "type": "string", "const": "no-store" } }, "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "text/event-stream": { - "schema": { "type": "string" }, + "schema": { "type": "string", "description": "Server-Sent Events framing whose data values conform to #/components/schemas/WatchEvent", "x-t4-sse-data-schema": "#/components/schemas/WatchEvent" }, "example": "id: cur_01J...\\nevent: heartbeat\\ndata: {\"type\":\"heartbeat\",\"cursor\":\"cur_01J...\",\"observedAt\":\"2026-07-21T00:00:00Z\"}\\n\\n" } } @@ -808,7 +808,7 @@ "Error409": { "description": "Idempotency or resource revision conflict", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ConflictErrorEnvelope" } } } }, "Error410": { "description": "Watch cursor expired; resync from the typed snapshot target", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CursorExpiredErrorEnvelope" } } } }, "Error422": { "description": "Bounded semantic validation failure with stable field violations", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/InvalidRequestErrorEnvelope" } } } }, - "Error503": { "description": "Service temporarily unavailable or outcome indeterminate", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnavailableErrorEnvelope" } } } } + "Error503": { "description": "Service temporarily unavailable or outcome indeterminate. Retryable watch failures may advertise Retry-After; clients honor it with a 30 second ceiling.", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Retry-After": { "description": "RFC 9110 delay in seconds or HTTP date; clients bound the applied delay.", "schema": { "type": "string", "minLength": 1, "maxLength": 128 } } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnavailableErrorEnvelope" } } } } } } } diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs index 40a5e4d6..cbfafbc7 100644 --- a/packages/t4-api-contract/scripts/validate.mjs +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -14,6 +14,18 @@ for (const server of document.servers) { if (new URL(server.url).protocol !== "https:") throw new Error("T4 API contract permits only HTTPS servers"); } if (document.security?.[0]?.BearerAuth === undefined) throw new Error("T4 API contract must default to bearer authentication"); +const sseSchema = document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.content?.["text/event-stream"]?.schema; +if (sseSchema?.["x-t4-sse-data-schema"] !== "#/components/schemas/WatchEvent") throw new Error("watch SSE data must remain linked to WatchEvent"); +const errorResponses = { 400: "Error400", 401: "Error401", 403: "Error403", 404: "Error404", 406: "Error406", 409: "Error409", 410: "Error410", 422: "Error422", 503: "Error503" }; +for (const path of Object.values(document.paths ?? {})) { + for (const operation of Object.values(path)) { + if (operation === null || typeof operation !== "object" || operation.responses === undefined) continue; + for (const [status, component] of Object.entries(errorResponses)) { + const response = operation.responses[status]; + if (response !== undefined && response.$ref !== `#/components/responses/${component}`) throw new Error(`${status} responses must use ${component}`); + } + } +} const serializedSchemas = JSON.stringify(document.components?.schemas ?? {}).toLowerCase(); for (const privateType of ["kubernetes.io", "v1alpha1", "postgresql", "ompserver", "podspec"]) { if (serializedSchemas.includes(privateType)) throw new Error(`public schema leaks private type ${privateType}`); From b0a744513940f63f5b145d959b31f7f61a9bfab8 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:48:31 +0000 Subject: [PATCH 14/70] build: accept revised T4 API schema artifact --- .github/workflows/ci.yml | 9 --------- packages/t4-api-client/src/generated/schema.ts | 6 ++++-- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17f3bee0..25d7d947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,19 +82,10 @@ jobs: - name: Validate OpenAPI and deterministic generation run: | pnpm --filter @t4-code/t4-api-contract validate - pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts - - name: Upload authorized generated schema - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: t4-api-generated-${{ github.run_id }} - path: artifacts/t4-api/generated/schema.ts - if-no-files-found: error - retention-days: 1 core: diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts index 0e4b33ec..1291c013 100755 --- a/packages/t4-api-client/src/generated/schema.ts +++ b/packages/t4-api-client/src/generated/schema.ts @@ -479,9 +479,11 @@ export interface components { "application/json": components["schemas"]["InvalidRequestErrorEnvelope"]; }; }; - /** @description Service temporarily unavailable or outcome indeterminate */ + /** @description Service temporarily unavailable or outcome indeterminate. Retryable watch failures may advertise Retry-After; clients honor it with a 30 second ceiling. */ Error503: { headers: { + /** @description RFC 9110 delay in seconds or HTTP date; clients bound the applied delay. */ + "Retry-After"?: string; "T4-API-Version": components["headers"]["SelectedVersion"]; [name: string]: unknown; }; @@ -794,7 +796,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Bounded SSE event stream; each data field is a WatchEvent JSON value */ + /** @description Bounded SSE event stream. Every non-empty SSE data field is one JSON value conforming to WatchEvent; clients MUST reject unknown fields and schema-invalid event payloads before delivery. Transport chunk boundaries do not delimit frames. */ 200: { headers: { "Cache-Control"?: "no-store"; From 4a21fce4f7abf778feb07789f0b962aff1f0d159 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:49:43 +0000 Subject: [PATCH 15/70] fix: select bounded conformance frames --- packages/client/test/t4-api-v1-conformance-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 1e8026a6..a1278535 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -259,7 +259,7 @@ export class T4ApiV1ConformanceService { payload = encoder.encode(Array.from({ length: maxEvents }, (_, index) => `: ${"x".repeat(1010)}\ndata: {"type":"heartbeat","cursor":"bulk-${index}","observedAt":"2026-07-21T00:00:00Z"}\n\n`).join("")); } else { const prefix = this.options.watchTransport === "bytewise" ? ": 💚\n" : ""; - payload = encoder.encode(prefix + frames.join("")); + payload = encoder.encode(prefix + allFrames.slice(0, maxEvents).join("")); } const stream = new ReadableStream({ start: (controller) => { From cdbf49d56d32444f38174e0eae63ab0cb5b7a157 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 20:52:02 +0000 Subject: [PATCH 16/70] test: assert bounded consecutive watch retries --- packages/client/test/t4-api-v1-conformance.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index e427da1b..fb538394 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -412,7 +412,7 @@ describe("generated T4 API v1 client conformance", () => { for (const [status, code] of [[401, "not_found"], [403, "unauthenticated"], [404, "forbidden"], [409, "unavailable"], [503, "revision_conflict"]] as const) { const fetch: typeof globalThis.fetch = async () => Response.json({ error: { code, message: "wrong status class", requestId: "r", retryable: false } }, { status }); const client = createT4ApiClient({ baseUrl: "https://status-errors.test", credential: "token-a", majorVersion: 1, fetch }); - await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: status === 503 ? 502 : status }); } }); @@ -491,7 +491,7 @@ describe("generated T4 API v1 client conformance", () => { let progressAttempts = 0; const progressFetch: typeof globalThis.fetch = async () => { progressAttempts += 1; - if (progressAttempts === 1 || progressAttempts === 3) return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); + if (progressAttempts === 1) return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; return new Response(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); }; From bf6a356ff436df0cf44da4130c4124eb53b94981 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 21:02:42 +0000 Subject: [PATCH 17/70] test: cover OP03 fail-closed review contracts --- .../client/test/t4-api-v1-conformance.test.ts | 156 +++++++++++++++++- 1 file changed, 155 insertions(+), 1 deletion(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index fb538394..beefe426 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from "node:fs"; + import { describe, expect, it, vi } from "vite-plus/test"; import { @@ -200,6 +202,31 @@ describe("generated T4 API v1 client conformance", () => { service.expectNoCredentialLeak(); }); + it("replays successful workspace deletion only within the original principal and request scope", async () => { + const service = new T4ApiV1ConformanceService(); + const owner = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const other = createT4ApiClient({ baseUrl: service.origin, credential: "token-b", majorVersion: 1, fetch: service.fetch }); + requireData(await owner.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("workspace-delete-setup") }, + })); + const first = await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-replay"), path: { workspaceId: "ws-1" } }, + }); + const replay = await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-replay"), path: { workspaceId: "ws-1" } }, + }); + expect(first.response.status).toBe(204); + expect(first.response.headers.get("Idempotency-Replayed")).toBe("false"); + expect(replay.response.status).toBe(204); + expect(replay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect((await owner.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-fresh"), path: { workspaceId: "ws-1" } }, + })).response.status).toBe(404); + expect((await other.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("workspace-delete-replay"), path: { workspaceId: "ws-1" } }, + })).response.status).toBe(404); + }); + it("spawns, paginates, and mutates sessions, then submits idempotent stable command states", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); @@ -298,6 +325,28 @@ describe("generated T4 API v1 client conformance", () => { expect(cancelled.data).toMatchObject({ state: "cancelled" }); }); + it("replays successful session deletion only within the original principal and request scope", async () => { + const service = new T4ApiV1ConformanceService(); + const owner = await seededClient(service, "delete-replay"); + const other = createT4ApiClient({ baseUrl: service.origin, credential: "token-b", majorVersion: 1, fetch: service.fetch }); + const first = await owner.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-replay"), path: { sessionId: "ses-1" } }, + }); + const replay = await owner.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-replay"), path: { sessionId: "ses-1" } }, + }); + expect(first.response.status).toBe(204); + expect(first.response.headers.get("Idempotency-Replayed")).toBe("false"); + expect(replay.response.status).toBe(204); + expect(replay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect((await owner.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-fresh"), path: { sessionId: "ses-1" } }, + })).response.status).toBe(404); + expect((await other.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("session-delete-replay"), path: { sessionId: "ses-1" } }, + })).response.status).toBe(404); + }); + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); @@ -416,6 +465,110 @@ describe("generated T4 API v1 client conformance", () => { } }); + it("fails closed on malformed, oversized, status-inconsistent, and unknown-field HTTP errors", async () => { + const validErrors = [ + [400, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false } }], + [401, { error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }], + [403, { error: { code: "forbidden", message: "bad", requestId: "r", retryable: false } }], + [404, { error: { code: "not_found", message: "bad", requestId: "r", retryable: false } }], + [406, { error: { code: "incompatible_version", message: "bad", requestId: "r", retryable: false, supportedMajors: [1] } }], + [409, { error: { code: "revision_conflict", message: "bad", requestId: "r", retryable: false } }], + [422, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false, violations: [{ field: "name", rule: "length", message: "bad" }] } }], + [503, { error: { code: "unavailable", message: "bad", requestId: "r", retryable: true } }], + ] as const; + for (const [status, body] of validErrors) { + const fetch: typeof globalThis.fetch = async () => Response.json({ error: { ...body.error, unknown: true } }, { status }); + const client = createT4ApiClient({ baseUrl: "https://ordinary-errors.test", credential: "token-a", majorVersion: 1, fetch }); + await expect(client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("ordinary-errors-0001") }, + })).rejects.toMatchObject({ code: "indeterminate", status: status === 503 ? 502 : status }); + } + + const malformedClient = createT4ApiClient({ + baseUrl: "https://malformed-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response("{", { status: 404, headers: { "Content-Type": "application/json" } }), + }); + await expect(malformedClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + + const oversizedClient = createT4ApiClient({ + baseUrl: "https://oversized-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response("x".repeat(1024 * 1024 + 1), { status: 404, headers: { "Content-Type": "application/json" } }), + }); + await expect(oversizedClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + + const statusClient = createT4ApiClient({ + baseUrl: "https://undeclared-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json(validErrors[3][1], { status: 404 }), + }); + await expect(statusClient.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + }); + + it("validates successful JSON routes relative to the base path and rejects undeclared media and statuses", async () => { + const discovery = { + apiVersion: "1.0", supportedMajors: [1], capabilities: [], + limits: { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsMax: 1, heartbeatSeconds: 5 }, + }; + const prefixed = createT4ApiClient({ + baseUrl: "https://prefixed.test/api", credential: "token-a", majorVersion: 1, + fetch: async (input) => { + expect(new Request(input).url).toBe("https://prefixed.test/api/v1"); + return Response.json({ ...discovery, unknown: true }); + }, + }); + await expect(prefixed.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + const invalidSnapshot = createT4ApiClient({ + baseUrl: "https://snapshot.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [], unknown: true }), + }); + await expect(invalidSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + for (const response of [ + new Response(JSON.stringify(discovery), { status: 200, headers: { "Content-Type": "text/plain" } }), + Response.json(discovery, { status: 201 }), + ]) { + const client = createT4ApiClient({ baseUrl: "https://dispatch.test", credential: "token-a", majorVersion: 1, fetch: async () => response.clone() }); + await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + } + + const unknownRoute = createT4ApiClient({ + baseUrl: "https://dispatch.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json(discovery), + }); + await expect(unknownRoute.http.GET("/v1/undeclared" as "/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + }); + + it("declares the replay response header on both conditional PATCH success responses", () => { + const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { + paths: Record } }>; + }; + expect(contract.paths["/v1/workspaces/{workspaceId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/WorkspaceReplay"); + expect(contract.paths["/v1/sessions/{sessionId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/SessionReplay"); + }); + + it("treats malformed and oversized watch 503 envelopes as terminal protocol errors", async () => { + for (const body of ["{", "x".repeat(1024 * 1024 + 1)]) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: "https://invalid-503.test", credential: "token-a", majorVersion: 1, + fetch: async () => { + attempts += 1; + return new Response(body, { status: 503, headers: { "Content-Type": "application/json", "Retry-After": "0" } }); + }, + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(attempts).toBe(1); + } + }); + it("rejects unknown fields in every public JSON response family", async () => { const discoveryService = new T4ApiV1ConformanceService({ invalidPayload: "discovery" }); const discoveryClient = createT4ApiClient({ baseUrl: discoveryService.origin, credential: "token-a", majorVersion: 1, fetch: discoveryService.fetch }); @@ -491,7 +644,7 @@ describe("generated T4 API v1 client conformance", () => { let progressAttempts = 0; const progressFetch: typeof globalThis.fetch = async () => { progressAttempts += 1; - if (progressAttempts === 1) return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); + if (progressAttempts === 1 || progressAttempts === 3) return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; return new Response(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); }; @@ -499,6 +652,7 @@ describe("generated T4 API v1 client conformance", () => { const progressEvents: WatchEvent[] = []; for await (const item of progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) progressEvents.push(item); expect(progressEvents.map((item) => item.cursor)).toEqual(["progress-1", "progress-2"]); + expect(progressAttempts).toBe(4); vi.useFakeTimers(); try { From dfba49756d2251801df1337c51b8e48bc3a62f46 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 21:11:15 +0000 Subject: [PATCH 18/70] fix: close OP03 fail-closed review gaps --- .github/workflows/ci.yml | 9 + .../test/t4-api-v1-conformance-service.ts | 24 +- .../client/test/t4-api-v1-conformance.test.ts | 15 +- packages/t4-api-client/src/index.ts | 209 ++++++++++++++---- packages/t4-api-contract/openapi.json | 4 +- 5 files changed, 192 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25d7d947..79960472 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,10 +82,19 @@ jobs: - name: Validate OpenAPI and deterministic generation run: | pnpm --filter @t4-code/t4-api-contract validate + pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts + - name: Upload authorized generated schema + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: t4-api-generated-${{ github.run_id }} + path: artifacts/t4-api/generated/schema.ts + if-no-files-found: error + core: diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index a1278535..af097f22 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -121,6 +121,12 @@ export class T4ApiV1ConformanceService { if (workspaceMatch) { const id = decodeURIComponent(workspaceMatch[1]!); const workspace = this.#workspaces.get(id); + if (request.method === "DELETE") { + return this.#idempotent(request, tenant, "deleteWorkspace", [id], null, 204, 204, () => { + this.#workspaces.delete(id); + return null; + }, () => workspace?.tenant === tenant ? undefined : problem(404, "not_found", "Workspace not found")); + } if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); if (request.method === "GET") return json(200, this.#payload("workspace", this.#visible(workspace))); if (request.method === "PATCH") { @@ -133,12 +139,6 @@ export class T4ApiV1ConformanceService { return updated; }, () => request.headers.get("If-Match") === String(workspace.revision) ? undefined : problem(409, "revision_conflict", "Workspace revision changed")); } - if (request.method === "DELETE") { - return this.#idempotent(request, tenant, "deleteWorkspace", [id], null, 204, 204, () => { - this.#workspaces.delete(id); - return null; - }); - } } const sessionsPath = url.pathname.match(/^\/v1\/workspaces\/([^/]+)\/sessions$/u); @@ -173,6 +173,12 @@ export class T4ApiV1ConformanceService { if (sessionMatch) { const id = decodeURIComponent(sessionMatch[1]!); const session = this.#sessions.get(id); + if (request.method === "DELETE") { + return this.#idempotent(request, tenant, "deleteSession", [id], null, 204, 204, () => { + this.#sessions.delete(id); + return null; + }, () => session?.tenant === tenant ? undefined : problem(404, "not_found", "Session not found")); + } if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); if (request.method === "GET") return json(200, this.#payload("session", this.#visible(session))); if (request.method === "PATCH") { @@ -185,12 +191,6 @@ export class T4ApiV1ConformanceService { return updated; }, () => request.headers.get("If-Match") === String(session.revision) ? undefined : problem(409, "revision_conflict", "Session revision changed")); } - if (request.method === "DELETE") { - return this.#idempotent(request, tenant, "deleteSession", [id], null, 204, 204, () => { - this.#sessions.delete(id); - return null; - }); - } } const cancelMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/cancel$/u); diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index beefe426..f8a9a3d7 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -644,15 +644,16 @@ describe("generated T4 API v1 client conformance", () => { let progressAttempts = 0; const progressFetch: typeof globalThis.fetch = async () => { progressAttempts += 1; - if (progressAttempts === 1 || progressAttempts === 3) return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); - const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; - return new Response(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + if (progressAttempts === 2) { + return new Response('data: {"type":"heartbeat","cursor":"progress-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + } + return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); }; const progressClient = createT4ApiClient({ baseUrl: "https://progress.test", credential: "token-a", majorVersion: 1, fetch: progressFetch }); - const progressEvents: WatchEvent[] = []; - for await (const item of progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) progressEvents.push(item); - expect(progressEvents.map((item) => item.cursor)).toEqual(["progress-1", "progress-2"]); - expect(progressAttempts).toBe(4); + const progressWatch = progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 }); + await expect(progressWatch.next()).resolves.toMatchObject({ value: { cursor: "progress-1" }, done: false }); + await expect(progressWatch.next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + expect(progressAttempts).toBe(3); vi.useFakeTimers(); try { diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 8b4e1559..62da04d2 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -224,29 +224,33 @@ function retryAfterMilliseconds(response: Response): number | undefined { return Math.min(30_000, Math.max(0, timestamp - Date.now())); } -async function boundedError(response: Response): Promise { - const text = await boundedResponseText(response); - const retryAfterMs = retryAfterMilliseconds(response); - if (text !== undefined) { - try { - const decoded = apiError(JSON.parse(text), response.status); - if (decoded !== undefined) { - return retryAfterMs === undefined - ? new T4ApiError(response.status, decoded) - : new T4ApiError(response.status, decoded, { retryAfterMs }); - } - } catch { - // Fall through to the stable indeterminate transport envelope. - } - } - return new T4ApiError(response.status, { - code: response.status === 503 ? "unavailable" : "indeterminate", - message: "T4 API returned an invalid or oversized error envelope", + +function protocolError(status: number, message: string): T4ApiError { + return new T4ApiError(status === 503 ? 502 : status, { + code: "indeterminate", + message, requestId: "unavailable", - retryable: response.status >= 500, + retryable: false, }); } +async function parsedError(response: Response): Promise { + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return undefined; + const text = await boundedResponseText(response); + if (text === undefined) return undefined; + try { + const decoded = apiError(JSON.parse(text), response.status); + if (decoded === undefined) return undefined; + const retryAfterMs = retryAfterMilliseconds(response); + return retryAfterMs === undefined + ? new T4ApiError(response.status, decoded) + : new T4ApiError(response.status, decoded, { retryAfterMs }); + } catch { + return undefined; + } +} + + function hasOnlyKeys(value: Record, keys: Readonly>): boolean { return Object.keys(value).every((key) => keys[key] === true); } @@ -301,30 +305,136 @@ function validDiscovery(value: unknown): boolean { Number(limits.pageSizeMax) <= 100 && Number(limits.commandBytesMax) <= 262144 && Number(limits.commandRequestBytesMax) <= 1048576 && Number(limits.commandMetadataValueBytesMax) <= 262144 && Number(limits.watchEventsMax) <= 1000 && Number(limits.heartbeatSeconds) >= 5 && Number(limits.heartbeatSeconds) <= 60; } -function validSuccessPayload(request: Request, value: unknown): boolean { - const path = new URL(request.url).pathname; - if (request.method === "GET" && path === "/v1") return validDiscovery(value); - if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+\/sessions$/u.test(path) && request.method === "GET") { - const page = record(value); - return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validSession) && (page.nextCursor === undefined || validCursor(page.nextCursor)); +function validSnapshot(value: unknown): boolean { + const snapshot = record(value); + return snapshot !== undefined && hasOnlyKeys(snapshot, { sessionId: true, cursor: true, state: true, entries: true }) && + validResourceId(snapshot.sessionId) && validCursor(snapshot.cursor) && + typeof snapshot.state === "string" && SESSION_STATES[snapshot.state as components["schemas"]["SessionState"]] === true && + Array.isArray(snapshot.entries) && snapshot.entries.length <= 1000 && snapshot.entries.every((value) => { + const entry = record(value); + return entry !== undefined && hasOnlyKeys(entry, { sequence: true, kind: true, text: true }) && + Number.isSafeInteger(entry.sequence) && Number(entry.sequence) >= 0 && + (entry.kind === "input" || entry.kind === "output" || entry.kind === "status") && + typeof entry.text === "string" && entry.text.length <= 1_048_576; + }); +} + +type SuccessValidator = ((value: unknown) => boolean) | "empty" | "event-stream"; + +interface ResponseContract { + readonly success: Readonly>; + readonly errors: readonly number[]; +} + +const DISCOVERY_RESPONSE: ResponseContract = { success: { 200: validDiscovery }, errors: [401, 403, 406, 503] }; +const WORKSPACE_LIST_RESPONSE: ResponseContract = { success: { 200: validWorkspacePage }, errors: [400, 401, 403, 406, 422, 503] }; +const WORKSPACE_CREATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace, 202: validWorkspace }, errors: [400, 401, 403, 406, 409, 422, 503] }; +const WORKSPACE_GET_RESPONSE: ResponseContract = { success: { 200: validWorkspace }, errors: [401, 403, 404, 406, 503] }; +const WORKSPACE_MUTATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; +const DELETE_RESPONSE: ResponseContract = { success: { 204: "empty" }, errors: [400, 401, 403, 404, 406, 409, 503] }; +const SESSION_LIST_RESPONSE: ResponseContract = { success: { 200: validSessionPage }, errors: [401, 403, 404, 406, 422, 503] }; +const SESSION_CREATE_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; +const SESSION_GET_RESPONSE: ResponseContract = { success: { 200: validSession }, errors: [401, 403, 404, 406, 503] }; +const SESSION_MUTATE_RESPONSE: ResponseContract = { success: { 200: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; +const SESSION_CANCEL_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 503] }; +const COMMAND_RESPONSE: ResponseContract = { success: { 200: validCommandResult, 202: validCommandResult }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; +const SNAPSHOT_RESPONSE: ResponseContract = { success: { 200: validSnapshot }, errors: [401, 403, 404, 406, 503] }; +const EVENTS_RESPONSE: ResponseContract = { success: { 200: "event-stream" }, errors: [400, 401, 403, 404, 406, 410, 422, 503] }; + +function validWorkspacePage(value: unknown): boolean { + const page = record(value); + return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && + Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validWorkspace) && + (page.nextCursor === undefined || validCursor(page.nextCursor)); +} + +function validSessionPage(value: unknown): boolean { + const page = record(value); + return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && + Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validSession) && + (page.nextCursor === undefined || validCursor(page.nextCursor)); +} + +function relativeApiPath(request: Request, baseUrl: string): string | undefined { + const requestUrl = new URL(request.url); + const base = new URL(baseUrl); + if (requestUrl.origin !== base.origin) return undefined; + const prefix = base.pathname === "/" ? "" : base.pathname; + if (prefix === "") return requestUrl.pathname; + if (!requestUrl.pathname.startsWith(`${prefix}/`)) return undefined; + return requestUrl.pathname.slice(prefix.length); +} + +function responseContract(method: string, path: string): ResponseContract | undefined { + if (path === "/v1" && method === "GET") return DISCOVERY_RESPONSE; + if (path === "/v1/workspaces") { + if (method === "GET") return WORKSPACE_LIST_RESPONSE; + if (method === "POST") return WORKSPACE_CREATE_RESPONSE; + return undefined; } - if (path === "/v1/workspaces" && request.method === "GET") { - const page = record(value); - return page !== undefined && hasOnlyKeys(page, { items: true, nextCursor: true }) && Array.isArray(page.items) && page.items.length <= 100 && page.items.every(validWorkspace) && (page.nextCursor === undefined || validCursor(page.nextCursor)); + if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+$/u.test(path)) { + if (method === "GET") return WORKSPACE_GET_RESPONSE; + if (method === "PATCH") return WORKSPACE_MUTATE_RESPONSE; + if (method === "DELETE") return DELETE_RESPONSE; + return undefined; + } + if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+\/sessions$/u.test(path)) { + if (method === "GET") return SESSION_LIST_RESPONSE; + if (method === "POST") return SESSION_CREATE_RESPONSE; + return undefined; + } + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+$/u.test(path)) { + if (method === "GET") return SESSION_GET_RESPONSE; + if (method === "PATCH") return SESSION_MUTATE_RESPONSE; + if (method === "DELETE") return DELETE_RESPONSE; + return undefined; + } + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/cancel$/u.test(path) && method === "POST") return SESSION_CANCEL_RESPONSE; + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/commands$/u.test(path) && method === "POST") return COMMAND_RESPONSE; + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/snapshot$/u.test(path) && method === "GET") return SNAPSHOT_RESPONSE; + if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/events$/u.test(path) && method === "GET") return EVENTS_RESPONSE; + return undefined; +} + +async function validateResponse(request: Request, response: Response, baseUrl: string): Promise { + const path = relativeApiPath(request, baseUrl); + const contract = path === undefined ? undefined : responseContract(request.method, path); + if (contract === undefined) { + throw protocolError(response.ok ? 502 : response.status, "T4 API returned a response for an undeclared route"); + } + if (!response.ok) { + if (!contract.errors.includes(response.status)) { + throw protocolError(response.status, "T4 API returned an undeclared error status"); + } + if (await parsedError(response.clone()) === undefined) { + throw protocolError(response.status, "T4 API returned an invalid or oversized error envelope"); + } + return; + } + const validator = contract.success[response.status]; + if (validator === undefined) { + throw protocolError(502, "T4 API returned an undeclared success status"); + } + if (validator === "empty") { + if (response.body !== null || response.headers.has("content-type")) { + throw protocolError(502, "T4 API returned content for a bodyless response"); + } + return; + } + if (validator === "event-stream") { + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { + throw protocolError(502, "T4 API returned an undeclared success media type"); + } + return; + } + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + throw protocolError(502, "T4 API returned an undeclared success media type"); } - if (path === "/v1/workspaces" || /^\/v1\/workspaces\/[A-Za-z0-9._~-]+$/u.test(path)) return validWorkspace(value); - if (/^\/v1\/workspaces\/[A-Za-z0-9._~-]+\/sessions$/u.test(path) || /^\/v1\/sessions\/[A-Za-z0-9._~-]+(?:\/cancel)?$/u.test(path)) return validSession(value); - if (/^\/v1\/sessions\/[A-Za-z0-9._~-]+\/commands$/u.test(path)) return validCommandResult(value); - return true; -} - -async function validateJsonSuccess(request: Request, response: Response): Promise { - if (!response.ok || response.status === 204 || !response.headers.get("content-type")?.toLowerCase().startsWith("application/json")) return; const text = await boundedResponseText(response.clone(), MAX_JSON_RESPONSE_BYTES); let value: unknown; try { value = text === undefined ? undefined : JSON.parse(text); } catch { value = undefined; } - if (text === undefined || !validSuccessPayload(request, value)) { - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid or oversized JSON response", requestId: "unavailable", retryable: true }); + if (text === undefined || !validator(value)) { + throw protocolError(502, "T4 API returned an invalid or oversized JSON response"); } } @@ -338,7 +448,7 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { event.cursor.length > 512 || !CURSOR_PATTERN.test(event.cursor) || (eventId !== undefined && eventId !== event.cursor) - ) throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid watch event", requestId: "unavailable", retryable: true }); + ) throw protocolError(502, "T4 API returned an invalid watch event"); if ( event.type === "heartbeat" && hasOnlyKeys(event, { type: true, cursor: true, observedAt: true }) && @@ -362,7 +472,7 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { typeof event.state === "string" && OPERATION_STATES[event.state as components["schemas"]["OperationState"]] === true ) return event as WatchEvent; - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned an invalid watch event", requestId: "unavailable", retryable: true }); + throw protocolError(502, "T4 API returned an invalid watch event"); } function decodeSseFrame(frame: string): WatchEvent | undefined { @@ -383,7 +493,7 @@ function decodeSseFrame(frame: string): WatchEvent | undefined { return watchEvent(JSON.parse(data.join("\n")), eventId); } catch (error) { if (error instanceof T4ApiError) throw error; - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned malformed SSE data", requestId: "unavailable", retryable: true }); + throw protocolError(502, "T4 API returned malformed SSE data"); } } @@ -451,7 +561,7 @@ class SseFrameParser { } #oversized(): never { - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch event exceeds the client bound", requestId: "unavailable", retryable: true }); + throw protocolError(502, "T4 API watch event exceeds the client bound"); } } @@ -460,7 +570,7 @@ function decodedFrames(parser: SseFrameParser, chunk: Uint8Array | undefined): W return frames.flatMap((bytes) => { let text: string; try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API returned malformed SSE data", requestId: "unavailable", retryable: true }); + throw protocolError(502, "T4 API returned malformed SSE data"); } const event = decodeSseFrame(text); return event === undefined ? [] : [event]; @@ -524,11 +634,14 @@ async function* watch( }); if (cursor !== undefined) headers.set("Last-Event-ID", cursor); const response = await fetchImpl(url, { method: "GET", headers, signal: controller.signal }); - if (!response.ok) throw await boundedError(response); - if (!response.headers.get("content-type")?.toLowerCase().startsWith("text/event-stream")) { - throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch did not return text/event-stream", requestId: "unavailable", retryable: true }); + if (!response.ok) { + const error = await parsedError(response); + throw error ?? protocolError(response.status, "T4 API returned an invalid or oversized error envelope"); + } + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { + throw protocolError(502, "T4 API watch did not return text/event-stream"); } - if (response.body === null) throw new TypeError("T4 API watch response body is unavailable"); + if (response.body === null) throw protocolError(502, "T4 API watch response body is unavailable"); reader = response.body.getReader(); const parser = new SseFrameParser(); while (delivered < maxEvents && !controller.signal.aborted) { @@ -583,7 +696,7 @@ export function createT4ApiClient(options: T4ApiClientOptions): T4ApiClient { request.headers.set("T4-API-Version", majorVersion); request.headers.set("Accept", "application/json"); const response = await fetchImpl(request); - await validateJsonSuccess(request, response); + await validateResponse(request, response, baseUrl); return response; }; const http = createClient({ baseUrl, fetch: authenticatedFetch }); diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index c883eab1..1ae79de1 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -102,7 +102,7 @@ }, "responses": { "400": { "$ref": "#/components/responses/Error400" }, - "200": { "$ref": "#/components/responses/Workspace" }, + "200": { "$ref": "#/components/responses/WorkspaceReplay" }, "401": {"$ref": "#/components/responses/Error401"}, "403": {"$ref": "#/components/responses/Error403"}, "404": {"$ref": "#/components/responses/Error404"}, @@ -205,7 +205,7 @@ }, "responses": { "400": { "$ref": "#/components/responses/Error400" }, - "200": { "$ref": "#/components/responses/Session" }, + "200": { "$ref": "#/components/responses/SessionReplay" }, "401": {"$ref": "#/components/responses/Error401"}, "403": {"$ref": "#/components/responses/Error403"}, "404": {"$ref": "#/components/responses/Error404"}, From 77c9380555fcb82b6f2a95fc15c5ab5494702d81 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 21:13:39 +0000 Subject: [PATCH 19/70] build: accept authorized OP03 schema artifact --- .github/workflows/ci.yml | 9 --------- packages/t4-api-client/src/generated/schema.ts | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79960472..25d7d947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,19 +82,10 @@ jobs: - name: Validate OpenAPI and deterministic generation run: | pnpm --filter @t4-code/t4-api-contract validate - pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts - - name: Upload authorized generated schema - if: ${{ always() }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: t4-api-generated-${{ github.run_id }} - path: artifacts/t4-api/generated/schema.ts - if-no-files-found: error - core: diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts index 1291c013..39321c83 100755 --- a/packages/t4-api-client/src/generated/schema.ts +++ b/packages/t4-api-client/src/generated/schema.ts @@ -705,7 +705,7 @@ export interface operations { }; }; responses: { - 200: components["responses"]["Session"]; + 200: components["responses"]["SessionReplay"]; 400: components["responses"]["Error400"]; 401: components["responses"]["Error401"]; 403: components["responses"]["Error403"]; @@ -964,7 +964,7 @@ export interface operations { }; }; responses: { - 200: components["responses"]["Workspace"]; + 200: components["responses"]["WorkspaceReplay"]; 400: components["responses"]["Error400"]; 401: components["responses"]["Error401"]; 403: components["responses"]["Error403"]; From 9209caf64ad75117057fb76971767f34a169a328 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 21:23:58 +0000 Subject: [PATCH 20/70] fix: enforce exact watch response contract --- .../client/test/t4-api-v1-conformance.test.ts | 83 ++++++++++++++++++- packages/t4-api-client/src/index.ts | 32 ++++++- 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index f8a9a3d7..3aac3a79 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -500,6 +500,28 @@ describe("generated T4 API v1 client conformance", () => { params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + vi.useFakeTimers(); + try { + let cancelled = false; + const chunk = new Uint8Array(600 * 1024); + const streamedClient = createT4ApiClient({ + baseUrl: "https://streamed-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(chunk); }, + cancel() { cancelled = true; }, + }), { status: 404, headers: { "Content-Type": "application/json" } }), + }); + const outcome = streamedClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, + }).then(() => "resolved" as const, () => "rejected" as const); + const bounded = Promise.race([outcome, new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 1_000))]); + await vi.advanceTimersByTimeAsync(1_000); + await expect(bounded).resolves.toBe("rejected"); + expect(cancelled).toBe(true); + } finally { + vi.useRealTimers(); + } + const statusClient = createT4ApiClient({ baseUrl: "https://undeclared-error.test", credential: "token-a", majorVersion: 1, fetch: async () => Response.json(validErrors[3][1], { status: 404 }), @@ -522,13 +544,26 @@ describe("generated T4 API v1 client conformance", () => { await expect(prefixed.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); const invalidSnapshot = createT4ApiClient({ - baseUrl: "https://snapshot.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [], unknown: true }), + baseUrl: "https://snapshot.test/api", credential: "token-a", majorVersion: 1, + fetch: async (input) => { + expect(new URL(new Request(input).url).pathname).toBe("/api/v1/sessions/ses-1/snapshot"); + return Response.json({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [], unknown: true }); + }, }); await expect(invalidSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + const astralText = "😀".repeat(524_289); + const unicodeSnapshot = createT4ApiClient({ + baseUrl: "https://unicode-snapshot.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [{ sequence: 0, kind: "output", text: astralText }] }), + }); + const unicode = requireData(await unicodeSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })); + expect(unicode.entries[0]?.text).toBe(astralText); + for (const response of [ new Response(JSON.stringify(discovery), { status: 200, headers: { "Content-Type": "text/plain" } }), Response.json(discovery, { status: 201 }), @@ -544,6 +579,48 @@ describe("generated T4 API v1 client conformance", () => { await expect(unknownRoute.http.GET("/v1/undeclared" as "/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); }); + it("accepts only the exact watch status matrix under a prefixed base path", async () => { + const prefixed = createT4ApiClient({ + baseUrl: "https://watch-status.test/api", credential: "token-a", majorVersion: 1, + fetch: async (input) => { + expect(new URL(new Request(input).url).pathname).toBe("/api/v1/sessions/ses-1/events"); + return new Response('data: {"type":"heartbeat","cursor":"prefixed-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "text/event-stream" } }); + }, + }); + await expect(prefixed.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "prefixed-1" } }); + + const declared = [ + [400, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false } }], + [401, { error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }], + [403, { error: { code: "forbidden", message: "bad", requestId: "r", retryable: false } }], + [404, { error: { code: "not_found", message: "bad", requestId: "r", retryable: false } }], + [406, { error: { code: "incompatible_version", message: "bad", requestId: "r", retryable: false, supportedMajors: [1] } }], + [410, { error: { code: "cursor_expired", message: "bad", requestId: "r", retryable: false, resync: { snapshotUrl: "/v1/sessions/ses-1/snapshot", cursor: "cursor-1" } } }], + [422, { error: { code: "invalid_request", message: "bad", requestId: "r", retryable: false, violations: [{ field: "maxEvents", rule: "range", message: "bad" }] } }], + [503, { error: { code: "unavailable", message: "bad", requestId: "r", retryable: false } }], + ] as const; + for (const [status, body] of declared) { + const client = createT4ApiClient({ baseUrl: "https://watch-status.test", credential: "token-a", majorVersion: 1, fetch: async () => Response.json(body, { status }) }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status, code: body.error.code }); + } + + for (const response of [ + new Response('data: {"type":"heartbeat","cursor":"wrong-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 201, headers: { "Content-Type": "text/event-stream" } }), + new Response(null, { status: 204, headers: { "Content-Type": "text/event-stream" } }), + Response.json({ error: { code: "revision_conflict", message: "bad", requestId: "r", retryable: false } }, { status: 409 }), + ]) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: "https://watch-status.test", credential: "token-a", majorVersion: 1, + fetch: async () => { attempts += 1; return response.clone(); }, + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + status: 502, code: "indeterminate", retryable: false, + }); + expect(attempts).toBe(1); + } + }); + it("declares the replay response header on both conditional PATCH success responses", () => { const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { paths: Record } }>; @@ -647,7 +724,7 @@ describe("generated T4 API v1 client conformance", () => { if (progressAttempts === 2) { return new Response('data: {"type":"heartbeat","cursor":"progress-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); } - return new Response(null, { headers: { "Content-Type": "text/event-stream" } }); + return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); }; const progressClient = createT4ApiClient({ baseUrl: "https://progress.test", credential: "token-a", majorVersion: 1, fetch: progressFetch }); const progressWatch = progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 }); diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 62da04d2..cdaab911 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -198,7 +198,7 @@ async function boundedResponseText(response: Response, maximumBytes = MAX_ERROR_ if (chunk.done) break; length += chunk.value.byteLength; if (length > maximumBytes) { - await reader.cancel(); + void reader.cancel().catch(() => {}); return undefined; } chunks.push(chunk.value); @@ -259,6 +259,15 @@ function validResourceId(value: unknown): value is string { return typeof value === "string" && value.length >= 1 && value.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value); } +function hasAtMostCodePoints(value: string, maximum: number): boolean { + let count = 0; + for (const _codePoint of value) { + count += 1; + if (count > maximum) return false; + } + return true; +} + function validCursor(value: unknown): value is string { return typeof value === "string" && value.length >= 1 && value.length <= 512 && CURSOR_PATTERN.test(value); } @@ -315,7 +324,7 @@ function validSnapshot(value: unknown): boolean { return entry !== undefined && hasOnlyKeys(entry, { sequence: true, kind: true, text: true }) && Number.isSafeInteger(entry.sequence) && Number(entry.sequence) >= 0 && (entry.kind === "input" || entry.kind === "output" || entry.kind === "status") && - typeof entry.text === "string" && entry.text.length <= 1_048_576; + typeof entry.text === "string" && hasAtMostCodePoints(entry.text, 1_048_576); }); } @@ -400,40 +409,48 @@ async function validateResponse(request: Request, response: Response, baseUrl: s const path = relativeApiPath(request, baseUrl); const contract = path === undefined ? undefined : responseContract(request.method, path); if (contract === undefined) { + void response.body?.cancel().catch(() => {}); throw protocolError(response.ok ? 502 : response.status, "T4 API returned a response for an undeclared route"); } if (!response.ok) { if (!contract.errors.includes(response.status)) { + void response.body?.cancel().catch(() => {}); throw protocolError(response.status, "T4 API returned an undeclared error status"); } if (await parsedError(response.clone()) === undefined) { + void response.body?.cancel().catch(() => {}); throw protocolError(response.status, "T4 API returned an invalid or oversized error envelope"); } return; } const validator = contract.success[response.status]; if (validator === undefined) { + void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared success status"); } if (validator === "empty") { if (response.body !== null || response.headers.has("content-type")) { + void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned content for a bodyless response"); } return; } if (validator === "event-stream") { if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { + void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared success media type"); } return; } if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared success media type"); } const text = await boundedResponseText(response.clone(), MAX_JSON_RESPONSE_BYTES); let value: unknown; try { value = text === undefined ? undefined : JSON.parse(text); } catch { value = undefined; } if (text === undefined || !validator(value)) { + void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an invalid or oversized JSON response"); } } @@ -635,10 +652,19 @@ async function* watch( if (cursor !== undefined) headers.set("Last-Event-ID", cursor); const response = await fetchImpl(url, { method: "GET", headers, signal: controller.signal }); if (!response.ok) { + if (!EVENTS_RESPONSE.errors.includes(response.status)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared watch error status"); + } const error = await parsedError(response); - throw error ?? protocolError(response.status, "T4 API returned an invalid or oversized error envelope"); + throw error ?? protocolError(502, "T4 API returned an invalid or oversized error envelope"); + } + if (response.status !== 200) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared watch success status"); } if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { + void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API watch did not return text/event-stream"); } if (response.body === null) throw protocolError(502, "T4 API watch response body is unavailable"); From ecc29a08443897a7507285a3abdaa2b1aead1f4e Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 21:33:07 +0000 Subject: [PATCH 21/70] fix: close watch retry and body ownership gaps --- .../client/test/t4-api-v1-conformance.test.ts | 44 +++++++++++-------- packages/t4-api-client/src/index.ts | 11 +++-- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 3aac3a79..294921f6 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -500,26 +500,20 @@ describe("generated T4 API v1 client conformance", () => { params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); - vi.useFakeTimers(); - try { + const streamedChunk = new Uint8Array(600 * 1024); + for (const contentType of ["application/json", "text/plain"]) { let cancelled = false; - const chunk = new Uint8Array(600 * 1024); const streamedClient = createT4ApiClient({ baseUrl: "https://streamed-error.test", credential: "token-a", majorVersion: 1, fetch: async () => new Response(new ReadableStream({ - pull(controller) { controller.enqueue(chunk); }, + pull(controller) { controller.enqueue(streamedChunk); }, cancel() { cancelled = true; }, - }), { status: 404, headers: { "Content-Type": "application/json" } }), + }), { status: 404, headers: { "Content-Type": contentType } }), }); - const outcome = streamedClient.http.GET("/v1/workspaces/{workspaceId}", { + await expect(streamedClient.http.GET("/v1/workspaces/{workspaceId}", { params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, - }).then(() => "resolved" as const, () => "rejected" as const); - const bounded = Promise.race([outcome, new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 1_000))]); - await vi.advanceTimersByTimeAsync(1_000); - await expect(bounded).resolves.toBe("rejected"); + })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); expect(cancelled).toBe(true); - } finally { - vi.useRealTimers(); } const statusClient = createT4ApiClient({ @@ -644,6 +638,19 @@ describe("generated T4 API v1 client conformance", () => { }); expect(attempts).toBe(1); } + + let nonJsonCancelled = false; + const nonJsonClient = createT4ApiClient({ + baseUrl: "https://invalid-503-media.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(1024)); }, + cancel() { nonJsonCancelled = true; }, + }), { status: 503, headers: { "Content-Type": "text/plain" } }), + }); + await expect(nonJsonClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(nonJsonCancelled).toBe(true); }); it("rejects unknown fields in every public JSON response family", async () => { @@ -721,16 +728,17 @@ describe("generated T4 API v1 client conformance", () => { let progressAttempts = 0; const progressFetch: typeof globalThis.fetch = async () => { progressAttempts += 1; - if (progressAttempts === 2) { - return new Response('data: {"type":"heartbeat","cursor":"progress-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + if (progressAttempts === 2 || progressAttempts === 4) { + const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; + return new Response(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); } return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); }; const progressClient = createT4ApiClient({ baseUrl: "https://progress.test", credential: "token-a", majorVersion: 1, fetch: progressFetch }); - const progressWatch = progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 }); - await expect(progressWatch.next()).resolves.toMatchObject({ value: { cursor: "progress-1" }, done: false }); - await expect(progressWatch.next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); - expect(progressAttempts).toBe(3); + const progressEvents: WatchEvent[] = []; + for await (const event of progressClient.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) progressEvents.push(event); + expect(progressEvents.map((event) => event.cursor)).toEqual(["progress-1", "progress-2"]); + expect(progressAttempts).toBe(4); vi.useFakeTimers(); try { diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index cdaab911..aa5f5112 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -235,7 +235,10 @@ function protocolError(status: number, message: string): T4ApiError { } async function parsedError(response: Response): Promise { - if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") return undefined; + if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") { + void response.body?.cancel().catch(() => {}); + return undefined; + } const text = await boundedResponseText(response); if (text === undefined) return undefined; try { @@ -635,6 +638,7 @@ async function* watch( let cursor = options.cursor; try { while (delivered < maxEvents && !controller.signal.aborted) { + const deliveredAtAttemptStart = delivered; let reader: ReadableStreamDefaultReader | undefined; let transientFailure: unknown; let retryAfterMs = 0; @@ -698,11 +702,12 @@ async function* watch( reader.releaseLock(); } } - if (reconnectAttempts >= maxReconnectAttempts) { + const madeProgress = delivered > deliveredAtAttemptStart; + if (!madeProgress && reconnectAttempts >= maxReconnectAttempts) { throw new T4ApiError(502, { code: "indeterminate", message: "T4 API watch reconnect attempts exhausted", requestId: "unavailable", retryable: true }, { cause: transientFailure }); } const delay = Math.min(30_000, Math.max(retryAfterMs, retryBackoffMs * (2 ** reconnectAttempts))); - reconnectAttempts += 1; + if (!madeProgress) reconnectAttempts += 1; if (!await retryDelay(delay, controller.signal)) return; } } finally { From fb42ef8e3bb84125f6febee44c9e6728e538894c Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 21:40:18 +0000 Subject: [PATCH 22/70] test: prove watch media and stream cleanup --- .../client/test/t4-api-v1-conformance.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 294921f6..694be793 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -599,6 +599,7 @@ describe("generated T4 API v1 client conformance", () => { } for (const response of [ + new Response('data: {"type":"heartbeat","cursor":"wrong-media-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "application/json" } }), new Response('data: {"type":"heartbeat","cursor":"wrong-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 201, headers: { "Content-Type": "text/event-stream" } }), new Response(null, { status: 204, headers: { "Content-Type": "text/event-stream" } }), Response.json({ error: { code: "revision_conflict", message: "bad", requestId: "r", retryable: false } }, { status: 409 }), @@ -639,6 +640,20 @@ describe("generated T4 API v1 client conformance", () => { expect(attempts).toBe(1); } + let oversizedCancelled = false; + const oversizedChunk = new Uint8Array(600 * 1024); + const streamedOversizedClient = createT4ApiClient({ + baseUrl: "https://streamed-invalid-503.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(oversizedChunk); }, + cancel() { oversizedCancelled = true; }, + }), { status: 503, headers: { "Content-Type": "application/json" } }), + }); + await expect(streamedOversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(oversizedCancelled).toBe(true); + let nonJsonCancelled = false; const nonJsonClient = createT4ApiClient({ baseUrl: "https://invalid-503-media.test", credential: "token-a", majorVersion: 1, From ed2c7f7b65e3453ae4971c4a3a1e18bfd1a5a578 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 21:47:19 +0000 Subject: [PATCH 23/70] test: align terminal watch protocol assertions --- packages/client/test/t4-api-v1-conformance.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 694be793..badf6807 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -456,12 +456,12 @@ describe("generated T4 API v1 client conformance", () => { for (const [status, code] of [[410, "cursor_expired"], [406, "incompatible_version"], [422, "invalid_request"]] as const) { const fetch: typeof globalThis.fetch = async () => Response.json({ error: { code, message: "incomplete", requestId: "r", retryable: false } }, { status }); const client = createT4ApiClient({ baseUrl: "https://errors.test", credential: "token-a", majorVersion: 1, fetch }); - await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); } for (const [status, code] of [[401, "not_found"], [403, "unauthenticated"], [404, "forbidden"], [409, "unavailable"], [503, "revision_conflict"]] as const) { const fetch: typeof globalThis.fetch = async () => Response.json({ error: { code, message: "wrong status class", requestId: "r", retryable: false } }, { status }); const client = createT4ApiClient({ baseUrl: "https://status-errors.test", credential: "token-a", majorVersion: 1, fetch }); - await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: status === 503 ? 502 : status }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); } }); From 7673616763d5fd204c7e7ca83e4e461b76d1538a Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 22:25:24 +0000 Subject: [PATCH 24/70] test: cover Unicode bounds and SSE cancellation --- .../client/test/t4-api-v1-conformance.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index badf6807..116edc14 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -447,6 +447,19 @@ describe("generated T4 API v1 client conformance", () => { const oversizedService = new T4ApiV1ConformanceService({ watchTransport: "oversized" }); const oversizedClient = await seededClient(oversizedService, "oversized"); await expect(oversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + + let ongoingOversizedCancelled = false; + const ongoingOversizedClient = createT4ApiClient({ + baseUrl: "https://ongoing-oversized-event.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(new Uint8Array(600 * 1024)); }, + cancel() { ongoingOversizedCancelled = true; }, + }), { status: 200, headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(ongoingOversizedClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + expect(ongoingOversizedCancelled).toBe(true); }); it("fails closed on unknown watch fields and incomplete typed errors", async () => { @@ -558,6 +571,41 @@ describe("generated T4 API v1 client conformance", () => { })); expect(unicode.entries[0]?.text).toBe(astralText); + const astral128 = "😀".repeat(128); + const unicodeWorkspace = createT4ApiClient({ + baseUrl: "https://unicode-workspace.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ id: "wsp-1", name: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), + }); + const workspace = requireData(await unicodeWorkspace.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "wsp-1" } }, + })); + expect(workspace).toMatchObject({ name: astral128, labels: { team: astral128 } }); + + const unicodeSession = createT4ApiClient({ + baseUrl: "https://unicode-session.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ id: "ses-1", workspaceId: "wsp-1", title: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), + }); + const session = requireData(await unicodeSession.http.GET("/v1/sessions/{sessionId}", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })); + expect(session).toMatchObject({ title: astral128, labels: { team: astral128 } }); + + const errorMessage = "😀".repeat(1024); + const errorRequestId = "😀".repeat(128); + const violationField = "😀".repeat(256); + const violationMessage = "😀".repeat(512); + const unicodeError = createT4ApiClient({ + baseUrl: "https://unicode-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ error: { + code: "invalid_request", message: errorMessage, requestId: errorRequestId, retryable: false, + violations: [{ field: violationField, rule: "range", message: violationMessage }], + } }, { status: 422 }), + }); + await expect(unicodeError.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: "invalid_request", status: 422, message: errorMessage, requestId: errorRequestId, + violations: [{ field: violationField, rule: "range", message: violationMessage }], + }); + for (const response of [ new Response(JSON.stringify(discovery), { status: 200, headers: { "Content-Type": "text/plain" } }), Response.json(discovery, { status: 201 }), From 578e3c4081a5509e17ee5d2b5882bfa505fec727 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 22:47:19 +0000 Subject: [PATCH 25/70] fix: validate response lengths by code point --- packages/t4-api-client/src/index.ts | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index aa5f5112..feb91ee6 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -154,16 +154,16 @@ function validViolation(value: unknown): boolean { const violation = record(value); return violation !== undefined && Object.keys(violation).every((key) => key === "field" || key === "rule" || key === "message") && - typeof violation.field === "string" && violation.field.length >= 1 && violation.field.length <= 256 && - typeof violation.rule === "string" && /^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(violation.rule) && - typeof violation.message === "string" && violation.message.length >= 1 && violation.message.length <= 512; + typeof violation.field === "string" && violation.field !== "" && hasAtMostCodePoints(violation.field, 256) && + typeof violation.rule === "string" && hasAtMostCodePoints(violation.rule, 64) && /^[A-Za-z][A-Za-z0-9._-]{0,63}$/u.test(violation.rule) && + typeof violation.message === "string" && violation.message !== "" && hasAtMostCodePoints(violation.message, 512); } function validResync(value: unknown): value is Resync { const resync = record(value); return resync !== undefined && Object.keys(resync).every((key) => key === "snapshotUrl" || key === "cursor") && - typeof resync.snapshotUrl === "string" && /^\/v1\/sessions\/[A-Za-z0-9._~-]+\/snapshot$/u.test(resync.snapshotUrl) && - typeof resync.cursor === "string" && resync.cursor.length <= 512 && CURSOR_PATTERN.test(resync.cursor); + typeof resync.snapshotUrl === "string" && hasAtMostCodePoints(resync.snapshotUrl, 512) && /^\/v1\/sessions\/[A-Za-z0-9._~-]+\/snapshot$/u.test(resync.snapshotUrl) && + typeof resync.cursor === "string" && hasAtMostCodePoints(resync.cursor, 512) && CURSOR_PATTERN.test(resync.cursor); } function apiError(value: unknown, status: number): ApiError | undefined { @@ -174,8 +174,8 @@ function apiError(value: unknown, status: number): ApiError | undefined { error === undefined || Object.keys(error).some((key) => ERROR_FIELDS[key] !== true) || typeof error.code !== "string" || ERROR_CODES[error.code as ApiError["code"]] !== true || ERROR_CODES_BY_STATUS[status]?.[error.code] !== true || - typeof error.message !== "string" || error.message.length < 1 || error.message.length > 1024 || - typeof error.requestId !== "string" || error.requestId.length < 1 || error.requestId.length > 128 || + typeof error.message !== "string" || error.message === "" || !hasAtMostCodePoints(error.message, 1024) || + typeof error.requestId !== "string" || error.requestId === "" || !hasAtMostCodePoints(error.requestId, 128) || typeof error.retryable !== "boolean" || (error.violations !== undefined && (!Array.isArray(error.violations) || error.violations.length > 64 || !error.violations.every(validViolation))) || (error.supportedMajors !== undefined && (!Array.isArray(error.supportedMajors) || error.supportedMajors.length > 8 || !error.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1))) || @@ -259,7 +259,7 @@ function hasOnlyKeys(value: Record, keys: Readonly= 1 && value.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value); + return typeof value === "string" && value !== "" && hasAtMostCodePoints(value, 128) && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value); } function hasAtMostCodePoints(value: string, maximum: number): boolean { @@ -272,20 +272,20 @@ function hasAtMostCodePoints(value: string, maximum: number): boolean { } function validCursor(value: unknown): value is string { - return typeof value === "string" && value.length >= 1 && value.length <= 512 && CURSOR_PATTERN.test(value); + return typeof value === "string" && value !== "" && hasAtMostCodePoints(value, 512) && CURSOR_PATTERN.test(value); } function validLabels(value: unknown): boolean { if (value === undefined) return true; const labels = record(value); return labels !== undefined && Object.keys(labels).length <= 32 && Object.entries(labels).every(([key, item]) => - /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && item.length <= 128); + /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && hasAtMostCodePoints(item, 128)); } function validWorkspace(value: unknown): boolean { const item = record(value); return item !== undefined && hasOnlyKeys(item, { id: true, name: true, state: true, revision: true, labels: true }) && - validResourceId(item.id) && typeof item.name === "string" && item.name.length >= 1 && item.name.length <= 128 && + validResourceId(item.id) && typeof item.name === "string" && item.name !== "" && hasAtMostCodePoints(item.name, 128) && typeof item.state === "string" && WORKSPACE_STATES[item.state as components["schemas"]["WorkspaceState"]] === true && Number.isSafeInteger(item.revision) && Number(item.revision) >= 1 && validLabels(item.labels); } @@ -293,7 +293,7 @@ function validWorkspace(value: unknown): boolean { function validSession(value: unknown): boolean { const item = record(value); return item !== undefined && hasOnlyKeys(item, { id: true, workspaceId: true, title: true, state: true, revision: true, labels: true }) && - validResourceId(item.id) && validResourceId(item.workspaceId) && typeof item.title === "string" && item.title.length >= 1 && item.title.length <= 128 && + validResourceId(item.id) && validResourceId(item.workspaceId) && typeof item.title === "string" && item.title !== "" && hasAtMostCodePoints(item.title, 128) && typeof item.state === "string" && SESSION_STATES[item.state as components["schemas"]["SessionState"]] === true && Number.isSafeInteger(item.revision) && Number(item.revision) >= 1 && validLabels(item.labels); } @@ -464,15 +464,15 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { event === undefined || typeof event.type !== "string" || typeof event.cursor !== "string" || - event.cursor.length < 1 || - event.cursor.length > 512 || + event.cursor === "" || + !hasAtMostCodePoints(event.cursor, 512) || !CURSOR_PATTERN.test(event.cursor) || (eventId !== undefined && eventId !== event.cursor) ) throw protocolError(502, "T4 API returned an invalid watch event"); if ( event.type === "heartbeat" && hasOnlyKeys(event, { type: true, cursor: true, observedAt: true }) && - typeof event.observedAt === "string" && event.observedAt.length <= 64 && + typeof event.observedAt === "string" && hasAtMostCodePoints(event.observedAt, 64) && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(event.observedAt) && Number.isFinite(Date.parse(event.observedAt)) ) return event as WatchEvent; @@ -487,7 +487,7 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { event.type === "command" && hasOnlyKeys(event, { type: true, cursor: true, commandId: true, state: true }) && typeof event.commandId === "string" && - event.commandId.length >= 1 && event.commandId.length <= 128 && + event.commandId !== "" && hasAtMostCodePoints(event.commandId, 128) && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(event.commandId) && typeof event.state === "string" && OPERATION_STATES[event.state as components["schemas"]["OperationState"]] === true From b555a13dd6ee3023fb6b94d775d542dcae193be4 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 22:58:56 +0000 Subject: [PATCH 26/70] test: cover strict response protocol gaps --- .../client/test/t4-api-v1-conformance.test.ts | 155 ++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 116edc14..6e294cf1 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -743,6 +743,161 @@ describe("generated T4 API v1 client conformance", () => { })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); }); + it("requires declared selected-version and replay response headers", async () => { + const discovery = { + apiVersion: "1.0", supportedMajors: [1], capabilities: [], + limits: { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsMax: 1, heartbeatSeconds: 5 }, + }; + for (const selectedVersion of [undefined, "1", "2.0", `1.${"0".repeat(15)}`]) { + const client = createT4ApiClient({ + baseUrl: "https://response-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json(discovery, selectedVersion === undefined ? {} : { headers: { "T4-API-Version": selectedVersion } }), + }); + await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, code: "indeterminate", retryable: false }); + } + + const unauthenticated = createT4ApiClient({ + baseUrl: "https://response-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ error: { code: "unauthenticated", message: "bad", requestId: "r", retryable: false } }, { status: 401 }), + }); + expect((await unauthenticated.http.GET("/v1", { params: { header: VERSION_HEADERS } })).response.status).toBe(401); + + const workspace = { id: "wsp-1", name: "workspace", state: "ready", revision: 1 }; + const missingReplay = createT4ApiClient({ + baseUrl: "https://response-replay.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json(workspace, { headers: { "T4-API-Version": "1.0" } }), + }); + await expect(missingReplay.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "response-replay-patch"), path: { workspaceId: "wsp-1" } }, body: { name: "workspace" }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + + for (const replayed of [undefined, "TRUE", "0"]) { + const client = createT4ApiClient({ + baseUrl: "https://delete-replay-header.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response(null, { status: 204, headers: { + "T4-API-Version": "1.0", ...(replayed === undefined ? {} : { "Idempotency-Replayed": replayed }), + } }), + }); + await expect(client.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("response-delete-key"), path: { workspaceId: "wsp-1" } }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + } + + const missingWatchVersion = createT4ApiClient({ + baseUrl: "https://watch-response-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response('data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(missingWatchVersion.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + + const missingWatchErrorVersion = createT4ApiClient({ + baseUrl: "https://watch-error-version.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503 }), + }); + await expect(missingWatchErrorVersion.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + }); + + it("rejects duplicate unique response arrays", async () => { + const limits = { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsMax: 1, heartbeatSeconds: 5 }; + for (const discovery of [ + { apiVersion: "1.0", supportedMajors: [1, 1], capabilities: [], limits }, + { apiVersion: "1.0", supportedMajors: [1], capabilities: ["session.watch.sse", "session.watch.sse"], limits }, + ]) { + const client = createT4ApiClient({ + baseUrl: "https://duplicate-discovery.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json(discovery, { headers: { "T4-API-Version": "1.0" } }), + }); + await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, retryable: false }); + } + const duplicateError = createT4ApiClient({ + baseUrl: "https://duplicate-error.test", credential: "token-a", majorVersion: 1, + fetch: async () => Response.json({ error: { + code: "incompatible_version", message: "bad", requestId: "r", retryable: false, supportedMajors: [1, 1], + } }, { status: 406, headers: { "T4-API-Version": "1.0" } }), + }); + await expect(duplicateError.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 406, code: "indeterminate", retryable: false }); + }); + + it("discards unterminated SSE data at EOF before reconnecting", async () => { + for (const unterminated of [ + 'data: {"type":"heartbeat","cursor":"unterminated","observedAt":"2026-07-21T00:00:00Z"}', + "data: {not-json", + ]) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: "https://unterminated-sse.test", credential: "token-a", majorVersion: 1, + fetch: async () => { + attempts += 1; + const body = attempts === 1 + ? unterminated + : 'data: {"type":"heartbeat","cursor":"complete","observedAt":"2026-07-21T00:00:00Z"}\n\n'; + return new Response(body, { headers: { "Content-Type": "text/event-stream", "T4-API-Version": "1.0" } }); + }, + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 1, retryBackoffMs: 0 }).next()).resolves.toMatchObject({ value: { cursor: "complete" } }); + expect(attempts).toBe(2); + } + }); + + it("accepts exact code-point request lengths and rejects one more", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + const exact = "😀".repeat(128); + const workspace = await client.http.POST("/v1/workspaces", { + body: { name: exact }, params: { header: idempotencyHeaders("unicode-workspace-exact") }, + }); + expect(workspace.response.status).toBe(202); + expect((await client.http.POST("/v1/workspaces", { + body: { name: `${exact}😀` }, params: { header: idempotencyHeaders("unicode-workspace-over") }, + })).response.status).toBe(422); + const session = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: exact }, params: { header: idempotencyHeaders("unicode-session-exact"), path: { workspaceId: requireData(workspace).id } }, + }); + expect(session.response.status).toBe(202); + expect((await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: `${exact}😀` }, params: { header: idempotencyHeaders("unicode-session-over"), path: { workspaceId: requireData(workspace).id } }, + })).response.status).toBe(422); + }); + + it("fails terminally and cancels an invalid UTF-8 SSE body", async () => { + let cancelled = false; + const client = createT4ApiClient({ + baseUrl: "https://invalid-utf8-sse.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array([100, 97, 116, 97, 58, 32, 195, 40, 10, 10])); }, + cancel() { cancelled = true; }, + }), { headers: { "Content-Type": "text/event-stream", "T4-API-Version": "1.0" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3 }).next()).rejects.toMatchObject({ status: 502, code: "indeterminate", retryable: false }); + expect(cancelled).toBe(true); + }); + + it("scopes one DELETE key independently by live target ID", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + for (const [key, name] of [["delete-target-workspace-a", "first"], ["delete-target-workspace-b", "second"]] as const) { + requireData(await client.http.POST("/v1/workspaces", { body: { name }, params: { header: idempotencyHeaders(key) } })); + } + for (const workspaceId of ["ws-1", "ws-2"] as const) { + expect((await client.http.DELETE("/v1/workspaces/{workspaceId}", { + params: { header: idempotencyHeaders("delete-shared-workspace"), path: { workspaceId } }, + })).response.status).toBe(204); + } + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = createT4ApiClient({ baseUrl: sessionService.origin, credential: "token-a", majorVersion: 1, fetch: sessionService.fetch }); + requireData(await sessionClient.http.POST("/v1/workspaces", { body: { name: "workspace" }, params: { header: idempotencyHeaders("delete-session-workspace") } })); + for (const [key, title] of [["delete-target-session-a", "first"], ["delete-target-session-b", "second"]] as const) { + requireData(await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title }, params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, + })); + } + for (const sessionId of ["ses-1", "ses-2"] as const) { + expect((await sessionClient.http.DELETE("/v1/sessions/{sessionId}", { + params: { header: idempotencyHeaders("delete-shared-session"), path: { sessionId } }, + })).response.status).toBe(204); + } + }); + it("bounds automatic EOF retry and supports explicit abort", async () => { let attempts = 0; const eofFetch: typeof globalThis.fetch = async () => { From ac595046faf68bdce5f76a5dec9995adda6d0bf1 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:04:00 +0000 Subject: [PATCH 27/70] fix: enforce declared response protocol --- .../test/t4-api-v1-conformance-service.ts | 13 ++- .../client/test/t4-api-v1-conformance.test.ts | 108 ++++++++++-------- packages/t4-api-client/src/index.ts | 71 +++++++++--- packages/t4-api-contract/openapi.json | 2 +- 4 files changed, 128 insertions(+), 66 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index af097f22..d7552c8d 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -5,6 +5,15 @@ const COMMAND_BYTES_MAX = 32; const COMMAND_REQUEST_BYTES_MAX = 256; const METADATA_VALUE_BYTES_MAX = 32; +function hasAtMostCodePoints(value: string, maximum: number): boolean { + let count = 0; + for (const _codePoint of value) { + count += 1; + if (count > maximum) return false; + } + return true; +} + function json(status: number, body: unknown, headers: Record = {}): Response { return Response.json(body, { status, headers: { "T4-API-Version": "1.0", ...headers } }); } @@ -98,7 +107,7 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (typeof body.name !== "string" || body.name.length < 1 || body.name.length > 128) return this.#invalid("name", "length", "name must contain 1 to 128 characters"); + if (typeof body.name !== "string" || body.name === "" || !hasAtMostCodePoints(body.name, 128)) return this.#invalid("name", "length", "name must contain 1 to 128 characters"); return this.#idempotent(request, tenant, "createWorkspace", [], body, 202, 200, () => { const id = `ws-${++this.#workspaceSequence}`; const workspace = { id, name: body.name, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; @@ -150,7 +159,7 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (typeof body.title !== "string" || body.title.length < 1 || body.title.length > 128) return this.#invalid("title", "length", "title must contain 1 to 128 characters"); + if (typeof body.title !== "string" || body.title === "" || !hasAtMostCodePoints(body.title, 128)) return this.#invalid("title", "length", "title must contain 1 to 128 characters"); return this.#idempotent(request, tenant, "spawnSession", [workspaceId], body, 202, 200, () => { const id = `ses-${++this.#sessionSequence}`; const session = { id, workspaceId, title: body.title, state: "accepted", revision: 1, tenant }; diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 6e294cf1..5c0f5a9c 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -48,6 +48,20 @@ function exhaustWatchEvent(event: WatchEvent): string { const VERSION_HEADERS = { "T4-API-Version": "1" } as const; +function withSelectedVersion(init: ResponseInit = {}): ResponseInit { + const headers = new Headers(init.headers); + if (!headers.has("T4-API-Version")) headers.set("T4-API-Version", "1.0"); + return { ...init, headers }; +} + +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return Response.json(body, withSelectedVersion(init)); +} + +function apiResponse(body?: BodyInit | null, init: ResponseInit = {}): Response { + return new Response(body, withSelectedVersion(init)); +} + function idempotencyHeaders(key: string): Readonly<{ "T4-API-Version": "1"; "Idempotency-Key": string }> { return { ...VERSION_HEADERS, "Idempotency-Key": key }; } @@ -451,7 +465,7 @@ describe("generated T4 API v1 client conformance", () => { let ongoingOversizedCancelled = false; const ongoingOversizedClient = createT4ApiClient({ baseUrl: "https://ongoing-oversized-event.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response(new ReadableStream({ + fetch: async () => apiResponse(new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(600 * 1024)); }, cancel() { ongoingOversizedCancelled = true; }, }), { status: 200, headers: { "Content-Type": "text/event-stream" } }), @@ -463,16 +477,16 @@ describe("generated T4 API v1 client conformance", () => { }); it("fails closed on unknown watch fields and incomplete typed errors", async () => { - const eventFetch: typeof globalThis.fetch = async () => new Response('data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z","unknown":true}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + const eventFetch: typeof globalThis.fetch = async () => apiResponse('data: {"type":"heartbeat","cursor":"c1","observedAt":"2026-07-21T00:00:00Z","unknown":true}\n\n', { headers: { "Content-Type": "text/event-stream" } }); const eventClient = createT4ApiClient({ baseUrl: "https://unknown.test", credential: "token-a", majorVersion: 1, fetch: eventFetch }); await expect(eventClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); for (const [status, code] of [[410, "cursor_expired"], [406, "incompatible_version"], [422, "invalid_request"]] as const) { - const fetch: typeof globalThis.fetch = async () => Response.json({ error: { code, message: "incomplete", requestId: "r", retryable: false } }, { status }); + const fetch: typeof globalThis.fetch = async () => jsonResponse({ error: { code, message: "incomplete", requestId: "r", retryable: false } }, { status }); const client = createT4ApiClient({ baseUrl: "https://errors.test", credential: "token-a", majorVersion: 1, fetch }); await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); } for (const [status, code] of [[401, "not_found"], [403, "unauthenticated"], [404, "forbidden"], [409, "unavailable"], [503, "revision_conflict"]] as const) { - const fetch: typeof globalThis.fetch = async () => Response.json({ error: { code, message: "wrong status class", requestId: "r", retryable: false } }, { status }); + const fetch: typeof globalThis.fetch = async () => jsonResponse({ error: { code, message: "wrong status class", requestId: "r", retryable: false } }, { status }); const client = createT4ApiClient({ baseUrl: "https://status-errors.test", credential: "token-a", majorVersion: 1, fetch }); await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); } @@ -490,48 +504,48 @@ describe("generated T4 API v1 client conformance", () => { [503, { error: { code: "unavailable", message: "bad", requestId: "r", retryable: true } }], ] as const; for (const [status, body] of validErrors) { - const fetch: typeof globalThis.fetch = async () => Response.json({ error: { ...body.error, unknown: true } }, { status }); + const fetch: typeof globalThis.fetch = async () => jsonResponse({ error: { ...body.error, unknown: true } }, { status }); const client = createT4ApiClient({ baseUrl: "https://ordinary-errors.test", credential: "token-a", majorVersion: 1, fetch }); await expect(client.http.POST("/v1/workspaces", { body: { name: "workspace" }, params: { header: idempotencyHeaders("ordinary-errors-0001") }, - })).rejects.toMatchObject({ code: "indeterminate", status: status === 503 ? 502 : status }); + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); } const malformedClient = createT4ApiClient({ baseUrl: "https://malformed-error.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response("{", { status: 404, headers: { "Content-Type": "application/json" } }), + fetch: async () => apiResponse("{", { status: 404, headers: { "Content-Type": "application/json" } }), }); await expect(malformedClient.http.GET("/v1/workspaces/{workspaceId}", { params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, - })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); const oversizedClient = createT4ApiClient({ baseUrl: "https://oversized-error.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response("x".repeat(1024 * 1024 + 1), { status: 404, headers: { "Content-Type": "application/json" } }), + fetch: async () => apiResponse("x".repeat(1024 * 1024 + 1), { status: 404, headers: { "Content-Type": "application/json" } }), }); await expect(oversizedClient.http.GET("/v1/workspaces/{workspaceId}", { params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, - })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); const streamedChunk = new Uint8Array(600 * 1024); for (const contentType of ["application/json", "text/plain"]) { let cancelled = false; const streamedClient = createT4ApiClient({ baseUrl: "https://streamed-error.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response(new ReadableStream({ + fetch: async () => apiResponse(new ReadableStream({ pull(controller) { controller.enqueue(streamedChunk); }, cancel() { cancelled = true; }, }), { status: 404, headers: { "Content-Type": contentType } }), }); await expect(streamedClient.http.GET("/v1/workspaces/{workspaceId}", { params: { header: VERSION_HEADERS, path: { workspaceId: "missing" } }, - })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); expect(cancelled).toBe(true); } const statusClient = createT4ApiClient({ baseUrl: "https://undeclared-error.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json(validErrors[3][1], { status: 404 }), + fetch: async () => jsonResponse(validErrors[3][1], { status: 404 }), }); await expect(statusClient.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); }); @@ -545,7 +559,7 @@ describe("generated T4 API v1 client conformance", () => { baseUrl: "https://prefixed.test/api", credential: "token-a", majorVersion: 1, fetch: async (input) => { expect(new Request(input).url).toBe("https://prefixed.test/api/v1"); - return Response.json({ ...discovery, unknown: true }); + return jsonResponse({ ...discovery, unknown: true }); }, }); await expect(prefixed.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); @@ -554,7 +568,7 @@ describe("generated T4 API v1 client conformance", () => { baseUrl: "https://snapshot.test/api", credential: "token-a", majorVersion: 1, fetch: async (input) => { expect(new URL(new Request(input).url).pathname).toBe("/api/v1/sessions/ses-1/snapshot"); - return Response.json({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [], unknown: true }); + return jsonResponse({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [], unknown: true }); }, }); await expect(invalidSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { @@ -564,7 +578,7 @@ describe("generated T4 API v1 client conformance", () => { const astralText = "😀".repeat(524_289); const unicodeSnapshot = createT4ApiClient({ baseUrl: "https://unicode-snapshot.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [{ sequence: 0, kind: "output", text: astralText }] }), + fetch: async () => jsonResponse({ sessionId: "ses-1", cursor: "cursor-1", state: "ready", entries: [{ sequence: 0, kind: "output", text: astralText }] }), }); const unicode = requireData(await unicodeSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, @@ -574,7 +588,7 @@ describe("generated T4 API v1 client conformance", () => { const astral128 = "😀".repeat(128); const unicodeWorkspace = createT4ApiClient({ baseUrl: "https://unicode-workspace.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json({ id: "wsp-1", name: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), + fetch: async () => jsonResponse({ id: "wsp-1", name: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), }); const workspace = requireData(await unicodeWorkspace.http.GET("/v1/workspaces/{workspaceId}", { params: { header: VERSION_HEADERS, path: { workspaceId: "wsp-1" } }, @@ -583,7 +597,7 @@ describe("generated T4 API v1 client conformance", () => { const unicodeSession = createT4ApiClient({ baseUrl: "https://unicode-session.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json({ id: "ses-1", workspaceId: "wsp-1", title: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), + fetch: async () => jsonResponse({ id: "ses-1", workspaceId: "wsp-1", title: astral128, state: "ready", revision: 1, labels: { team: astral128 } }), }); const session = requireData(await unicodeSession.http.GET("/v1/sessions/{sessionId}", { params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, @@ -596,7 +610,7 @@ describe("generated T4 API v1 client conformance", () => { const violationMessage = "😀".repeat(512); const unicodeError = createT4ApiClient({ baseUrl: "https://unicode-error.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json({ error: { + fetch: async () => jsonResponse({ error: { code: "invalid_request", message: errorMessage, requestId: errorRequestId, retryable: false, violations: [{ field: violationField, rule: "range", message: violationMessage }], } }, { status: 422 }), @@ -607,8 +621,8 @@ describe("generated T4 API v1 client conformance", () => { }); for (const response of [ - new Response(JSON.stringify(discovery), { status: 200, headers: { "Content-Type": "text/plain" } }), - Response.json(discovery, { status: 201 }), + apiResponse(JSON.stringify(discovery), { status: 200, headers: { "Content-Type": "text/plain" } }), + jsonResponse(discovery, { status: 201 }), ]) { const client = createT4ApiClient({ baseUrl: "https://dispatch.test", credential: "token-a", majorVersion: 1, fetch: async () => response.clone() }); await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); @@ -616,7 +630,7 @@ describe("generated T4 API v1 client conformance", () => { const unknownRoute = createT4ApiClient({ baseUrl: "https://dispatch.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json(discovery), + fetch: async () => jsonResponse(discovery), }); await expect(unknownRoute.http.GET("/v1/undeclared" as "/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502 }); }); @@ -626,7 +640,7 @@ describe("generated T4 API v1 client conformance", () => { baseUrl: "https://watch-status.test/api", credential: "token-a", majorVersion: 1, fetch: async (input) => { expect(new URL(new Request(input).url).pathname).toBe("/api/v1/sessions/ses-1/events"); - return new Response('data: {"type":"heartbeat","cursor":"prefixed-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "text/event-stream" } }); + return apiResponse('data: {"type":"heartbeat","cursor":"prefixed-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "text/event-stream" } }); }, }); await expect(prefixed.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "prefixed-1" } }); @@ -642,15 +656,15 @@ describe("generated T4 API v1 client conformance", () => { [503, { error: { code: "unavailable", message: "bad", requestId: "r", retryable: false } }], ] as const; for (const [status, body] of declared) { - const client = createT4ApiClient({ baseUrl: "https://watch-status.test", credential: "token-a", majorVersion: 1, fetch: async () => Response.json(body, { status }) }); + const client = createT4ApiClient({ baseUrl: "https://watch-status.test", credential: "token-a", majorVersion: 1, fetch: async () => jsonResponse(body, { status }) }); await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status, code: body.error.code }); } for (const response of [ - new Response('data: {"type":"heartbeat","cursor":"wrong-media-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "application/json" } }), - new Response('data: {"type":"heartbeat","cursor":"wrong-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 201, headers: { "Content-Type": "text/event-stream" } }), - new Response(null, { status: 204, headers: { "Content-Type": "text/event-stream" } }), - Response.json({ error: { code: "revision_conflict", message: "bad", requestId: "r", retryable: false } }, { status: 409 }), + apiResponse('data: {"type":"heartbeat","cursor":"wrong-media-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 200, headers: { "Content-Type": "application/json" } }), + apiResponse('data: {"type":"heartbeat","cursor":"wrong-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { status: 201, headers: { "Content-Type": "text/event-stream" } }), + apiResponse(null, { status: 204, headers: { "Content-Type": "text/event-stream" } }), + jsonResponse({ error: { code: "revision_conflict", message: "bad", requestId: "r", retryable: false } }, { status: 409 }), ]) { let attempts = 0; const client = createT4ApiClient({ @@ -679,7 +693,7 @@ describe("generated T4 API v1 client conformance", () => { baseUrl: "https://invalid-503.test", credential: "token-a", majorVersion: 1, fetch: async () => { attempts += 1; - return new Response(body, { status: 503, headers: { "Content-Type": "application/json", "Retry-After": "0" } }); + return apiResponse(body, { status: 503, headers: { "Content-Type": "application/json", "Retry-After": "0" } }); }, }); await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 3, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ @@ -692,7 +706,7 @@ describe("generated T4 API v1 client conformance", () => { const oversizedChunk = new Uint8Array(600 * 1024); const streamedOversizedClient = createT4ApiClient({ baseUrl: "https://streamed-invalid-503.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response(new ReadableStream({ + fetch: async () => apiResponse(new ReadableStream({ pull(controller) { controller.enqueue(oversizedChunk); }, cancel() { oversizedCancelled = true; }, }), { status: 503, headers: { "Content-Type": "application/json" } }), @@ -705,7 +719,7 @@ describe("generated T4 API v1 client conformance", () => { let nonJsonCancelled = false; const nonJsonClient = createT4ApiClient({ baseUrl: "https://invalid-503-media.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response(new ReadableStream({ + fetch: async () => apiResponse(new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(1024)); }, cancel() { nonJsonCancelled = true; }, }), { status: 503, headers: { "Content-Type": "text/plain" } }), @@ -765,7 +779,7 @@ describe("generated T4 API v1 client conformance", () => { const workspace = { id: "wsp-1", name: "workspace", state: "ready", revision: 1 }; const missingReplay = createT4ApiClient({ baseUrl: "https://response-replay.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json(workspace, { headers: { "T4-API-Version": "1.0" } }), + fetch: async () => jsonResponse(workspace, { headers: { "T4-API-Version": "1.0" } }), }); await expect(missingReplay.http.PATCH("/v1/workspaces/{workspaceId}", { params: { header: mutationHeaders(1, "response-replay-patch"), path: { workspaceId: "wsp-1" } }, body: { name: "workspace" }, @@ -774,7 +788,7 @@ describe("generated T4 API v1 client conformance", () => { for (const replayed of [undefined, "TRUE", "0"]) { const client = createT4ApiClient({ baseUrl: "https://delete-replay-header.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response(null, { status: 204, headers: { + fetch: async () => apiResponse(null, { status: 204, headers: { "T4-API-Version": "1.0", ...(replayed === undefined ? {} : { "Idempotency-Replayed": replayed }), } }), }); @@ -804,17 +818,17 @@ describe("generated T4 API v1 client conformance", () => { ]) { const client = createT4ApiClient({ baseUrl: "https://duplicate-discovery.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json(discovery, { headers: { "T4-API-Version": "1.0" } }), + fetch: async () => jsonResponse(discovery, { headers: { "T4-API-Version": "1.0" } }), }); await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, retryable: false }); } const duplicateError = createT4ApiClient({ baseUrl: "https://duplicate-error.test", credential: "token-a", majorVersion: 1, - fetch: async () => Response.json({ error: { + fetch: async () => jsonResponse({ error: { code: "incompatible_version", message: "bad", requestId: "r", retryable: false, supportedMajors: [1, 1], } }, { status: 406, headers: { "T4-API-Version": "1.0" } }), }); - await expect(duplicateError.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 406, code: "indeterminate", retryable: false }); + await expect(duplicateError.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, code: "indeterminate", retryable: false }); }); it("discards unterminated SSE data at EOF before reconnecting", async () => { @@ -830,7 +844,7 @@ describe("generated T4 API v1 client conformance", () => { const body = attempts === 1 ? unterminated : 'data: {"type":"heartbeat","cursor":"complete","observedAt":"2026-07-21T00:00:00Z"}\n\n'; - return new Response(body, { headers: { "Content-Type": "text/event-stream", "T4-API-Version": "1.0" } }); + return apiResponse(body, { headers: { "Content-Type": "text/event-stream", "T4-API-Version": "1.0" } }); }, }); await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 1, retryBackoffMs: 0 }).next()).resolves.toMatchObject({ value: { cursor: "complete" } }); @@ -862,7 +876,7 @@ describe("generated T4 API v1 client conformance", () => { let cancelled = false; const client = createT4ApiClient({ baseUrl: "https://invalid-utf8-sse.test", credential: "token-a", majorVersion: 1, - fetch: async () => new Response(new ReadableStream({ + fetch: async () => apiResponse(new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([100, 97, 116, 97, 58, 32, 195, 40, 10, 10])); }, cancel() { cancelled = true; }, }), { headers: { "Content-Type": "text/event-stream", "T4-API-Version": "1.0" } }), @@ -902,7 +916,7 @@ describe("generated T4 API v1 client conformance", () => { let attempts = 0; const eofFetch: typeof globalThis.fetch = async () => { attempts += 1; - return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); + return apiResponse(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); }; const eofClient = createT4ApiClient({ baseUrl: "https://eof.test", credential: "token-a", majorVersion: 1, fetch: eofFetch }); await expect(eofClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 2, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); @@ -915,7 +929,7 @@ describe("generated T4 API v1 client conformance", () => { networkAttempt += 1; if (networkAttempt === 1) { let sent = false; - return new Response(new ReadableStream({ + return apiResponse(new ReadableStream({ pull(stream) { if (sent) stream.error(new TypeError("transient network loss")); else { @@ -925,7 +939,7 @@ describe("generated T4 API v1 client conformance", () => { }, }), { headers: { "Content-Type": "text/event-stream" } }); } - return new Response('data: {"type":"session","cursor":"network-2","state":"ready","revision":2}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + return apiResponse('data: {"type":"session","cursor":"network-2","state":"ready","revision":2}\n\n', { headers: { "Content-Type": "text/event-stream" } }); }; const networkClient = createT4ApiClient({ baseUrl: "https://network.test", credential: "token-a", majorVersion: 1, fetch: networkFetch }); const networkEvents: WatchEvent[] = []; @@ -936,8 +950,8 @@ describe("generated T4 API v1 client conformance", () => { let retryableAttempts = 0; const retryableFetch: typeof globalThis.fetch = async () => { retryableAttempts += 1; - if (retryableAttempts === 1) return Response.json({ error: { code: "unavailable", message: "retry", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "0" } }); - return new Response('data: {"type":"heartbeat","cursor":"retry-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); + if (retryableAttempts === 1) return jsonResponse({ error: { code: "unavailable", message: "retry", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "0" } }); + return apiResponse('data: {"type":"heartbeat","cursor":"retry-1","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }); }; const retryableClient = createT4ApiClient({ baseUrl: "https://retryable.test", credential: "token-a", majorVersion: 1, fetch: retryableFetch }); expect((await retryableClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 1, retryBackoffMs: 0 }).next()).value).toMatchObject({ cursor: "retry-1" }); @@ -948,9 +962,9 @@ describe("generated T4 API v1 client conformance", () => { progressAttempts += 1; if (progressAttempts === 2 || progressAttempts === 4) { const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; - return new Response(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + return apiResponse(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); } - return new Response(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); + return apiResponse(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); }; const progressClient = createT4ApiClient({ baseUrl: "https://progress.test", credential: "token-a", majorVersion: 1, fetch: progressFetch }); const progressEvents: WatchEvent[] = []; @@ -964,7 +978,7 @@ describe("generated T4 API v1 client conformance", () => { let retryAfterAttempts = 0; const retryAfterFetch: typeof globalThis.fetch = async () => { retryAfterAttempts += 1; - return Response.json({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "30" } }); + return jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "30" } }); }; const retryAfterClient = createT4ApiClient({ baseUrl: "https://retry-after.test", credential: "token-a", majorVersion: 1, fetch: retryAfterFetch }); const retryAfterPending = retryAfterClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 2, retryBackoffMs: 0, signal: retryAfterController.signal }).next(); @@ -978,7 +992,7 @@ describe("generated T4 API v1 client conformance", () => { } const controller = new AbortController(); - const abortFetch: typeof globalThis.fetch = async (_input, init) => new Response(new ReadableStream({ + const abortFetch: typeof globalThis.fetch = async (_input, init) => apiResponse(new ReadableStream({ start(stream) { init?.signal?.addEventListener("abort", () => stream.close(), { once: true }); }, }), { headers: { "Content-Type": "text/event-stream" } }); const abortClient = createT4ApiClient({ baseUrl: "https://abort.test", credential: "token-a", majorVersion: 1, fetch: abortFetch }); diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index feb91ee6..425059d9 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -9,6 +9,8 @@ const MAX_ERROR_BYTES = 1024 * 1024; const MAX_EVENT_BYTES = 1024 * 1024; const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024; const CURSOR_PATTERN = /^[A-Za-z0-9._~-]+$/u; +const SELECTED_VERSION_MAX_LENGTH = 16; +const SELECTED_VERSION_PATTERN = /^1\.[0-9]+$/u; const ERROR_CODES = { invalid_request: true, unauthenticated: true, forbidden: true, not_found: true, idempotency_key_required: true, idempotency_conflict: true, revision_conflict: true, @@ -178,7 +180,7 @@ function apiError(value: unknown, status: number): ApiError | undefined { typeof error.requestId !== "string" || error.requestId === "" || !hasAtMostCodePoints(error.requestId, 128) || typeof error.retryable !== "boolean" || (error.violations !== undefined && (!Array.isArray(error.violations) || error.violations.length > 64 || !error.violations.every(validViolation))) || - (error.supportedMajors !== undefined && (!Array.isArray(error.supportedMajors) || error.supportedMajors.length > 8 || !error.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1))) || + (error.supportedMajors !== undefined && (!Array.isArray(error.supportedMajors) || error.supportedMajors.length > 8 || !hasUniqueItems(error.supportedMajors) || !error.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1))) || (error.resync !== undefined && !validResync(error.resync)) || (status === 406 && (error.code !== "incompatible_version" || !Array.isArray(error.supportedMajors) || error.supportedMajors.length < 1)) || (status === 410 && (error.code !== "cursor_expired" || !validResync(error.resync))) || @@ -258,6 +260,15 @@ function hasOnlyKeys(value: Record, keys: Readonly keys[key] === true); } +function hasUniqueItems(value: readonly unknown[]): boolean { + for (let index = 0; index < value.length; index += 1) { + for (let prior = 0; prior < index; prior += 1) { + if (value[index] === value[prior]) return false; + } + } + return true; +} + function validResourceId(value: unknown): value is string { return typeof value === "string" && value !== "" && hasAtMostCodePoints(value, 128) && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(value); } @@ -309,8 +320,8 @@ function validDiscovery(value: unknown): boolean { const limits = record(discovery?.limits); return discovery !== undefined && hasOnlyKeys(discovery, { apiVersion: true, supportedMajors: true, capabilities: true, limits: true }) && typeof discovery.apiVersion === "string" && /^1\.[0-9]+$/u.test(discovery.apiVersion) && - Array.isArray(discovery.supportedMajors) && discovery.supportedMajors.length >= 1 && discovery.supportedMajors.length <= 8 && discovery.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1) && - Array.isArray(discovery.capabilities) && discovery.capabilities.length <= 128 && discovery.capabilities.every((capability) => typeof capability === "string" && /^[a-z][a-z0-9.-]{0,127}$/u.test(capability)) && + Array.isArray(discovery.supportedMajors) && discovery.supportedMajors.length >= 1 && discovery.supportedMajors.length <= 8 && hasUniqueItems(discovery.supportedMajors) && discovery.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1) && + Array.isArray(discovery.capabilities) && discovery.capabilities.length <= 128 && hasUniqueItems(discovery.capabilities) && discovery.capabilities.every((capability) => typeof capability === "string" && /^[a-z][a-z0-9.-]{0,127}$/u.test(capability)) && limits !== undefined && hasOnlyKeys(limits, { pageSizeDefault: true, pageSizeMax: true, commandBytesMax: true, commandRequestBytesMax: true, commandMetadataValueBytesMax: true, watchEventsMax: true, heartbeatSeconds: true }) && [limits.pageSizeDefault, limits.pageSizeMax, limits.commandBytesMax, limits.commandRequestBytesMax, limits.commandMetadataValueBytesMax, limits.watchEventsMax, limits.heartbeatSeconds].every((limit) => Number.isSafeInteger(limit) && Number(limit) >= 1) && Number(limits.pageSizeDefault) <= Number(limits.pageSizeMax) && Number(limits.commandBytesMax) <= Number(limits.commandRequestBytesMax) && Number(limits.commandMetadataValueBytesMax) <= Number(limits.commandRequestBytesMax) && @@ -336,20 +347,21 @@ type SuccessValidator = ((value: unknown) => boolean) | "empty" | "event-stream" interface ResponseContract { readonly success: Readonly>; readonly errors: readonly number[]; + readonly replaySuccess?: readonly number[]; } const DISCOVERY_RESPONSE: ResponseContract = { success: { 200: validDiscovery }, errors: [401, 403, 406, 503] }; const WORKSPACE_LIST_RESPONSE: ResponseContract = { success: { 200: validWorkspacePage }, errors: [400, 401, 403, 406, 422, 503] }; -const WORKSPACE_CREATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace, 202: validWorkspace }, errors: [400, 401, 403, 406, 409, 422, 503] }; +const WORKSPACE_CREATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace, 202: validWorkspace }, errors: [400, 401, 403, 406, 409, 422, 503], replaySuccess: [200, 202] }; const WORKSPACE_GET_RESPONSE: ResponseContract = { success: { 200: validWorkspace }, errors: [401, 403, 404, 406, 503] }; -const WORKSPACE_MUTATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; -const DELETE_RESPONSE: ResponseContract = { success: { 204: "empty" }, errors: [400, 401, 403, 404, 406, 409, 503] }; +const WORKSPACE_MUTATE_RESPONSE: ResponseContract = { success: { 200: validWorkspace }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200] }; +const DELETE_RESPONSE: ResponseContract = { success: { 204: "empty" }, errors: [400, 401, 403, 404, 406, 409, 503], replaySuccess: [204] }; const SESSION_LIST_RESPONSE: ResponseContract = { success: { 200: validSessionPage }, errors: [401, 403, 404, 406, 422, 503] }; -const SESSION_CREATE_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; +const SESSION_CREATE_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200, 202] }; const SESSION_GET_RESPONSE: ResponseContract = { success: { 200: validSession }, errors: [401, 403, 404, 406, 503] }; -const SESSION_MUTATE_RESPONSE: ResponseContract = { success: { 200: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; -const SESSION_CANCEL_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 503] }; -const COMMAND_RESPONSE: ResponseContract = { success: { 200: validCommandResult, 202: validCommandResult }, errors: [400, 401, 403, 404, 406, 409, 422, 503] }; +const SESSION_MUTATE_RESPONSE: ResponseContract = { success: { 200: validSession }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200] }; +const SESSION_CANCEL_RESPONSE: ResponseContract = { success: { 200: validSession, 202: validSession }, errors: [400, 401, 403, 404, 406, 409, 503], replaySuccess: [200, 202] }; +const COMMAND_RESPONSE: ResponseContract = { success: { 200: validCommandResult, 202: validCommandResult }, errors: [400, 401, 403, 404, 406, 409, 422, 503], replaySuccess: [200, 202] }; const SNAPSHOT_RESPONSE: ResponseContract = { success: { 200: validSnapshot }, errors: [401, 403, 404, 406, 503] }; const EVENTS_RESPONSE: ResponseContract = { success: { 200: "event-stream" }, errors: [400, 401, 403, 404, 406, 410, 422, 503] }; @@ -408,6 +420,16 @@ function responseContract(method: string, path: string): ResponseContract | unde return undefined; } +function validSelectedVersion(response: Response): boolean { + const selected = response.headers.get("T4-API-Version"); + return selected !== null && selected.length <= SELECTED_VERSION_MAX_LENGTH && SELECTED_VERSION_PATTERN.test(selected); +} + +function validReplayHeader(response: Response): boolean { + const replayed = response.headers.get("Idempotency-Replayed"); + return replayed === "true" || replayed === "false"; +} + async function validateResponse(request: Request, response: Response, baseUrl: string): Promise { const path = relativeApiPath(request, baseUrl); const contract = path === undefined ? undefined : responseContract(request.method, path); @@ -420,9 +442,13 @@ async function validateResponse(request: Request, response: Response, baseUrl: s void response.body?.cancel().catch(() => {}); throw protocolError(response.status, "T4 API returned an undeclared error status"); } + if (response.status !== 401 && !validSelectedVersion(response)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid selected version"); + } if (await parsedError(response.clone()) === undefined) { void response.body?.cancel().catch(() => {}); - throw protocolError(response.status, "T4 API returned an invalid or oversized error envelope"); + throw protocolError(502, "T4 API returned an invalid or oversized error envelope"); } return; } @@ -431,6 +457,14 @@ async function validateResponse(request: Request, response: Response, baseUrl: s void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared success status"); } + if (!validSelectedVersion(response)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid selected version"); + } + if (contract.replaySuccess?.includes(response.status) === true && !validReplayHeader(response)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid replay header"); + } if (validator === "empty") { if (response.body !== null || response.headers.has("content-type")) { void response.body?.cancel().catch(() => {}); @@ -535,11 +569,8 @@ class SseFrameParser { this.#finishLine(this.#pendingCarriageReturn, frames); this.#pendingCarriageReturn = -1; } - if (this.#length > 0) { - if (this.#length > MAX_EVENT_BYTES) this.#oversized(); - frames.push(this.#buffer.slice(0, this.#length)); - this.#reset(); - } + if (this.#length > MAX_EVENT_BYTES) this.#oversized(); + this.#reset(); return frames; } @@ -660,6 +691,10 @@ async function* watch( void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared watch error status"); } + if (response.status !== 401 && !validSelectedVersion(response)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid selected version"); + } const error = await parsedError(response); throw error ?? protocolError(502, "T4 API returned an invalid or oversized error envelope"); } @@ -667,6 +702,10 @@ async function* watch( void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared watch success status"); } + if (!validSelectedVersion(response)) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid selected version"); + } if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API watch did not return text/event-stream"); diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index 1ae79de1..ac0d907e 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -420,7 +420,7 @@ "headers": { "SelectedVersion": { "description": "Selected compatible T4 API minor version.", - "schema": { "type": "string", "pattern": "^1\\.[0-9]+$" } + "schema": { "type": "string", "pattern": "^1\\.[0-9]+$", "maxLength": 16 } }, "IdempotencyReplayed": { "description": "True when this response replays a prior identical request.", From afb136cc90088b19bb776c2da18429f77cadcc28 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:08:05 +0000 Subject: [PATCH 28/70] test: reject invalid mutations before idempotency --- .../client/test/t4-api-v1-conformance.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 5c0f5a9c..6136a7db 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -361,6 +361,51 @@ describe("generated T4 API v1 client conformance", () => { })).response.status).toBe(404); }); + it("validates mutations before state changes and idempotency recording", async () => { + const workspaceService = new T4ApiV1ConformanceService(); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + requireData(await workspaceClient.http.POST("/v1/workspaces", { + body: { name: "original" }, params: { header: idempotencyHeaders("mutation-workspace-setup") }, + })); + const invalidWorkspaceBodies = [ + {}, { unknown: true }, { name: 42 }, { name: "" }, { name: "😀".repeat(129) }, + { labels: { Invalid: "value" } }, { labels: { valid: "😀".repeat(129) } }, + ]; + for (const [index, body] of invalidWorkspaceBodies.entries()) { + const result = await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, `invalid-workspace-${index}`), path: { workspaceId: "ws-1" } }, body: body as never, + }); + expect(result.response.status).toBe(422); + expect(result.error).toMatchObject({ error: { code: "invalid_request" } }); + } + expect(requireData(await workspaceClient.http.GET("/v1/workspaces/{workspaceId}", { + params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" } }, + }))).toMatchObject({ name: "original", revision: 1 }); + expect((await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "invalid-workspace-0"), path: { workspaceId: "ws-1" } }, body: { name: "valid" }, + })).response.status).toBe(200); + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = await seededClient(sessionService, "mutation-validation"); + const invalidSessionBodies = [ + {}, { unknown: true }, { title: 42 }, { title: "" }, { title: "😀".repeat(129) }, + { labels: { Invalid: "value" } }, { labels: { valid: "😀".repeat(129) } }, + ]; + for (const [index, body] of invalidSessionBodies.entries()) { + const result = await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, `invalid-session-${index}`), path: { sessionId: "ses-1" } }, body: body as never, + }); + expect(result.response.status).toBe(422); + expect(result.error).toMatchObject({ error: { code: "invalid_request" } }); + } + expect(requireData(await sessionClient.http.GET("/v1/sessions/{sessionId}", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + }))).toMatchObject({ title: "agent", revision: 1 }); + expect((await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + params: { header: mutationHeaders(1, "invalid-session-0"), path: { sessionId: "ses-1" } }, body: { title: "valid" }, + })).response.status).toBe(200); + }); + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); From 3a056c78ae2c5620428cc3ccdf972be9d17c41b9 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:11:27 +0000 Subject: [PATCH 29/70] fix: close strict API contract gaps --- .github/workflows/ci.yml | 13 +++++++++- .woodpecker.yml | 3 +++ .../test/t4-api-v1-conformance-service.ts | 21 ++++++++++++++-- .../client/test/t4-api-v1-conformance.test.ts | 2 +- packages/t4-api-client/src/index.ts | 4 +-- packages/t4-api-contract/scripts/validate.mjs | 25 +++++++++++++++++++ 6 files changed, 62 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25d7d947..f5f56a9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,10 +82,19 @@ jobs: - name: Validate OpenAPI and deterministic generation run: | pnpm --filter @t4-code/t4-api-contract validate + pnpm --filter @t4-code/t4-api-contract generate:ci pnpm --filter @t4-code/t4-api-contract check:generated pnpm --filter @t4-code/t4-api-client typecheck pnpm --filter @t4-code/client typecheck pnpm --filter @t4-code/client exec vp test run test/t4-api-v1-conformance.test.ts + - name: Upload deterministic T4 API client + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: t4-api-generated-${{ github.run_id }} + path: artifacts/t4-api/generated/schema.ts + if-no-files-found: error + core: @@ -552,7 +561,7 @@ jobs: verify: name: verify if: ${{ always() }} - needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple] + needs: [changes, t4-api-generation, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple] runs-on: ubuntu-24.04 timeout-minutes: 5 steps: @@ -560,6 +569,7 @@ jobs: shell: bash env: CHANGES_RESULT: ${{ needs.changes.result }} + T4_API_GENERATION_RESULT: ${{ needs.t4-api-generation.result }} CORE_RESULT: ${{ needs.core.result }} CONTINUITY_RESULT: ${{ needs.legacy-bridge-continuity.result }} OFFICIAL_OMP_GATE0_RESULT: ${{ needs.official-omp-gate0.result }} @@ -572,6 +582,7 @@ jobs: run: | set -euo pipefail test "$CHANGES_RESULT" = success + test "$T4_API_GENERATION_RESULT" = success test "$CORE_RESULT" = success for result in \ "$CONTINUITY_RESULT" \ diff --git a/.woodpecker.yml b/.woodpecker.yml index c7ad7c55..78d6da92 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -37,6 +37,9 @@ steps: commands: - corepack enable - pnpm --filter @t4-code/t4-api-contract validate + - pnpm --filter @t4-code/t4-api-contract generate:ci + - test -f artifacts/t4-api/generated/schema.ts + - echo "T4_API_GENERATED_ARTIFACT=artifacts/t4-api/generated/schema.ts" - pnpm --filter @t4-code/t4-api-contract check:generated - pnpm --filter @t4-code/t4-api-client typecheck - pnpm --filter @t4-code/client typecheck diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index d7552c8d..b29e84eb 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -14,6 +14,21 @@ function hasAtMostCodePoints(value: string, maximum: number): boolean { return true; } +function validLabels(value: unknown): boolean { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const entries = Object.entries(value as Record); + return entries.length <= 32 && entries.every(([key, item]) => + /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && hasAtMostCodePoints(item, 128)); +} + +function validMutation(body: Record, textField: "name" | "title"): boolean { + const keys = Object.keys(body); + if (keys.length < 1 || keys.some((key) => key !== textField && key !== "labels")) return false; + const text = body[textField]; + return (text === undefined || (typeof text === "string" && text !== "" && hasAtMostCodePoints(text, 128))) && + (body.labels === undefined || validLabels(body.labels)); +} + function json(status: number, body: unknown, headers: Record = {}): Response { return Response.json(body, { status, headers: { "T4-API-Version": "1.0", ...headers } }); } @@ -142,8 +157,9 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; + if (!validMutation(body, "name")) return this.#invalid("body", "schema", "workspace mutation must match WorkspaceMutation"); return this.#idempotent(request, tenant, "mutateWorkspace", [id], body, 200, 200, () => { - const updated = { ...workspace, name: body.name ?? workspace.name, revision: Number(workspace.revision) + 1 }; + const updated = { ...workspace, ...(body.name === undefined ? {} : { name: body.name }), ...(body.labels === undefined ? {} : { labels: body.labels }), revision: Number(workspace.revision) + 1 }; this.#workspaces.set(id, updated); return updated; }, () => request.headers.get("If-Match") === String(workspace.revision) ? undefined : problem(409, "revision_conflict", "Workspace revision changed")); @@ -194,8 +210,9 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; + if (!validMutation(body, "title")) return this.#invalid("body", "schema", "session mutation must match SessionMutation"); return this.#idempotent(request, tenant, "mutateSession", [id], body, 200, 200, () => { - const updated = { ...session, title: body.title ?? session.title, revision: Number(session.revision) + 1 }; + const updated = { ...session, ...(body.title === undefined ? {} : { title: body.title }), ...(body.labels === undefined ? {} : { labels: body.labels }), revision: Number(session.revision) + 1 }; this.#sessions.set(id, updated); return updated; }, () => request.headers.get("If-Match") === String(session.revision) ? undefined : problem(409, "revision_conflict", "Session revision changed")); diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 6136a7db..de2b00c4 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -592,7 +592,7 @@ describe("generated T4 API v1 client conformance", () => { baseUrl: "https://undeclared-error.test", credential: "token-a", majorVersion: 1, fetch: async () => jsonResponse(validErrors[3][1], { status: 404 }), }); - await expect(statusClient.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 404 }); + await expect(statusClient.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); }); it("validates successful JSON routes relative to the base path and rejects undeclared media and statuses", async () => { diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 425059d9..1d024ed5 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -435,12 +435,12 @@ async function validateResponse(request: Request, response: Response, baseUrl: s const contract = path === undefined ? undefined : responseContract(request.method, path); if (contract === undefined) { void response.body?.cancel().catch(() => {}); - throw protocolError(response.ok ? 502 : response.status, "T4 API returned a response for an undeclared route"); + throw protocolError(502, "T4 API returned a response for an undeclared route"); } if (!response.ok) { if (!contract.errors.includes(response.status)) { void response.body?.cancel().catch(() => {}); - throw protocolError(response.status, "T4 API returned an undeclared error status"); + throw protocolError(502, "T4 API returned an undeclared error status"); } if (response.status !== 401 && !validSelectedVersion(response)) { void response.body?.cancel().catch(() => {}); diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs index cbfafbc7..4a9562eb 100644 --- a/packages/t4-api-contract/scripts/validate.mjs +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -16,6 +16,31 @@ for (const server of document.servers) { if (document.security?.[0]?.BearerAuth === undefined) throw new Error("T4 API contract must default to bearer authentication"); const sseSchema = document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.content?.["text/event-stream"]?.schema; if (sseSchema?.["x-t4-sse-data-schema"] !== "#/components/schemas/WatchEvent") throw new Error("watch SSE data must remain linked to WatchEvent"); +const schemas = document.components?.schemas ?? {}; +const commandCreate = schemas.CommandCreate; +if (commandCreate?.["x-t4-maxUtf8Bytes"] !== 1048576) throw new Error("CommandCreate must retain its UTF-8 request-byte bound"); +if (commandCreate?.properties?.command?.["x-t4-maxUtf8Bytes"] !== 262144) throw new Error("command must retain its UTF-8 byte bound"); +const metadataString = commandCreate?.properties?.metadata?.additionalProperties?.oneOf?.find((item) => item?.type === "string"); +if (metadataString?.["x-t4-maxUtf8Bytes"] !== 262144) throw new Error("command metadata strings must retain their UTF-8 byte bound"); + +for (const [schema, property] of [["Discovery", "supportedMajors"], ["Discovery", "capabilities"], ["ApiError", "supportedMajors"]]) { + if (schemas[schema]?.properties?.[property]?.uniqueItems !== true) throw new Error(`${schema}.${property} must retain uniqueItems`); +} + +const selectedVersion = document.components?.headers?.SelectedVersion?.schema; +if (selectedVersion?.pattern !== "^1\\.[0-9]+$" || selectedVersion?.maxLength !== 16) throw new Error("SelectedVersion must remain a bounded v1 minor"); +const selectedVersionRef = "#/components/headers/SelectedVersion"; +const replayRef = "#/components/headers/IdempotencyReplayed"; +const selectedVersionResponses = ["Discovery", "Workspace", "WorkspaceAccepted", "WorkspaceReplay", "WorkspacePage", "Session", "SessionAccepted", "SessionReplay", "SessionPage", "CommandAccepted", "CommandReplay", "Snapshot", "Deleted", "Error400", "Error403", "Error404", "Error406", "Error409", "Error410", "Error422", "Error503"]; +for (const responseName of selectedVersionResponses) { + if (document.components?.responses?.[responseName]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error(`${responseName} must declare SelectedVersion`); +} +for (const responseName of ["WorkspaceAccepted", "WorkspaceReplay", "SessionAccepted", "SessionReplay", "CommandAccepted", "CommandReplay", "Deleted"]) { + if (document.components?.responses?.[responseName]?.headers?.["Idempotency-Replayed"]?.$ref !== replayRef) throw new Error(`${responseName} must declare IdempotencyReplayed`); +} +if (document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error("watch success must declare SelectedVersion"); +if (document.paths?.["/v1/workspaces/{workspaceId}"]?.patch?.responses?.["200"]?.$ref !== "#/components/responses/WorkspaceReplay") throw new Error("workspace PATCH success must declare replay headers"); +if (document.paths?.["/v1/sessions/{sessionId}"]?.patch?.responses?.["200"]?.$ref !== "#/components/responses/SessionReplay") throw new Error("session PATCH success must declare replay headers"); const errorResponses = { 400: "Error400", 401: "Error401", 403: "Error403", 404: "Error404", 406: "Error406", 409: "Error409", 410: "Error410", 422: "Error422", 503: "Error503" }; for (const path of Object.values(document.paths ?? {})) { for (const operation of Object.values(path)) { From 2b1433fb3b19c771b25bd46d607cb7b7ed39c81d Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:17:56 +0000 Subject: [PATCH 30/70] test: type omitted command metadata --- packages/client/test/t4-api-v1-conformance.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index de2b00c4..93c13ca2 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -323,15 +323,13 @@ describe("generated T4 API v1 client conformance", () => { body: { command: "ok", metadata: Object.fromEntries(Array.from({ length: 8 }, (_, index) => [`field-${index}`, "x".repeat(32)])) }, }); expect(requestOversized.response.status).toBe(422); - const defaultedMetadataResponse = await service.fetch(`${service.origin}/v1/sessions/ses-1/commands`, { - method: "POST", - headers: { Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": "command-default-0001", "Content-Type": "application/json" }, - body: '{"command":"ok"}', + const defaultedMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { + params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok" }, }); const explicitMetadata = await client.http.POST("/v1/sessions/{sessionId}/commands", { params: { header: idempotencyHeaders("command-default-0001"), path: { sessionId: "ses-1" } }, body: { command: "ok", metadata: {} }, }); - expect(explicitMetadata.data).toEqual(await defaultedMetadataResponse.json()); + expect(explicitMetadata.data).toEqual(defaultedMetadata.data); const cancelled = await client.http.POST("/v1/sessions/{sessionId}/cancel", { params: { header: idempotencyHeaders("session-cancel-0001"), path: { sessionId: "ses-1" } }, }); From 94e74dfc9315cbc652e2b77cffcef73b48755518 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:19:52 +0000 Subject: [PATCH 31/70] fix: preserve optional generated defaults --- packages/t4-api-contract/openapi.json | 2 +- packages/t4-api-contract/scripts/generate.mjs | 2 +- packages/t4-api-contract/scripts/validate.mjs | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index ac0d907e..f4433421 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -603,7 +603,7 @@ "properties": { "type": { "type": "string", "const": "heartbeat" }, "cursor": { "$ref": "#/components/schemas/Cursor" }, - "observedAt": { "type": "string", "format": "date-time" } + "observedAt": { "type": "string", "format": "date-time", "maxLength": 64 } } }, "SessionWatchEvent": { diff --git a/packages/t4-api-contract/scripts/generate.mjs b/packages/t4-api-contract/scripts/generate.mjs index 3bc58f15..e216f71a 100644 --- a/packages/t4-api-contract/scripts/generate.mjs +++ b/packages/t4-api-contract/scripts/generate.mjs @@ -13,7 +13,7 @@ if (!new Set(["--write", "--check", "--ci-artifact"]).has(mode)) { throw new Error("usage: node scripts/generate.mjs --write|--check|--ci-artifact"); } -const generated = `${astToString(await openapiTS(schemaUrl, { alphabetize: true }))}\n`; +const generated = `${astToString(await openapiTS(schemaUrl, { alphabetize: true, defaultNonNullable: false }))}\n`; const digest = createHash("sha256").update(generated).digest("hex"); if (mode === "--write") { diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs index 4a9562eb..e3a12460 100644 --- a/packages/t4-api-contract/scripts/validate.mjs +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -29,6 +29,9 @@ for (const [schema, property] of [["Discovery", "supportedMajors"], ["Discovery" const selectedVersion = document.components?.headers?.SelectedVersion?.schema; if (selectedVersion?.pattern !== "^1\\.[0-9]+$" || selectedVersion?.maxLength !== 16) throw new Error("SelectedVersion must remain a bounded v1 minor"); +const replayHeader = document.components?.headers?.IdempotencyReplayed?.schema; +if (replayHeader?.type !== "string" || JSON.stringify(replayHeader.enum) !== '["true","false"]') throw new Error("IdempotencyReplayed must remain the exact true|false string enum"); +if (schemas.HeartbeatWatchEvent?.properties?.observedAt?.maxLength !== 64) throw new Error("HeartbeatWatchEvent.observedAt must remain bounded to 64 characters"); const selectedVersionRef = "#/components/headers/SelectedVersion"; const replayRef = "#/components/headers/IdempotencyReplayed"; const selectedVersionResponses = ["Discovery", "Workspace", "WorkspaceAccepted", "WorkspaceReplay", "WorkspacePage", "Session", "SessionAccepted", "SessionReplay", "SessionPage", "CommandAccepted", "CommandReplay", "Snapshot", "Deleted", "Error400", "Error403", "Error404", "Error406", "Error409", "Error410", "Error422", "Error503"]; From b541ed53af69f45d12098ec3b4fdcbd67c7cc2dd Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:22:07 +0000 Subject: [PATCH 32/70] chore: accept authorized API schema artifact --- packages/t4-api-client/src/generated/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts index 39321c83..45a57cbf 100755 --- a/packages/t4-api-client/src/generated/schema.ts +++ b/packages/t4-api-client/src/generated/schema.ts @@ -178,7 +178,7 @@ export interface components { CommandCreate: { command: string; /** @default {} */ - metadata: { + metadata?: { [key: string]: string | number | boolean | null; }; }; From ca908f9a064411c20d6201367c366733992b5ed6 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:24:47 +0000 Subject: [PATCH 33/70] fix: require API generation in release gate --- scripts/check-release-consistency.mjs | 3 ++- scripts/check-release-consistency.test.mjs | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 7694fc50..53b1f09d 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -968,8 +968,9 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "test -x apps/flutter/build/macos/Build/Products/Debug/t4code.app/Contents/Resources/runtime/t4-host", "name: verify", "if: ${{ always() }}", - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, t4-api-generation, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", 'test "$CHANGES_RESULT" = success', + 'test "$T4_API_GENERATION_RESULT" = success', 'test "$CORE_RESULT" = success', "for result in \\", "success|skipped) ;;", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index b049aba9..c3df7de9 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -275,7 +275,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift", ".github/workflows/ci.yml", (text) => text.replace( - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, t4-api-generation, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", "needs: [changes, core, tooling, android-debug]", ), ], @@ -580,10 +580,11 @@ test("deploys release site source only after artifact publication", () => { assert.ok(ciWorkflow.includes("if: ${{ always() }}")); assert.ok( ciWorkflow.includes( - "needs: [changes, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", + "needs: [changes, t4-api-generation, core, legacy-bridge-continuity, official-omp-gate0, cluster, tooling, android-debug, flutter, flutter-android, flutter-apple]", ), ); assert.ok(ciWorkflow.includes('test "$CHANGES_RESULT" = success')); + assert.ok(ciWorkflow.includes('test "$T4_API_GENERATION_RESULT" = success')); assert.ok(ciWorkflow.includes('test "$CORE_RESULT" = success')); assert.ok(ciWorkflow.includes("for result in \\")); assert.ok(ciWorkflow.includes("success|skipped) ;;")); From 1577e8b8868476353c4d35084f015c97ee623ff3 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:36:28 +0000 Subject: [PATCH 34/70] test: cover final strict conformance gaps --- .../client/test/t4-api-v1-conformance.test.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 93c13ca2..54dde6da 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -404,6 +404,117 @@ describe("generated T4 API v1 client conformance", () => { })).response.status).toBe(200); }); + it("validates creates before idempotency and round-trips valid labels", async () => { + const invalidLabels = [ + null, [], Object.fromEntries(Array.from({ length: 33 }, (_, index) => [`key-${index}`, "value"])), + { Invalid: "value" }, { valid: 1 }, { valid: "😀".repeat(129) }, + ]; + const workspaceService = new T4ApiV1ConformanceService(); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + const workspaceBodies: unknown[] = [{ name: "workspace", unknown: true }, ...invalidLabels.map((labels) => ({ name: "workspace", labels }))]; + for (const [index, body] of workspaceBodies.entries()) { + const key = `invalid-workspace-create-${index}`; + const invalid = await workspaceClient.http.POST("/v1/workspaces", { body: body as never, params: { header: idempotencyHeaders(key) } }); + expect(invalid.response.status).toBe(422); + expect(invalid.error).toMatchObject({ error: { code: "invalid_request" } }); + expect((await workspaceClient.http.POST("/v1/workspaces", { + body: { name: `valid-${index}` }, params: { header: idempotencyHeaders(key) }, + })).response.status).toBe(202); + } + const workspaceLabels = { team: "😀".repeat(128) }; + expect(requireData(await workspaceClient.http.POST("/v1/workspaces", { + body: { name: "labelled", labels: workspaceLabels }, params: { header: idempotencyHeaders("valid-workspace-labels") }, + }))).toMatchObject({ labels: workspaceLabels }); + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = createT4ApiClient({ baseUrl: sessionService.origin, credential: "token-a", majorVersion: 1, fetch: sessionService.fetch }); + requireData(await sessionClient.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("session-create-workspace") }, + })); + const sessionBodies: unknown[] = [{ title: "session", unknown: true }, ...invalidLabels.map((labels) => ({ title: "session", labels }))]; + for (const [index, body] of sessionBodies.entries()) { + const key = `invalid-session-create-${index}`; + const invalid = await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: body as never, params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, + }); + expect(invalid.response.status).toBe(422); + expect(invalid.error).toMatchObject({ error: { code: "invalid_request" } }); + expect((await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: `valid-${index}` }, params: { header: idempotencyHeaders(key), path: { workspaceId: "ws-1" } }, + })).response.status).toBe(202); + } + const sessionLabels = { team: "😀".repeat(128) }; + expect(requireData(await sessionClient.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "labelled", labels: sessionLabels }, params: { header: idempotencyHeaders("valid-session-labels"), path: { workspaceId: "ws-1" } }, + }))).toMatchObject({ labels: sessionLabels }); + + const commandClient = await seededClient(new T4ApiV1ConformanceService(), "command-create-validation"); + const invalidCommand = await commandClient.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: "ok", unknown: true } as never, + params: { header: idempotencyHeaders("invalid-command-create"), path: { sessionId: "ses-1" } }, + }); + expect(invalidCommand.response.status).toBe(422); + expect((await commandClient.http.POST("/v1/sessions/{sessionId}/commands", { + body: { command: "ok" }, params: { header: idempotencyHeaders("invalid-command-create"), path: { sessionId: "ses-1" } }, + })).response.status).toBe(202); + }); + + it("validates If-Match syntax before PATCH idempotency lookup", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + requireData(await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("if-match-workspace-setup") }, + })); + for (const [index, ifMatch] of [undefined, "", "0", "01", "abc", "1.0", "11111111111111111111"].entries()) { + const key = `invalid-if-match-${index}`; + const response = await service.fetch(`${service.origin}/v1/workspaces/ws-1`, { + method: "PATCH", + headers: { + Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": key, "Content-Type": "application/json", + ...(ifMatch === undefined ? {} : { "If-Match": ifMatch }), + }, + body: '{"name":"updated"}', + }); + expect(response.status).toBe(400); + expect((await response.json()) as unknown).toMatchObject({ error: { code: "invalid_request" } }); + } + const first = await client.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "updated" }, params: { header: mutationHeaders(1, "if-match-replay-order"), path: { workspaceId: "ws-1" } }, + }); + expect(first.response.status).toBe(200); + const missingOnReplay = await service.fetch(`${service.origin}/v1/workspaces/ws-1`, { + method: "PATCH", + headers: { Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": "if-match-replay-order", "Content-Type": "application/json" }, + body: '{"name":"updated"}', + }); + expect(missingOnReplay.status).toBe(400); + }); + + it("rejects invalid declared watch headers and calendar timestamps", async () => { + let cacheBodyCancelled = false; + const cacheClient = createT4ApiClient({ + baseUrl: "https://watch-cache.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode('data: {"type":"heartbeat","cursor":"cache","observedAt":"2026-07-21T00:00:00Z"}\n\n')); }, + cancel() { cacheBodyCancelled = true; }, + }), { headers: { "Content-Type": "text/event-stream", "Cache-Control": "max-age=3600" } }), + }); + await expect(cacheClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + expect(cacheBodyCancelled).toBe(true); + + for (const observedAt of [ + "2026-02-31T00:00:00Z", "2026-13-01T00:00:00Z", "2025-02-29T00:00:00Z", + "2026-04-31T00:00:00Z", "2026-01-01T24:00:00Z", "2026-01-01T00:60:00Z", + "2026-01-01T00:00:00+24:00", "2026-01-01T00:00:00+01:60", + ]) { + const calendarClient = createT4ApiClient({ + baseUrl: "https://watch-calendar.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(`data: {"type":"heartbeat","cursor":"calendar","observedAt":"${observedAt}"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(calendarClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + } + }); + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); From d31442ea46f5518e248ef9e1398b28be8d62062b Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:39:27 +0000 Subject: [PATCH 35/70] fix: validate create and watch contracts --- .../test/t4-api-v1-conformance-service.ts | 25 +++++++++++++---- .../client/test/t4-api-v1-conformance.test.ts | 6 ++++- packages/t4-api-client/src/index.ts | 27 ++++++++++++++++--- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index b29e84eb..3604a717 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -21,6 +21,14 @@ function validLabels(value: unknown): boolean { /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && hasAtMostCodePoints(item, 128)); } +function validCreate(body: Record, textField: "name" | "title"): boolean { + const keys = Object.keys(body); + if (keys.some((key) => key !== textField && key !== "labels")) return false; + const text = body[textField]; + return typeof text === "string" && text !== "" && hasAtMostCodePoints(text, 128) && + (body.labels === undefined || validLabels(body.labels)); +} + function validMutation(body: Record, textField: "name" | "title"): boolean { const keys = Object.keys(body); if (keys.length < 1 || keys.some((key) => key !== textField && key !== "labels")) return false; @@ -122,7 +130,7 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (typeof body.name !== "string" || body.name === "" || !hasAtMostCodePoints(body.name, 128)) return this.#invalid("name", "length", "name must contain 1 to 128 characters"); + if (!validCreate(body, "name")) return this.#invalid("body", "schema", "workspace create must match WorkspaceCreate"); return this.#idempotent(request, tenant, "createWorkspace", [], body, 202, 200, () => { const id = `ws-${++this.#workspaceSequence}`; const workspace = { id, name: body.name, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; @@ -154,6 +162,8 @@ export class T4ApiV1ConformanceService { if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); if (request.method === "GET") return json(200, this.#payload("workspace", this.#visible(workspace))); if (request.method === "PATCH") { + const ifMatch = request.headers.get("If-Match"); + if (ifMatch === null || !/^[1-9][0-9]{0,18}$/u.test(ifMatch)) return problem(400, "invalid_request", "If-Match is invalid"); const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; @@ -175,10 +185,10 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (typeof body.title !== "string" || body.title === "" || !hasAtMostCodePoints(body.title, 128)) return this.#invalid("title", "length", "title must contain 1 to 128 characters"); + if (!validCreate(body, "title")) return this.#invalid("body", "schema", "session create must match SessionCreate"); return this.#idempotent(request, tenant, "spawnSession", [workspaceId], body, 202, 200, () => { const id = `ses-${++this.#sessionSequence}`; - const session = { id, workspaceId, title: body.title, state: "accepted", revision: 1, tenant }; + const session = { id, workspaceId, title: body.title, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; this.#sessions.set(id, session); return session; }); @@ -207,6 +217,8 @@ export class T4ApiV1ConformanceService { if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); if (request.method === "GET") return json(200, this.#payload("session", this.#visible(session))); if (request.method === "PATCH") { + const ifMatch = request.headers.get("If-Match"); + if (ifMatch === null || !/^[1-9][0-9]{0,18}$/u.test(ifMatch)) return problem(400, "invalid_request", "If-Match is invalid"); const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; @@ -238,8 +250,11 @@ export class T4ApiV1ConformanceService { if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); const text = await request.text(); if (encoder.encode(text).byteLength > COMMAND_REQUEST_BYTES_MAX) return this.#invalid("body", "maxBytes", `request must not exceed ${COMMAND_REQUEST_BYTES_MAX} UTF-8 bytes`); - let body: Record; - try { body = JSON.parse(text) as Record; } catch { return problem(400, "invalid_request", "Malformed JSON request"); } + let decoded: unknown; + try { decoded = JSON.parse(text); } catch { return problem(400, "invalid_request", "Malformed JSON request"); } + if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) return this.#invalid("body", "schema", "command create must match CommandCreate"); + const body = decoded as Record; + if (Object.keys(body).some((key) => key !== "command" && key !== "metadata")) return this.#invalid("body", "schema", "command create must match CommandCreate"); if (typeof body.command !== "string" || body.command.length < 1 || encoder.encode(body.command).byteLength > COMMAND_BYTES_MAX) return this.#invalid("command", "maxBytes", `command must contain 1 to ${COMMAND_BYTES_MAX} UTF-8 bytes`); if (!this.#validMetadata(body.metadata)) return this.#invalid("metadata", "bounds", "metadata keys and values exceed the discovered bounds"); const states: Record = { accepted: true, rejected: true, conflict: true, unavailable: true, indeterminate: true }; diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 54dde6da..6d16db23 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -51,6 +51,7 @@ const VERSION_HEADERS = { "T4-API-Version": "1" } as const; function withSelectedVersion(init: ResponseInit = {}): ResponseInit { const headers = new Headers(init.headers); if (!headers.has("T4-API-Version")) headers.set("T4-API-Version", "1.0"); + if (headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase() === "text/event-stream" && !headers.has("Cache-Control")) headers.set("Cache-Control", "no-store"); return { ...init, headers }; } @@ -490,7 +491,7 @@ describe("generated T4 API v1 client conformance", () => { expect(missingOnReplay.status).toBe(400); }); - it("rejects invalid declared watch headers and calendar timestamps", async () => { + it("rejects an invalid declared watch cache header", async () => { let cacheBodyCancelled = false; const cacheClient = createT4ApiClient({ baseUrl: "https://watch-cache.test", credential: "token-a", majorVersion: 1, @@ -501,6 +502,9 @@ describe("generated T4 API v1 client conformance", () => { }); await expect(cacheClient.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); expect(cacheBodyCancelled).toBe(true); + }); + + it("rejects invalid RFC 3339 calendar timestamps", async () => { for (const observedAt of [ "2026-02-31T00:00:00Z", "2026-13-01T00:00:00Z", "2025-02-29T00:00:00Z", diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 1d024ed5..dbf55669 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -492,6 +492,25 @@ async function validateResponse(request: Request, response: Response, baseUrl: s } } +function validRfc3339DateTime(value: string): boolean { + if (!hasAtMostCodePoints(value, 64)) return false; + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/u.exec(value); + if (match === null) return false; + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if (month < 1 || month > 12 || hour > 23 || minute > 59 || second > 60) return false; + let maximumDay = 31; + if (month === 2) maximumDay = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; + else if (month === 4 || month === 6 || month === 9 || month === 11) maximumDay = 30; + if (day < 1 || day > maximumDay) return false; + if (second === 60 && (hour !== 23 || minute !== 59)) return false; + return match[7] === undefined || (Number(match[8]) <= 23 && Number(match[9]) <= 59); +} + function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { const event = record(value); if ( @@ -506,9 +525,7 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { if ( event.type === "heartbeat" && hasOnlyKeys(event, { type: true, cursor: true, observedAt: true }) && - typeof event.observedAt === "string" && hasAtMostCodePoints(event.observedAt, 64) && - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test(event.observedAt) && - Number.isFinite(Date.parse(event.observedAt)) + typeof event.observedAt === "string" && validRfc3339DateTime(event.observedAt) ) return event as WatchEvent; if ( event.type === "session" && @@ -706,6 +723,10 @@ async function* watch( void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned a missing or invalid selected version"); } + if (response.headers.get("Cache-Control") !== "no-store") { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API watch did not return Cache-Control: no-store"); + } if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API watch did not return text/event-stream"); From ff25fdaaee972ac01df59c9a8a5b18ec8d5d0a02 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:49:28 +0000 Subject: [PATCH 36/70] test: cover final header and precondition parity --- .../client/test/t4-api-v1-conformance.test.ts | 80 ++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 6d16db23..02637096 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -405,6 +405,41 @@ describe("generated T4 API v1 client conformance", () => { })).response.status).toBe(200); }); + it("replays exact PATCH results after an intervening revision", async () => { + const workspaceService = new T4ApiV1ConformanceService(); + const workspaceClient = createT4ApiClient({ baseUrl: workspaceService.origin, credential: "token-a", majorVersion: 1, fetch: workspaceService.fetch }); + requireData(await workspaceClient.http.POST("/v1/workspaces", { + body: { name: "original" }, params: { header: idempotencyHeaders("workspace-replay-setup") }, + })); + const originalWorkspace = requireData(await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "first" }, params: { header: mutationHeaders(1, "workspace-replay-original"), path: { workspaceId: "ws-1" } }, + })); + requireData(await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "second" }, params: { header: mutationHeaders(2, "workspace-replay-intervening"), path: { workspaceId: "ws-1" } }, + })); + const workspaceReplay = await workspaceClient.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "first" }, params: { header: mutationHeaders(1, "workspace-replay-original"), path: { workspaceId: "ws-1" } }, + }); + expect(workspaceReplay.response.status).toBe(200); + expect(workspaceReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(workspaceReplay.data).toEqual(originalWorkspace); + + const sessionService = new T4ApiV1ConformanceService(); + const sessionClient = await seededClient(sessionService, "patch-replay-intervening"); + const originalSession = requireData(await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "first" }, params: { header: mutationHeaders(1, "session-replay-original"), path: { sessionId: "ses-1" } }, + })); + requireData(await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "second" }, params: { header: mutationHeaders(2, "session-replay-intervening"), path: { sessionId: "ses-1" } }, + })); + const sessionReplay = await sessionClient.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "first" }, params: { header: mutationHeaders(1, "session-replay-original"), path: { sessionId: "ses-1" } }, + }); + expect(sessionReplay.response.status).toBe(200); + expect(sessionReplay.response.headers.get("Idempotency-Replayed")).toBe("true"); + expect(sessionReplay.data).toEqual(originalSession); + }); + it("validates creates before idempotency and round-trips valid labels", async () => { const invalidLabels = [ null, [], Object.fromEntries(Array.from({ length: 33 }, (_, index) => [`key-${index}`, "value"])), @@ -463,6 +498,20 @@ describe("generated T4 API v1 client conformance", () => { it("validates If-Match syntax before PATCH idempotency lookup", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + for (const [index, path] of ["/v1/workspaces/missing", "/v1/sessions/missing"].entries()) { + for (const ifMatch of [undefined, "invalid"]) { + const response = await service.fetch(`${service.origin}${path}`, { + method: "PATCH", + headers: { + Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": `missing-if-match-${index}-${ifMatch ?? "absent"}`, "Content-Type": "application/json", + ...(ifMatch === undefined ? {} : { "If-Match": ifMatch }), + }, + body: path.includes("workspaces") ? '{"name":"updated"}' : '{"title":"updated"}', + }); + expect(response.status).toBe(400); + expect((await response.json()) as unknown).toMatchObject({ error: { code: "invalid_request" } }); + } + } requireData(await client.http.POST("/v1/workspaces", { body: { name: "workspace" }, params: { header: idempotencyHeaders("if-match-workspace-setup") }, })); @@ -504,6 +553,20 @@ describe("generated T4 API v1 client conformance", () => { expect(cacheBodyCancelled).toBe(true); }); + it("requires no-store through the exported HTTP watch operation", async () => { + for (const cacheControl of [undefined, "max-age=3600"]) { + const client = createT4ApiClient({ + baseUrl: "https://raw-watch-cache.test", credential: "token-a", majorVersion: 1, + fetch: async () => new Response('data: {"type":"heartbeat","cursor":"raw","observedAt":"2026-07-21T00:00:00Z"}\n\n', { headers: { + "Content-Type": "text/event-stream", "T4-API-Version": "1.0", ...(cacheControl === undefined ? {} : { "Cache-Control": cacheControl }), + } }), + }); + await expect(client.http.GET("/v1/sessions/{sessionId}/events", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" }, query: { maxEvents: 1, heartbeatSeconds: 5 } }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + } + }); + it("rejects invalid RFC 3339 calendar timestamps", async () => { for (const observedAt of [ @@ -519,6 +582,14 @@ describe("generated T4 API v1 client conformance", () => { } }); + it("accepts an RFC 3339 leap second after applying its offset", async () => { + const client = createT4ApiClient({ + baseUrl: "https://watch-leap-second.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse('data: {"type":"heartbeat","cursor":"leap","observedAt":"1990-12-31T15:59:60-08:00"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "leap" } }); + }); + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); @@ -836,12 +907,17 @@ describe("generated T4 API v1 client conformance", () => { } }); - it("declares the replay response header on both conditional PATCH success responses", () => { + it("declares required public response headers while preserving the 401 exception", () => { const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { - paths: Record } }>; + paths: Record }; get?: { responses: Record }> } }>; + components: { headers: Record; responses: Record }> }; }; expect(contract.paths["/v1/workspaces/{workspaceId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/WorkspaceReplay"); expect(contract.paths["/v1/sessions/{sessionId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/SessionReplay"); + expect(contract.components.headers.SelectedVersion?.required).toBe(true); + expect(contract.components.headers.IdempotencyReplayed?.required).toBe(true); + expect(contract.paths["/v1/sessions/{sessionId}/events"]?.get?.responses["200"]?.headers?.["Cache-Control"]?.required).toBe(true); + expect(contract.components.responses.Error401?.headers?.["T4-API-Version"]).toBeUndefined(); }); it("treats malformed and oversized watch 503 envelopes as terminal protocol errors", async () => { From e0f397265e8e5d00eb71164d27db10acdaad2144 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:52:21 +0000 Subject: [PATCH 37/70] fix: align required response headers --- .../test/t4-api-v1-conformance-service.ts | 28 +++++++++++-------- packages/t4-api-client/src/index.ts | 19 +++++++++++-- packages/t4-api-contract/openapi.json | 4 ++- packages/t4-api-contract/scripts/validate.mjs | 7 +++++ 4 files changed, 44 insertions(+), 14 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 3604a717..89e08371 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -21,12 +21,12 @@ function validLabels(value: unknown): boolean { /^[a-z][a-z0-9.-]{0,62}$/u.test(key) && typeof item === "string" && hasAtMostCodePoints(item, 128)); } -function validCreate(body: Record, textField: "name" | "title"): boolean { - const keys = Object.keys(body); - if (keys.some((key) => key !== textField && key !== "labels")) return false; +function createViolation(body: Record, textField: "name" | "title"): { field: string; rule: string } | undefined { + if (Object.keys(body).some((key) => key !== textField && key !== "labels")) return { field: "body", rule: "schema" }; const text = body[textField]; - return typeof text === "string" && text !== "" && hasAtMostCodePoints(text, 128) && - (body.labels === undefined || validLabels(body.labels)); + if (typeof text !== "string" || text === "" || !hasAtMostCodePoints(text, 128)) return { field: textField, rule: "length" }; + if (body.labels !== undefined && !validLabels(body.labels)) return { field: "labels", rule: "bounds" }; + return undefined; } function validMutation(body: Record, textField: "name" | "title"): boolean { @@ -130,7 +130,8 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (!validCreate(body, "name")) return this.#invalid("body", "schema", "workspace create must match WorkspaceCreate"); + const violation = createViolation(body, "name"); + if (violation !== undefined) return this.#invalid(violation.field, violation.rule, "workspace create must match WorkspaceCreate"); return this.#idempotent(request, tenant, "createWorkspace", [], body, 202, 200, () => { const id = `ws-${++this.#workspaceSequence}`; const workspace = { id, name: body.name, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; @@ -152,6 +153,10 @@ export class T4ApiV1ConformanceService { const workspaceMatch = url.pathname.match(/^\/v1\/workspaces\/([^/]+)$/u); if (workspaceMatch) { const id = decodeURIComponent(workspaceMatch[1]!); + if (request.method === "PATCH") { + const ifMatch = request.headers.get("If-Match"); + if (ifMatch === null || !/^[1-9][0-9]{0,18}$/u.test(ifMatch)) return problem(400, "invalid_request", "If-Match is invalid"); + } const workspace = this.#workspaces.get(id); if (request.method === "DELETE") { return this.#idempotent(request, tenant, "deleteWorkspace", [id], null, 204, 204, () => { @@ -162,8 +167,6 @@ export class T4ApiV1ConformanceService { if (workspace?.tenant !== tenant) return problem(404, "not_found", "Workspace not found"); if (request.method === "GET") return json(200, this.#payload("workspace", this.#visible(workspace))); if (request.method === "PATCH") { - const ifMatch = request.headers.get("If-Match"); - if (ifMatch === null || !/^[1-9][0-9]{0,18}$/u.test(ifMatch)) return problem(400, "invalid_request", "If-Match is invalid"); const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; @@ -185,7 +188,8 @@ export class T4ApiV1ConformanceService { const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; - if (!validCreate(body, "title")) return this.#invalid("body", "schema", "session create must match SessionCreate"); + const violation = createViolation(body, "title"); + if (violation !== undefined) return this.#invalid(violation.field, violation.rule, "session create must match SessionCreate"); return this.#idempotent(request, tenant, "spawnSession", [workspaceId], body, 202, 200, () => { const id = `ses-${++this.#sessionSequence}`; const session = { id, workspaceId, title: body.title, ...(body.labels === undefined ? {} : { labels: body.labels }), state: "accepted", revision: 1, tenant }; @@ -207,6 +211,10 @@ export class T4ApiV1ConformanceService { const sessionMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)$/u); if (sessionMatch) { const id = decodeURIComponent(sessionMatch[1]!); + if (request.method === "PATCH") { + const ifMatch = request.headers.get("If-Match"); + if (ifMatch === null || !/^[1-9][0-9]{0,18}$/u.test(ifMatch)) return problem(400, "invalid_request", "If-Match is invalid"); + } const session = this.#sessions.get(id); if (request.method === "DELETE") { return this.#idempotent(request, tenant, "deleteSession", [id], null, 204, 204, () => { @@ -217,8 +225,6 @@ export class T4ApiV1ConformanceService { if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); if (request.method === "GET") return json(200, this.#payload("session", this.#visible(session))); if (request.method === "PATCH") { - const ifMatch = request.headers.get("If-Match"); - if (ifMatch === null || !/^[1-9][0-9]{0,18}$/u.test(ifMatch)) return problem(400, "invalid_request", "If-Match is invalid"); const parsed = await this.#jsonBody(request); if (parsed instanceof Response) return parsed; const body = parsed; diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index dbf55669..a5b43e1c 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -473,6 +473,10 @@ async function validateResponse(request: Request, response: Response, baseUrl: s return; } if (validator === "event-stream") { + if (response.headers.get("Cache-Control") !== "no-store") { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API watch did not return Cache-Control: no-store"); + } if (response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "text/event-stream") { void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared success media type"); @@ -507,8 +511,19 @@ function validRfc3339DateTime(value: string): boolean { if (month === 2) maximumDay = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28; else if (month === 4 || month === 6 || month === 9 || month === 11) maximumDay = 30; if (day < 1 || day > maximumDay) return false; - if (second === 60 && (hour !== 23 || minute !== 59)) return false; - return match[7] === undefined || (Number(match[8]) <= 23 && Number(match[9]) <= 59); + let offsetMinutes = 0; + if (match[7] !== undefined) { + const offsetHour = Number(match[8]); + const offsetMinute = Number(match[9]); + if (offsetHour > 23 || offsetMinute > 59) return false; + offsetMinutes = (match[7] === "+" ? 1 : -1) * (offsetHour * 60 + offsetMinute); + } + if (second < 60) return true; + const utc = new Date(0); + utc.setUTCFullYear(year, month - 1, day); + utc.setUTCHours(hour, minute - offsetMinutes, 0, 0); + return utc.getUTCHours() === 23 && utc.getUTCMinutes() === 59 && + ((utc.getUTCMonth() === 5 && utc.getUTCDate() === 30) || (utc.getUTCMonth() === 11 && utc.getUTCDate() === 31)); } function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index f4433421..5be952d5 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -319,7 +319,7 @@ "200": { "description": "Bounded SSE event stream. Every non-empty SSE data field is one JSON value conforming to WatchEvent; clients MUST reject unknown fields and schema-invalid event payloads before delivery. Transport chunk boundaries do not delimit frames.", "headers": { - "Cache-Control": { "schema": { "type": "string", "const": "no-store" } }, + "Cache-Control": { "required": true, "schema": { "type": "string", "const": "no-store" } }, "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { @@ -420,10 +420,12 @@ "headers": { "SelectedVersion": { "description": "Selected compatible T4 API minor version.", + "required": true, "schema": { "type": "string", "pattern": "^1\\.[0-9]+$", "maxLength": 16 } }, "IdempotencyReplayed": { "description": "True when this response replays a prior identical request.", + "required": true, "schema": { "type": "string", "enum": ["true", "false"] } } }, diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs index e3a12460..d43c7e84 100644 --- a/packages/t4-api-contract/scripts/validate.mjs +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -27,6 +27,10 @@ for (const [schema, property] of [["Discovery", "supportedMajors"], ["Discovery" if (schemas[schema]?.properties?.[property]?.uniqueItems !== true) throw new Error(`${schema}.${property} must retain uniqueItems`); } +const selectedVersionHeader = document.components?.headers?.SelectedVersion; +const replayHeaderObject = document.components?.headers?.IdempotencyReplayed; +if (selectedVersionHeader?.required !== true) throw new Error("SelectedVersion must remain required wherever referenced"); +if (replayHeaderObject?.required !== true) throw new Error("IdempotencyReplayed must remain required wherever referenced"); const selectedVersion = document.components?.headers?.SelectedVersion?.schema; if (selectedVersion?.pattern !== "^1\\.[0-9]+$" || selectedVersion?.maxLength !== 16) throw new Error("SelectedVersion must remain a bounded v1 minor"); const replayHeader = document.components?.headers?.IdempotencyReplayed?.schema; @@ -42,6 +46,9 @@ for (const responseName of ["WorkspaceAccepted", "WorkspaceReplay", "SessionAcce if (document.components?.responses?.[responseName]?.headers?.["Idempotency-Replayed"]?.$ref !== replayRef) throw new Error(`${responseName} must declare IdempotencyReplayed`); } if (document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error("watch success must declare SelectedVersion"); +const watchCacheControl = document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.headers?.["Cache-Control"]; +if (watchCacheControl?.required !== true || watchCacheControl?.schema?.const !== "no-store") throw new Error("watch success must require Cache-Control: no-store"); +if (document.components?.responses?.Error401?.headers?.["T4-API-Version"] !== undefined) throw new Error("Error401 must omit SelectedVersion"); if (document.paths?.["/v1/workspaces/{workspaceId}"]?.patch?.responses?.["200"]?.$ref !== "#/components/responses/WorkspaceReplay") throw new Error("workspace PATCH success must declare replay headers"); if (document.paths?.["/v1/sessions/{sessionId}"]?.patch?.responses?.["200"]?.$ref !== "#/components/responses/SessionReplay") throw new Error("session PATCH success must declare replay headers"); const errorResponses = { 400: "Error400", 401: "Error401", 403: "Error403", 404: "Error404", 406: "Error406", 409: "Error409", 410: "Error410", 422: "Error422", 503: "Error503" }; From a0c3a08006665575260f67c598dc3606697ad5f2 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Tue, 21 Jul 2026 23:54:30 +0000 Subject: [PATCH 38/70] chore: accept required-header schema artifact --- packages/t4-api-client/src/generated/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts index 45a57cbf..4cdd50b9 100755 --- a/packages/t4-api-client/src/generated/schema.ts +++ b/packages/t4-api-client/src/generated/schema.ts @@ -799,7 +799,7 @@ export interface operations { /** @description Bounded SSE event stream. Every non-empty SSE data field is one JSON value conforming to WatchEvent; clients MUST reject unknown fields and schema-invalid event payloads before delivery. Transport chunk boundaries do not delimit frames. */ 200: { headers: { - "Cache-Control"?: "no-store"; + "Cache-Control": "no-store"; "T4-API-Version": components["headers"]["SelectedVersion"]; [name: string]: unknown; }; From d71383f17ded91bbeda9e31b7889209b58945258 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 00:08:56 +0000 Subject: [PATCH 39/70] test(t4-api): cover final strict response gaps --- .../client/test/t4-api-v1-conformance.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 02637096..5065950e 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -590,6 +590,48 @@ describe("generated T4 API v1 client conformance", () => { await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "leap" } }); }); + it("accepts lowercase RFC 3339 delimiters", async () => { + const client = createT4ApiClient({ + baseUrl: "https://watch-lowercase-time.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse('data: {"type":"heartbeat","cursor":"lowercase","observedAt":"2026-07-21t00:00:00z"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { cursor: "lowercase" } }); + }); + + it("uses typed low-level stream parsing and the SSE Accept header", async () => { + const service = new T4ApiV1ConformanceService(); + const client = await seededClient(service, "raw-stream"); + const wrongAccept = await service.fetch(`${service.origin}/v1/sessions/ses-1/events?maxEvents=1&heartbeatSeconds=5`, { + headers: { Authorization: "Bearer token-a", "T4-API-Version": "1", Accept: "application/json" }, + }); + expect(wrongAccept.status).toBe(406); + const streamed = await client.http.GET("/v1/sessions/{sessionId}/events", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" }, query: { maxEvents: 1, heartbeatSeconds: 5 } }, + parseAs: "stream", + }); + expect(streamed.response.headers.get("Content-Type")).toBe("text/event-stream"); + expect(streamed.response.headers.get("Cache-Control")).toBe("no-store"); + expect(streamed.data).toBeInstanceOf(ReadableStream); + await streamed.data?.cancel(); + }); + + it("rejects malformed present Retry-After on ordinary and watch 503 responses", async () => { + for (const retryAfter of ["", "not-a-date", "-1", "x".repeat(129)]) { + const body = { error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }; + const ordinary = createT4ApiClient({ + baseUrl: "https://ordinary-retry-after.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(body, { status: 503, headers: { "Retry-After": retryAfter } }), + }); + await expect(ordinary.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, retryable: false }); + + const watch = createT4ApiClient({ + baseUrl: "https://watch-retry-after.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(body, { status: 503, headers: { "Retry-After": retryAfter } }), + }); + await expect(watch.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); + } + }); + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); From deff712ad320d592913115199637a095f3a7b288 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 00:15:53 +0000 Subject: [PATCH 40/70] fix(t4-api): close strict response gaps --- .../test/t4-api-v1-conformance-service.ts | 1 + .../client/test/t4-api-v1-conformance.test.ts | 14 +++++ packages/t4-api-client/src/index.ts | 60 +++++++++++++++++-- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 89e08371..4c288abd 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -277,6 +277,7 @@ export class T4ApiV1ConformanceService { const watchMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/events$/u); if (watchMatch && request.method === "GET") { + if (request.headers.get("Accept") !== "text/event-stream") return problem(406, "incompatible_version", "Watch requests must accept text/event-stream", { supportedMajors: [1] }); const id = decodeURIComponent(watchMatch[1]!); const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 5065950e..9c847949 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -632,6 +632,20 @@ describe("generated T4 API v1 client conformance", () => { } }); + it("accepts every RFC 9110 HTTP-date form in Retry-After", async () => { + for (const retryAfter of [ + "Sun, 06 Nov 1994 08:49:37 GMT", + "Sunday, 06-Nov-94 08:49:37 GMT", + "Sun Nov 6 08:49:37 1994", + ]) { + const client = createT4ApiClient({ + baseUrl: "https://http-date-retry-after.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": retryAfter } }), + }); + await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).resolves.toMatchObject({ error: { error: { code: "unavailable" } } }); + } + }); + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index a5b43e1c..3d2fcd8a 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -10,6 +10,16 @@ const MAX_EVENT_BYTES = 1024 * 1024; const MAX_JSON_RESPONSE_BYTES = 16 * 1024 * 1024; const CURSOR_PATTERN = /^[A-Za-z0-9._~-]+$/u; const SELECTED_VERSION_MAX_LENGTH = 16; +const INVALID_RETRY_AFTER = Symbol("invalid Retry-After"); +const HTTP_MONTHS: Readonly> = { + Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, + Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11, +}; +const HTTP_WEEKDAYS: Readonly> = { + Sun: 0, Sunday: 0, Mon: 1, Monday: 1, Tue: 2, Tuesday: 2, + Wed: 3, Wednesday: 3, Thu: 4, Thursday: 4, Fri: 5, Friday: 5, + Sat: 6, Saturday: 6, +}; const SELECTED_VERSION_PATTERN = /^1\.[0-9]+$/u; const ERROR_CODES = { invalid_request: true, unauthenticated: true, forbidden: true, not_found: true, @@ -65,6 +75,10 @@ export interface WatchSessionOptions { } export interface T4ApiClient { + /** + * Low-level generated client. Event-stream calls must pass `parseAs: "stream"`; + * use `watchSession` for bounded, validated event decoding and reconnects. + */ readonly http: Client; watchSession(sessionId: string, options?: WatchSessionOptions): AsyncGenerator; } @@ -217,12 +231,43 @@ async function boundedResponseText(response: Response, maximumBytes = MAX_ERROR_ try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return undefined; } } -function retryAfterMilliseconds(response: Response): number | undefined { - const value = response.headers.get("Retry-After")?.trim(); - if (value === undefined || value === "") return undefined; - if (/^[0-9]+$/u.test(value)) return Math.min(30_000, Number(value) * 1000); +function matchingHttpDate( + timestamp: number, + parts: RegExpExecArray, + indexes: { readonly weekday: number; readonly day: number; readonly alternateDay?: number; readonly month: number; readonly year: number; readonly hour: number; readonly minute: number; readonly second: number }, + shortYear = false, +): boolean { + const date = new Date(timestamp); + const year = Number(parts[indexes.year]); + return date.getUTCDay() === HTTP_WEEKDAYS[parts[indexes.weekday]!] && + date.getUTCDate() === Number(parts[indexes.day] ?? parts[indexes.alternateDay ?? indexes.day]) && + date.getUTCMonth() === HTTP_MONTHS[parts[indexes.month]!] && + (shortYear ? date.getUTCFullYear() % 100 === year : date.getUTCFullYear() === year) && + date.getUTCHours() === Number(parts[indexes.hour]) && + date.getUTCMinutes() === Number(parts[indexes.minute]) && + date.getUTCSeconds() === Number(parts[indexes.second]); +} + +function httpDateTimestamp(value: string): number | undefined { const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) return undefined; + const imf = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), ([0-9]{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-9]{4}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/u.exec(value); + if (imf !== null && matchingHttpDate(timestamp, imf, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 })) return timestamp; + const rfc850 = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), ([0-9]{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/u.exec(value); + if (rfc850 !== null && matchingHttpDate(timestamp, rfc850, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 }, true)) return timestamp; + const asctime = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?:([0-9]{2})| ([0-9])) ([0-9]{2}):([0-9]{2}):([0-9]{2}) ([0-9]{4})$/u.exec(value); + if (asctime === null) return undefined; + return matchingHttpDate(timestamp, asctime, { weekday: 1, day: 3, alternateDay: 4, month: 2, year: 8, hour: 5, minute: 6, second: 7 }) ? timestamp : undefined; +} + +function retryAfterMilliseconds(response: Response): number | undefined | typeof INVALID_RETRY_AFTER { + const header = response.headers.get("Retry-After"); + if (header === null) return undefined; + const value = header.trim(); + if (value === "" || !hasAtMostCodePoints(value, 128)) return INVALID_RETRY_AFTER; + if (/^[0-9]+$/u.test(value)) return Math.min(30_000, Number(value) * 1000); + const timestamp = httpDateTimestamp(value); + if (timestamp === undefined) return INVALID_RETRY_AFTER; return Math.min(30_000, Math.max(0, timestamp - Date.now())); } @@ -247,6 +292,7 @@ async function parsedError(response: Response): Promise const decoded = apiError(JSON.parse(text), response.status); if (decoded === undefined) return undefined; const retryAfterMs = retryAfterMilliseconds(response); + if (retryAfterMs === INVALID_RETRY_AFTER) return undefined; return retryAfterMs === undefined ? new T4ApiError(response.status, decoded) : new T4ApiError(response.status, decoded, { retryAfterMs }); @@ -498,7 +544,7 @@ async function validateResponse(request: Request, response: Response, baseUrl: s function validRfc3339DateTime(value: string): boolean { if (!hasAtMostCodePoints(value, 64)) return false; - const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/u.exec(value); + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/iu.exec(value); if (match === null) return false; const year = Number(match[1]); const month = Number(match[2]); @@ -800,7 +846,9 @@ export function createT4ApiClient(options: T4ApiClientOptions): T4ApiClient { const request = new Request(input, init); request.headers.set("Authorization", `Bearer ${credential}`); request.headers.set("T4-API-Version", majorVersion); - request.headers.set("Accept", "application/json"); + const path = relativeApiPath(request, baseUrl); + const contract = path === undefined ? undefined : responseContract(request.method, path); + request.headers.set("Accept", contract?.success[200] === "event-stream" ? "text/event-stream" : "application/json"); const response = await fetchImpl(request); await validateResponse(request, response, baseUrl); return response; From 6ca35ab37e2556e3c313ad3f8361110499111f83 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 00:25:48 +0000 Subject: [PATCH 41/70] test(t4-api): isolate watch query validation --- packages/client/test/t4-api-v1-conformance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 9c847949..68d20b67 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -724,7 +724,7 @@ describe("generated T4 API v1 client conformance", () => { expect(await response.json()).toMatchObject({ error: { code: "invalid_request" } }); } for (const query of ["maxEvents=0", "maxEvents=5", "heartbeatSeconds=4", "heartbeatSeconds=61"]) { - const response = await service.fetch(`${service.origin}/v1/sessions/ses-1/events?${query}`, { headers: baseHeaders }); + const response = await service.fetch(`${service.origin}/v1/sessions/ses-1/events?${query}`, { headers: { ...baseHeaders, Accept: "text/event-stream" } }); expect(response.status).toBe(422); expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: expect.any(Array) } }); } From 643de6e9cb4f4218f2d20ae12cd76040bf5527ce Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 00:37:01 +0000 Subject: [PATCH 42/70] test(t4-api): pin leap dates and RFC850 pivot --- .../client/test/t4-api-v1-conformance.test.ts | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 68d20b67..22b1e29a 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -573,6 +573,7 @@ describe("generated T4 API v1 client conformance", () => { "2026-02-31T00:00:00Z", "2026-13-01T00:00:00Z", "2025-02-29T00:00:00Z", "2026-04-31T00:00:00Z", "2026-01-01T24:00:00Z", "2026-01-01T00:60:00Z", "2026-01-01T00:00:00+24:00", "2026-01-01T00:00:00+01:60", + "2026-06-30T23:59:60Z", "2026-12-31T23:59:60Z", "2026-06-30T15:59:60-08:00", ]) { const calendarClient = createT4ApiClient({ baseUrl: "https://watch-calendar.test", credential: "token-a", majorVersion: 1, @@ -632,7 +633,7 @@ describe("generated T4 API v1 client conformance", () => { } }); - it("accepts every RFC 9110 HTTP-date form in Retry-After", async () => { + it("accepts every RFC 9110 HTTP-date form in Retry-After without dropping its delay", async () => { for (const retryAfter of [ "Sun, 06 Nov 1994 08:49:37 GMT", "Sunday, 06-Nov-94 08:49:37 GMT", @@ -642,7 +643,25 @@ describe("generated T4 API v1 client conformance", () => { baseUrl: "https://http-date-retry-after.test", credential: "token-a", majorVersion: 1, fetch: async () => jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": retryAfter } }), }); - await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).resolves.toMatchObject({ error: { error: { code: "unavailable" } } }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + status: 502, cause: { status: 503, retryAfterMs: 0 }, + }); + } + }); + + it("resolves RFC 850 two-digit years relative to the current year", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-07-21T00:00:00Z")); + const client = createT4ApiClient({ + baseUrl: "https://rfc850-year.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "Saturday, 01-Jan-50 00:00:00 GMT" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + status: 502, cause: { status: 503, retryAfterMs: 30_000 }, + }); + } finally { + vi.useRealTimers(); } }); From 451b3271414c7b0b413a6b3de2c6985520ae6e46 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 00:41:02 +0000 Subject: [PATCH 43/70] fix(t4-api): validate announced leap seconds --- packages/t4-api-client/src/index.ts | 43 +++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 3d2fcd8a..2cf5ea93 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -235,29 +235,39 @@ function matchingHttpDate( timestamp: number, parts: RegExpExecArray, indexes: { readonly weekday: number; readonly day: number; readonly alternateDay?: number; readonly month: number; readonly year: number; readonly hour: number; readonly minute: number; readonly second: number }, - shortYear = false, + expectedYear = Number(parts[indexes.year]), ): boolean { const date = new Date(timestamp); - const year = Number(parts[indexes.year]); return date.getUTCDay() === HTTP_WEEKDAYS[parts[indexes.weekday]!] && date.getUTCDate() === Number(parts[indexes.day] ?? parts[indexes.alternateDay ?? indexes.day]) && date.getUTCMonth() === HTTP_MONTHS[parts[indexes.month]!] && - (shortYear ? date.getUTCFullYear() % 100 === year : date.getUTCFullYear() === year) && + date.getUTCFullYear() === expectedYear && date.getUTCHours() === Number(parts[indexes.hour]) && date.getUTCMinutes() === Number(parts[indexes.minute]) && date.getUTCSeconds() === Number(parts[indexes.second]); } function httpDateTimestamp(value: string): number | undefined { - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return undefined; const imf = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), ([0-9]{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([0-9]{4}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/u.exec(value); - if (imf !== null && matchingHttpDate(timestamp, imf, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 })) return timestamp; + if (imf !== null) { + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && matchingHttpDate(timestamp, imf, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 }) ? timestamp : undefined; + } const rfc850 = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), ([0-9]{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/u.exec(value); - if (rfc850 !== null && matchingHttpDate(timestamp, rfc850, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 }, true)) return timestamp; + if (rfc850 !== null) { + const currentYear = new Date(Date.now()).getUTCFullYear(); + let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[4]); + if (year - currentYear > 50) year -= 100; + const date = new Date(0); + date.setUTCFullYear(year, HTTP_MONTHS[rfc850[3]!]!, Number(rfc850[2])); + date.setUTCHours(Number(rfc850[5]), Number(rfc850[6]), Number(rfc850[7]), 0); + const timestamp = date.getTime(); + return matchingHttpDate(timestamp, rfc850, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 }, year) ? timestamp : undefined; + } const asctime = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?:([0-9]{2})| ([0-9])) ([0-9]{2}):([0-9]{2}):([0-9]{2}) ([0-9]{4})$/u.exec(value); if (asctime === null) return undefined; - return matchingHttpDate(timestamp, asctime, { weekday: 1, day: 3, alternateDay: 4, month: 2, year: 8, hour: 5, minute: 6, second: 7 }) ? timestamp : undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) && matchingHttpDate(timestamp, asctime, { weekday: 1, day: 3, alternateDay: 4, month: 2, year: 8, hour: 5, minute: 6, second: 7 }) ? timestamp : undefined; } function retryAfterMilliseconds(response: Response): number | undefined | typeof INVALID_RETRY_AFTER { @@ -542,6 +552,21 @@ async function validateResponse(request: Request, response: Response, baseUrl: s } } +function isKnownUtcLeapSecondDate(year: number, month: number, day: number): boolean { + // Update this IERS insertion-date table when a new UTC leap second is announced. + switch (year * 10_000 + month * 100 + day) { + case 19720630: case 19721231: case 19731231: case 19741231: case 19751231: + case 19761231: case 19771231: case 19781231: case 19791231: case 19810630: + case 19820630: case 19830630: case 19850630: case 19871231: case 19891231: + case 19901231: case 19920630: case 19930630: case 19940630: case 19951231: + case 19970630: case 19981231: case 20051231: case 20081231: case 20120630: + case 20150630: case 20161231: + return true; + default: + return false; + } +} + function validRfc3339DateTime(value: string): boolean { if (!hasAtMostCodePoints(value, 64)) return false; const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/iu.exec(value); @@ -569,7 +594,7 @@ function validRfc3339DateTime(value: string): boolean { utc.setUTCFullYear(year, month - 1, day); utc.setUTCHours(hour, minute - offsetMinutes, 0, 0); return utc.getUTCHours() === 23 && utc.getUTCMinutes() === 59 && - ((utc.getUTCMonth() === 5 && utc.getUTCDate() === 30) || (utc.getUTCMonth() === 11 && utc.getUTCDate() === 31)); + isKnownUtcLeapSecondDate(utc.getUTCFullYear(), utc.getUTCMonth() + 1, utc.getUTCDate()); } function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { From 842395866a1cac5780a62e038ec3a18fc90f28a7 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 00:56:13 +0000 Subject: [PATCH 44/70] test(t4-api): require declared request media --- .../client/test/t4-api-v1-conformance.test.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 22b1e29a..2d86037f 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -495,6 +495,35 @@ describe("generated T4 API v1 client conformance", () => { })).response.status).toBe(202); }); + it("rejects undeclared JSON request media before mutation and idempotency", async () => { + for (const contentType of [undefined, "text/plain"]) { + const service = new T4ApiV1ConformanceService(); + await seededClient(service, `request-media-${contentType ?? "missing"}`); + const cases = [ + ["POST", "/v1/workspaces", '{"name":"media-workspace"}', {}, 202, { id: "ws-2" }], + ["POST", "/v1/workspaces/ws-1/sessions", '{"title":"media-session"}', {}, 202, { id: "ses-2" }], + ["PATCH", "/v1/workspaces/ws-1", '{"name":"renamed"}', { "If-Match": "1" }, 200, { revision: 2 }], + ["PATCH", "/v1/sessions/ses-1", '{"title":"retitled"}', { "If-Match": "1" }, 200, { revision: 2 }], + ["POST", "/v1/sessions/ses-1/commands", '{"command":"ok"}', {}, 202, { commandId: "cmd-1" }], + ] as const; + for (const [index, [method, path, body, routeHeaders, successStatus, successBody]] of cases.entries()) { + const key = `request-media-${contentType ?? "missing"}-${index}`; + const headers = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": key, ...routeHeaders }; + const invalid = await service.fetch(`${service.origin}${path}`, { + method, headers: { ...headers, ...(contentType === undefined ? {} : { "Content-Type": contentType }) }, body, + }); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toMatchObject({ error: { code: "invalid_request" } }); + const valid = await service.fetch(`${service.origin}${path}`, { + method, headers: { ...headers, "Content-Type": "Application/JSON; Charset=UTF-8" }, body, + }); + expect(valid.status).toBe(successStatus); + expect(valid.headers.get("Idempotency-Replayed")).toBe("false"); + expect(await valid.json()).toMatchObject(successBody); + } + } + }); + it("validates If-Match syntax before PATCH idempotency lookup", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); @@ -665,6 +694,22 @@ describe("generated T4 API v1 client conformance", () => { } }); + it("applies the RFC 850 pivot to the complete timestamp", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-07-21T00:00:00Z")); + const client = createT4ApiClient({ + baseUrl: "https://rfc850-boundary.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ error: { code: "unavailable", message: "later", requestId: "r", retryable: true } }, { status: 503, headers: { "Retry-After": "Friday, 31-Dec-76 00:00:00 GMT" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + status: 502, cause: { status: 503, retryAfterMs: 0 }, + }); + } finally { + vi.useRealTimers(); + } + }); + it("takes a snapshot and watches bounded SSE with heartbeat, reconnect, cancellation, and typed resync", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); From 01a49d63b572a07cbc513727ed895ce3192ebefc Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 00:59:26 +0000 Subject: [PATCH 45/70] fix(t4-api): enforce JSON request media --- packages/client/test/t4-api-v1-conformance-service.ts | 9 +++++++++ packages/t4-api-client/src/index.ts | 11 ++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 4c288abd..03599b26 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -254,6 +254,8 @@ export class T4ApiV1ConformanceService { const id = decodeURIComponent(commandMatch[1]!); const session = this.#sessions.get(id); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + const mediaError = this.#jsonMediaError(request); + if (mediaError !== undefined) return mediaError; const text = await request.text(); if (encoder.encode(text).byteLength > COMMAND_REQUEST_BYTES_MAX) return this.#invalid("body", "maxBytes", `request must not exceed ${COMMAND_REQUEST_BYTES_MAX} UTF-8 bytes`); let decoded: unknown; @@ -332,7 +334,14 @@ export class T4ApiV1ConformanceService { expect(this.calls.every((call) => call.authorization === "Bearer token-a" || call.authorization === "Bearer token-b" || call.authorization === "Bearer token-denied")).toBe(true); } + #jsonMediaError(request: Request): Response | undefined { + const mediaType = request.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase(); + return mediaType === "application/json" ? undefined : problem(400, "invalid_request", "Content-Type must be application/json"); + } + async #jsonBody(request: Request): Promise | Response> { + const mediaError = this.#jsonMediaError(request); + if (mediaError !== undefined) return mediaError; try { const value: unknown = await request.json(); if (value === null || typeof value !== "object" || Array.isArray(value)) return problem(400, "invalid_request", "JSON request body must be an object"); diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 2cf5ea93..b639ff5c 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -255,12 +255,17 @@ function httpDateTimestamp(value: string): number | undefined { } const rfc850 = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), ([0-9]{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-([0-9]{2}) ([0-9]{2}):([0-9]{2}):([0-9]{2}) GMT$/u.exec(value); if (rfc850 !== null) { - const currentYear = new Date(Date.now()).getUTCFullYear(); - let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[4]); - if (year - currentYear > 50) year -= 100; + const now = new Date(Date.now()); + let year = Math.floor(now.getUTCFullYear() / 100) * 100 + Number(rfc850[4]); const date = new Date(0); date.setUTCFullYear(year, HTTP_MONTHS[rfc850[3]!]!, Number(rfc850[2])); date.setUTCHours(Number(rfc850[5]), Number(rfc850[6]), Number(rfc850[7]), 0); + const fiftyYearsFromNow = new Date(now.getTime()); + fiftyYearsFromNow.setUTCFullYear(now.getUTCFullYear() + 50); + if (date.getTime() > fiftyYearsFromNow.getTime()) { + year -= 100; + date.setUTCFullYear(year); + } const timestamp = date.getTime(); return matchingHttpDate(timestamp, rfc850, { weekday: 1, day: 2, month: 3, year: 4, hour: 5, minute: 6, second: 7 }, year) ? timestamp : undefined; } From 681f6db44e0200d0fffbdd23429138df86f2e673 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 01:03:26 +0000 Subject: [PATCH 46/70] test(t4-api): sanitize media fixture keys --- packages/client/test/t4-api-v1-conformance.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 2d86037f..53b0f26c 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -497,8 +497,9 @@ describe("generated T4 API v1 client conformance", () => { it("rejects undeclared JSON request media before mutation and idempotency", async () => { for (const contentType of [undefined, "text/plain"]) { + const mediaName = contentType === undefined ? "missing" : "plain"; const service = new T4ApiV1ConformanceService(); - await seededClient(service, `request-media-${contentType ?? "missing"}`); + await seededClient(service, `request-media-${mediaName}`); const cases = [ ["POST", "/v1/workspaces", '{"name":"media-workspace"}', {}, 202, { id: "ws-2" }], ["POST", "/v1/workspaces/ws-1/sessions", '{"title":"media-session"}', {}, 202, { id: "ses-2" }], @@ -507,7 +508,7 @@ describe("generated T4 API v1 client conformance", () => { ["POST", "/v1/sessions/ses-1/commands", '{"command":"ok"}', {}, 202, { commandId: "cmd-1" }], ] as const; for (const [index, [method, path, body, routeHeaders, successStatus, successBody]] of cases.entries()) { - const key = `request-media-${contentType ?? "missing"}-${index}`; + const key = `request-media-${mediaName}-${index}`; const headers = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Idempotency-Key": key, ...routeHeaders }; const invalid = await service.fetch(`${service.origin}${path}`, { method, headers: { ...headers, ...(contentType === undefined ? {} : { "Content-Type": contentType }) }, body, From 2df2da47bb7457e9e14323b7cff2fe7e32a42b97 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:03:27 +0000 Subject: [PATCH 47/70] test(t4-api): bound resync session ids --- .../client/test/t4-api-v1-conformance.test.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 53b0f26c..148982db 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -754,6 +754,34 @@ describe("generated T4 API v1 client conformance", () => { } satisfies Partial); }); + it("enforces the ResourceId bound in cursor-expired resync snapshot URLs", async () => { + const resourceId128 = `s${"a".repeat(127)}`; + const resourceId129 = `s${"a".repeat(128)}`; + const cursorExpired = (sessionId: string) => ({ error: { + code: "cursor_expired", message: "expired", requestId: "r", retryable: false, + resync: { snapshotUrl: `/v1/sessions/${sessionId}/snapshot`, cursor: "cursor-1" }, + } }); + + for (const [sessionId, expected] of [[resourceId128, "cursor_expired"], [resourceId129, "indeterminate"]] as const) { + const client = createT4ApiClient({ + baseUrl: "https://resync-bound.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(cursorExpired(sessionId), { status: 410 }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: expected, status: expected === "cursor_expired" ? 410 : 502, + }); + } + + const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { + components: { schemas: { Resync: { properties: { snapshotUrl: { pattern: string; maxLength: number } } } } }; + }; + const snapshotUrlSchema = contract.components.schemas.Resync.properties.snapshotUrl; + const pattern = new RegExp(snapshotUrlSchema.pattern, "u"); + expect(pattern.test(`/v1/sessions/${resourceId128}/snapshot`)).toBe(true); + expect(pattern.test(`/v1/sessions/${resourceId129}/snapshot`)).toBe(false); + expect(`/v1/sessions/${resourceId128}/snapshot`.length).toBeLessThanOrEqual(snapshotUrlSchema.maxLength); + }); + it("rejects malformed mutation idempotency and watch query contracts", async () => { const service = new T4ApiV1ConformanceService(); const baseHeaders = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; From 319ccf1b71ac82a30d5d0e7cd431b018dfab969f Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:13:47 +0000 Subject: [PATCH 48/70] fix(t4-api): bound resync session ids --- packages/t4-api-client/src/index.ts | 5 +++-- packages/t4-api-contract/openapi.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index b639ff5c..693a9feb 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -177,8 +177,9 @@ function validViolation(value: unknown): boolean { function validResync(value: unknown): value is Resync { const resync = record(value); - return resync !== undefined && Object.keys(resync).every((key) => key === "snapshotUrl" || key === "cursor") && - typeof resync.snapshotUrl === "string" && hasAtMostCodePoints(resync.snapshotUrl, 512) && /^\/v1\/sessions\/[A-Za-z0-9._~-]+\/snapshot$/u.test(resync.snapshotUrl) && + if (resync === undefined || Object.keys(resync).some((key) => key !== "snapshotUrl" && key !== "cursor") || typeof resync.snapshotUrl !== "string") return false; + const snapshot = /^\/v1\/sessions\/([^/]+)\/snapshot$/u.exec(resync.snapshotUrl); + return hasAtMostCodePoints(resync.snapshotUrl, 150) && snapshot !== null && validResourceId(snapshot[1]) && typeof resync.cursor === "string" && hasAtMostCodePoints(resync.cursor, 512) && CURSOR_PATTERN.test(resync.cursor); } diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index 5be952d5..8481901e 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -652,7 +652,7 @@ "additionalProperties": false, "required": ["snapshotUrl", "cursor"], "properties": { - "snapshotUrl": { "type": "string", "pattern": "^/v1/sessions/[A-Za-z0-9._~-]+/snapshot$", "maxLength": 512 }, + "snapshotUrl": { "type": "string", "pattern": "^/v1/sessions/[A-Za-z0-9][A-Za-z0-9._~-]{0,127}/snapshot$", "maxLength": 150 }, "cursor": { "$ref": "#/components/schemas/Cursor" } } }, From 32aab1e76a24e8adc82e50dc0d714be412c8d435 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:32:39 +0000 Subject: [PATCH 49/70] test(t4-api): require truthful mutation contracts --- .../client/test/t4-api-v1-conformance.test.ts | 157 +++++++++++++++--- 1 file changed, 138 insertions(+), 19 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 148982db..472be3e2 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -48,6 +48,25 @@ function exhaustWatchEvent(event: WatchEvent): string { const VERSION_HEADERS = { "T4-API-Version": "1" } as const; +const COMMAND_STATES = [ + "accepted", "projected", "dispatching", "running", "succeeded", "failed", + "cancelling", "cancelled", "rejected", "unavailable", "indeterminate", +] as const; + +const DISCOVERY = { + apiVersion: "1.0", + serverBuild: { version: "0.1.30", revision: "fixture-revision-1" }, + supportedMajors: [1], + capabilities: { + "workspace.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "optional.preview": { + supported: true, enabled: false, authorized: false, available: false, + deprecation: { message: "Use workspace.lifecycle", sinceVersion: "1.0", sunsetAt: "2027-01-01T00:00:00Z", replacement: "workspace.lifecycle" }, + }, + }, + limits: { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsMax: 1, heartbeatSeconds: 5 }, +} as const; + function withSelectedVersion(init: ResponseInit = {}): ResponseInit { const headers = new Headers(init.headers); if (!headers.has("T4-API-Version")) headers.set("T4-API-Version", "1.0"); @@ -71,6 +90,12 @@ function mutationHeaders(revision: number, key: string): Readonly<{ "T4-API-Vers return { ...VERSION_HEADERS, "If-Match": String(revision), "Idempotency-Key": key }; } +function requireEventCursor(response: Response): string { + const cursor = response.headers.get("T4-Event-Cursor"); + expect(cursor).toMatch(/^[A-Za-z0-9._~-]+$/u); + return cursor ?? ""; +} + function requireData(result: { data?: T; error?: unknown }): T { expect(result.error).toBeUndefined(); expect(result.data).toBeDefined(); @@ -89,14 +114,18 @@ async function seededClient(service: T4ApiV1ConformanceService, key: string) { } describe("generated T4 API v1 client conformance", () => { - it("negotiates discovery, capabilities, bounds, and rejects an incompatible major", async () => { + it("negotiates truthful discovery metadata and rejects an incompatible major", async () => { const service = new T4ApiV1ConformanceService(); const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); const discovery = requireData(await client.http.GET("/v1", { params: { header: VERSION_HEADERS } })); expect(discovery).toMatchObject({ apiVersion: "1.0", + serverBuild: { version: "0.1.30", revision: "fixture-revision-1" }, supportedMajors: [1], - capabilities: expect.arrayContaining(["workspace.lifecycle", "session.watch.sse"]), + capabilities: { + "workspace.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "optional.preview": { supported: true, enabled: false, authorized: false, available: false }, + }, limits: { pageSizeMax: 3, commandBytesMax: 32, watchEventsMax: 4, heartbeatSeconds: 15 }, }); @@ -104,7 +133,10 @@ describe("generated T4 API v1 client conformance", () => { const rejected = await incompatible.http.GET("/v1", { params: { header: VERSION_HEADERS } }); expect(rejected.response.status).toBe(406); expect(rejected.error).toMatchObject({ error: { code: "incompatible_version", retryable: false, supportedMajors: [1] } }); - expect(typedErrors.unauthorized.error.code).toBe("unauthenticated"); + + const unauthenticated = await service.fetch(`${service.origin}/v1`, { headers: { "T4-API-Version": "1" } }); + expect(unauthenticated.status).toBe(401); + expect(await unauthenticated.json()).toMatchObject({ error: { code: "unauthenticated", retryable: false } }); const denied = createT4ApiClient({ baseUrl: service.origin, credential: "token-denied", majorVersion: 1, fetch: service.fetch }); const forbidden = await denied.http.GET("/v1", { params: { header: VERSION_HEADERS } }); @@ -291,7 +323,7 @@ describe("generated T4 API v1 client conformance", () => { expect(stale.response.status).toBe(409); expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); - for (const state of ["accepted", "rejected", "conflict", "unavailable", "indeterminate"] as const) { + for (const state of COMMAND_STATES) { const command = { command: state, metadata: {} } satisfies CommandCreate; const result = await client.http.POST("/v1/sessions/{sessionId}/commands", { params: { header: idempotencyHeaders(`command-${state}-0001`), path: { sessionId: "ses-1" } }, body: command, @@ -338,6 +370,53 @@ describe("generated T4 API v1 client conformance", () => { expect(cancelled.data).toMatchObject({ state: "cancelled" }); }); + it("returns one durable event cursor for each committed mutation and its replay", async () => { + const service = new T4ApiV1ConformanceService(); + const client = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 1, fetch: service.fetch }); + + const workspaceFirst = await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("cursor-workspace-create") }, + }); + const workspaceReplay = await client.http.POST("/v1/workspaces", { + body: { name: "workspace" }, params: { header: idempotencyHeaders("cursor-workspace-create") }, + }); + expect(requireEventCursor(workspaceReplay.response)).toBe(requireEventCursor(workspaceFirst.response)); + + const workspacePatchFirst = await client.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "renamed" }, params: { header: mutationHeaders(1, "cursor-workspace-patch"), path: { workspaceId: "ws-1" } }, + }); + const workspacePatchReplay = await client.http.PATCH("/v1/workspaces/{workspaceId}", { + body: { name: "renamed" }, params: { header: mutationHeaders(1, "cursor-workspace-patch"), path: { workspaceId: "ws-1" } }, + }); + expect(requireEventCursor(workspacePatchReplay.response)).toBe(requireEventCursor(workspacePatchFirst.response)); + + const sessionFirst = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "session" }, params: { header: idempotencyHeaders("cursor-session-create"), path: { workspaceId: "ws-1" } }, + }); + const sessionReplay = await client.http.POST("/v1/workspaces/{workspaceId}/sessions", { + body: { title: "session" }, params: { header: idempotencyHeaders("cursor-session-create"), path: { workspaceId: "ws-1" } }, + }); + expect(requireEventCursor(sessionReplay.response)).toBe(requireEventCursor(sessionFirst.response)); + + const sessionPatchFirst = await client.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "retitled" }, params: { header: mutationHeaders(1, "cursor-session-patch"), path: { sessionId: "ses-1" } }, + }); + const sessionPatchReplay = await client.http.PATCH("/v1/sessions/{sessionId}", { + body: { title: "retitled" }, params: { header: mutationHeaders(1, "cursor-session-patch"), path: { sessionId: "ses-1" } }, + }); + expect(requireEventCursor(sessionPatchReplay.response)).toBe(requireEventCursor(sessionPatchFirst.response)); + + for (const [operation, invoke] of [ + ["cancel", () => client.http.POST("/v1/sessions/{sessionId}/cancel", { params: { header: idempotencyHeaders("cursor-session-cancel"), path: { sessionId: "ses-1" } } })], + ["command", () => client.http.POST("/v1/sessions/{sessionId}/commands", { body: { command: "run" }, params: { header: idempotencyHeaders("cursor-session-command"), path: { sessionId: "ses-1" } } })], + ["delete", () => client.http.DELETE("/v1/sessions/{sessionId}", { params: { header: idempotencyHeaders("cursor-session-delete"), path: { sessionId: "ses-1" } } })], + ] as const) { + const first = await invoke(); + const replay = await invoke(); + expect(requireEventCursor(replay.response), operation).toBe(requireEventCursor(first.response)); + } + }); + it("replays successful session deletion only within the original principal and request scope", async () => { const service = new T4ApiV1ConformanceService(); const owner = await seededClient(service, "delete-replay"); @@ -782,6 +861,23 @@ describe("generated T4 API v1 client conformance", () => { expect(`/v1/sessions/${resourceId128}/snapshot`.length).toBeLessThanOrEqual(snapshotUrlSchema.maxLength); }); + it("accepts every command lifecycle watch state and rejects removed states", async () => { + for (const [index, state] of COMMAND_STATES.entries()) { + const cursor = `command-state-${index}`; + const client = createT4ApiClient({ + baseUrl: "https://command-states.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(`data: ${JSON.stringify({ type: "command", cursor, commandId: "cmd-1", state })}\n\n`, { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(client.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ value: { state } }); + } + + const removed = createT4ApiClient({ + baseUrl: "https://removed-command-state.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse('data: {"type":"command","cursor":"removed-1","commandId":"cmd-1","state":"conflict"}\n\n', { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(removed.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ code: "indeterminate", status: 502 }); + }); + it("rejects malformed mutation idempotency and watch query contracts", async () => { const service = new T4ApiV1ConformanceService(); const baseHeaders = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; @@ -929,10 +1025,7 @@ describe("generated T4 API v1 client conformance", () => { }); it("validates successful JSON routes relative to the base path and rejects undeclared media and statuses", async () => { - const discovery = { - apiVersion: "1.0", supportedMajors: [1], capabilities: [], - limits: { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsMax: 1, heartbeatSeconds: 5 }, - }; + const discovery = DISCOVERY; const prefixed = createT4ApiClient({ baseUrl: "https://prefixed.test/api", credential: "token-a", majorVersion: 1, fetch: async (input) => { @@ -1056,17 +1149,37 @@ describe("generated T4 API v1 client conformance", () => { } }); - it("declares required public response headers while preserving the 401 exception", () => { + it("declares truthful discovery and required public response headers while preserving the 401 exception", () => { const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { paths: Record }; get?: { responses: Record }> } }>; - components: { headers: Record; responses: Record }> }; + components: { + headers: Record; + responses: Record }>; + schemas: Record }>; + }; }; expect(contract.paths["/v1/workspaces/{workspaceId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/WorkspaceReplay"); expect(contract.paths["/v1/sessions/{sessionId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/SessionReplay"); expect(contract.components.headers.SelectedVersion?.required).toBe(true); expect(contract.components.headers.IdempotencyReplayed?.required).toBe(true); + expect(contract.components.headers.EventCursor?.required).toBe(true); + for (const response of ["WorkspaceAccepted", "WorkspaceReplay", "SessionAccepted", "SessionReplay", "CommandAccepted", "CommandReplay", "Deleted"]) { + expect(contract.components.responses[response]?.headers?.["T4-Event-Cursor"]?.$ref).toBe("#/components/headers/EventCursor"); + } expect(contract.paths["/v1/sessions/{sessionId}/events"]?.get?.responses["200"]?.headers?.["Cache-Control"]?.required).toBe(true); expect(contract.components.responses.Error401?.headers?.["T4-API-Version"]).toBeUndefined(); + + const discovery = contract.components.schemas.Discovery!; + expect(discovery.required).toContain("serverBuild"); + expect(discovery.properties?.capabilities).toMatchObject({ + type: "object", maxProperties: 128, + propertyNames: { pattern: "^[a-z][a-z0-9.-]*$", maxLength: 128 }, + additionalProperties: { $ref: "#/components/schemas/CapabilityStatus" }, + }); + expect(contract.components.schemas.CapabilityStatus).toMatchObject({ + additionalProperties: false, + required: ["supported", "enabled", "authorized", "available"], + }); }); it("treats malformed and oversized watch 503 envelopes as terminal protocol errors", async () => { @@ -1141,10 +1254,7 @@ describe("generated T4 API v1 client conformance", () => { }); it("requires declared selected-version and replay response headers", async () => { - const discovery = { - apiVersion: "1.0", supportedMajors: [1], capabilities: [], - limits: { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsMax: 1, heartbeatSeconds: 5 }, - }; + const discovery = DISCOVERY; for (const selectedVersion of [undefined, "1", "2.0", `1.${"0".repeat(15)}`]) { const client = createT4ApiClient({ baseUrl: "https://response-version.test", credential: "token-a", majorVersion: 1, @@ -1168,6 +1278,14 @@ describe("generated T4 API v1 client conformance", () => { params: { header: mutationHeaders(1, "response-replay-patch"), path: { workspaceId: "wsp-1" } }, body: { name: "workspace" }, })).rejects.toMatchObject({ status: 502, retryable: false }); + const missingEventCursor = createT4ApiClient({ + baseUrl: "https://response-event-cursor.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(workspace, { headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "false" } }), + }); + await expect(missingEventCursor.http.PATCH("/v1/workspaces/{workspaceId}", { + params: { header: mutationHeaders(1, "response-event-cursor"), path: { workspaceId: "wsp-1" } }, body: { name: "workspace" }, + })).rejects.toMatchObject({ status: 502, retryable: false }); + for (const replayed of [undefined, "TRUE", "0"]) { const client = createT4ApiClient({ baseUrl: "https://delete-replay-header.test", credential: "token-a", majorVersion: 1, @@ -1193,14 +1311,15 @@ describe("generated T4 API v1 client conformance", () => { await expect(missingWatchErrorVersion.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); }); - it("rejects duplicate unique response arrays", async () => { - const limits = { pageSizeDefault: 1, pageSizeMax: 1, commandBytesMax: 1, commandRequestBytesMax: 1, commandMetadataValueBytesMax: 1, watchEventsMax: 1, heartbeatSeconds: 5 }; + it("rejects duplicate version entries and malformed capability maps", async () => { + const status = { supported: true, enabled: true, authorized: true, available: true }; for (const discovery of [ - { apiVersion: "1.0", supportedMajors: [1, 1], capabilities: [], limits }, - { apiVersion: "1.0", supportedMajors: [1], capabilities: ["session.watch.sse", "session.watch.sse"], limits }, + { ...DISCOVERY, supportedMajors: [1, 1] }, + { ...DISCOVERY, capabilities: { Invalid: status } }, + { ...DISCOVERY, capabilities: { "session.watch.sse": { ...status, unknown: true } } }, ]) { const client = createT4ApiClient({ - baseUrl: "https://duplicate-discovery.test", credential: "token-a", majorVersion: 1, + baseUrl: "https://invalid-discovery.test", credential: "token-a", majorVersion: 1, fetch: async () => jsonResponse(discovery, { headers: { "T4-API-Version": "1.0" } }), }); await expect(client.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ status: 502, retryable: false }); From b115a3fb2ccecdd0daeac6e76af7751c65eb7692 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:38:23 +0000 Subject: [PATCH 50/70] feat(t4-api): expose truthful mutation contracts --- .../test/t4-api-v1-conformance-service.ts | 29 ++++++-- .../client/test/t4-api-v1-conformance.test.ts | 10 +-- packages/t4-api-client/src/index.ts | 42 ++++++++++-- packages/t4-api-contract/openapi.json | 68 +++++++++++++++---- packages/t4-api-contract/scripts/validate.mjs | 10 ++- 5 files changed, 126 insertions(+), 33 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 03599b26..ede36272 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -70,6 +70,7 @@ interface ReplayRecord { readonly identity: string; readonly body: unknown; readonly replayStatus: number; + readonly cursor: string; } export interface T4ApiV1ConformanceOptions { @@ -87,6 +88,7 @@ export class T4ApiV1ConformanceService { #workspaceSequence = 0; #sessionSequence = 0; #commandSequence = 0; + #eventSequence = 0; readonly #workspaces = new Map>(); readonly #sessions = new Map>(); readonly #replays = new Map(); @@ -112,8 +114,18 @@ export class T4ApiV1ConformanceService { if (request.method === "GET" && url.pathname === "/v1") { return json(200, this.#payload("discovery", { apiVersion: "1.0", + serverBuild: { version: "0.1.30", revision: "fixture-revision-1" }, supportedMajors: [1], - capabilities: ["workspace.lifecycle", "session.lifecycle", "session.commands", "session.watch.sse"], + capabilities: { + "workspace.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "session.lifecycle": { supported: true, enabled: true, authorized: true, available: true }, + "session.commands": { supported: true, enabled: true, authorized: true, available: true }, + "session.watch.sse": { supported: true, enabled: true, authorized: true, available: true }, + "optional.preview": { + supported: true, enabled: false, authorized: false, available: false, + deprecation: { message: "Use workspace.lifecycle", sinceVersion: "1.0", sunsetAt: "2027-01-01T00:00:00Z", replacement: "workspace.lifecycle" }, + }, + }, limits: { pageSizeDefault: 2, pageSizeMax: 3, @@ -265,7 +277,7 @@ export class T4ApiV1ConformanceService { if (Object.keys(body).some((key) => key !== "command" && key !== "metadata")) return this.#invalid("body", "schema", "command create must match CommandCreate"); if (typeof body.command !== "string" || body.command.length < 1 || encoder.encode(body.command).byteLength > COMMAND_BYTES_MAX) return this.#invalid("command", "maxBytes", `command must contain 1 to ${COMMAND_BYTES_MAX} UTF-8 bytes`); if (!this.#validMetadata(body.metadata)) return this.#invalid("metadata", "bounds", "metadata keys and values exceed the discovered bounds"); - const states: Record = { accepted: true, rejected: true, conflict: true, unavailable: true, indeterminate: true }; + const states: Record = { accepted: true, projected: true, dispatching: true, running: true, succeeded: true, failed: true, cancelling: true, cancelled: true, rejected: true, unavailable: true, indeterminate: true }; const state = states[body.command] === true ? body.command : "accepted"; return this.#idempotent(request, tenant, "submitCommand", [id], { metadata: {}, ...body }, 202, 200, () => ({ commandId: `cmd-${++this.#commandSequence}`, state })); } @@ -395,13 +407,15 @@ export class T4ApiV1ConformanceService { const prior = this.#replays.get(scope); if (prior !== undefined) { if (prior.identity !== identity) return problem(409, "idempotency_conflict", "Idempotency key was reused with a different canonical request"); + const headers = { "T4-API-Version": "1.0", "Idempotency-Replayed": "true", "T4-Event-Cursor": prior.cursor }; return prior.body === null - ? new Response(null, { status: prior.replayStatus, headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "true" } }) - : json(prior.replayStatus, prior.body, { "Idempotency-Replayed": "true" }); + ? new Response(null, { status: prior.replayStatus, headers }) + : json(prior.replayStatus, prior.body, headers); } const validation = validate?.(); if (validation !== undefined) return validation; const created = create(); + const cursor = `event-${++this.#eventSequence}`; const visibleValue = created !== null && typeof created === "object" && !Array.isArray(created) ? this.#visible(created as Record) : created; @@ -415,9 +429,10 @@ export class T4ApiV1ConformanceService { const visible = visibleValue !== null && typeof visibleValue === "object" && !Array.isArray(visibleValue) && responseKind !== undefined ? this.#payload(responseKind, visibleValue as Record) : visibleValue; - this.#replays.set(scope, { identity, body: visible, replayStatus }); + this.#replays.set(scope, { identity, body: visible, replayStatus, cursor }); + const headers = { "Idempotency-Replayed": "false", "T4-Event-Cursor": cursor }; return visible === null - ? new Response(null, { status: firstStatus, headers: { "T4-API-Version": "1.0", "Idempotency-Replayed": "false" } }) - : json(firstStatus, visible, { "Idempotency-Replayed": "false" }); + ? new Response(null, { status: firstStatus, headers: { "T4-API-Version": "1.0", ...headers } }) + : json(firstStatus, visible, headers); } } diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 472be3e2..ae746584 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -51,8 +51,7 @@ const VERSION_HEADERS = { "T4-API-Version": "1" } as const; const COMMAND_STATES = [ "accepted", "projected", "dispatching", "running", "succeeded", "failed", "cancelling", "cancelled", "rejected", "unavailable", "indeterminate", -] as const; - +] as const satisfies readonly components["schemas"]["CommandState"][]; const DISCOVERY = { apiVersion: "1.0", serverBuild: { version: "0.1.30", revision: "fixture-revision-1" }, @@ -1155,7 +1154,7 @@ describe("generated T4 API v1 client conformance", () => { components: { headers: Record; responses: Record }>; - schemas: Record }>; + schemas: Record }>; }; }; expect(contract.paths["/v1/workspaces/{workspaceId}"]?.patch?.responses["200"]?.$ref).toBe("#/components/responses/WorkspaceReplay"); @@ -1171,8 +1170,9 @@ describe("generated T4 API v1 client conformance", () => { const discovery = contract.components.schemas.Discovery!; expect(discovery.required).toContain("serverBuild"); - expect(discovery.properties?.capabilities).toMatchObject({ - type: "object", maxProperties: 128, + expect(discovery.properties?.capabilities).toEqual({ $ref: "#/components/schemas/Capabilities" }); + expect(contract.components.schemas.Capabilities).toMatchObject({ + maxProperties: 128, propertyNames: { pattern: "^[a-z][a-z0-9.-]*$", maxLength: 128 }, additionalProperties: { $ref: "#/components/schemas/CapabilityStatus" }, }); diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 693a9feb..c52f5999 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -50,9 +50,10 @@ const SESSION_STATES = { accepted: true, provisioning: true, ready: true, cancelling: true, cancelled: true, failed: true, unavailable: true, indeterminate: true, } as const satisfies Record; -const OPERATION_STATES = { - accepted: true, rejected: true, conflict: true, unavailable: true, indeterminate: true, -} as const satisfies Record; +const COMMAND_STATES = { + accepted: true, projected: true, dispatching: true, running: true, succeeded: true, failed: true, + cancelling: true, cancelled: true, rejected: true, unavailable: true, indeterminate: true, +} as const satisfies Record; type ApiError = components["schemas"]["ApiError"]; type Resync = components["schemas"]["Resync"]; @@ -374,16 +375,38 @@ function validSession(value: unknown): boolean { function validCommandResult(value: unknown): boolean { const item = record(value); return item !== undefined && hasOnlyKeys(item, { commandId: true, state: true }) && validResourceId(item.commandId) && - typeof item.state === "string" && OPERATION_STATES[item.state as components["schemas"]["OperationState"]] === true; + typeof item.state === "string" && COMMAND_STATES[item.state as components["schemas"]["CommandState"]] === true; +} + +function validCapabilityDeprecation(value: unknown): boolean { + if (value === undefined) return true; + const deprecation = record(value); + return deprecation !== undefined && hasOnlyKeys(deprecation, { message: true, sinceVersion: true, sunsetAt: true, replacement: true }) && + typeof deprecation.message === "string" && deprecation.message !== "" && hasAtMostCodePoints(deprecation.message, 1024) && + (deprecation.sinceVersion === undefined || (typeof deprecation.sinceVersion === "string" && deprecation.sinceVersion !== "" && hasAtMostCodePoints(deprecation.sinceVersion, 128))) && + (deprecation.sunsetAt === undefined || (typeof deprecation.sunsetAt === "string" && validRfc3339DateTime(deprecation.sunsetAt))) && + (deprecation.replacement === undefined || (typeof deprecation.replacement === "string" && /^[a-z][a-z0-9.-]{0,127}$/u.test(deprecation.replacement))); +} + +function validCapabilityStatus(value: unknown): boolean { + const status = record(value); + return status !== undefined && hasOnlyKeys(status, { supported: true, enabled: true, authorized: true, available: true, deprecation: true }) && + typeof status.supported === "boolean" && typeof status.enabled === "boolean" && + typeof status.authorized === "boolean" && typeof status.available === "boolean" && validCapabilityDeprecation(status.deprecation); } function validDiscovery(value: unknown): boolean { const discovery = record(value); + const serverBuild = record(discovery?.serverBuild); + const capabilities = record(discovery?.capabilities); const limits = record(discovery?.limits); - return discovery !== undefined && hasOnlyKeys(discovery, { apiVersion: true, supportedMajors: true, capabilities: true, limits: true }) && + return discovery !== undefined && hasOnlyKeys(discovery, { apiVersion: true, serverBuild: true, supportedMajors: true, capabilities: true, limits: true }) && typeof discovery.apiVersion === "string" && /^1\.[0-9]+$/u.test(discovery.apiVersion) && + serverBuild !== undefined && hasOnlyKeys(serverBuild, { version: true, revision: true }) && + typeof serverBuild.version === "string" && serverBuild.version !== "" && hasAtMostCodePoints(serverBuild.version, 128) && + typeof serverBuild.revision === "string" && serverBuild.revision !== "" && hasAtMostCodePoints(serverBuild.revision, 128) && Array.isArray(discovery.supportedMajors) && discovery.supportedMajors.length >= 1 && discovery.supportedMajors.length <= 8 && hasUniqueItems(discovery.supportedMajors) && discovery.supportedMajors.every((major) => Number.isSafeInteger(major) && Number(major) >= 1) && - Array.isArray(discovery.capabilities) && discovery.capabilities.length <= 128 && hasUniqueItems(discovery.capabilities) && discovery.capabilities.every((capability) => typeof capability === "string" && /^[a-z][a-z0-9.-]{0,127}$/u.test(capability)) && + capabilities !== undefined && Object.keys(capabilities).length <= 128 && Object.entries(capabilities).every(([id, status]) => /^[a-z][a-z0-9.-]{0,127}$/u.test(id) && validCapabilityStatus(status)) && limits !== undefined && hasOnlyKeys(limits, { pageSizeDefault: true, pageSizeMax: true, commandBytesMax: true, commandRequestBytesMax: true, commandMetadataValueBytesMax: true, watchEventsMax: true, heartbeatSeconds: true }) && [limits.pageSizeDefault, limits.pageSizeMax, limits.commandBytesMax, limits.commandRequestBytesMax, limits.commandMetadataValueBytesMax, limits.watchEventsMax, limits.heartbeatSeconds].every((limit) => Number.isSafeInteger(limit) && Number(limit) >= 1) && Number(limits.pageSizeDefault) <= Number(limits.pageSizeMax) && Number(limits.commandBytesMax) <= Number(limits.commandRequestBytesMax) && Number(limits.commandMetadataValueBytesMax) <= Number(limits.commandRequestBytesMax) && @@ -492,6 +515,7 @@ function validReplayHeader(response: Response): boolean { return replayed === "true" || replayed === "false"; } + async function validateResponse(request: Request, response: Response, baseUrl: string): Promise { const path = relativeApiPath(request, baseUrl); const contract = path === undefined ? undefined : responseContract(request.method, path); @@ -527,6 +551,10 @@ async function validateResponse(request: Request, response: Response, baseUrl: s void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned a missing or invalid replay header"); } + if (contract.replaySuccess?.includes(response.status) === true && !validCursor(response.headers.get("T4-Event-Cursor"))) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned a missing or invalid durable event cursor"); + } if (validator === "empty") { if (response.body !== null || response.headers.has("content-type")) { void response.body?.cancel().catch(() => {}); @@ -633,7 +661,7 @@ function watchEvent(value: unknown, eventId: string | undefined): WatchEvent { event.commandId !== "" && hasAtMostCodePoints(event.commandId, 128) && /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(event.commandId) && typeof event.state === "string" && - OPERATION_STATES[event.state as components["schemas"]["OperationState"]] === true + COMMAND_STATES[event.state as components["schemas"]["CommandState"]] === true ) return event as WatchEvent; throw protocolError(502, "T4 API returned an invalid watch event"); } diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index 8481901e..12bb55c5 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -427,23 +427,67 @@ "description": "True when this response replays a prior identical request.", "required": true, "schema": { "type": "string", "enum": ["true", "false"] } + }, + "EventCursor": { + "description": "Durable event cursor committed with the accepted mutation and preserved by replay.", + "required": true, + "schema": { "$ref": "#/components/schemas/Cursor" } } }, "schemas": { "ResourceId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._~-]*$" }, "Cursor": { "type": "string", "minLength": 1, "maxLength": 512, "pattern": "^[A-Za-z0-9._~-]+$", "description": "Opaque server-issued header-safe SSE cursor." }, "Revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, - "OperationState": { "type": "string", "enum": ["accepted", "rejected", "conflict", "unavailable", "indeterminate"] }, + "CommandState": { "type": "string", "enum": ["accepted", "projected", "dispatching", "running", "succeeded", "failed", "cancelling", "cancelled", "rejected", "unavailable", "indeterminate"] }, "WorkspaceState": { "type": "string", "enum": ["accepted", "provisioning", "ready", "deleting", "deleted", "failed", "unavailable", "indeterminate"] }, "SessionState": { "type": "string", "enum": ["accepted", "provisioning", "ready", "cancelling", "cancelled", "failed", "unavailable", "indeterminate"] }, + "ServerBuild": { + "type": "object", + "additionalProperties": false, + "required": ["version", "revision"], + "properties": { + "version": { "type": "string", "minLength": 1, "maxLength": 128 }, + "revision": { "type": "string", "minLength": 1, "maxLength": 128 } + } + }, + "CapabilityDeprecation": { + "type": "object", + "additionalProperties": false, + "required": ["message"], + "properties": { + "message": { "type": "string", "minLength": 1, "maxLength": 1024 }, + "sinceVersion": { "type": "string", "minLength": 1, "maxLength": 128 }, + "sunsetAt": { "type": "string", "format": "date-time", "maxLength": 64 }, + "replacement": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9.-]*$" } + } + }, + "CapabilityStatus": { + "type": "object", + "additionalProperties": false, + "required": ["supported", "enabled", "authorized", "available"], + "properties": { + "supported": { "type": "boolean" }, + "enabled": { "type": "boolean" }, + "authorized": { "type": "boolean" }, + "available": { "type": "boolean" }, + "deprecation": { "$ref": "#/components/schemas/CapabilityDeprecation" } + } + }, + "Capabilities": { + "type": "object", + "maxProperties": 128, + "propertyNames": { "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9.-]*$" }, + "additionalProperties": { "$ref": "#/components/schemas/CapabilityStatus" } + }, "Discovery": { "type": "object", "additionalProperties": false, - "required": ["apiVersion", "supportedMajors", "capabilities", "limits"], + "required": ["apiVersion", "serverBuild", "supportedMajors", "capabilities", "limits"], "properties": { "apiVersion": { "type": "string", "pattern": "^1\\.[0-9]+$" }, + "serverBuild": { "$ref": "#/components/schemas/ServerBuild" }, "supportedMajors": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "integer", "minimum": 1 } }, - "capabilities": { "type": "array", "maxItems": 128, "uniqueItems": true, "items": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[a-z][a-z0-9.-]*$" } }, + "capabilities": { "$ref": "#/components/schemas/Capabilities" }, "limits": { "type": "object", "additionalProperties": false, @@ -574,7 +618,7 @@ "required": ["commandId", "state"], "properties": { "commandId": { "$ref": "#/components/schemas/ResourceId" }, - "state": { "$ref": "#/components/schemas/OperationState" } + "state": { "$ref": "#/components/schemas/CommandState" } } }, "SnapshotEntry": { @@ -627,7 +671,7 @@ "type": { "type": "string", "const": "command" }, "cursor": { "$ref": "#/components/schemas/Cursor" }, "commandId": { "$ref": "#/components/schemas/ResourceId" }, - "state": { "$ref": "#/components/schemas/OperationState" } + "state": { "$ref": "#/components/schemas/CommandState" } } }, "WatchEvent": { @@ -791,17 +835,17 @@ "responses": { "Discovery": { "description": "Negotiated v1 discovery", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Discovery" } } } }, "Workspace": { "description": "Workspace", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, - "WorkspaceAccepted": { "description": "Workspace intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, - "WorkspaceReplay": { "description": "Identical workspace request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspaceAccepted": { "description": "Workspace intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, + "WorkspaceReplay": { "description": "Identical workspace request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Workspace" } } } }, "WorkspacePage": { "description": "Bounded workspace page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/WorkspacePage" } } } }, "Session": { "description": "Session", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, - "SessionAccepted": { "description": "Session intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, - "SessionReplay": { "description": "Identical session mutation replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionAccepted": { "description": "Session intent accepted", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, + "SessionReplay": { "description": "Identical session mutation replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Session" } } } }, "SessionPage": { "description": "Bounded session page", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionPage" } } } }, - "CommandAccepted": { "description": "Command intent accepted with a stable outcome state", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, - "CommandReplay": { "description": "Identical command request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, + "CommandAccepted": { "description": "Command intent accepted with a stable outcome state", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, + "CommandReplay": { "description": "Identical command request replay", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CommandResult" } } } }, "Snapshot": { "description": "Bounded session snapshot and reconnect cursor", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionSnapshot" } } } }, - "Deleted": { "description": "Idempotent deletion accepted or resource already absent", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" } } }, + "Deleted": { "description": "Idempotent deletion accepted or resource already absent", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" }, "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }, "T4-Event-Cursor": { "$ref": "#/components/headers/EventCursor" } } }, "Error400": { "description": "Malformed request or missing idempotency key", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/BadRequestErrorEnvelope" } } } }, "Error401": { "description": "Missing or invalid opaque bearer credential", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UnauthenticatedErrorEnvelope" } } } }, "Error403": { "description": "Credential lacks the deny-by-default operation or resource scope", "headers": { "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ForbiddenErrorEnvelope" } } } }, diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs index d43c7e84..1e594993 100644 --- a/packages/t4-api-contract/scripts/validate.mjs +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -23,14 +23,18 @@ if (commandCreate?.properties?.command?.["x-t4-maxUtf8Bytes"] !== 262144) throw const metadataString = commandCreate?.properties?.metadata?.additionalProperties?.oneOf?.find((item) => item?.type === "string"); if (metadataString?.["x-t4-maxUtf8Bytes"] !== 262144) throw new Error("command metadata strings must retain their UTF-8 byte bound"); -for (const [schema, property] of [["Discovery", "supportedMajors"], ["Discovery", "capabilities"], ["ApiError", "supportedMajors"]]) { +for (const [schema, property] of [["Discovery", "supportedMajors"], ["ApiError", "supportedMajors"]]) { if (schemas[schema]?.properties?.[property]?.uniqueItems !== true) throw new Error(`${schema}.${property} must retain uniqueItems`); } +if (schemas.Discovery?.required?.includes("serverBuild") !== true || schemas.Discovery?.properties?.capabilities?.$ref !== "#/components/schemas/Capabilities") throw new Error("Discovery must require structured server build and capabilities"); +if (schemas.Capabilities?.maxProperties !== 128 || schemas.Capabilities?.additionalProperties?.$ref !== "#/components/schemas/CapabilityStatus") throw new Error("Capabilities must remain a bounded status map"); const selectedVersionHeader = document.components?.headers?.SelectedVersion; const replayHeaderObject = document.components?.headers?.IdempotencyReplayed; +const eventCursorHeader = document.components?.headers?.EventCursor; if (selectedVersionHeader?.required !== true) throw new Error("SelectedVersion must remain required wherever referenced"); if (replayHeaderObject?.required !== true) throw new Error("IdempotencyReplayed must remain required wherever referenced"); +if (eventCursorHeader?.required !== true || eventCursorHeader?.schema?.$ref !== "#/components/schemas/Cursor") throw new Error("EventCursor must remain a required header-safe cursor"); const selectedVersion = document.components?.headers?.SelectedVersion?.schema; if (selectedVersion?.pattern !== "^1\\.[0-9]+$" || selectedVersion?.maxLength !== 16) throw new Error("SelectedVersion must remain a bounded v1 minor"); const replayHeader = document.components?.headers?.IdempotencyReplayed?.schema; @@ -42,8 +46,10 @@ const selectedVersionResponses = ["Discovery", "Workspace", "WorkspaceAccepted", for (const responseName of selectedVersionResponses) { if (document.components?.responses?.[responseName]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error(`${responseName} must declare SelectedVersion`); } -for (const responseName of ["WorkspaceAccepted", "WorkspaceReplay", "SessionAccepted", "SessionReplay", "CommandAccepted", "CommandReplay", "Deleted"]) { +const mutationResponses = ["WorkspaceAccepted", "WorkspaceReplay", "SessionAccepted", "SessionReplay", "CommandAccepted", "CommandReplay", "Deleted"]; +for (const responseName of mutationResponses) { if (document.components?.responses?.[responseName]?.headers?.["Idempotency-Replayed"]?.$ref !== replayRef) throw new Error(`${responseName} must declare IdempotencyReplayed`); + if (document.components?.responses?.[responseName]?.headers?.["T4-Event-Cursor"]?.$ref !== "#/components/headers/EventCursor") throw new Error(`${responseName} must declare EventCursor`); } if (document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error("watch success must declare SelectedVersion"); const watchCacheControl = document.paths?.["/v1/sessions/{sessionId}/events"]?.get?.responses?.["200"]?.headers?.["Cache-Control"]; From d430685adc782d0bb10433e5f1b1adb2e623bf4f Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:40:09 +0000 Subject: [PATCH 51/70] chore: accept truthful API schema artifact --- .../t4-api-client/src/generated/schema.ts | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/t4-api-client/src/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts index 4cdd50b9..ad07c82b 100755 --- a/packages/t4-api-client/src/generated/schema.ts +++ b/packages/t4-api-client/src/generated/schema.ts @@ -175,6 +175,23 @@ export interface components { BadRequestErrorEnvelope: { error: components["schemas"]["BadRequestApiError"]; }; + Capabilities: { + [key: string]: components["schemas"]["CapabilityStatus"]; + }; + CapabilityDeprecation: { + message: string; + replacement?: string; + sinceVersion?: string; + /** Format: date-time */ + sunsetAt?: string; + }; + CapabilityStatus: { + authorized: boolean; + available: boolean; + deprecation?: components["schemas"]["CapabilityDeprecation"]; + enabled: boolean; + supported: boolean; + }; CommandCreate: { command: string; /** @default {} */ @@ -184,12 +201,14 @@ export interface components { }; CommandResult: { commandId: components["schemas"]["ResourceId"]; - state: components["schemas"]["OperationState"]; + state: components["schemas"]["CommandState"]; }; + /** @enum {string} */ + CommandState: "accepted" | "projected" | "dispatching" | "running" | "succeeded" | "failed" | "cancelling" | "cancelled" | "rejected" | "unavailable" | "indeterminate"; CommandWatchEvent: { commandId: components["schemas"]["ResourceId"]; cursor: components["schemas"]["Cursor"]; - state: components["schemas"]["OperationState"]; + state: components["schemas"]["CommandState"]; /** @constant */ type: "command"; }; @@ -212,7 +231,7 @@ export interface components { }; Discovery: { apiVersion: string; - capabilities: string[]; + capabilities: components["schemas"]["Capabilities"]; limits: { commandBytesMax: number; commandMetadataValueBytesMax: number; @@ -222,6 +241,7 @@ export interface components { pageSizeMax: number; watchEventsMax: number; }; + serverBuild: components["schemas"]["ServerBuild"]; supportedMajors: number[]; }; ErrorEnvelope: { @@ -272,14 +292,16 @@ export interface components { NotFoundErrorEnvelope: { error: components["schemas"]["NotFoundApiError"]; }; - /** @enum {string} */ - OperationState: "accepted" | "rejected" | "conflict" | "unavailable" | "indeterminate"; ResourceId: string; Resync: { cursor: components["schemas"]["Cursor"]; snapshotUrl: string; }; Revision: number; + ServerBuild: { + revision: string; + version: string; + }; Session: { id: components["schemas"]["ResourceId"]; labels?: components["schemas"]["Labels"]; @@ -364,6 +386,7 @@ export interface components { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; [name: string]: unknown; }; content: { @@ -375,6 +398,7 @@ export interface components { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; [name: string]: unknown; }; content: { @@ -386,6 +410,7 @@ export interface components { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; [name: string]: unknown; }; content?: never; @@ -506,6 +531,7 @@ export interface components { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; [name: string]: unknown; }; content: { @@ -527,6 +553,7 @@ export interface components { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; [name: string]: unknown; }; content: { @@ -558,6 +585,7 @@ export interface components { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; [name: string]: unknown; }; content: { @@ -579,6 +607,7 @@ export interface components { headers: { "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; [name: string]: unknown; }; content: { @@ -607,6 +636,8 @@ export interface components { }; requestBodies: never; headers: { + /** @description Durable event cursor committed with the accepted mutation and preserved by replay. */ + EventCursor: components["schemas"]["Cursor"]; /** @description True when this response replays a prior identical request. */ IdempotencyReplayed: "true" | "false"; /** @description Selected compatible T4 API minor version. */ From 1f574dc4713831ca1800221eea9c5cb7e0072ae8 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:43:49 +0000 Subject: [PATCH 52/70] test(t4-api): use fixture auth proof --- .../client/test/t4-api-v1-conformance.test.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index ae746584..b4171582 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -6,7 +6,6 @@ import { T4ApiError, createT4ApiClient, type components, - type operations, } from "@t4-code/t4-api-client"; import { T4ApiV1ConformanceService, canonicalJson } from "./t4-api-v1-conformance-service.ts"; @@ -15,24 +14,6 @@ type SessionCreate = components["schemas"]["SessionCreate"]; type CommandCreate = components["schemas"]["CommandCreate"]; type WatchEvent = components["schemas"]["WatchEvent"]; -type JsonBody = Response extends { content: { "application/json": infer Body } } ? Body : never; -type SpawnBadRequest = JsonBody; -type CommandBadRequest = JsonBody; -type Unauthorized = JsonBody; -type Forbidden = JsonBody; -type Missing = JsonBody; -type Conflict = JsonBody; -type Unavailable = JsonBody; - -const typedErrors = { - badSpawn: { error: { code: "idempotency_key_required", message: "required", requestId: "r", retryable: false } } satisfies SpawnBadRequest, - badCommand: { error: { code: "invalid_request", message: "invalid", requestId: "r", retryable: false } } satisfies CommandBadRequest, - unauthorized: { error: { code: "unauthenticated", message: "no", requestId: "r", retryable: false } } satisfies Unauthorized, - forbidden: { error: { code: "forbidden", message: "no", requestId: "r", retryable: false } } satisfies Forbidden, - missing: { error: { code: "not_found", message: "no", requestId: "r", retryable: false } } satisfies Missing, - conflict: { error: { code: "idempotency_conflict", message: "no", requestId: "r", retryable: false } } satisfies Conflict, - unavailable: { error: { code: "unavailable", message: "later", requestId: "r", retryable: true } } satisfies Unavailable, -}; function exhaustWatchEvent(event: WatchEvent): string { switch (event.type) { From 409882bf4380ba8ba18d99f35ba815895ed15864 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:44:29 +0000 Subject: [PATCH 53/70] test(t4-api): reject malformed page cursors --- .../client/test/t4-api-v1-conformance.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index b4171582..d394c004 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -899,6 +899,25 @@ describe("generated T4 API v1 client conformance", () => { } }); + it("rejects malformed workspace and session pagination cursors", async () => { + const service = new T4ApiV1ConformanceService(); + const headers = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; + expect((await service.fetch(`${service.origin}/v1/workspaces`, { + method: "POST", headers: { ...headers, "Idempotency-Key": "pagination-workspace" }, body: '{"name":"workspace"}', + })).status).toBe(202); + expect((await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { + method: "POST", headers: { ...headers, "Idempotency-Key": "pagination-session-1" }, body: '{"title":"session"}', + })).status).toBe(202); + + for (const path of ["/v1/workspaces", "/v1/workspaces/ws-1/sessions"]) { + for (const cursor of ["foo", "page--1", "page-01", "page-9007199254740992"]) { + const response = await service.fetch(`${service.origin}${path}?cursor=${encodeURIComponent(cursor)}`, { headers }); + expect(response.status, `${path} accepted ${cursor}`).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "cursor", rule: "format" }] } }); + } + } + }); + it("parses service frames incrementally across split UTF-8 and per-frame bounds", async () => { const bytewiseService = new T4ApiV1ConformanceService({ watchTransport: "bytewise" }); const bytewiseClient = await seededClient(bytewiseService, "bytewise"); From e15eb7c3dd627814c324cc1ffc1fc345aa2393c5 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:45:31 +0000 Subject: [PATCH 54/70] test(t4-api): reject silent version downgrade --- packages/client/test/t4-api-v1-conformance.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index d394c004..ce133366 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -114,6 +114,14 @@ describe("generated T4 API v1 client conformance", () => { expect(rejected.response.status).toBe(406); expect(rejected.error).toMatchObject({ error: { code: "incompatible_version", retryable: false, supportedMajors: [1] } }); + const silentDowngrade = createT4ApiClient({ + baseUrl: "https://silent-downgrade.test", credential: "token-a", majorVersion: 2, + fetch: async () => jsonResponse(DISCOVERY), + }); + await expect(silentDowngrade.http.GET("/v1", { params: { header: VERSION_HEADERS } })).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + const unauthenticated = await service.fetch(`${service.origin}/v1`, { headers: { "T4-API-Version": "1" } }); expect(unauthenticated.status).toBe(401); expect(await unauthenticated.json()).toMatchObject({ error: { code: "unauthenticated", retryable: false } }); From 3fa3bf3e1e35ffd2ff440d74cbe7c9c4b66d4f63 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:48:52 +0000 Subject: [PATCH 55/70] fix(t4-api): reject invalid cursors and downgrades --- .../client/test/t4-api-v1-conformance-service.ts | 13 +++++++++++-- packages/t4-api-client/src/index.ts | 9 +++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index ede36272..788b9c1f 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -14,6 +14,13 @@ function hasAtMostCodePoints(value: string, maximum: number): boolean { return true; } +function pageOffset(value: string | null): number | undefined { + if (value === null) return 0; + if (value.length > 512 || !/^page-(?:0|[1-9][0-9]*)$/u.test(value)) return undefined; + const offset = Number(value.slice(5)); + return Number.isSafeInteger(offset) ? offset : undefined; +} + function validLabels(value: unknown): boolean { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; const entries = Object.entries(value as Record); @@ -155,7 +162,8 @@ export class T4ApiV1ConformanceService { if (request.method === "GET" && url.pathname === "/v1/workspaces") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); - const start = Number(url.searchParams.get("cursor")?.replace("page-", "") ?? "0"); + const start = pageOffset(url.searchParams.get("cursor")); + if (start === undefined) return this.#invalid("cursor", "format", "cursor must be a canonical bounded page offset"); const visible = [...this.#workspaces.values()].filter((item) => item.tenant === tenant); const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; @@ -212,7 +220,8 @@ export class T4ApiV1ConformanceService { if (request.method === "GET") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); - const start = Number(url.searchParams.get("cursor")?.replace("page-", "") ?? "0"); + const start = pageOffset(url.searchParams.get("cursor")); + if (start === undefined) return this.#invalid("cursor", "format", "cursor must be a canonical bounded page offset"); const visible = [...this.#sessions.values()].filter((item) => item.workspaceId === workspaceId && item.tenant === tenant); const items = visible.slice(start, start + pageSize).map((item) => this.#visible(item)); const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index c52f5999..4058a424 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -505,9 +505,10 @@ function responseContract(method: string, path: string): ResponseContract | unde return undefined; } -function validSelectedVersion(response: Response): boolean { +function validSelectedVersion(response: Response, expectedMajor?: string): boolean { const selected = response.headers.get("T4-API-Version"); - return selected !== null && selected.length <= SELECTED_VERSION_MAX_LENGTH && SELECTED_VERSION_PATTERN.test(selected); + return selected !== null && selected.length <= SELECTED_VERSION_MAX_LENGTH && SELECTED_VERSION_PATTERN.test(selected) && + (expectedMajor === undefined || selected.startsWith(`${expectedMajor}.`)); } function validReplayHeader(response: Response): boolean { @@ -543,7 +544,7 @@ async function validateResponse(request: Request, response: Response, baseUrl: s void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared success status"); } - if (!validSelectedVersion(response)) { + if (!validSelectedVersion(response, request.headers.get("T4-API-Version") ?? "")) { void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned a missing or invalid selected version"); } @@ -839,7 +840,7 @@ async function* watch( void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned an undeclared watch success status"); } - if (!validSelectedVersion(response)) { + if (!validSelectedVersion(response, majorVersion)) { void response.body?.cancel().catch(() => {}); throw protocolError(502, "T4 API returned a missing or invalid selected version"); } From ad46ce7259e53aee7d84e9361547165105c4b1bc Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:51:49 +0000 Subject: [PATCH 56/70] test(t4-api): require issued page cursors --- .../client/test/t4-api-v1-conformance.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index ce133366..fdb4bea3 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -926,6 +926,39 @@ describe("generated T4 API v1 client conformance", () => { } }); + it("accepts only pagination cursors issued for the principal and collection", async () => { + const service = new T4ApiV1ConformanceService(); + const headers = { Authorization: "Bearer token-a", "T4-API-Version": "1", "Content-Type": "application/json" }; + for (const [key, name] of [["issued-workspace-1", "one"], ["issued-workspace-2", "two"], ["issued-workspace-3", "three"]]) { + expect((await service.fetch(`${service.origin}/v1/workspaces`, { + method: "POST", headers: { ...headers, "Idempotency-Key": key }, body: JSON.stringify({ name }), + })).status).toBe(202); + } + for (const [key, title] of [["issued-session-01", "one"], ["issued-session-02", "two"]]) { + expect((await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions`, { + method: "POST", headers: { ...headers, "Idempotency-Key": key }, body: JSON.stringify({ title }), + })).status).toBe(202); + } + + const workspacePage = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1`, { headers }); + const workspaceCursor = (await workspacePage.json() as { nextCursor: string }).nextCursor; + expect((await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${workspaceCursor}`, { headers })).status).toBe(200); + const sessionPage = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions?pageSize=1`, { headers }); + const sessionCursor = (await sessionPage.json() as { nextCursor: string }).nextCursor; + + for (const [path, cursor, authorization] of [ + ["/v1/workspaces", "page-999", "Bearer token-a"], + ["/v1/workspaces/ws-1/sessions", "page-999", "Bearer token-a"], + ["/v1/workspaces/ws-1/sessions", workspaceCursor, "Bearer token-a"], + ["/v1/workspaces", sessionCursor, "Bearer token-a"], + ["/v1/workspaces", workspaceCursor, "Bearer token-b"], + ]) { + const response = await service.fetch(`${service.origin}${path}?pageSize=1&cursor=${cursor}`, { headers: { ...headers, Authorization: authorization } }); + expect(response.status, `${path} accepted unissued cursor ${cursor}`).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "cursor", rule: "issued" }] } }); + } + }); + it("parses service frames incrementally across split UTF-8 and per-frame bounds", async () => { const bytewiseService = new T4ApiV1ConformanceService({ watchTransport: "bytewise" }); const bytewiseClient = await seededClient(bytewiseService, "bytewise"); From c4e2336f9da8894c28aaa862589fa7a7d1b8e796 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:54:55 +0000 Subject: [PATCH 57/70] fix(t4-api): bind issued page cursors --- .../client/test/t4-api-v1-conformance-service.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 788b9c1f..abbd4962 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -99,6 +99,7 @@ export class T4ApiV1ConformanceService { readonly #workspaces = new Map>(); readonly #sessions = new Map>(); readonly #replays = new Map(); + readonly #pageCursors = new Set(); constructor(readonly options: T4ApiV1ConformanceOptions = {}) {} @@ -162,11 +163,16 @@ export class T4ApiV1ConformanceService { if (request.method === "GET" && url.pathname === "/v1/workspaces") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); - const start = pageOffset(url.searchParams.get("cursor")); + const cursor = url.searchParams.get("cursor"); + const start = pageOffset(cursor); if (start === undefined) return this.#invalid("cursor", "format", "cursor must be a canonical bounded page offset"); + if (cursor !== null && !this.#pageCursors.has(canonicalJson({ principal: tenant, collection: "workspaces", cursor }))) { + return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); + } const visible = [...this.#workspaces.values()].filter((item) => item.tenant === tenant); const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; + if (next !== undefined) this.#pageCursors.add(canonicalJson({ principal: tenant, collection: "workspaces", cursor: next })); return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } @@ -220,11 +226,17 @@ export class T4ApiV1ConformanceService { if (request.method === "GET") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); - const start = pageOffset(url.searchParams.get("cursor")); + const cursor = url.searchParams.get("cursor"); + const start = pageOffset(cursor); if (start === undefined) return this.#invalid("cursor", "format", "cursor must be a canonical bounded page offset"); + const collection = `workspaces/${workspaceId}/sessions`; + if (cursor !== null && !this.#pageCursors.has(canonicalJson({ principal: tenant, collection, cursor }))) { + return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); + } const visible = [...this.#sessions.values()].filter((item) => item.workspaceId === workspaceId && item.tenant === tenant); const items = visible.slice(start, start + pageSize).map((item) => this.#visible(item)); const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; + if (next !== undefined) this.#pageCursors.add(canonicalJson({ principal: tenant, collection, cursor: next })); return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } } From 907b5aa9dae26a06cf81edc0c1955035925db6e5 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:57:51 +0000 Subject: [PATCH 58/70] fix(t4-api): issue unique opaque page cursors --- .../test/t4-api-v1-conformance-service.ts | 63 +++++++++++++------ 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index abbd4962..76ee1663 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -14,12 +14,6 @@ function hasAtMostCodePoints(value: string, maximum: number): boolean { return true; } -function pageOffset(value: string | null): number | undefined { - if (value === null) return 0; - if (value.length > 512 || !/^page-(?:0|[1-9][0-9]*)$/u.test(value)) return undefined; - const offset = Number(value.slice(5)); - return Number.isSafeInteger(offset) ? offset : undefined; -} function validLabels(value: unknown): boolean { if (value === null || typeof value !== "object" || Array.isArray(value)) return false; @@ -80,6 +74,12 @@ interface ReplayRecord { readonly cursor: string; } +interface PageCursorRecord { + readonly principal: string; + readonly collection: string; + readonly offset: number; +} + export interface T4ApiV1ConformanceOptions { readonly invalidPayload?: "discovery" | "workspace" | "session" | "command"; readonly watchTransport?: "normal" | "bytewise" | "oversized" | "many-small"; @@ -96,10 +96,12 @@ export class T4ApiV1ConformanceService { #sessionSequence = 0; #commandSequence = 0; #eventSequence = 0; + #pageCursorSequence = 0; readonly #workspaces = new Map>(); readonly #sessions = new Map>(); readonly #replays = new Map(); - readonly #pageCursors = new Set(); + readonly #pageCursors = new Map(); + readonly #pageCursorByPosition = new Map(); constructor(readonly options: T4ApiV1ConformanceOptions = {}) {} @@ -163,16 +165,22 @@ export class T4ApiV1ConformanceService { if (request.method === "GET" && url.pathname === "/v1/workspaces") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); + const collection = "workspaces"; const cursor = url.searchParams.get("cursor"); - const start = pageOffset(cursor); - if (start === undefined) return this.#invalid("cursor", "format", "cursor must be a canonical bounded page offset"); - if (cursor !== null && !this.#pageCursors.has(canonicalJson({ principal: tenant, collection: "workspaces", cursor }))) { - return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); + let start = 0; + if (cursor !== null) { + if (cursor.length > 512 || !/^page-(?:0|[1-9][0-9]*)$/u.test(cursor) || !Number.isSafeInteger(Number(cursor.slice(5)))) { + return this.#invalid("cursor", "format", "cursor must be a canonical bounded opaque token"); + } + const issued = this.#pageCursors.get(cursor); + if (issued === undefined || issued.principal !== tenant || issued.collection !== collection) { + return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); + } + start = issued.offset; } const visible = [...this.#workspaces.values()].filter((item) => item.tenant === tenant); const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); - const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; - if (next !== undefined) this.#pageCursors.add(canonicalJson({ principal: tenant, collection: "workspaces", cursor: next })); + const next = start + items.length < visible.length ? this.#issuePageCursor(tenant, collection, start + items.length) : undefined; return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } @@ -226,17 +234,22 @@ export class T4ApiV1ConformanceService { if (request.method === "GET") { const pageSize = Number(url.searchParams.get("pageSize") ?? "2"); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > 3) return this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); - const cursor = url.searchParams.get("cursor"); - const start = pageOffset(cursor); - if (start === undefined) return this.#invalid("cursor", "format", "cursor must be a canonical bounded page offset"); const collection = `workspaces/${workspaceId}/sessions`; - if (cursor !== null && !this.#pageCursors.has(canonicalJson({ principal: tenant, collection, cursor }))) { - return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); + const cursor = url.searchParams.get("cursor"); + let start = 0; + if (cursor !== null) { + if (cursor.length > 512 || !/^page-(?:0|[1-9][0-9]*)$/u.test(cursor) || !Number.isSafeInteger(Number(cursor.slice(5)))) { + return this.#invalid("cursor", "format", "cursor must be a canonical bounded opaque token"); + } + const issued = this.#pageCursors.get(cursor); + if (issued === undefined || issued.principal !== tenant || issued.collection !== collection) { + return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); + } + start = issued.offset; } const visible = [...this.#sessions.values()].filter((item) => item.workspaceId === workspaceId && item.tenant === tenant); const items = visible.slice(start, start + pageSize).map((item) => this.#visible(item)); - const next = start + items.length < visible.length ? `page-${start + items.length}` : undefined; - if (next !== undefined) this.#pageCursors.add(canonicalJson({ principal: tenant, collection, cursor: next })); + const next = start + items.length < visible.length ? this.#issuePageCursor(tenant, collection, start + items.length) : undefined; return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } } @@ -408,6 +421,16 @@ export class T4ApiV1ConformanceService { (typeof item === "string" && encoder.encode(item).byteLength <= METADATA_VALUE_BYTES_MAX))); } + #issuePageCursor(principal: string, collection: string, offset: number): string { + const position = canonicalJson({ principal, collection, offset }); + const prior = this.#pageCursorByPosition.get(position); + if (prior !== undefined) return prior; + const cursor = `page-${++this.#pageCursorSequence}`; + this.#pageCursors.set(cursor, { principal, collection, offset }); + this.#pageCursorByPosition.set(position, cursor); + return cursor; + } + #idempotent( request: Request, principal: string, From 97fe26fc7e8831eb9c973fa3204e86cc6d53489a Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 02:59:30 +0000 Subject: [PATCH 59/70] test(t4-api): reject stale issued cursors --- .../client/test/t4-api-v1-conformance.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index fdb4bea3..8766acad 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -957,6 +957,23 @@ describe("generated T4 API v1 client conformance", () => { expect(response.status, `${path} accepted unissued cursor ${cursor}`).toBe(422); expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "cursor", rule: "issued" }] } }); } + + const laterWorkspacePage = await service.fetch(`${service.origin}/v1/workspaces?pageSize=2`, { headers }); + const staleWorkspaceCursor = (await laterWorkspacePage.json() as { nextCursor: string }).nextCursor; + expect((await service.fetch(`${service.origin}/v1/workspaces/ws-3`, { + method: "DELETE", headers: { ...headers, "Idempotency-Key": "shrink-workspace-3" }, + })).status).toBe(204); + expect((await service.fetch(`${service.origin}/v1/sessions/ses-2`, { + method: "DELETE", headers: { ...headers, "Idempotency-Key": "shrink-session-02" }, + })).status).toBe(204); + for (const [path, cursor] of [ + ["/v1/workspaces", staleWorkspaceCursor], + ["/v1/workspaces/ws-1/sessions", sessionCursor], + ]) { + const response = await service.fetch(`${service.origin}${path}?pageSize=1&cursor=${cursor}`, { headers }); + expect(response.status, `${path} accepted a cursor past the shrunken collection`).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: [{ field: "cursor", rule: "issued" }] } }); + } }); it("parses service frames incrementally across split UTF-8 and per-frame bounds", async () => { From 3e5295a68c17502e694a4c3b1d32c65ff6687c20 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:02:48 +0000 Subject: [PATCH 60/70] fix(t4-api): sign scoped page cursors --- .../test/t4-api-v1-conformance-service.ts | 76 ++++++++++--------- 1 file changed, 41 insertions(+), 35 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 76ee1663..5e381192 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -1,6 +1,8 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; import { expect } from "vite-plus/test"; const encoder = new TextEncoder(); +const PAGE_CURSOR_SECRET = "t4-api-v1-conformance-page-cursor-secret-2026"; const COMMAND_BYTES_MAX = 32; const COMMAND_REQUEST_BYTES_MAX = 256; const METADATA_VALUE_BYTES_MAX = 32; @@ -67,6 +69,35 @@ export function canonicalJson(value: unknown): string { throw new TypeError("JCS identity requires a JSON value"); } +type PageCursorResult = { readonly offset: number } | { readonly error: "format" | "issued" }; + +function issuePageCursor(principal: string, collection: string, offset: number): string { + const payload = Buffer.from(canonicalJson({ principal, collection, offset })).toString("base64url"); + const signature = createHmac("sha256", PAGE_CURSOR_SECRET).update(payload).digest("base64url"); + return `page.${payload}.${signature}`; +} + +function decodePageCursor(value: string, principal: string, collection: string): PageCursorResult { + if (value.length > 512) return { error: "format" }; + const legacy = /^page-(?:0|[1-9][0-9]*)$/u.exec(value); + if (legacy !== null) return Number.isSafeInteger(Number(value.slice(5))) ? { error: "issued" } : { error: "format" }; + const match = /^page\.([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{43})$/u.exec(value); + if (match === null) return { error: "format" }; + const expected = createHmac("sha256", PAGE_CURSOR_SECRET).update(match[1]!).digest(); + const signature = Buffer.from(match[2]!, "base64url"); + if (signature.byteLength !== expected.byteLength || !timingSafeEqual(signature, expected)) return { error: "issued" }; + let decoded: unknown; + try { decoded = JSON.parse(Buffer.from(match[1]!, "base64url").toString("utf8")); } catch { return { error: "format" }; } + if (decoded === null || typeof decoded !== "object" || Array.isArray(decoded)) return { error: "format" }; + const payload = decoded as Record; + if (Object.keys(payload).length !== 3 || !Object.hasOwn(payload, "principal") || !Object.hasOwn(payload, "collection") || !Object.hasOwn(payload, "offset") || + Buffer.from(canonicalJson(payload)).toString("base64url") !== match[1] || + typeof payload.principal !== "string" || typeof payload.collection !== "string" || + !Number.isSafeInteger(payload.offset) || Number(payload.offset) < 1) return { error: "format" }; + if (payload.principal !== principal || payload.collection !== collection) return { error: "issued" }; + return { offset: Number(payload.offset) }; +} + interface ReplayRecord { readonly identity: string; readonly body: unknown; @@ -74,11 +105,6 @@ interface ReplayRecord { readonly cursor: string; } -interface PageCursorRecord { - readonly principal: string; - readonly collection: string; - readonly offset: number; -} export interface T4ApiV1ConformanceOptions { readonly invalidPayload?: "discovery" | "workspace" | "session" | "command"; @@ -96,12 +122,9 @@ export class T4ApiV1ConformanceService { #sessionSequence = 0; #commandSequence = 0; #eventSequence = 0; - #pageCursorSequence = 0; readonly #workspaces = new Map>(); readonly #sessions = new Map>(); readonly #replays = new Map(); - readonly #pageCursors = new Map(); - readonly #pageCursorByPosition = new Map(); constructor(readonly options: T4ApiV1ConformanceOptions = {}) {} @@ -169,18 +192,14 @@ export class T4ApiV1ConformanceService { const cursor = url.searchParams.get("cursor"); let start = 0; if (cursor !== null) { - if (cursor.length > 512 || !/^page-(?:0|[1-9][0-9]*)$/u.test(cursor) || !Number.isSafeInteger(Number(cursor.slice(5)))) { - return this.#invalid("cursor", "format", "cursor must be a canonical bounded opaque token"); - } - const issued = this.#pageCursors.get(cursor); - if (issued === undefined || issued.principal !== tenant || issued.collection !== collection) { - return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); - } - start = issued.offset; + const decoded = decodePageCursor(cursor, tenant, collection); + if ("error" in decoded) return this.#invalid("cursor", decoded.error, decoded.error === "format" ? "cursor must be a canonical bounded opaque token" : "cursor was not issued for this principal and collection"); + start = decoded.offset; } const visible = [...this.#workspaces.values()].filter((item) => item.tenant === tenant); + if (cursor !== null && start >= visible.length) return this.#invalid("cursor", "issued", "cursor is outside the current collection"); const items = visible.slice(start, start + pageSize).map(({ tenant: _tenant, ...item }) => item); - const next = start + items.length < visible.length ? this.#issuePageCursor(tenant, collection, start + items.length) : undefined; + const next = start + items.length < visible.length ? issuePageCursor(tenant, collection, start + items.length) : undefined; return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } @@ -238,18 +257,14 @@ export class T4ApiV1ConformanceService { const cursor = url.searchParams.get("cursor"); let start = 0; if (cursor !== null) { - if (cursor.length > 512 || !/^page-(?:0|[1-9][0-9]*)$/u.test(cursor) || !Number.isSafeInteger(Number(cursor.slice(5)))) { - return this.#invalid("cursor", "format", "cursor must be a canonical bounded opaque token"); - } - const issued = this.#pageCursors.get(cursor); - if (issued === undefined || issued.principal !== tenant || issued.collection !== collection) { - return this.#invalid("cursor", "issued", "cursor was not issued for this principal and collection"); - } - start = issued.offset; + const decoded = decodePageCursor(cursor, tenant, collection); + if ("error" in decoded) return this.#invalid("cursor", decoded.error, decoded.error === "format" ? "cursor must be a canonical bounded opaque token" : "cursor was not issued for this principal and collection"); + start = decoded.offset; } const visible = [...this.#sessions.values()].filter((item) => item.workspaceId === workspaceId && item.tenant === tenant); + if (cursor !== null && start >= visible.length) return this.#invalid("cursor", "issued", "cursor is outside the current collection"); const items = visible.slice(start, start + pageSize).map((item) => this.#visible(item)); - const next = start + items.length < visible.length ? this.#issuePageCursor(tenant, collection, start + items.length) : undefined; + const next = start + items.length < visible.length ? issuePageCursor(tenant, collection, start + items.length) : undefined; return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); } } @@ -421,15 +436,6 @@ export class T4ApiV1ConformanceService { (typeof item === "string" && encoder.encode(item).byteLength <= METADATA_VALUE_BYTES_MAX))); } - #issuePageCursor(principal: string, collection: string, offset: number): string { - const position = canonicalJson({ principal, collection, offset }); - const prior = this.#pageCursorByPosition.get(position); - if (prior !== undefined) return prior; - const cursor = `page-${++this.#pageCursorSequence}`; - this.#pageCursors.set(cursor, { principal, collection, offset }); - this.#pageCursorByPosition.set(position, cursor); - return cursor; - } #idempotent( request: Request, From 5ae50a074b96eabb95190dc91ed6af7b07104284 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:07:22 +0000 Subject: [PATCH 61/70] test(t4-api): reject noncanonical cursor signatures --- packages/client/test/t4-api-v1-conformance.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 8766acad..0e334b07 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -943,6 +943,12 @@ describe("generated T4 API v1 client conformance", () => { const workspacePage = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1`, { headers }); const workspaceCursor = (await workspacePage.json() as { nextCursor: string }).nextCursor; expect((await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${workspaceCursor}`, { headers })).status).toBe(200); + const signatureAlias = ({ A: "B", Q: "R", g: "h", w: "x" } as const)[workspaceCursor.at(-1) as "A" | "Q" | "g" | "w"]; + expect(signatureAlias).toBeDefined(); + const noncanonicalCursor = `${workspaceCursor.slice(0, -1)}${signatureAlias}`; + const noncanonical = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${noncanonicalCursor}`, { headers }); + expect(noncanonical.status).toBe(422); + expect(await noncanonical.json()).toMatchObject({ error: { code: "invalid_request" } }); const sessionPage = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions?pageSize=1`, { headers }); const sessionCursor = (await sessionPage.json() as { nextCursor: string }).nextCursor; From 9cd5494a4eb62974984ec9a8f420c88f95cc742d Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:10:31 +0000 Subject: [PATCH 62/70] fix(t4-api): require canonical cursor signatures --- packages/client/test/t4-api-v1-conformance-service.ts | 1 + packages/client/test/t4-api-v1-conformance.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 5e381192..c2065516 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -85,6 +85,7 @@ function decodePageCursor(value: string, principal: string, collection: string): if (match === null) return { error: "format" }; const expected = createHmac("sha256", PAGE_CURSOR_SECRET).update(match[1]!).digest(); const signature = Buffer.from(match[2]!, "base64url"); + if (signature.toString("base64url") !== match[2]) return { error: "format" }; if (signature.byteLength !== expected.byteLength || !timingSafeEqual(signature, expected)) return { error: "issued" }; let decoded: unknown; try { decoded = JSON.parse(Buffer.from(match[1]!, "base64url").toString("utf8")); } catch { return { error: "format" }; } diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 0e334b07..fb8b3ecb 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -182,7 +182,7 @@ describe("generated T4 API v1 client conformance", () => { params: { header: VERSION_HEADERS, query: { pageSize: 3 } }, })); expect(pageOne.items).toHaveLength(3); - expect(pageOne.nextCursor).toBe("page-3"); + expect(pageOne.nextCursor).toMatch(/^[A-Za-z0-9._~-]+$/u); const pageTwo = requireData(await owner.http.GET("/v1/workspaces", { params: { header: VERSION_HEADERS, query: { pageSize: 3, cursor: pageOne.nextCursor! } }, })); @@ -289,7 +289,7 @@ describe("generated T4 API v1 client conformance", () => { params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2 } }, })); expect(sessionPageOne.items).toHaveLength(2); - expect(sessionPageOne.nextCursor).toBe("page-2"); + expect(sessionPageOne.nextCursor).toMatch(/^[A-Za-z0-9._~-]+$/u); const sessionPageTwo = requireData(await client.http.GET("/v1/workspaces/{workspaceId}/sessions", { params: { header: VERSION_HEADERS, path: { workspaceId: "ws-1" }, query: { pageSize: 2, cursor: sessionPageOne.nextCursor! } }, })); From 00c27f3271e9048463b347bf9fcc791737f8f96b Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:16:15 +0000 Subject: [PATCH 63/70] test(t4-api): cover every cursor signature alias --- packages/client/test/t4-api-v1-conformance.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index fb8b3ecb..8e31ad27 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -943,9 +943,11 @@ describe("generated T4 API v1 client conformance", () => { const workspacePage = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1`, { headers }); const workspaceCursor = (await workspacePage.json() as { nextCursor: string }).nextCursor; expect((await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${workspaceCursor}`, { headers })).status).toBe(200); - const signatureAlias = ({ A: "B", Q: "R", g: "h", w: "x" } as const)[workspaceCursor.at(-1) as "A" | "Q" | "g" | "w"]; - expect(signatureAlias).toBeDefined(); - const noncanonicalCursor = `${workspaceCursor.slice(0, -1)}${signatureAlias}`; + const base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + const terminalIndex = base64UrlAlphabet.indexOf(workspaceCursor.at(-1)!); + expect(terminalIndex).toBeGreaterThanOrEqual(0); + expect(terminalIndex % 4).toBe(0); + const noncanonicalCursor = `${workspaceCursor.slice(0, -1)}${base64UrlAlphabet[terminalIndex + 1]}`; const noncanonical = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${noncanonicalCursor}`, { headers }); expect(noncanonical.status).toBe(422); expect(await noncanonical.json()).toMatchObject({ error: { code: "invalid_request" } }); From da2f32d4d4475beddb065a19dc522339c4cc1d78 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:22:06 +0000 Subject: [PATCH 64/70] test(t4-api): reject every cursor signature alias --- .../client/test/t4-api-v1-conformance.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 8e31ad27..903e014e 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -944,13 +944,25 @@ describe("generated T4 API v1 client conformance", () => { const workspaceCursor = (await workspacePage.json() as { nextCursor: string }).nextCursor; expect((await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${workspaceCursor}`, { headers })).status).toBe(200); const base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - const terminalIndex = base64UrlAlphabet.indexOf(workspaceCursor.at(-1)!); + const signatureSegment = workspaceCursor.split(".").at(-1)!; + const encodedRemainder = signatureSegment.length % 4; + const unusedBits = encodedRemainder === 2 ? 4 : encodedRemainder === 3 ? 2 : 0; + const terminalIndex = base64UrlAlphabet.indexOf(signatureSegment.at(-1)!); expect(terminalIndex).toBeGreaterThanOrEqual(0); - expect(terminalIndex % 4).toBe(0); - const noncanonicalCursor = `${workspaceCursor.slice(0, -1)}${base64UrlAlphabet[terminalIndex + 1]}`; - const noncanonical = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${noncanonicalCursor}`, { headers }); - expect(noncanonical.status).toBe(422); - expect(await noncanonical.json()).toMatchObject({ error: { code: "invalid_request" } }); + const aliasMask = (1 << unusedBits) - 1; + expect(terminalIndex & aliasMask).toBe(0); + const signatureAliases = [...base64UrlAlphabet.slice( + terminalIndex & ~aliasMask, + (terminalIndex & ~aliasMask) + aliasMask + 1, + )].filter((terminal) => terminal !== signatureSegment.at(-1)); + expect(signatureAliases.length).toBeGreaterThan(0); + expect(signatureAliases).toHaveLength(3); + for (const signatureAlias of signatureAliases) { + const noncanonicalCursor = `${workspaceCursor.slice(0, -1)}${signatureAlias}`; + const noncanonical = await service.fetch(`${service.origin}/v1/workspaces?pageSize=1&cursor=${noncanonicalCursor}`, { headers }); + expect(noncanonical.status).toBe(422); + expect(await noncanonical.json()).toMatchObject({ error: { code: "invalid_request" } }); + } const sessionPage = await service.fetch(`${service.origin}/v1/workspaces/ws-1/sessions?pageSize=1`, { headers }); const sessionCursor = (await sessionPage.json() as { nextCursor: string }).nextCursor; From 5c8ae4144006b0ddf4837394ddc7a252e3aba1eb Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:28:02 +0000 Subject: [PATCH 65/70] test(t4-api): avoid needless alias allocation --- packages/client/test/t4-api-v1-conformance.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 903e014e..e4499222 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -951,10 +951,10 @@ describe("generated T4 API v1 client conformance", () => { expect(terminalIndex).toBeGreaterThanOrEqual(0); const aliasMask = (1 << unusedBits) - 1; expect(terminalIndex & aliasMask).toBe(0); - const signatureAliases = [...base64UrlAlphabet.slice( + const signatureAliases = base64UrlAlphabet.slice( terminalIndex & ~aliasMask, (terminalIndex & ~aliasMask) + aliasMask + 1, - )].filter((terminal) => terminal !== signatureSegment.at(-1)); + ).replace(signatureSegment.at(-1)!, ""); expect(signatureAliases.length).toBeGreaterThan(0); expect(signatureAliases).toHaveLength(3); for (const signatureAlias of signatureAliases) { From 4859cac7759c6dbc6bd5bdd82ee0b07585078f39 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:45:36 +0000 Subject: [PATCH 66/70] test(t4-api): reject oversized snapshots and duplicate cursors --- .../client/test/t4-api-v1-conformance.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index e4499222..51d5b1a1 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -1389,6 +1389,42 @@ describe("generated T4 API v1 client conformance", () => { await expect(missingWatchErrorVersion.watchSession("ses-1", { maxEvents: 1, maxReconnectAttempts: 0, retryBackoffMs: 0 }).next()).rejects.toMatchObject({ status: 502, retryable: false }); }); + it("aligns the snapshot aggregate schema bound with high-level response validation", async () => { + const contract = JSON.parse(readFileSync(new URL("../../t4-api-contract/openapi.json", import.meta.url), "utf8")) as { + components: { schemas: { SessionSnapshot: { "x-t4-maxUtf8Bytes"?: number } } }; + }; + expect(contract.components.schemas.SessionSnapshot["x-t4-maxUtf8Bytes"]).toBe(16 * 1024 * 1024); + + const oversizedSnapshot = createT4ApiClient({ + baseUrl: "https://aggregate-snapshot.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse({ + sessionId: "ses-1", cursor: "cursor-1", state: "ready", + entries: Array.from({ length: 17 }, (_, sequence) => ({ sequence, kind: "output", text: "x".repeat(1024 * 1024) })), + }), + }); + await expect(oversizedSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + }); + + it("rejects duplicate durable cursors within one SSE stream and after reconnect", async () => { + const event = 'data: {"type":"heartbeat","cursor":"duplicate-1","observedAt":"2026-07-21T00:00:00Z"}\n\n'; + for (const delivery of ["same-stream", "reconnect"] as const) { + let attempts = 0; + const client = createT4ApiClient({ + baseUrl: `https://duplicate-${delivery}.test`, credential: "token-a", majorVersion: 1, + fetch: async () => { + attempts += 1; + return apiResponse(delivery === "same-stream" ? `${event}${event}` : event, { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + const stream = client.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 }); + await expect(stream.next()).resolves.toMatchObject({ value: { cursor: "duplicate-1" }, done: false }); + await expect(stream.next()).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + expect(attempts).toBe(delivery === "same-stream" ? 1 : 2); + } + }); + it("rejects duplicate version entries and malformed capability maps", async () => { const status = { supported: true, enabled: true, authorized: true, available: true }; for (const discovery of [ From 9ce270c1cfe19750824509b42c0636eff7f09346 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 03:49:59 +0000 Subject: [PATCH 67/70] fix(t4-api): align snapshot and watch cursor bounds --- packages/t4-api-client/src/index.ts | 4 ++++ packages/t4-api-contract/openapi.json | 1 + packages/t4-api-contract/scripts/validate.mjs | 1 + 3 files changed, 6 insertions(+) diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 4058a424..84b3bf82 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -805,6 +805,8 @@ async function* watch( let delivered = 0; let reconnectAttempts = 0; let cursor = options.cursor; + const deliveredCursors = new Set(); + if (cursor !== undefined) deliveredCursors.add(cursor); try { while (delivered < maxEvents && !controller.signal.aborted) { const deliveredAtAttemptStart = delivered; @@ -859,6 +861,8 @@ async function* watch( const chunk = await reader.read(); const events = chunk.done ? decodedFrames(parser, undefined) : decodedFrames(parser, chunk.value); for (const event of events) { + if (deliveredCursors.has(event.cursor)) throw protocolError(502, "T4 API watch repeated a durable event cursor"); + deliveredCursors.add(event.cursor); cursor = event.cursor; delivered += 1; reconnectAttempts = 0; diff --git a/packages/t4-api-contract/openapi.json b/packages/t4-api-contract/openapi.json index 12bb55c5..66d77c91 100644 --- a/packages/t4-api-contract/openapi.json +++ b/packages/t4-api-contract/openapi.json @@ -634,6 +634,7 @@ "SessionSnapshot": { "type": "object", "additionalProperties": false, + "x-t4-maxUtf8Bytes": 16777216, "required": ["sessionId", "cursor", "state", "entries"], "properties": { "sessionId": { "$ref": "#/components/schemas/ResourceId" }, diff --git a/packages/t4-api-contract/scripts/validate.mjs b/packages/t4-api-contract/scripts/validate.mjs index 1e594993..b7eb134a 100644 --- a/packages/t4-api-contract/scripts/validate.mjs +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -22,6 +22,7 @@ if (commandCreate?.["x-t4-maxUtf8Bytes"] !== 1048576) throw new Error("CommandCr if (commandCreate?.properties?.command?.["x-t4-maxUtf8Bytes"] !== 262144) throw new Error("command must retain its UTF-8 byte bound"); const metadataString = commandCreate?.properties?.metadata?.additionalProperties?.oneOf?.find((item) => item?.type === "string"); if (metadataString?.["x-t4-maxUtf8Bytes"] !== 262144) throw new Error("command metadata strings must retain their UTF-8 byte bound"); +if (schemas.SessionSnapshot?.["x-t4-maxUtf8Bytes"] !== 16777216) throw new Error("SessionSnapshot must retain its aggregate UTF-8 response-byte bound"); for (const [schema, property] of [["Discovery", "supportedMajors"], ["ApiError", "supportedMajors"]]) { if (schemas[schema]?.properties?.[property]?.uniqueItems !== true) throw new Error(`${schema}.${property} must retain uniqueItems`); From 1e22cdde25924c22cd524664189de98afad5fb61 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 04:21:02 +0000 Subject: [PATCH 68/70] test(t4-api): cover snapshot boundary and heartbeat cursors --- .../test/t4-api-v1-conformance-service.ts | 24 +++++++ .../client/test/t4-api-v1-conformance.test.ts | 65 +++++++++++++++---- 2 files changed, 78 insertions(+), 11 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index c2065516..12fad3b7 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -47,6 +47,26 @@ function json(status: number, body: unknown, headers: Record = { function problem(status: number, code: string, message: string, extra: Record = {}): Response { return json(status, { error: { code, message, requestId: `req-${code}`, retryable: status >= 500, ...extra } }); } +const SNAPSHOT_BYTES_MAX = 16 * 1024 * 1024; + +function snapshotResponseAtBytes(sessionId: unknown, state: unknown, targetBytes: number): Response { + const entries = Array.from({ length: 4 }, (_, sequence) => ({ sequence, kind: "output", text: "" })); + const snapshot = { sessionId, cursor: "cursor-2", state, entries }; + let remaining = targetBytes - encoder.encode(JSON.stringify(snapshot)).byteLength; + for (const entry of entries) { + const astralCodePoints = Math.min(1_048_576, Math.floor(remaining / 4)); + entry.text = "💚".repeat(astralCodePoints); + remaining -= astralCodePoints * 4; + if (remaining > 0 && remaining < 4) { + entry.text += "x".repeat(remaining); + remaining = 0; + } + } + expect(remaining).toBe(0); + const body = JSON.stringify(snapshot); + expect(encoder.encode(body).byteLength).toBe(targetBytes); + return new Response(body, { status: 200, headers: { "Content-Type": "application/json", "T4-API-Version": "1.0" } }); +} function compareUtf16(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; @@ -109,6 +129,7 @@ interface ReplayRecord { export interface T4ApiV1ConformanceOptions { readonly invalidPayload?: "discovery" | "workspace" | "session" | "command"; + readonly snapshotBoundary?: "exact" | "over"; readonly watchTransport?: "normal" | "bytewise" | "oversized" | "many-small"; } @@ -336,6 +357,9 @@ export class T4ApiV1ConformanceService { if (snapshotMatch && request.method === "GET") { const session = this.#sessions.get(decodeURIComponent(snapshotMatch[1]!)); if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + if (this.options.snapshotBoundary !== undefined) { + return snapshotResponseAtBytes(session.id, session.state, SNAPSHOT_BYTES_MAX + (this.options.snapshotBoundary === "over" ? 1 : 0)); + } return json(200, { sessionId: session.id, cursor: "cursor-2", state: session.state, entries: [{ sequence: 2, kind: "output", text: "ready" }] }); } diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index 51d5b1a1..ebf0aee3 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -1395,20 +1395,24 @@ describe("generated T4 API v1 client conformance", () => { }; expect(contract.components.schemas.SessionSnapshot["x-t4-maxUtf8Bytes"]).toBe(16 * 1024 * 1024); - const oversizedSnapshot = createT4ApiClient({ - baseUrl: "https://aggregate-snapshot.test", credential: "token-a", majorVersion: 1, - fetch: async () => jsonResponse({ - sessionId: "ses-1", cursor: "cursor-1", state: "ready", - entries: Array.from({ length: 17 }, (_, sequence) => ({ sequence, kind: "output", text: "x".repeat(1024 * 1024) })), - }), - }); - await expect(oversizedSnapshot.http.GET("/v1/sessions/{sessionId}/snapshot", { - params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, - })).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + for (const boundary of ["exact", "over"] as const) { + const service = new T4ApiV1ConformanceService({ snapshotBoundary: boundary }); + const client = await seededClient(service, `snapshot-${boundary}`); + const response = client.http.GET("/v1/sessions/{sessionId}/snapshot", { + params: { header: VERSION_HEADERS, path: { sessionId: "ses-1" } }, + }); + if (boundary === "exact") { + const snapshot = requireData(await response); + expect(snapshot.entries).toHaveLength(4); + expect(snapshot.entries[0]?.text).toContain("💚"); + } else { + await expect(response).rejects.toMatchObject({ code: "indeterminate", status: 502, retryable: false }); + } + } }); it("rejects duplicate durable cursors within one SSE stream and after reconnect", async () => { - const event = 'data: {"type":"heartbeat","cursor":"duplicate-1","observedAt":"2026-07-21T00:00:00Z"}\n\n'; + const event = 'data: {"type":"session","cursor":"duplicate-1","state":"ready","revision":1}\n\n'; for (const delivery of ["same-stream", "reconnect"] as const) { let attempts = 0; const client = createT4ApiClient({ @@ -1425,6 +1429,45 @@ describe("generated T4 API v1 client conformance", () => { } }); + it("accepts current-cursor heartbeats without allowing them to advance the durable cursor", async () => { + const heartbeat = (cursor: string) => `data: ${JSON.stringify({ type: "heartbeat", cursor, observedAt: "2026-07-21T00:00:00Z" })}\n\n`; + const startingRequests: Array<{ query: string | null; header: string | null }> = []; + const starting = createT4ApiClient({ + baseUrl: "https://starting-heartbeat.test", credential: "token-a", majorVersion: 1, + fetch: async (input, init) => { + const request = new Request(input, init); + startingRequests.push({ query: new URL(request.url).searchParams.get("cursor"), header: request.headers.get("Last-Event-ID") }); + return apiResponse(heartbeat("duplicate-1"), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + await expect(starting.watchSession("ses-1", { cursor: "duplicate-1", maxEvents: 1, maxReconnectAttempts: 0 }).next()).resolves.toMatchObject({ + value: { type: "heartbeat", cursor: "duplicate-1" }, done: false, + }); + expect(startingRequests).toEqual([{ query: "duplicate-1", header: "duplicate-1" }]); + + const reconnectRequests: Array<{ query: string | null; header: string | null }> = []; + const reconnecting = createT4ApiClient({ + baseUrl: "https://reconnect-heartbeat.test", credential: "token-a", majorVersion: 1, + fetch: async (input, init) => { + const request = new Request(input, init); + reconnectRequests.push({ query: new URL(request.url).searchParams.get("cursor"), header: request.headers.get("Last-Event-ID") }); + return apiResponse(heartbeat("duplicate-1"), { headers: { "Content-Type": "text/event-stream" } }); + }, + }); + const liveness: WatchEvent[] = []; + for await (const event of reconnecting.watchSession("ses-1", { maxEvents: 2, maxReconnectAttempts: 1, retryBackoffMs: 0 })) liveness.push(event); + expect(liveness.map((event) => event.cursor)).toEqual(["duplicate-1", "duplicate-1"]); + expect(reconnectRequests).toEqual([{ query: null, header: null }, { query: "duplicate-1", header: "duplicate-1" }]); + + const advancing = createT4ApiClient({ + baseUrl: "https://advancing-heartbeat.test", credential: "token-a", majorVersion: 1, + fetch: async () => apiResponse(heartbeat("different-2"), { headers: { "Content-Type": "text/event-stream" } }), + }); + await expect(advancing.watchSession("ses-1", { cursor: "duplicate-1", maxEvents: 1, maxReconnectAttempts: 0 }).next()).rejects.toMatchObject({ + code: "indeterminate", status: 502, retryable: false, + }); + }); + it("rejects duplicate version entries and malformed capability maps", async () => { const status = { supported: true, enabled: true, authorized: true, available: true }; for (const discovery of [ From b9163ce0761cb388f4e0dbc8947610359daf41e4 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 04:23:55 +0000 Subject: [PATCH 69/70] fix(t4-api): preserve heartbeat cursor semantics --- packages/t4-api-client/src/index.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts index 84b3bf82..412d6756 100644 --- a/packages/t4-api-client/src/index.ts +++ b/packages/t4-api-client/src/index.ts @@ -861,9 +861,18 @@ async function* watch( const chunk = await reader.read(); const events = chunk.done ? decodedFrames(parser, undefined) : decodedFrames(parser, chunk.value); for (const event of events) { - if (deliveredCursors.has(event.cursor)) throw protocolError(502, "T4 API watch repeated a durable event cursor"); - deliveredCursors.add(event.cursor); - cursor = event.cursor; + if (event.type === "heartbeat") { + if (cursor === undefined) { + cursor = event.cursor; + deliveredCursors.add(event.cursor); + } else if (event.cursor !== cursor) { + throw protocolError(502, "T4 API watch heartbeat advanced the durable event cursor"); + } + } else { + if (deliveredCursors.has(event.cursor)) throw protocolError(502, "T4 API watch repeated a durable event cursor"); + deliveredCursors.add(event.cursor); + cursor = event.cursor; + } delivered += 1; reconnectAttempts = 0; yield event; From 07688834242b4a6243b682ef37a8e579bcfc91eb Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Wed, 22 Jul 2026 04:38:55 +0000 Subject: [PATCH 70/70] test(t4-api): model heartbeat cursor liveness --- packages/client/test/t4-api-v1-conformance-service.ts | 6 +++--- packages/client/test/t4-api-v1-conformance.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts index 12fad3b7..312a2b84 100644 --- a/packages/client/test/t4-api-v1-conformance-service.ts +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -381,18 +381,18 @@ export class T4ApiV1ConformanceService { const cursor = queryCursor ?? headerCursor; if (cursor === "expired") return problem(410, "cursor_expired", "Watch cursor is no longer retained", { resync: { snapshotUrl: `/v1/sessions/${id}/snapshot`, cursor: "cursor-2" } }); const allFrames = cursor === "cursor-4" - ? [`id: cursor-5\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-5","observedAt":"2026-07-21T00:00:15Z"}\r\n\r\n`] + ? [`id: cursor-4\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-4","observedAt":"2026-07-21T00:00:15Z"}\r\n\r\n`] : cursor === "cursor-5" ? [`id: cursor-6\nevent: command\ndata: {"type":"command","cursor":"cursor-6","commandId":"cmd-1","state":"accepted"}\n\n`] : [ - `id: cursor-3\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"cursor-3","observedAt":"2026-07-21T00:00:00Z"}\r\n\r\n`, + `id: ${cursor ?? "cursor-3"}\r\nevent: heartbeat\r\ndata: {"type":"heartbeat","cursor":"${cursor ?? "cursor-3"}","observedAt":"2026-07-21T00:00:00Z"}\r\n\r\n`, `id: cursor-4\r\nevent: session\r\ndata: {"type":"session","cursor":"cursor-4","state":"accepted","revision":2}\r\n\r\n`, ]; let payload: Uint8Array; if (this.options.watchTransport === "oversized") { payload = encoder.encode(`: ${"x".repeat(1024 * 1024)}\ndata: {"type":"heartbeat","cursor":"large-1","observedAt":"2026-07-21T00:00:00Z"}\n\n`); } else if (this.options.watchTransport === "many-small") { - payload = encoder.encode(Array.from({ length: maxEvents }, (_, index) => `: ${"x".repeat(1010)}\ndata: {"type":"heartbeat","cursor":"bulk-${index}","observedAt":"2026-07-21T00:00:00Z"}\n\n`).join("")); + payload = encoder.encode(Array.from({ length: maxEvents }, () => `: ${"x".repeat(1010)}\ndata: {"type":"heartbeat","cursor":"bulk-0","observedAt":"2026-07-21T00:00:00Z"}\n\n`).join("")); } else { const prefix = this.options.watchTransport === "bytewise" ? ": 💚\n" : ""; payload = encoder.encode(prefix + allFrames.slice(0, maxEvents).join("")); diff --git a/packages/client/test/t4-api-v1-conformance.test.ts b/packages/client/test/t4-api-v1-conformance.test.ts index ebf0aee3..ea8afef9 100644 --- a/packages/client/test/t4-api-v1-conformance.test.ts +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -797,9 +797,9 @@ describe("generated T4 API v1 client conformance", () => { received.push(event); } expect(received).toEqual([ - expect.objectContaining({ type: "heartbeat", cursor: "cursor-3" }), + expect.objectContaining({ type: "heartbeat", cursor: "cursor-2" }), expect.objectContaining({ type: "session", cursor: "cursor-4", state: "accepted" }), - expect.objectContaining({ type: "heartbeat", cursor: "cursor-5" }), + expect.objectContaining({ type: "heartbeat", cursor: "cursor-4" }), ]); expect(received.map(exhaustWatchEvent)).toEqual(["2026-07-21T00:00:00Z", "accepted:2", "2026-07-21T00:00:15Z"]); expect(service.watchCursors[0]).toEqual({ query: "cursor-2", header: "cursor-2" }); @@ -1621,7 +1621,7 @@ describe("generated T4 API v1 client conformance", () => { progressAttempts += 1; if (progressAttempts === 2 || progressAttempts === 4) { const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; - return apiResponse(`data: {"type":"heartbeat","cursor":"${cursor}","observedAt":"2026-07-21T00:00:00Z"}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); + return apiResponse(`data: {"type":"session","cursor":"${cursor}","state":"ready","revision":${progressAttempts / 2}}\n\n`, { headers: { "Content-Type": "text/event-stream" } }); } return apiResponse(new ReadableStream({ start(controller) { controller.close(); } }), { headers: { "Content-Type": "text/event-stream" } }); };