From 59765d6beb4ab754cb0cf95d386f505a64c8c420 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:43:57 -0500 Subject: [PATCH 1/6] fix(web): honour Idempotency-Key when creating an Infra upload --- .../unit/developer-transcript-docs.test.ts | 1 + .../unit/infra-upload-idempotency.test.ts | 217 ++++++++++++++++++ .../developer/v1/[...route]/transcripts.ts | 11 +- .../api/developer/v1/[...route]/uploads.ts | 9 +- apps/web/lib/developer-ids.ts | 28 +++ apps/web/lib/developer-video-upload.ts | 6 +- apps/web/lib/infra-video-upload.ts | 130 ++++++++++- 7 files changed, 381 insertions(+), 21 deletions(-) create mode 100644 apps/web/__tests__/unit/infra-upload-idempotency.test.ts diff --git a/apps/web/__tests__/unit/developer-transcript-docs.test.ts b/apps/web/__tests__/unit/developer-transcript-docs.test.ts index e085486d958..f2a33a78492 100644 --- a/apps/web/__tests__/unit/developer-transcript-docs.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-docs.test.ts @@ -30,6 +30,7 @@ const REQUEST_SOURCES = [ "lib/developer-transcript-store.ts", "lib/developer-transcript-billing.ts", "app/api/developer/v1/[...route]/transcripts.ts", + "lib/developer-ids.ts", ]; const codesIn = (source: string) => { diff --git a/apps/web/__tests__/unit/infra-upload-idempotency.test.ts b/apps/web/__tests__/unit/infra-upload-idempotency.test.ts new file mode 100644 index 00000000000..62a865e2b1a --- /dev/null +++ b/apps/web/__tests__/unit/infra-upload-idempotency.test.ts @@ -0,0 +1,217 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record; + +const state = vi.hoisted(() => ({ + videos: new Map>(), + inserts: 0, + nextId: 0, + admission: vi.fn(), +})); + +vi.mock("@cap/database", async () => { + const { drizzle } = await import("drizzle-orm/mysql-proxy"); + const columnsOf = (list: string) => + [...list.matchAll(/`([A-Za-z0-9]+)`(?:,|\))/g)].map( + (match) => match[1] ?? "", + ); + const client = drizzle(async (sql, params) => { + if (sql.startsWith("insert into `developer_videos`")) { + const columns = columnsOf( + sql.slice(sql.indexOf("("), sql.indexOf(")") + 1), + ); + const values = sql + .slice(sql.indexOf("values (") + 8, sql.lastIndexOf(")")) + .split(", "); + const row: Row = {}; + let param = 0; + columns.forEach((column, index) => { + row[column] = values[index] === "?" ? params[param++] : null; + }); + const id = String(row.id); + if (state.videos.has(id)) { + if (!sql.includes("on duplicate key update")) { + throw new Error("Duplicate entry for key PRIMARY"); + } + return { rows: [{ affectedRows: 0 }] }; + } + state.inserts += 1; + state.videos.set(id, { + ...row, + metadata: + typeof row.metadata === "string" + ? JSON.parse(row.metadata) + : row.metadata, + duration: null, + deletedAt: null, + status: "uploading", + }); + return { rows: [{ affectedRows: 1 }] }; + } + if (sql.includes("from `developer_videos`")) { + const fields = sql + .slice("select ".length, sql.indexOf(" from ")) + .split(", ") + .map((field) => field.replaceAll("`", "").split(".").pop() ?? ""); + const video = state.videos.get(String(params[0])); + if (!video) return { rows: [] }; + return { + rows: [fields.map((field) => video[field] ?? null)], + }; + } + throw new Error(`Unexpected query: ${sql}`); + }); + return { db: () => client }; +}); +vi.mock("@cap/database/helpers", () => ({ + nanoId: () => `random-video-${++state.nextId}`, + nanoIdLength: 15, +})); +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_WEB_URL: "https://cap.test" }, + serverEnv: () => ({ WEB_URL: "https://cap.test" }), +})); +vi.mock("@cap/web-backend", () => ({ + provideOptionalAuth: (effect: T) => effect, + S3Buckets: {}, +})); +vi.mock("@/lib/server", () => ({ runPromise: async () => undefined })); +vi.mock("@/lib/infra-standing", async (original) => ({ + ...(await original()), + getInfraUploadAdmission: state.admission, +})); +vi.mock("@/lib/developer-video-start", () => ({ + claimDeveloperVideoUpload: vi.fn(), + releaseDeveloperVideoUpload: vi.fn(), + startDeveloperVideoProcessing: vi.fn(), + redriveDeveloperVideoProcessing: vi.fn(), +})); + +import { developerVideoIdForIdempotencyKey } from "@/lib/developer-ids"; +import { createInfraVideo } from "@/lib/infra-video-upload"; + +const admitted = { + allowed: true, + reason: null, + standing: { state: "active", pausesAt: null }, + balanceMicroCredits: 1_000_000, + requiredMicroCredits: 1, + shortfallMicroCredits: 0, + message: null, +}; + +const KEY = "retry-upload-0001"; +const request = { + appId: "app-1", + name: "Standup", + userId: "user_42", + metadata: { project: "alpha", tags: ["a", "b"] }, + maxResolutionTier: "1440p" as const, + idempotencyKey: KEY, +}; + +beforeEach(() => { + state.videos = new Map(); + state.inserts = 0; + state.nextId = 0; + state.admission.mockReset(); + state.admission.mockResolvedValue(admitted); +}); + +describe("create upload with an Idempotency-Key", () => { + it("returns the same video for a repeated request and makes one row", async () => { + const first = await createInfraVideo(request); + const second = await createInfraVideo({ + ...request, + metadata: { tags: ["a", "b"], project: "alpha" }, + }); + + expect(first.status).toBe(200); + expect(second).toEqual(first); + expect(first.status === 200 && first.body.videoId).toBe( + developerVideoIdForIdempotencyKey("app-1", KEY), + ); + expect(state.inserts).toBe(1); + expect(state.videos.size).toBe(1); + }); + + it("makes one video when the same request races itself", async () => { + const results = await Promise.all( + Array.from({ length: 5 }, () => createInfraVideo(request)), + ); + + expect(new Set(results.map((result) => result.status))).toEqual( + new Set([200]), + ); + expect( + new Set(results.map((result) => JSON.stringify(result.body))).size, + ).toBe(1); + expect(state.videos.size).toBe(1); + }); + + it.each([ + ["name", { name: "Retro" }], + ["userId", { userId: "user_43" }], + ["maxResolutionTier", { maxResolutionTier: "2160p" as const }], + ["metadata", { metadata: { project: "beta" } }], + ])("refuses the same key with a different %s", async (field, change) => { + await createInfraVideo(request); + + const conflict = await createInfraVideo({ ...request, ...change }); + + expect(conflict).toMatchObject({ + status: 409, + body: { + code: "idempotency_conflict", + message: `Idempotency key was already used with a different ${field}`, + }, + }); + expect(state.videos.size).toBe(1); + }); + + it("refuses a key whose video was deleted and answers a completed upload like a token renewal", async () => { + await createInfraVideo(request); + const id = developerVideoIdForIdempotencyKey("app-1", KEY); + const stored = state.videos.get(id); + if (!stored) throw new Error("video missing"); + + stored.duration = 12; + expect(await createInfraVideo(request)).toMatchObject({ + status: 409, + body: { code: "upload_already_completed" }, + }); + + stored.deletedAt = "2026-11-13 10:00:00"; + expect(await createInfraVideo(request)).toMatchObject({ + status: 409, + body: { + code: "idempotency_conflict", + message: "Idempotency key belongs to a video that was deleted", + }, + }); + expect(state.videos.size).toBe(1); + }); + + it("checks upload admission on a repeat too", async () => { + await createInfraVideo(request); + state.admission.mockResolvedValueOnce({ + ...admitted, + allowed: false, + reason: "insufficient_credits", + message: "Add credit", + }); + + expect(await createInfraVideo(request)).toMatchObject({ status: 402 }); + expect(state.admission).toHaveBeenCalledTimes(2); + expect(state.videos.size).toBe(1); + }); + + it("keeps the same key separate per app, and creates a new video each time without a key", async () => { + await createInfraVideo(request); + await createInfraVideo({ ...request, appId: "app-2" }); + await createInfraVideo({ ...request, idempotencyKey: undefined }); + await createInfraVideo({ ...request, idempotencyKey: undefined }); + + expect(state.videos.size).toBe(4); + }); +}); diff --git a/apps/web/app/api/developer/v1/[...route]/transcripts.ts b/apps/web/app/api/developer/v1/[...route]/transcripts.ts index 93ec5a63345..bae27ccc1e1 100644 --- a/apps/web/app/api/developer/v1/[...route]/transcripts.ts +++ b/apps/web/app/api/developer/v1/[...route]/transcripts.ts @@ -1,6 +1,7 @@ import { type Context, Hono } from "hono"; import { z } from "zod"; import { apiErrorBody } from "@/lib/api-errors"; +import { invalidIdempotencyKey } from "@/lib/developer-ids"; import { parseDeveloperListQuery } from "@/lib/developer-pagination"; import { createTranscriptFromUrl, @@ -56,12 +57,6 @@ const readIdempotencyKey = (header: string | undefined) => { const INCLUDE_VALUES = ["words", "segments"] as const; -const invalidIdempotencyKey = apiErrorBody( - "invalid_idempotency_key", - "Idempotency-Key is not valid", - "Send 8 to 128 characters from letters, digits, dot, dash and underscore.", -); - app.post( "/", developerZValidator( @@ -70,7 +65,7 @@ app.post( ), async (c) => { const idempotency = readIdempotencyKey(c.req.header("idempotency-key")); - if (!idempotency.ok) return c.json(invalidIdempotencyKey, 400); + if (!idempotency.ok) return c.json(invalidIdempotencyKey(), 400); const result = await createTranscriptFromUrl(c.get("developerAppId"), { ...c.req.valid("json"), @@ -93,7 +88,7 @@ app.post( ), async (c) => { const idempotency = readIdempotencyKey(c.req.header("idempotency-key")); - if (!idempotency.ok) return c.json(invalidIdempotencyKey, 400); + if (!idempotency.ok) return c.json(invalidIdempotencyKey(), 400); const result = await createTranscriptUpload(c.get("developerAppId"), { ...c.req.valid("json"), diff --git a/apps/web/app/api/developer/v1/[...route]/uploads.ts b/apps/web/app/api/developer/v1/[...route]/uploads.ts index 672bdffbaad..2d0379f1f55 100644 --- a/apps/web/app/api/developer/v1/[...route]/uploads.ts +++ b/apps/web/app/api/developer/v1/[...route]/uploads.ts @@ -2,6 +2,7 @@ import { DEVELOPER_VIDEO_RESOLUTION_TIERS } from "@cap/database/types"; import { serverEnv } from "@cap/env"; import { Hono } from "hono"; import { z } from "zod"; +import { invalidIdempotencyKey, readIdempotencyKey } from "@/lib/developer-ids"; import { createDeveloperUploadToken, UPLOAD_TOKEN_DEFAULT_SECONDS, @@ -95,11 +96,17 @@ app.post( }), ), async (c) => { + const idempotency = readIdempotencyKey(c.req.header("idempotency-key")); + if (!idempotency.ok) return c.json(invalidIdempotencyKey(), 400); const appId = c.get("developerAppId"); const { corsOrigin, timeoutSeconds, maxBytes, ...video } = c.req.valid("json"); - const created = await createInfraVideo({ appId, ...video }); + const created = await createInfraVideo({ + appId, + ...video, + idempotencyKey: idempotency.key, + }); if (created.status !== 200) return c.json(created.body, created.status); const { videoId, playbackId, shareUrl, embedUrl } = created.body; diff --git a/apps/web/lib/developer-ids.ts b/apps/web/lib/developer-ids.ts index f669ffb9227..96d38a566c0 100644 --- a/apps/web/lib/developer-ids.ts +++ b/apps/web/lib/developer-ids.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; +import { apiErrorBody } from "@/lib/api-errors"; const DEVELOPER_ID_LENGTH = 15; @@ -23,3 +24,30 @@ export function randomDeveloperId( if (!id.startsWith("-")) return id; } } + +export function isDeveloperIdempotencyKey(value: string | undefined | null) { + return ( + typeof value === "string" && + value.length >= 8 && + value.length <= 128 && + /^[A-Za-z0-9._-]+$/.test(value) + ); +} + +export const invalidIdempotencyKey = () => + apiErrorBody( + "invalid_idempotency_key", + "Idempotency-Key is not valid", + "Send 8 to 128 characters from letters, digits, dot, dash and underscore.", + ); + +export function readIdempotencyKey(header: string | undefined) { + if (header === undefined) return { ok: true as const, key: undefined }; + return isDeveloperIdempotencyKey(header) + ? { ok: true as const, key: header } + : { ok: false as const }; +} + +export function developerVideoIdForIdempotencyKey(appId: string, key: string) { + return developerIdFromKey("developer-video", appId, key); +} diff --git a/apps/web/lib/developer-video-upload.ts b/apps/web/lib/developer-video-upload.ts index d52ae7b268c..2b630517fbe 100644 --- a/apps/web/lib/developer-video-upload.ts +++ b/apps/web/lib/developer-video-upload.ts @@ -2,7 +2,7 @@ import { db } from "@cap/database"; import { nanoId } from "@cap/database/helpers"; import { developerVideos } from "@cap/database/schema"; import { and, eq, isNull } from "drizzle-orm"; -import { developerIdFromKey } from "@/lib/developer-ids"; +import { developerVideoIdForIdempotencyKey } from "@/lib/developer-ids"; import { copyDeveloperObject, deleteDeveloperObjects, @@ -104,9 +104,7 @@ export function developerVideoUploadKey(appId: string, videoId: string) { return `${DEVELOPER_UPLOAD_PREFIX}${appId}/v-${videoId}`; } -export function developerVideoIdForIdempotencyKey(appId: string, key: string) { - return developerIdFromKey("developer-video", appId, key); -} +export { developerVideoIdForIdempotencyKey }; export function developerVideoRecordedSeconds(input: { measuredSeconds: number | null; diff --git a/apps/web/lib/infra-video-upload.ts b/apps/web/lib/infra-video-upload.ts index 45bdf29ee00..52072dcf651 100644 --- a/apps/web/lib/infra-video-upload.ts +++ b/apps/web/lib/infra-video-upload.ts @@ -8,6 +8,7 @@ import type { User } from "@cap/web-domain"; import { and, asc, eq, gt, isNotNull, isNull, lt, lte } from "drizzle-orm"; import { Cause, Effect, Exit, Option } from "effect"; import { API_RETRY_HINT, apiErrorBody } from "@/lib/api-errors"; +import { developerVideoIdForIdempotencyKey } from "@/lib/developer-ids"; import { developerVideoPageUrls } from "@/lib/developer-video-api"; import { admitDeveloperVideoUpload, @@ -320,18 +321,113 @@ export async function ownsInfraApp( return !!app; } +const canonicalJson = (value: unknown): string => { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${canonicalJson((value as Record)[key])}`, + ) + .join(",")}}`; + } + return JSON.stringify(value ?? null); +}; + +const storedJson = (value: unknown) => { + if (typeof value !== "string") return value ?? null; + try { + return JSON.parse(value); + } catch { + return value; + } +}; + +const idempotencyConflict = (message: string) => + fail( + 409, + "idempotency_conflict", + message, + "Send the same request to repeat it, or use a new Idempotency-Key for a new video.", + ); + +type VideoRequest = { + name: string; + externalUserId: string | null; + metadata: Record | null; + maxResolutionTier: DeveloperVideoResolutionTier; +}; + +async function replayKeyedVideo( + appId: string, + videoId: string, + request: VideoRequest, +) { + const [video] = await db() + .select({ + appId: developerVideos.appId, + name: developerVideos.name, + externalUserId: developerVideos.externalUserId, + metadata: developerVideos.metadata, + maxResolutionTier: developerVideos.maxResolutionTier, + playbackId: developerVideos.playbackId, + deletedAt: developerVideos.deletedAt, + }) + .from(developerVideos) + .where(eq(developerVideos.id, videoId)) + .limit(1); + if (!video) return null; + if (video.appId !== appId) { + return idempotencyConflict( + "Idempotency key was already used for a different request", + ); + } + const differs = + video.name !== request.name + ? "name" + : (video.externalUserId ?? null) !== request.externalUserId + ? "userId" + : video.maxResolutionTier !== request.maxResolutionTier + ? "maxResolutionTier" + : canonicalJson(storedJson(video.metadata)) !== + canonicalJson(request.metadata) + ? "metadata" + : null; + if (differs) { + return idempotencyConflict( + `Idempotency key was already used with a different ${differs}`, + ); + } + if (video.deletedAt) { + return idempotencyConflict( + "Idempotency key belongs to a video that was deleted", + ); + } + const upload = await openUpload(appId, videoId); + if (upload.status !== 200) return upload; + if (!video.playbackId) return videoNotUploadable(); + return ok({ + videoId, + playbackId: video.playbackId, + ...developerVideoPageUrls(video.playbackId, buildEnv.NEXT_PUBLIC_WEB_URL), + }); +} + export async function createInfraVideo({ appId, name, userId, metadata, maxResolutionTier, + idempotencyKey, }: { appId: string; name?: string; userId?: string; metadata?: Record; maxResolutionTier?: DeveloperVideoResolutionTier; + idempotencyKey?: string; }): Promise< UploadResult<{ videoId: string; @@ -345,23 +441,41 @@ export async function createInfraVideo({ return { status: 402, body: uploadAdmissionError(admission) }; } - const videoId = nanoId(); - const playbackId = createDeveloperPlaybackId(); - const s3Key = `developer/${appId}/${videoId}/video`; + const request: VideoRequest = { + name: name ?? "Untitled", + externalUserId: userId?.trim() || null, + metadata: metadata ?? null, + maxResolutionTier: maxResolutionTier ?? DEVELOPER_VIDEO_DEFAULT_TIER, + }; + const videoId = idempotencyKey + ? developerVideoIdForIdempotencyKey(appId, idempotencyKey) + : nanoId(); + + if (idempotencyKey) { + const replayed = await replayKeyedVideo(appId, videoId, request); + if (replayed) return replayed; + } - await db() + const playbackId = createDeveloperPlaybackId(); + const insert = db() .insert(developerVideos) .values({ id: videoId, appId, - externalUserId: userId?.trim() || null, - name: name ?? "Untitled", - s3Key, + externalUserId: request.externalUserId, + name: request.name, + s3Key: `developer/${appId}/${videoId}/video`, metadata, playbackId, - maxResolutionTier: maxResolutionTier ?? DEVELOPER_VIDEO_DEFAULT_TIER, + maxResolutionTier: request.maxResolutionTier, }); + if (idempotencyKey) { + await insert.onDuplicateKeyUpdate({ set: { id: videoId } }); + return (await replayKeyedVideo(appId, videoId, request)) ?? videoNotFound(); + } + + await insert; return ok({ videoId, playbackId, From 1370528909155aedf1f6b62c8581fde6eb9f7954 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:43:57 -0500 Subject: [PATCH 2/6] fix(web): answer a repeat delete of a deleted transcript with success --- .../unit/developer-transcript-store.test.ts | 45 +++++++++++++++++-- apps/web/lib/developer-transcript-store.ts | 19 ++++---- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/apps/web/__tests__/unit/developer-transcript-store.test.ts b/apps/web/__tests__/unit/developer-transcript-store.test.ts index d18dda13969..0c92a936819 100644 --- a/apps/web/__tests__/unit/developer-transcript-store.test.ts +++ b/apps/web/__tests__/unit/developer-transcript-store.test.ts @@ -1139,10 +1139,49 @@ describe("deleting", () => { "developer/app-1/transcripts/tr-1/result.json", ]); expect(await getTranscript("app-1", "tr-1")).toBeNull(); - expect(await deleteTranscript("app-1", "tr-1")).toMatchObject({ - ok: false, - status: 404, + }); + + it("answers a repeat delete of a deleted transcript with success and clears its files again", async () => { + fake().seed(developerTranscripts, { + id: "tr-1", + appId: "app-1", + status: "complete", + sourceType: "upload", + text: "secret words", }); + expect(await deleteTranscript("app-1", "tr-1")).toEqual({ ok: true }); + const deletedAt = transcripts()[0]?.deletedAt; + context.deleted = []; + + expect(await deleteTranscript("app-1", "tr-1")).toEqual({ ok: true }); + + expect(transcripts()[0]?.deletedAt).toEqual(deletedAt); + expect(context.deleted).toEqual([ + "developer/_uploads/app-1/t-tr-1", + "developer/app-1/transcripts/tr-1/audio.mp3", + "developer/app-1/transcripts/tr-1/result.json", + ]); + }); + + it("still answers 404 for a transcript that never existed, or another app's deleted one", async () => { + fake().seed(developerTranscripts, { + id: "tr-2", + appId: "app-2", + status: "complete", + sourceType: "upload", + text: "other app", + }); + expect(await deleteTranscript("app-2", "tr-2")).toEqual({ ok: true }); + context.deleted = []; + + for (const id of ["tr-2", "never-existed"]) { + expect(await deleteTranscript("app-1", id)).toMatchObject({ + ok: false, + status: 404, + code: "transcript_not_found", + }); + } + expect(context.deleted).toEqual([]); }); it("does not delete another app's transcript", async () => { diff --git a/apps/web/lib/developer-transcript-store.ts b/apps/web/lib/developer-transcript-store.ts index fb23d05c063..076e126637e 100644 --- a/apps/web/lib/developer-transcript-store.ts +++ b/apps/web/lib/developer-transcript-store.ts @@ -9,7 +9,10 @@ import { import { AI_GENERATION_LANGUAGE_CODES } from "@cap/web-domain"; import { and, count, desc, eq, gte, isNull, lt, or, sql } from "drizzle-orm"; import { start } from "workflow/api"; -import { developerIdFromKey } from "@/lib/developer-ids"; +import { + developerIdFromKey, + isDeveloperIdempotencyKey, +} from "@/lib/developer-ids"; import { transcriptStartRefusal } from "@/lib/developer-transcript-billing"; import { isSpeechConfigured, @@ -164,12 +167,7 @@ export function transcriptIdForIdempotencyKey(appId: string, key: string) { } export function isTranscriptIdempotencyKey(value: string | undefined | null) { - return ( - typeof value === "string" && - value.length >= 8 && - value.length <= 128 && - /^[A-Za-z0-9._-]+$/.test(value) - ); + return isDeveloperIdempotencyKey(value); } export type TranscriptOptionsInput = { @@ -863,7 +861,12 @@ export async function deleteTranscript( transcriptId: string, ): Promise<{ ok: true } | TranscriptFailure> { const transcript = await findTranscript(appId, transcriptId); - if (!transcript) return NOT_FOUND; + if (!transcript) { + const deleted = await findTranscriptAnyState(appId, transcriptId); + if (!deleted?.deletedAt) return NOT_FOUND; + await deleteTranscriptObjects(appId, deleted.id); + return { ok: true }; + } await db() .update(developerTranscripts) From f25d98cf596802e04bd1006b340c9258cd101aa3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:43:57 -0500 Subject: [PATCH 3/6] fix(web): send private no-store and a request id on developer API answers --- .../unit/developer-api-contract.test.ts | 129 +++++++++++++++++- .../api/developer/sdk/v1/[...route]/route.ts | 2 + .../app/api/developer/v1/[...route]/route.ts | 2 + apps/web/app/api/utils.ts | 8 ++ 4 files changed, 140 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/developer-api-contract.test.ts b/apps/web/__tests__/unit/developer-api-contract.test.ts index e6f317b6f5b..fab1d8c6b19 100644 --- a/apps/web/__tests__/unit/developer-api-contract.test.ts +++ b/apps/web/__tests__/unit/developer-api-contract.test.ts @@ -348,6 +348,7 @@ describe("rate limit headers", () => { "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", + "X-Request-Id", ]); }); }); @@ -639,7 +640,8 @@ describe("browser access", () => { "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "Content-Type,Authorization", "access-control-expose-headers": - "Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset", + "Retry-After,X-RateLimit-Limit,X-RateLimit-Remaining,X-RateLimit-Reset,X-Request-Id", + "cache-control": "private, no-store", vary: "Access-Control-Request-Headers", }); }); @@ -924,3 +926,128 @@ describe("usage", () => { ); }); }); + +describe("Idempotency-Key on create upload", () => { + const create = (headers: Record) => + call(rest, "POST", "/api/developer/v1/uploads", { + key: freshKey("csk"), + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ name: "Demo" }), + }); + + it("passes a valid key to the store", async () => { + const response = await create({ "Idempotency-Key": "retry-upload-0001" }); + + expect(response.status).toBe(200); + expect(mocks.createVideo).toHaveBeenCalledExactlyOnceWith({ + appId: "app-1", + name: "Demo", + idempotencyKey: "retry-upload-0001", + }); + }); + + it.each([ + ["too short", "short"], + ["a colon", "retry:upload:0001"], + ["a space", "retry upload 0001"], + ["too long", "k".repeat(129)], + ])("refuses a key with %s before creating anything", async (_label, key) => { + const response = await create({ "Idempotency-Key": key }); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + code: "invalid_idempotency_key", + error: "Idempotency-Key is not valid", + }); + expect(mocks.createVideo).not.toHaveBeenCalled(); + }); +}); + +describe("response headers on the developer APIs", () => { + const PRIVATE = "private, no-store"; + + it("marks every secret-key API answer private, errors and limits included", async () => { + const key = freshKey("csk"); + const answers = [ + await call(rest, "GET", "/api/developer/v1/usage", { key }), + await call(rest, "GET", "/api/developer/v1/usage"), + await call(rest, "GET", "/api/developer/v1/nope", { key }), + await call(rest, "POST", "/api/developer/v1/usage", { key }), + ]; + for (let request = 3; request < 60; request++) { + await call(rest, "GET", "/api/developer/v1/usage", { key }); + } + answers.push(await call(rest, "GET", "/api/developer/v1/usage", { key })); + + expect(answers.map((response) => response.status)).toEqual([ + 200, 401, 404, 405, 429, + ]); + for (const response of answers) { + expect(response.headers.get("Cache-Control")).toBe(PRIVATE); + } + }); + + it("marks the upload token API answers private too", async () => { + const answers = [ + await call( + sdk, + "POST", + "/api/developer/sdk/v1/upload/multipart/initiate", + { + key: freshKey("cut"), + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ), + await call( + sdk, + "POST", + "/api/developer/sdk/v1/upload/multipart/initiate", + { + headers: { "Content-Type": "application/json" }, + body: "{}", + }, + ), + ]; + + expect(answers.map((response) => response.status)).toEqual([200, 401]); + for (const response of answers) { + expect(response.headers.get("Cache-Control")).toBe(PRIVATE); + } + }); + + it("echoes the platform request id and lets a page read it", async () => { + const requestId = "fra1::iad1::abcde-1763031000000-0123456789ab"; + const rested = await call(rest, "GET", "/api/developer/v1/usage", { + key: freshKey("csk"), + headers: { "x-vercel-id": requestId }, + }); + const sdked = await call( + sdk, + "POST", + "/api/developer/sdk/v1/upload/multipart/initiate", + { + key: freshKey("cut"), + headers: { + Origin: "https://app.example", + "Content-Type": "application/json", + "x-vercel-id": requestId, + }, + body: "{}", + }, + ); + + expect(rested.headers.get("X-Request-Id")).toBe(requestId); + expect(sdked.headers.get("X-Request-Id")).toBe(requestId); + expect( + sdked.headers + .get("Access-Control-Expose-Headers") + ?.split(",") + .map((name) => name.trim()), + ).toContain("X-Request-Id"); + const withoutId = await call(rest, "GET", "/api/developer/v1/usage", { + key: freshKey("csk"), + }); + expect(withoutId.headers.get("X-Request-Id")).toBeNull(); + }); +}); diff --git a/apps/web/app/api/developer/sdk/v1/[...route]/route.ts b/apps/web/app/api/developer/sdk/v1/[...route]/route.ts index f3777814193..b9fc9928035 100644 --- a/apps/web/app/api/developer/sdk/v1/[...route]/route.ts +++ b/apps/web/app/api/developer/sdk/v1/[...route]/route.ts @@ -3,6 +3,7 @@ import { handle } from "hono/vercel"; import { developerApiError, developerApiNotFound, + developerResponseHeaders, developerSdkCors, developerUploadRateLimiter, refuseDeveloperPublicKey, @@ -11,6 +12,7 @@ import * as upload from "./upload"; const app = new Hono() .basePath("/api/developer/sdk/v1") + .use(developerResponseHeaders) .use(developerSdkCors) .use(developerUploadRateLimiter) .use(refuseDeveloperPublicKey) diff --git a/apps/web/app/api/developer/v1/[...route]/route.ts b/apps/web/app/api/developer/v1/[...route]/route.ts index a0c26a9e2ff..305273842fa 100644 --- a/apps/web/app/api/developer/v1/[...route]/route.ts +++ b/apps/web/app/api/developer/v1/[...route]/route.ts @@ -4,6 +4,7 @@ import { developerApiError, developerApiNotFound, developerRateLimiter, + developerResponseHeaders, } from "../../../utils"; import * as transactions from "./transactions"; @@ -14,6 +15,7 @@ import * as videos from "./videos"; const app = new Hono() .basePath("/api/developer/v1") + .use(developerResponseHeaders) .use(developerRateLimiter) .route("/videos", videos.app) .route("/uploads", uploads.app) diff --git a/apps/web/app/api/utils.ts b/apps/web/app/api/utils.ts index a879f0a0f90..3b31ac9cce4 100644 --- a/apps/web/app/api/utils.ts +++ b/apps/web/app/api/utils.ts @@ -231,6 +231,13 @@ export const corsMiddleware = cors({ allowHeaders: ["Content-Type", "Authorization", "sentry-trace", "baggage"], }); +export const developerResponseHeaders = createMiddleware(async (c, next) => { + await next(); + c.header("Cache-Control", "private, no-store"); + const requestId = c.req.header("x-vercel-id"); + if (requestId) c.header("X-Request-Id", requestId); +}); + export const developerSdkCors = cors({ origin: "*", credentials: false, @@ -241,6 +248,7 @@ export const developerSdkCors = cors({ "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset", + "X-Request-Id", ], }); From cd6c766a72f9c1f31ea051827817cdf7b01cd3c3 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:43:57 -0500 Subject: [PATCH 4/6] docs(web): document upload idempotency, repeat transcript deletes and the request id --- apps/web/content/docs/api/rest-api.mdx | 6 +++++- apps/web/lib/developer-openapi.ts | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index f85bd5603e4..3dd381cd38f 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -75,7 +75,7 @@ If you are self-hosting Cap, replace `cap.so` with your custom domain. ## Error Handling -Every error is a JSON object with an `error` string, a stable `code`, a `hint` that says what to change, and a link to these docs. `message` holds the same text as `error`. Switch on `code`, not on the text. +Every error is a JSON object with an `error` string, a stable `code`, a `hint` that says what to change, and a link to these docs. `message` holds the same text as `error`. Switch on `code`, not on the text. Every response on cap.so carries an `X-Request-Id` header: quote it when you contact support about a call. ```json { @@ -187,6 +187,8 @@ POST /api/developer/v1/uploads The body is optional. A request with no body creates an upload with the defaults. +Send an `Idempotency-Key` header so a retried request returns the same video with a new upload token instead of creating a second one. The key follows the same rules as for [transcripts](/docs/api/rest-api#transcribe-a-url): the same key with a different body, or for a video that was deleted, is refused with `409 idempotency_conflict` and the message names the field that differs, and a repeat after the upload completed answers `409 upload_already_completed`. + A field this list does not have returns `400` with the code `invalid_request`, and the error names the field. Field names are case-sensitive, so `user_id` in place of `userId` is refused instead of creating a video with no user: ```json @@ -864,6 +866,8 @@ DELETE /api/developer/v1/transcripts/:id Deletes the transcript, its text and its stored files. A transcript deleted before its audio is sent to speech recognition is not charged. One deleted after that is still charged for the length of its audio when it finishes, because the audio was already transcribed. It stays deleted: it is not listed and its text is not kept. +Deleting a transcript this app already deleted returns success again, so the call is safe to repeat. An ID that never belonged to this app returns `404`. + #### Transcript billing - The price is $0.10 per audio hour, which is 10,000 micro-credits. Cap measures the audio itself, rounds its length to the nearest whole second with a minimum of one, and charges those seconds at the hourly rate, rounded down to a whole micro-credit and never less than one. The example above is 754.2 seconds, billed as 754 seconds: 754 × 10,000 ÷ 3,600 is 2,094.4, so it costs 2,094 micro-credits. A length sent by the client is never used. diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index a7dd4f86516..920e5d32ff9 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -81,6 +81,20 @@ const transcriptIdParam = { schema: { type: "string" }, }; +const uploadIdempotencyKeyParam = { + name: "Idempotency-Key", + in: "header", + required: false, + description: + "8 to 128 characters from letters, digits, dot, dash and underscore. Repeating the request with the same key and the same body returns the same video with a new upload token instead of creating a second one. The same key with a different body, or for a video that was deleted, is refused with 409 idempotency_conflict, and once the upload completed the repeat answers 409 upload_already_completed.", + schema: { + type: "string", + minLength: 8, + maxLength: 128, + pattern: "^[A-Za-z0-9._-]+$", + }, +}; + const idempotencyKeyParam = { name: "Idempotency-Key", in: "header", @@ -678,7 +692,7 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ operationId: "deleteTranscript", summary: "Delete a transcript", description: - "Deletes the transcript, its text and its stored files. A transcript deleted before its audio is sent to speech recognition is not charged. One deleted after that is still charged for the length of its audio when it finishes, because the audio was already transcribed.", + "Deletes the transcript, its text and its stored files. A transcript deleted before its audio is sent to speech recognition is not charged. One deleted after that is still charged for the length of its audio when it finishes, because the audio was already transcribed. Deleting a transcript this app already deleted returns success again, so the call is safe to repeat. An ID that never belonged to this app returns 404.", tags: ["Transcripts"], security: secretAuth, parameters: [transcriptIdParam], @@ -739,14 +753,16 @@ export const developerOpenApiSpec = (baseUrl: string) => ({ operationId: "createUpload", summary: "Create an upload", description: - "Creates a video record and returns an upload token for that one video, with the video's ID and its share and embed URLs. Call this from your server, then give the token to the browser that uploads the file. The token is the only credential the upload operations accept. It works until expiresAt, and stops working once the upload completes, the video is deleted or the secret key that created it is revoked. When corsOrigin is set, the upload operations also refuse a request whose Origin header differs: this is a convenience for browsers, and the token is the security boundary. Creating and uploading are free. The app must be in good standing with a balance that covers 7 days of storage for everything it stores, otherwise this returns 402 with the amount needed. Set maxBytes to refuse a file larger than that many bytes. A field the request does not know returns 400 with code invalid_request and names the field, so a misspelt userId never creates a video with no user.", + "Creates a video record and returns an upload token for that one video, with the video's ID and its share and embed URLs. Call this from your server, then give the token to the browser that uploads the file. The token is the only credential the upload operations accept. It works until expiresAt, and stops working once the upload completes, the video is deleted or the secret key that created it is revoked. When corsOrigin is set, the upload operations also refuse a request whose Origin header differs: this is a convenience for browsers, and the token is the security boundary. Creating and uploading are free. The app must be in good standing with a balance that covers 7 days of storage for everything it stores, otherwise this returns 402 with the amount needed. Set maxBytes to refuse a file larger than that many bytes. Send an Idempotency-Key so a retried request returns the same video with a new token instead of creating a second one. A field the request does not know returns 400 with code invalid_request and names the field, so a misspelt userId never creates a video with no user.", tags: ["Uploads"], security: secretAuth, + parameters: [uploadIdempotencyKeyParam], requestBody: jsonBody("CreateUploadRequest"), responses: { "200": json(dataEnvelope(ref("Upload")), "The created upload."), "400": response("BadRequest"), "402": response("UploadPaymentRequired"), + "409": response("Conflict"), "415": response("UnsupportedMediaType"), ...restErrors, }, From 5e2bb5d8ec0c6741e5594dc88ab1d350c5b66459 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 00:58:36 -0500 Subject: [PATCH 5/6] docs(web): name the fields an upload Idempotency-Key compares --- apps/web/content/docs/api/rest-api.mdx | 2 +- apps/web/lib/developer-openapi.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/content/docs/api/rest-api.mdx b/apps/web/content/docs/api/rest-api.mdx index a4cd8079320..17ca3bdad8b 100644 --- a/apps/web/content/docs/api/rest-api.mdx +++ b/apps/web/content/docs/api/rest-api.mdx @@ -187,7 +187,7 @@ POST /api/developer/v1/uploads The body is optional. A request with no body creates an upload with the defaults. -Send an `Idempotency-Key` header so a retried request returns the same video with a new upload token instead of creating a second one. The key follows the same rules as for [transcripts](/docs/api/rest-api#transcribe-a-url): the same key with a different body, or for a video that was deleted, is refused with `409 idempotency_conflict` and the message names the field that differs, and a repeat after the upload completed answers `409 upload_already_completed`. +Send an `Idempotency-Key` header so a retried request returns the same video with a new upload token instead of creating a second one. The key follows the same rules as for [transcripts](/docs/api/rest-api#transcribe-a-url). A repeat must send the same `name`, `userId`, `maxResolutionTier` and `metadata`, otherwise it is refused with `409 idempotency_conflict` and the message names the field that differs. `corsOrigin`, `timeoutSeconds` and `maxBytes` only shape the new token, so they may change. A key for a video that was deleted is refused with `409 idempotency_conflict`, and a repeat after the upload completed answers `409 upload_already_completed`. A field this list does not have returns `400` with the code `invalid_request`, and the error names the field. Field names are case-sensitive, so `user_id` in place of `userId` is refused instead of creating a video with no user: diff --git a/apps/web/lib/developer-openapi.ts b/apps/web/lib/developer-openapi.ts index 6c0bb98a713..11320b5b24f 100644 --- a/apps/web/lib/developer-openapi.ts +++ b/apps/web/lib/developer-openapi.ts @@ -86,7 +86,7 @@ const uploadIdempotencyKeyParam = { in: "header", required: false, description: - "8 to 128 characters from letters, digits, dot, dash and underscore. Repeating the request with the same key and the same body returns the same video with a new upload token instead of creating a second one. The same key with a different body, or for a video that was deleted, is refused with 409 idempotency_conflict, and once the upload completed the repeat answers 409 upload_already_completed.", + "8 to 128 characters from letters, digits, dot, dash and underscore. Repeating the request with the same key returns the same video with a new upload token instead of creating a second one. A repeat must send the same name, userId, maxResolutionTier and metadata, otherwise it is refused with 409 idempotency_conflict naming the field. corsOrigin, timeoutSeconds and maxBytes only shape the new token, so they may change. A key for a video that was deleted is refused with 409 idempotency_conflict, and once the upload completed the repeat answers 409 upload_already_completed.", schema: { type: "string", minLength: 8, From d980b2616c6bf048ccefa0bc9b675445242138b5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Fri, 13 Nov 2026 01:09:47 -0500 Subject: [PATCH 6/6] test(web): reach the key limit from its constant in the response header test --- apps/web/__tests__/unit/developer-api-contract.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/__tests__/unit/developer-api-contract.test.ts b/apps/web/__tests__/unit/developer-api-contract.test.ts index fab1d8c6b19..06223292edc 100644 --- a/apps/web/__tests__/unit/developer-api-contract.test.ts +++ b/apps/web/__tests__/unit/developer-api-contract.test.ts @@ -974,7 +974,11 @@ describe("response headers on the developer APIs", () => { await call(rest, "GET", "/api/developer/v1/nope", { key }), await call(rest, "POST", "/api/developer/v1/usage", { key }), ]; - for (let request = 3; request < 60; request++) { + for ( + let request = 3; + request < DEVELOPER_KEY_REQUESTS_PER_MINUTE; + request++ + ) { await call(rest, "GET", "/api/developer/v1/usage", { key }); } answers.push(await call(rest, "GET", "/api/developer/v1/usage", { key }));