diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8872e37..f5f56a9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,46 @@ 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: Install dependencies from the committed lockfile + run: pnpm install --frozen-lockfile + + - 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: runs-on: ubuntu-24.04 timeout-minutes: 25 @@ -521,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: @@ -529,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 }} @@ -541,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 4f8c556e..78d6da92 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -31,6 +31,32 @@ 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 + - 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 + - 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 +76,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/client/test/t4-api-v1-conformance-service.ts b/packages/client/test/t4-api-v1-conformance-service.ts new file mode 100644 index 00000000..312a2b84 --- /dev/null +++ b/packages/client/test/t4-api-v1-conformance-service.ts @@ -0,0 +1,513 @@ +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; + +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 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 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]; + 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 { + 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 } }); +} + +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; +} + +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) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => compareUtf16(left, right)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(",")}}`; + } + 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.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" }; } + 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; + readonly replayStatus: number; + readonly cursor: string; +} + + +export interface T4ApiV1ConformanceOptions { + readonly invalidPayload?: "discovery" | "workspace" | "session" | "command"; + readonly snapshotBoundary?: "exact" | "over"; + 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; + #commandSequence = 0; + #eventSequence = 0; + readonly #workspaces = new Map>(); + 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); + 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" && 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] }); + } + 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, this.#payload("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 }, + "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, + commandBytesMax: COMMAND_BYTES_MAX, + commandRequestBytesMax: COMMAND_REQUEST_BYTES_MAX, + commandMetadataValueBytesMax: METADATA_VALUE_BYTES_MAX, + watchEventsMax: this.options.watchTransport === "many-small" ? 1000 : 4, + heartbeatSeconds: 15, + }, + })); + } + + if (request.method === "POST" && url.pathname === "/v1/workspaces") { + const parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; + 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 }; + 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 this.#invalid("pageSize", "range", "pageSize must be between 1 and 3"); + const collection = "workspaces"; + const cursor = url.searchParams.get("cursor"); + let start = 0; + if (cursor !== null) { + 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 ? issuePageCursor(tenant, collection, 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]!); + 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, () => { + 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") { + 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, ...(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")); + } + } + + 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 parsed = await this.#jsonBody(request); + if (parsed instanceof Response) return parsed; + const body = parsed; + 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 }; + this.#sessions.set(id, session); + return session; + }); + } + 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 collection = `workspaces/${workspaceId}/sessions`; + const cursor = url.searchParams.get("cursor"); + let start = 0; + if (cursor !== null) { + 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 ? issuePageCursor(tenant, collection, start + items.length) : undefined; + return json(200, { items, ...(next === undefined ? {} : { nextCursor: next }) }); + } + } + + 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, () => { + 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") { + 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, ...(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")); + } + } + + const cancelMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)\/cancel$/u); + if (cancelMatch && request.method === "POST") { + const id = decodeURIComponent(cancelMatch[1]!); + const session = this.#sessions.get(id); + if (session?.tenant !== tenant) return problem(404, "not_found", "Session not found"); + 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 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; + 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, 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 })); + } + + 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"); + 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" }] }); + } + + 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"); + const maxEvents = Number(url.searchParams.get("maxEvents") ?? "100"); + const heartbeat = Number(url.searchParams.get("heartbeatSeconds") ?? "15"); + 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"); + 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 allFrames = cursor === "cursor-4" + ? [`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 ?? "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 }, () => `: ${"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("")); + } + const stream = new ReadableStream({ + start: (controller) => { + 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 }); + }, + }); + 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" || 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"); + 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 }] }); + } + + #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, + principal: string, + operationId: string, + targets: readonly string[], + body: unknown, + 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"); + 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"); + 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 }) + : 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; + 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, cursor }); + const headers = { "Idempotency-Replayed": "false", "T4-Event-Cursor": cursor }; + return visible === null + ? 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 new file mode 100644 index 00000000..ea8afef9 --- /dev/null +++ b/packages/client/test/t4-api-v1-conformance.test.ts @@ -0,0 +1,1662 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it, vi } from "vite-plus/test"; + +import { + T4ApiError, + createT4ApiClient, + type components, +} from "@t4-code/t4-api-client"; +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"]; + +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; + +const COMMAND_STATES = [ + "accepted", "projected", "dispatching", "running", "succeeded", "failed", + "cancelling", "cancelled", "rejected", "unavailable", "indeterminate", +] as const satisfies readonly components["schemas"]["CommandState"][]; +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"); + 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 }; +} + +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 }; +} + +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 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(); + 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 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: { + "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 }, + }); + + const incompatible = createT4ApiClient({ baseUrl: service.origin, credential: "token-a", majorVersion: 2, fetch: service.fetch }); + 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] } }); + + 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 } }); + + 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).toMatchObject({ error: { code: "forbidden" } }); + expect(discovery.limits).toMatchObject({ commandBytesMax: 32, commandRequestBytesMax: 256, commandMetadataValueBytesMax: 32 }); + }); + + 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", 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: { 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" }, + params: { header: idempotencyHeaders("workspace-create-0001") }, + }); + 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 }, + params: { header: idempotencyHeaders(`workspace-${name}-0001`) }, + })); + } + const pageOne = requireData(await owner.http.GET("/v1/workspaces", { + params: { header: VERSION_HEADERS, query: { pageSize: 3 } }, + })); + expect(pageOne.items).toHaveLength(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! } }, + })); + expect(pageTwo.items).toHaveLength(2); + expect(pageTwo.nextCursor).toBeUndefined(); + + 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(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: { header: mutationHeaders(1, "workspace-patch-0001"), path: { workspaceId: "ws-1" } }, + 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" }, + }); + 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); + 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 }); + 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: { 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: { header: idempotencyHeaders("session-spawn-0001"), path: { workspaceId: "ws-1" } }, + body, + }); + expect(replay.data).toEqual(session); + 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).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! } }, + })); + expect(sessionPageTwo.items).toHaveLength(1); + + const mutated = requireData(await client.http.PATCH("/v1/sessions/{sessionId}", { + 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" }, + }); + expect(stale.response.status).toBe(409); + expect(stale.error).toMatchObject({ error: { code: "revision_conflict" } }); + + 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, + }); + expect(requireData(result).state).toBe(state); + const commandReplay = await client.http.POST("/v1/sessions/{sessionId}/commands", { + 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: { header: idempotencyHeaders("command-oversized-0001"), path: { sessionId: "ses-1" } }, + 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 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 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: idempotencyHeaders("session-cancel-0001"), path: { sessionId: "ses-1" } }, + }); + expect(cancelled.response.status).toBe(202); + 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"); + 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("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("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"])), + { 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("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-${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" }], + ["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-${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, + }); + 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 }); + 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") }, + })); + 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 an invalid declared watch cache header", 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); + }); + + 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 [ + "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, + 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("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("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("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", + "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.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(); + } + }); + + 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 }); + 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: { 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" } }, + })); + 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, heartbeatSeconds: 20, maxReconnectAttempts: 2, retryBackoffMs: 0 })) { + received.push(event); + } + expect(received).toEqual([ + expect.objectContaining({ type: "heartbeat", cursor: "cursor-2" }), + expect.objectContaining({ type: "session", cursor: "cursor-4", state: "accepted" }), + 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" }); + 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 })) { + 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); + }); + + 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("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" }; + 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({ 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"}' }); + 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 === undefined ? {} : { body }) }); + 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, Accept: "text/event-stream" } }); + expect(response.status).toBe(422); + expect(await response.json()).toMatchObject({ error: { code: "invalid_request", violations: expect.any(Array) } }); + } + }); + + 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("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 base64UrlAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + 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); + const aliasMask = (1 << unusedBits) - 1; + expect(terminalIndex & aliasMask).toBe(0); + const signatureAliases = base64UrlAlphabet.slice( + terminalIndex & ~aliasMask, + (terminalIndex & ~aliasMask) + aliasMask + 1, + ).replace(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; + + 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" }] } }); + } + + 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 () => { + 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 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 () => apiResponse(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 () => { + 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 () => 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 () => 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 }); + } + }); + + 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 () => 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: 502, retryable: false }); + } + + const malformedClient = createT4ApiClient({ + baseUrl: "https://malformed-error.test", credential: "token-a", majorVersion: 1, + 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: 502, retryable: false }); + + const oversizedClient = createT4ApiClient({ + baseUrl: "https://oversized-error.test", credential: "token-a", majorVersion: 1, + 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: 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 () => 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: 502, retryable: false }); + expect(cancelled).toBe(true); + } + + const statusClient = createT4ApiClient({ + 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: 502, retryable: false }); + }); + + it("validates successful JSON routes relative to the base path and rejects undeclared media and statuses", async () => { + const discovery = DISCOVERY; + 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 jsonResponse({ ...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/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 jsonResponse({ 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 () => 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" } }, + })); + 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 () => 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" } }, + })); + expect(workspace).toMatchObject({ name: astral128, labels: { team: astral128 } }); + + const unicodeSession = createT4ApiClient({ + baseUrl: "https://unicode-session.test", credential: "token-a", majorVersion: 1, + 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" } }, + })); + 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 () => jsonResponse({ 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 [ + 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 }); + } + + const unknownRoute = createT4ApiClient({ + baseUrl: "https://dispatch.test", credential: "token-a", majorVersion: 1, + fetch: async () => jsonResponse(discovery), + }); + 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 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" } }); + + 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 () => 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 [ + 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({ + 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 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 }>; + 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).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" }, + }); + 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 () => { + 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 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({ + code: "indeterminate", status: 502, retryable: false, + }); + 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 () => apiResponse(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, + fetch: async () => apiResponse(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 () => { + 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("requires declared selected-version and replay response headers", async () => { + 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, + 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 () => 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" }, + })).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, + fetch: async () => apiResponse(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("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); + + 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":"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({ + 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("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 [ + { ...DISCOVERY, supportedMajors: [1, 1] }, + { ...DISCOVERY, capabilities: { Invalid: status } }, + { ...DISCOVERY, capabilities: { "session.watch.sse": { ...status, unknown: true } } }, + ]) { + const client = createT4ApiClient({ + 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 }); + } + const duplicateError = createT4ApiClient({ + baseUrl: "https://duplicate-error.test", credential: "token-a", majorVersion: 1, + 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: 502, 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 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" } }); + 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 () => 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" } }), + }); + 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 () => { + attempts += 1; + 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 }); + 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) { + let sent = false; + return apiResponse(new ReadableStream({ + 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" } }); + } + 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[] = []; + 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"]); + + let retryableAttempts = 0; + const retryableFetch: typeof globalThis.fetch = async () => { + retryableAttempts += 1; + 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" }); + expect(retryableAttempts).toBe(2); + + let progressAttempts = 0; + const progressFetch: typeof globalThis.fetch = async () => { + progressAttempts += 1; + if (progressAttempts === 2 || progressAttempts === 4) { + const cursor = progressAttempts === 2 ? "progress-1" : "progress-2"; + 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" } }); + }; + const progressClient = createT4ApiClient({ baseUrl: "https://progress.test", credential: "token-a", majorVersion: 1, fetch: progressFetch }); + 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 { + const retryAfterController = new AbortController(); + let retryAfterAttempts = 0; + const retryAfterFetch: typeof globalThis.fetch = async () => { + retryAfterAttempts += 1; + 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(); + 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) => 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 }); + const pending = abortClient.watchSession("ses-1", { signal: controller.signal }).next(); + controller.abort("done"); + await expect(pending).resolves.toMatchObject({ done: true }); + }); +}); 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/generated/schema.ts b/packages/t4-api-client/src/generated/schema.ts new file mode 100755 index 00000000..ad07c82b --- /dev/null +++ b/packages/t4-api-client/src/generated/schema.ts @@ -0,0 +1,1069 @@ +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"][]; + }; + BadRequestApiError: components["schemas"]["ApiError"] & { + /** @enum {unknown} */ + code?: "invalid_request" | "idempotency_key_required" | "invalid_origin" | "https_required"; + }; + 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 {} */ + metadata?: { + [key: string]: string | number | boolean | null; + }; + }; + CommandResult: { + commandId: components["schemas"]["ResourceId"]; + 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"]["CommandState"]; + /** @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; + CursorExpiredApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "cursor_expired"; + resync: components["schemas"]["Resync"]; + }; + CursorExpiredErrorEnvelope: { + error: components["schemas"]["CursorExpiredApiError"]; + }; + Discovery: { + apiVersion: string; + capabilities: components["schemas"]["Capabilities"]; + limits: { + commandBytesMax: number; + commandMetadataValueBytesMax: number; + commandRequestBytesMax: number; + heartbeatSeconds: number; + pageSizeDefault: number; + pageSizeMax: number; + watchEventsMax: number; + }; + serverBuild: components["schemas"]["ServerBuild"]; + supportedMajors: number[]; + }; + ErrorEnvelope: { + error: components["schemas"]["ApiError"]; + }; + FieldViolation: { + field: string; + 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; + /** @constant */ + type: "heartbeat"; + }; + 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; + }; + NotFoundApiError: components["schemas"]["ApiError"] & { + /** @constant */ + code?: "not_found"; + }; + NotFoundErrorEnvelope: { + error: components["schemas"]["NotFoundApiError"]; + }; + 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"]; + 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"]; + /** @constant */ + type: "session"; + }; + SnapshotEntry: { + /** @enum {string} */ + kind: "input" | "output" | "status"; + 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"]; + 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: { + /** @description Command intent accepted with a stable outcome state */ + CommandAccepted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [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"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResult"]; + }; + }; + /** @description Idempotent deletion accepted or resource already absent */ + Deleted: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [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"]["BadRequestErrorEnvelope"]; + }; + }; + /** @description Missing or invalid opaque bearer credential */ + Error401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthenticatedErrorEnvelope"]; + }; + }; + /** @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"]["ForbiddenErrorEnvelope"]; + }; + }; + /** @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"]["NotFoundErrorEnvelope"]; + }; + }; + /** @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"]["ConflictErrorEnvelope"]; + }; + }; + /** @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. 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; + }; + content: { + "application/json": components["schemas"]["UnavailableErrorEnvelope"]; + }; + }; + /** @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"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [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 mutation replay */ + SessionReplay: { + headers: { + "Idempotency-Replayed": components["headers"]["IdempotencyReplayed"]; + "T4-API-Version": components["headers"]["SelectedVersion"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [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"]; + }; + }; + /** @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"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [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"]; + "T4-Event-Cursor": components["headers"]["EventCursor"]; + [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 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; + /** @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 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. */ + 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"]["Error401"]; + 403: components["responses"]["Error403"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; + }; + }; + 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"]["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"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: components["responses"]["Deleted"]; + 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. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionMutation"]; + }; + }; + responses: { + 200: components["responses"]["SessionReplay"]; + 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"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["SessionReplay"]; + 202: components["responses"]["SessionAccepted"]; + 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 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"]; + }; + path: { + sessionId: components["parameters"]["SessionId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CommandCreate"]; + }; + }; + responses: { + 200: components["responses"]["CommandReplay"]; + 202: components["responses"]["CommandAccepted"]; + 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: { + 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. 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"; + "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"]["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: { + 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"]["Error401"]; + 403: components["responses"]["Error403"]; + 404: components["responses"]["Error404"]; + 406: components["responses"]["Error406"]; + 503: components["responses"]["Error503"]; + }; + }; + 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"]["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 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"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkspaceCreate"]; + }; + }; + responses: { + 200: components["responses"]["WorkspaceReplay"]; + 202: components["responses"]["WorkspaceAccepted"]; + 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: { + 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"]["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"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: components["responses"]["Deleted"]; + 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. */ + "T4-API-Version": components["parameters"]["ApiVersion"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["WorkspaceMutation"]; + }; + }; + responses: { + 200: components["responses"]["WorkspaceReplay"]; + 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: { + 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"]["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 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"]; + }; + path: { + workspaceId: components["parameters"]["WorkspaceId"]; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SessionCreate"]; + }; + }; + responses: { + 200: components["responses"]["SessionReplay"]; + 202: components["responses"]["SessionAccepted"]; + 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"]; + }; + }; +} + diff --git a/packages/t4-api-client/src/index.ts b/packages/t4-api-client/src/index.ts new file mode 100644 index 00000000..412d6756 --- /dev/null +++ b/packages/t4-api-client/src/index.ts @@ -0,0 +1,935 @@ +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 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, + 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 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 WORKSPACE_STATES = { + accepted: true, provisioning: true, ready: true, deleting: true, + deleted: true, failed: true, unavailable: true, indeterminate: true, +} as const satisfies Record; +const SESSION_STATES = { + accepted: true, provisioning: true, ready: true, cancelling: true, + cancelled: true, failed: 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"]; +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; + readonly maxReconnectAttempts?: number; + readonly retryBackoffMs?: number; +} + +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; +} + +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; + readonly retryAfterMs?: number; + + constructor(status: number, error: ApiError, options?: ErrorOptions & { readonly retryAfterMs?: number }) { + super(error.message, options); + 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; + if (options?.retryAfterMs !== undefined) this.retryAfterMs = options.retryAfterMs; + } +} + +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 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 !== "" && 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); + 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); +} + +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 === "" || !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 || !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))) || + (status === 422 && (error.code !== "invalid_request" || !Array.isArray(error.violations) || error.violations.length < 1)) + ) return undefined; + return error as ApiError; +} + +async function boundedResponseText(response: Response, maximumBytes = MAX_ERROR_BYTES): 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 > maximumBytes) { + void reader.cancel().catch(() => {}); + 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; } +} + +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 }, + expectedYear = Number(parts[indexes.year]), +): boolean { + const date = new Date(timestamp); + 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]!] && + 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 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) { + 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) { + 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; + } + 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; + 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 { + 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())); +} + + +function protocolError(status: number, message: string): T4ApiError { + return new T4ApiError(status === 503 ? 502 : status, { + code: "indeterminate", + message, + requestId: "unavailable", + retryable: false, + }); +} + +async function parsedError(response: Response): Promise { + 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 { + 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 }); + } catch { + return undefined; + } +} + + +function hasOnlyKeys(value: Record, keys: Readonly>): boolean { + return Object.keys(value).every((key) => 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); +} + +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 !== "" && 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" && 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 !== "" && 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); +} + +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 !== "" && 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); +} + +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" && 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, 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) && + 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) && + 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 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" && hasAtMostCodePoints(entry.text, 1_048_576); + }); +} + +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], 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], 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], 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], 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] }; + +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 (/^\/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; +} + +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) && + (expectedMajor === undefined || selected.startsWith(`${expectedMajor}.`)); +} + +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); + if (contract === undefined) { + void response.body?.cancel().catch(() => {}); + 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(502, "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(502, "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 (!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"); + } + 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 (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(() => {}); + throw protocolError(502, "T4 API returned content for a bodyless response"); + } + 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"); + } + 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"); + } +} + +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); + 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; + 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 && + isKnownUtcLeapSecondDate(utc.getUTCFullYear(), utc.getUTCMonth() + 1, utc.getUTCDate()); +} + +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 === "" || + !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" && validRfc3339DateTime(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 !== "" && hasAtMostCodePoints(event.commandId, 128) && + /^[A-Za-z0-9][A-Za-z0-9._~-]*$/u.test(event.commandId) && + typeof event.state === "string" && + COMMAND_STATES[event.state as components["schemas"]["CommandState"]] === true + ) return event as WatchEvent; + throw protocolError(502, "T4 API returned an invalid watch event"); +} + +function decodeSseFrame(frame: string): WatchEvent | undefined { + let eventId: string | undefined; + const data: string[] = []; + 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); + 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 protocolError(502, "T4 API returned malformed SSE data"); + } +} + +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 > MAX_EVENT_BYTES) this.#oversized(); + 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 protocolError(502, "T4 API watch event exceeds the client bound"); + } +} + +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 protocolError(502, "T4 API returned malformed SSE data"); + } + 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( + 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"); + 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 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 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; + 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)); + 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) { + if (!EVENTS_RESPONSE.errors.includes(response.status)) { + 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"); + } + if (response.status !== 200) { + void response.body?.cancel().catch(() => {}); + throw protocolError(502, "T4 API returned an undeclared watch success status"); + } + if (!validSelectedVersion(response, majorVersion)) { + 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"); + } + 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) { + const chunk = await reader.read(); + const events = chunk.done ? decodedFrames(parser, undefined) : decodedFrames(parser, chunk.value); + for (const event of events) { + 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; + 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; + 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) { + try { await reader.cancel(); } catch { /* cancellation is best effort */ } + reader.releaseLock(); + } + } + 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))); + if (!madeProgress) reconnectAttempts += 1; + if (!await retryDelay(delay, controller.signal)) return; + } + } finally { + options.signal?.removeEventListener("abort", abort); + controller.abort(); + } +} + +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); + 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; + }; + 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..66d77c91 --- /dev/null +++ b/packages/t4-api-contract/openapi.json @@ -0,0 +1,861 @@ +{ + "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/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/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/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": { + "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/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"} + } + } + }, + "/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/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "patch": { + "operationId": "mutateWorkspace", + "tags": ["Workspaces"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$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/WorkspaceReplay" }, + "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" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "responses": { + "204": { "$ref": "#/components/responses/Deleted" }, + "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"} + } + } + }, + "/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/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": { + "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" }, + "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"} + } + } + }, + "/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/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + }, + "patch": { + "operationId": "mutateSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/ApiVersion" }, + { "$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/SessionReplay" }, + "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" }, + { "$ref": "#/components/parameters/IdempotencyKey" } + ], + "responses": { + "204": { "$ref": "#/components/responses/Deleted" }, + "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"} + } + } + }, + "/v1/sessions/{sessionId}/cancel": { + "post": { + "operationId": "cancelSession", + "tags": ["Sessions"], + "parameters": [ + { "$ref": "#/components/parameters/SessionId" }, + { "$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/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"} + } + } + }, + "/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" }, + "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"} + } + } + }, + "/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/Error401"}, + "403": {"$ref": "#/components/responses/Error403"}, + "404": {"$ref": "#/components/responses/Error404"}, + "406": {"$ref": "#/components/responses/Error406"}, + "503": {"$ref": "#/components/responses/Error503"} + } + } + }, + "/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. 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": { "required": true, "schema": { "type": "string", "const": "no-store" } }, + "T4-API-Version": { "$ref": "#/components/headers/SelectedVersion" } + }, + "content": { + "text/event-stream": { + "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" + } + } + }, + "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"} + } + } + } + }, + "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-9][0-9]{0,3}(?:\\.[0-9]+)?$", "maxLength": 16 } + }, + "IdempotencyKey": { + "name": "Idempotency-Key", + "in": "header", + "required": true, + "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": { + "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.", + "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"] } + }, + "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 }, + "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", "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": { "$ref": "#/components/schemas/Capabilities" }, + "limits": { + "type": "object", + "additionalProperties": false, + "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": 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 } + } + } + } + }, + "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"], + "x-t4-maxUtf8Bytes": 1048576, + "properties": { + "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": { + "type": "object", + "additionalProperties": false, + "required": ["commandId", "state"], + "properties": { + "commandId": { "$ref": "#/components/schemas/ResourceId" }, + "state": { "$ref": "#/components/schemas/CommandState" } + } + }, + "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, + "x-t4-maxUtf8Bytes": 16777216, + "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", "maxLength": 64 } + } + }, + "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/CommandState" } + } + }, + "WatchEvent": { + "oneOf": [ + { "$ref": "#/components/schemas/HeartbeatWatchEvent" }, + { "$ref": "#/components/schemas/SessionWatchEvent" }, + { "$ref": "#/components/schemas/CommandWatchEvent" } + ] + }, + "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][A-Za-z0-9._~-]{0,127}/snapshot$", "maxLength": 150 }, + "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" } + } + }, + "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" }, + { "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" } } + }, + "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, + "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": { + "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" }, "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" }, "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" }, "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" }, "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" } } } }, + "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/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. 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/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..e216f71a --- /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, defaultNonNullable: false }))}\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..b7eb134a --- /dev/null +++ b/packages/t4-api-contract/scripts/validate.mjs @@ -0,0 +1,75 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { compileErrors, validate } from "@readme/openapi-parser"; + +const source = new URL("../openapi.json", import.meta.url); +const document = JSON.parse(await readFile(source, "utf8")); +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"); +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 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"); +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`); +} +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; +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"]; +for (const responseName of selectedVersionResponses) { + if (document.components?.responses?.[responseName]?.headers?.["T4-API-Version"]?.$ref !== selectedVersionRef) throw new Error(`${responseName} must declare SelectedVersion`); +} +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"]; +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" }; +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}`); +} +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": [] +} 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: 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) ;;"));