From 81c2a19a682a55b3ad4d6f798012a135f4481352 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Fri, 13 Nov 2026 00:23:07 -0500
Subject: [PATCH 1/6] fix(web): reserve comment media objects atomically before
issuing uploads
---
.../api/upload/[...route]/comment-media.ts | 65 ++++++++++------
apps/web/lib/comment-media-quota.ts | 78 +++++++++++++++++++
apps/web/lib/rate-limit.ts | 6 ++
3 files changed, 127 insertions(+), 22 deletions(-)
create mode 100644 apps/web/lib/comment-media-quota.ts
diff --git a/apps/web/app/api/upload/[...route]/comment-media.ts b/apps/web/app/api/upload/[...route]/comment-media.ts
index 7dd712868a9..d15b4b42ca9 100644
--- a/apps/web/app/api/upload/[...route]/comment-media.ts
+++ b/apps/web/app/api/upload/[...route]/comment-media.ts
@@ -17,7 +17,12 @@ import { Hono } from "hono";
import { z } from "zod";
import { withAuth } from "@/app/api/utils";
import { commentMediaKeys } from "@/lib/comment-media";
+import {
+ MAX_MEDIA_OBJECTS_PER_VIDEO,
+ reserveCommentMediaObjects,
+} from "@/lib/comment-media-quota";
import { canUseMediaComments } from "@/lib/media-comments";
+import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit";
import { runPromise } from "@/lib/server";
import { stringOrNumberOptional } from "@/utils/zod";
import { zValidator } from "@/utils/zod-validator";
@@ -28,12 +33,11 @@ const runPromiseAnyEnv = runPromise as (
effect: Effect.Effect,
) => Promise;
-// Stateless storage-growth bounds. Uploads land in the video owner's
-// (possibly BYO) bucket, so an unbounded viewer must not be able to fill it
-// unattended: per-commenter comment-rate, per-video object count, and a
-// per-object multipart ceiling (single PUTs are capped at 5GB by S3 itself).
+// Storage-growth bounds. Uploads land in the video owner's (possibly BYO)
+// bucket, so an unbounded viewer must not be able to fill it unattended:
+// per-commenter comment-rate, per-video object count, and a per-object
+// multipart ceiling (single PUTs are capped at 5GB by S3 itself).
const MAX_MEDIA_COMMENTS_PER_HOUR = 30;
-const MAX_MEDIA_OBJECTS_PER_VIDEO = 400;
const MAX_MEDIA_OBJECT_BYTES = 20 * 1024 * 1024 * 1024;
app.post(
@@ -96,26 +100,19 @@ app.post(
return c.json({ error: "Too many media comments, slow down" });
}
- const [bucket] = yield* Storage.getAccessForVideo(video);
-
- // The comment-row throttle above only counts finished comments; an
- // initiate-and-PUT loop that never creates rows would bypass it. The
- // object count under the comments prefix is the ground truth for bytes
- // actually landed in the owner's (possibly BYO) bucket, so it is the
- // bound that matters.
- const existingObjects = yield* bucket.listObjects({
- prefix: `${video.ownerId}/${video.id}/comments/`,
- maxKeys: MAX_MEDIA_OBJECTS_PER_VIDEO,
- });
- if (
- (existingObjects.Contents?.length ?? 0) >= MAX_MEDIA_OBJECTS_PER_VIDEO
- ) {
+ const initiatesLimited = yield* Effect.promise(() =>
+ isRateLimited(RATE_LIMIT_IDS.COMMENT_MEDIA_UPLOAD, {
+ key: `comment-media-upload:${user.id}`,
+ headers: c.req.raw.headers,
+ }),
+ );
+ if (initiatesLimited) {
c.status(429);
- return c.json({
- error: "This video has reached its media comment limit",
- });
+ return c.json({ error: "Too many media comments, slow down" });
}
+ const [bucket] = yield* Storage.getAccessForVideo(video);
+
const commentId = Comment.CommentId.make(nanoId());
const keys = commentMediaKeys({
ownerId: video.ownerId,
@@ -131,6 +128,30 @@ app.post(
}
const { contentType, key, thumbnailKey } = keys;
+ // The comment-row throttle above only counts finished comments; an
+ // initiate-and-PUT loop that never creates rows would bypass it. The
+ // object count under the comments prefix is the ground truth for bytes
+ // actually landed in the owner's (possibly BYO) bucket. Uploads that
+ // have not landed yet are counted through a reservation taken under a
+ // row lock, so concurrent initiates cannot all pass the same snapshot.
+ const existingObjects = yield* bucket.listObjects({
+ prefix: `${video.ownerId}/${video.id}/comments/`,
+ maxKeys: MAX_MEDIA_OBJECTS_PER_VIDEO,
+ });
+ const reserved = yield* Effect.tryPromise(() =>
+ reserveCommentMediaObjects({
+ videoId: video.id,
+ objects: thumbnailKey ? 2 : 1,
+ storedObjects: existingObjects.Contents?.length ?? 0,
+ }),
+ );
+ if (!reserved) {
+ c.status(429);
+ return c.json({
+ error: "This video has reached its media comment limit",
+ });
+ }
+
// Drive rejects presigned POST, so upload targets are always PUT-style;
// createUploadTarget returns the provider-appropriate target shape.
const thumbnailUpload = thumbnailKey
diff --git a/apps/web/lib/comment-media-quota.ts b/apps/web/lib/comment-media-quota.ts
new file mode 100644
index 00000000000..b90394682f2
--- /dev/null
+++ b/apps/web/lib/comment-media-quota.ts
@@ -0,0 +1,78 @@
+import { createHash } from "node:crypto";
+import { db } from "@cap/database";
+import { mediaProcessingBudgets } from "@cap/database/schema";
+import { asc, inArray, sql } from "drizzle-orm";
+
+export const MAX_MEDIA_OBJECTS_PER_VIDEO = 400;
+
+// Matches the comment media upload token lifetime, so every upload that can
+// still land was reserved in the current or the previous window.
+export const COMMENT_MEDIA_RESERVATION_WINDOW_MS = 6 * 60 * 60 * 1000;
+
+const reservationKey = (videoId: string, windowIndex: number) =>
+ createHash("sha256")
+ .update(JSON.stringify(["comment-media", videoId, windowIndex]))
+ .digest("hex");
+
+export async function reserveCommentMediaObjects(input: {
+ videoId: string;
+ objects: number;
+ storedObjects: number;
+ now?: Date;
+}) {
+ const now = (input.now ?? new Date()).getTime();
+ const windowIndex = Math.floor(now / COMMENT_MEDIA_RESERVATION_WINDOW_MS);
+ const currentKey = reservationKey(input.videoId, windowIndex);
+ const windows = [
+ { id: currentKey, index: windowIndex },
+ {
+ id: reservationKey(input.videoId, windowIndex - 1),
+ index: windowIndex - 1,
+ },
+ ].sort((left, right) => left.id.localeCompare(right.id));
+
+ return db().transaction(async (tx) => {
+ await tx
+ .insert(mediaProcessingBudgets)
+ .values(
+ windows.map((window) => ({
+ id: window.id,
+ limitBytes: MAX_MEDIA_OBJECTS_PER_VIDEO,
+ expiresAt: new Date(
+ (window.index + 2) * COMMENT_MEDIA_RESERVATION_WINDOW_MS,
+ ),
+ })),
+ )
+ .onDuplicateKeyUpdate({
+ set: { id: sql`${mediaProcessingBudgets.id}` },
+ });
+ const locked = await tx
+ .select()
+ .from(mediaProcessingBudgets)
+ .where(
+ inArray(
+ mediaProcessingBudgets.id,
+ windows.map((window) => window.id),
+ ),
+ )
+ .orderBy(asc(mediaProcessingBudgets.id))
+ .for("update");
+ if (locked.length !== windows.length)
+ throw new Error("Comment media quota is unavailable");
+
+ const reserved = locked.reduce((sum, row) => sum + row.reservedBytes, 0);
+ if (
+ input.storedObjects + reserved + input.objects >
+ MAX_MEDIA_OBJECTS_PER_VIDEO
+ )
+ return false;
+
+ await tx
+ .update(mediaProcessingBudgets)
+ .set({
+ reservedBytes: sql`${mediaProcessingBudgets.reservedBytes} + ${input.objects}`,
+ })
+ .where(inArray(mediaProcessingBudgets.id, [currentKey]));
+ return true;
+ });
+}
diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts
index 2b46bf8327b..626a3a4250d 100644
--- a/apps/web/lib/rate-limit.ts
+++ b/apps/web/lib/rate-limit.ts
@@ -207,6 +207,8 @@ export const RATE_LIMIT_IDS = {
COLLECTION_PASSWORD_PER_VIEWER: "rl_collection_password_per_viewer",
/** Comments, replies and reactions per author. Suggested: 60 / 1m per key (user). */
COMMENT_CREATE: "rl_comment_create",
+ /** Comment media upload starts per viewer. Suggested: 60 / 1h per key (user). */
+ COMMENT_MEDIA_UPLOAD: "rl_comment_media_upload",
/** Video edit saves and restores per user: each one re-encodes the video and regenerates AI output. Suggested: 30 / 1h per key (user). */
VIDEO_EDIT: "rl_video_edit",
/** Share-page downloads by viewers who are not members. Suggested: 30 / 1m per IP. */
@@ -269,6 +271,10 @@ const FALLBACK_RATE_LIMITS: Record = {
limit: 1,
windowMs: 60 * MINUTE_MS,
},
+ [RATE_LIMIT_IDS.COMMENT_MEDIA_UPLOAD]: {
+ limit: 60,
+ windowMs: 60 * MINUTE_MS,
+ },
[RATE_LIMIT_IDS.VIDEO_EDIT]: { limit: 30, windowMs: 60 * MINUTE_MS },
[RATE_LIMIT_IDS.VIEWER_DOWNLOAD]: { limit: 30, windowMs: MINUTE_MS },
[RATE_LIMIT_IDS.MOBILE_EVENTS]: { limit: 30, windowMs: MINUTE_MS },
From c568148eb0edf8c7ca354c1fa6a1d4fd7efeec70 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Fri, 13 Nov 2026 00:23:07 -0500
Subject: [PATCH 2/6] test(web): cover the comment media quota under concurrent
initiates
---
.../comment-media-initiate-limits.test.ts | 166 ++++++++++++++++
.../unit/comment-media-quota.test.ts | 184 ++++++++++++++++++
2 files changed, 350 insertions(+)
create mode 100644 apps/web/__tests__/unit/comment-media-initiate-limits.test.ts
create mode 100644 apps/web/__tests__/unit/comment-media-quota.test.ts
diff --git a/apps/web/__tests__/unit/comment-media-initiate-limits.test.ts b/apps/web/__tests__/unit/comment-media-initiate-limits.test.ts
new file mode 100644
index 00000000000..445b309129c
--- /dev/null
+++ b/apps/web/__tests__/unit/comment-media-initiate-limits.test.ts
@@ -0,0 +1,166 @@
+import { Effect, Layer, Option } from "effect";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ reserve: vi.fn(),
+ rateLimited: vi.fn(),
+ listObjects: vi.fn(),
+ createUploadTarget: vi.fn(),
+}));
+
+const video = { id: "video-1", ownerId: "owner-1" };
+
+vi.mock("@/app/api/utils", () => ({
+ withAuth: async (
+ c: { set: (key: string, value: unknown) => void },
+ next: () => Promise,
+ ) => {
+ c.set("user", { id: "viewer-1" });
+ await next();
+ },
+}));
+vi.mock("@cap/database", () => ({
+ db: () => ({
+ select: () => ({
+ from: () => ({ where: () => ({ limit: async () => [] }) }),
+ }),
+ }),
+}));
+vi.mock("@cap/database/helpers", () => ({ nanoId: () => "comment-1" }));
+vi.mock("@cap/database/schema", () => ({ comments: {} }));
+vi.mock("drizzle-orm", () => ({
+ and: () => undefined,
+ eq: () => undefined,
+ gt: () => undefined,
+ inArray: () => undefined,
+}));
+vi.mock("@cap/web-domain", () => ({
+ Comment: { CommentId: { make: (value: string) => value } },
+ Policy: { withPublicPolicy: () => (effect: unknown) => effect },
+ Video: {
+ VideoId: { make: (value: string) => value },
+ NotFoundError: class {},
+ },
+}));
+vi.mock("@cap/web-backend", () => ({
+ createCommentMediaUploadToken: () => "upload-token",
+ verifyCommentMediaUploadToken: () => null,
+ makeCurrentUserLayer: () => Layer.empty,
+ provideOptionalAuth: (effect: unknown) => effect,
+ VideosRepo: Effect.succeed({
+ getById: () => Effect.succeed(Option.some([video])),
+ }),
+ VideosPolicy: Effect.succeed({ canView: () => Effect.void }),
+ Storage: {
+ getAccessForVideo: () =>
+ Effect.succeed([
+ {
+ provider: "aws",
+ listObjects: mocks.listObjects,
+ createUploadTarget: mocks.createUploadTarget,
+ },
+ ]),
+ },
+}));
+vi.mock("@/lib/media-comments", () => ({
+ canUseMediaComments: async () => true,
+}));
+vi.mock("@/lib/comment-media-quota", () => ({
+ MAX_MEDIA_OBJECTS_PER_VIDEO: 400,
+ reserveCommentMediaObjects: mocks.reserve,
+}));
+vi.mock("@/lib/rate-limit", () => ({
+ RATE_LIMIT_IDS: { COMMENT_MEDIA_UPLOAD: "rl_comment_media_upload" },
+ isRateLimited: mocks.rateLimited,
+}));
+vi.mock("@/lib/server", () => ({ runPromise: Effect.runPromise }));
+
+import { app } from "@/app/api/upload/[...route]/comment-media";
+
+const initiate = (body: Record = {}) =>
+ app.request("/initiate", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ videoId: "video-1",
+ kind: "video",
+ contentType: "video/webm",
+ mode: "single",
+ withThumbnail: true,
+ ...body,
+ }),
+ });
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.rateLimited.mockResolvedValue(false);
+ mocks.reserve.mockResolvedValue(true);
+ mocks.listObjects.mockReturnValue(Effect.succeed({ Contents: [{}, {}, {}] }));
+ mocks.createUploadTarget.mockImplementation((key: string) =>
+ Effect.succeed({ url: `https://upload.test/${key}` }),
+ );
+});
+
+describe("comment media initiate limits", () => {
+ it("issues upload targets for a normal media comment", async () => {
+ const response = await initiate();
+
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as { upload: { url: string } };
+ expect(body.upload.url).toContain("comments/comment-1/media.webm");
+ expect(mocks.reserve).toHaveBeenCalledExactlyOnceWith({
+ videoId: "video-1",
+ objects: 2,
+ storedObjects: 3,
+ });
+ expect(mocks.rateLimited).toHaveBeenCalledWith(
+ "rl_comment_media_upload",
+ expect.objectContaining({ key: "comment-media-upload:viewer-1" }),
+ );
+ });
+
+ it("reserves one object for audio", async () => {
+ const response = await initiate({
+ kind: "audio",
+ contentType: "audio/webm",
+ withThumbnail: false,
+ });
+
+ expect(response.status).toBe(200);
+ expect(mocks.reserve).toHaveBeenCalledWith(
+ expect.objectContaining({ objects: 1 }),
+ );
+ });
+
+ it("refuses with a clear error once the video is full", async () => {
+ mocks.reserve.mockResolvedValue(false);
+
+ const response = await initiate();
+
+ expect(response.status).toBe(429);
+ expect(await response.json()).toEqual({
+ error: "This video has reached its media comment limit",
+ });
+ expect(mocks.createUploadTarget).not.toHaveBeenCalled();
+ });
+
+ it("refuses a viewer who starts too many uploads", async () => {
+ mocks.rateLimited.mockResolvedValue(true);
+
+ const response = await initiate();
+
+ expect(response.status).toBe(429);
+ expect(await response.json()).toEqual({
+ error: "Too many media comments, slow down",
+ });
+ expect(mocks.reserve).not.toHaveBeenCalled();
+ expect(mocks.createUploadTarget).not.toHaveBeenCalled();
+ });
+
+ it("does not reserve space for an unsupported content type", async () => {
+ const response = await initiate({ contentType: "video/x-msvideo" });
+
+ expect(response.status).toBe(415);
+ expect(mocks.reserve).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/__tests__/unit/comment-media-quota.test.ts b/apps/web/__tests__/unit/comment-media-quota.test.ts
new file mode 100644
index 00000000000..ee8e8e4ac5e
--- /dev/null
+++ b/apps/web/__tests__/unit/comment-media-quota.test.ts
@@ -0,0 +1,184 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ COMMENT_MEDIA_RESERVATION_WINDOW_MS,
+ MAX_MEDIA_OBJECTS_PER_VIDEO,
+ reserveCommentMediaObjects,
+} from "@/lib/comment-media-quota";
+
+type Row = {
+ id: string;
+ limitBytes: number;
+ reservedBytes: number;
+ expiresAt: Date;
+};
+
+const state = vi.hoisted(() => ({
+ rows: new Map(),
+ locks: new Map>(),
+ lockRows: true,
+}));
+
+const pause = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+vi.mock("@cap/database/schema", () => ({
+ mediaProcessingBudgets: { id: "id", reservedBytes: "reservedBytes" },
+}));
+vi.mock("drizzle-orm", () => ({
+ asc: (value: unknown) => value,
+ inArray: (_column: unknown, ids: string[]) => ids,
+ sql: (_strings: TemplateStringsArray, ...values: unknown[]) => values,
+}));
+vi.mock("@cap/database", () => ({
+ db: () => ({
+ transaction: async (run: (tx: unknown) => Promise) => {
+ const releases: (() => void)[] = [];
+ const acquire = async (id: string) => {
+ const previous = state.locks.get(id) ?? Promise.resolve();
+ let release = () => {};
+ const held = new Promise((resolve) => {
+ release = resolve;
+ });
+ state.locks.set(
+ id,
+ previous.then(() => held),
+ );
+ await previous;
+ releases.push(release);
+ };
+ try {
+ return await run({
+ insert: () => ({
+ values: (rows: Omit[]) => ({
+ onDuplicateKeyUpdate: async () => {
+ await pause();
+ for (const row of rows)
+ if (!state.rows.has(row.id))
+ state.rows.set(row.id, { ...row, reservedBytes: 0 });
+ },
+ }),
+ }),
+ select: () => ({
+ from: () => ({
+ where: (ids: string[]) => ({
+ orderBy: () => ({
+ for: async () => {
+ if (state.lockRows) for (const id of ids) await acquire(id);
+ await pause();
+ return ids
+ .map((id) => state.rows.get(id))
+ .filter(Boolean)
+ .map((row) => ({ ...(row as Row) }));
+ },
+ }),
+ }),
+ }),
+ }),
+ update: () => ({
+ set: (values: { reservedBytes: [unknown, number] }) => ({
+ where: async (ids: string[]) => {
+ await pause();
+ for (const id of ids) {
+ const row = state.rows.get(id);
+ if (row) row.reservedBytes += values.reservedBytes[1];
+ }
+ },
+ }),
+ }),
+ });
+ } finally {
+ for (const release of releases) release();
+ }
+ },
+ }),
+}));
+
+const now = new Date(COMMENT_MEDIA_RESERVATION_WINDOW_MS * 1000 + 60_000);
+
+const reserve = (
+ input: Partial[0]> = {},
+) =>
+ reserveCommentMediaObjects({
+ videoId: "video-1",
+ objects: 1,
+ storedObjects: 0,
+ now,
+ ...input,
+ });
+
+beforeEach(() => {
+ state.rows.clear();
+ state.locks.clear();
+ state.lockRows = true;
+});
+
+describe("comment media object quota", () => {
+ it("lets a normal upload through on a video with room", async () => {
+ await expect(reserve()).resolves.toBe(true);
+ await expect(reserve({ objects: 2, storedObjects: 3 })).resolves.toBe(true);
+ });
+
+ it("refuses an upload that would pass the per-video cap", async () => {
+ await expect(
+ reserve({ storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO }),
+ ).resolves.toBe(false);
+ await expect(
+ reserve({ objects: 2, storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO - 1 }),
+ ).resolves.toBe(false);
+ await expect(
+ reserve({ storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO - 1 }),
+ ).resolves.toBe(true);
+ await expect(
+ reserve({ storedObjects: MAX_MEDIA_OBJECTS_PER_VIDEO - 1 }),
+ ).resolves.toBe(false);
+ });
+
+ it("never issues more than the cap to concurrent initiates", async () => {
+ const results = await Promise.all(
+ Array.from({ length: 500 }, () => reserve()),
+ );
+ expect(results.filter(Boolean)).toHaveLength(MAX_MEDIA_OBJECTS_PER_VIDEO);
+ });
+
+ it("counts thumbnails against the cap under concurrency", async () => {
+ const results = await Promise.all(
+ Array.from({ length: 300 }, () => reserve({ objects: 2 })),
+ );
+ expect(results.filter(Boolean)).toHaveLength(
+ MAX_MEDIA_OBJECTS_PER_VIDEO / 2,
+ );
+ });
+
+ it("needs the row lock to hold the cap", async () => {
+ state.lockRows = false;
+ const results = await Promise.all(
+ Array.from({ length: 500 }, () => reserve()),
+ );
+ expect(results.filter(Boolean).length).toBeGreaterThan(
+ MAX_MEDIA_OBJECTS_PER_VIDEO,
+ );
+ });
+
+ it("keeps counting uploads that can still land and frees them after", async () => {
+ await Promise.all(
+ Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () => reserve()),
+ );
+ await expect(reserve()).resolves.toBe(false);
+
+ const nextWindow = new Date(
+ now.getTime() + COMMENT_MEDIA_RESERVATION_WINDOW_MS,
+ );
+ await expect(reserve({ now: nextWindow })).resolves.toBe(false);
+
+ const afterTokensExpire = new Date(
+ now.getTime() + 2 * COMMENT_MEDIA_RESERVATION_WINDOW_MS,
+ );
+ await expect(reserve({ now: afterTokensExpire })).resolves.toBe(true);
+ });
+
+ it("keeps each video's reservations separate", async () => {
+ await Promise.all(
+ Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () => reserve()),
+ );
+ await expect(reserve({ videoId: "video-2" })).resolves.toBe(true);
+ });
+});
From 2929ff3a31452fbe4d7679c62d35fa206759160c Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Fri, 13 Nov 2026 00:33:28 -0500
Subject: [PATCH 3/6] fix(web): keep comment media reservations counted past
their token
---
apps/web/lib/comment-media-quota.ts | 22 ++++++++++++----------
1 file changed, 12 insertions(+), 10 deletions(-)
diff --git a/apps/web/lib/comment-media-quota.ts b/apps/web/lib/comment-media-quota.ts
index b90394682f2..6cb525a4fe9 100644
--- a/apps/web/lib/comment-media-quota.ts
+++ b/apps/web/lib/comment-media-quota.ts
@@ -5,9 +5,13 @@ import { asc, inArray, sql } from "drizzle-orm";
export const MAX_MEDIA_OBJECTS_PER_VIDEO = 400;
-// Matches the comment media upload token lifetime, so every upload that can
-// still land was reserved in the current or the previous window.
+// Matches the comment media upload token lifetime. A reservation counts in its
+// own window and the two after it, so it outlives its token by at least one
+// full window. That margin covers the bucket listing taken before the
+// reservation and the token minted after it falling on either side of a window
+// boundary.
export const COMMENT_MEDIA_RESERVATION_WINDOW_MS = 6 * 60 * 60 * 1000;
+const COUNTED_WINDOWS = 3;
const reservationKey = (videoId: string, windowIndex: number) =>
createHash("sha256")
@@ -23,13 +27,10 @@ export async function reserveCommentMediaObjects(input: {
const now = (input.now ?? new Date()).getTime();
const windowIndex = Math.floor(now / COMMENT_MEDIA_RESERVATION_WINDOW_MS);
const currentKey = reservationKey(input.videoId, windowIndex);
- const windows = [
- { id: currentKey, index: windowIndex },
- {
- id: reservationKey(input.videoId, windowIndex - 1),
- index: windowIndex - 1,
- },
- ].sort((left, right) => left.id.localeCompare(right.id));
+ const windows = Array.from({ length: COUNTED_WINDOWS }, (_, age) => ({
+ id: reservationKey(input.videoId, windowIndex - age),
+ index: windowIndex - age,
+ })).sort((left, right) => left.id.localeCompare(right.id));
return db().transaction(async (tx) => {
await tx
@@ -39,7 +40,8 @@ export async function reserveCommentMediaObjects(input: {
id: window.id,
limitBytes: MAX_MEDIA_OBJECTS_PER_VIDEO,
expiresAt: new Date(
- (window.index + 2) * COMMENT_MEDIA_RESERVATION_WINDOW_MS,
+ (window.index + COUNTED_WINDOWS) *
+ COMMENT_MEDIA_RESERVATION_WINDOW_MS,
),
})),
)
From 34ac758bdab45fdaa570a82a554c8d41af4cc4d6 Mon Sep 17 00:00:00 2001
From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com>
Date: Fri, 13 Nov 2026 00:33:28 -0500
Subject: [PATCH 4/6] test(web): cover comment media reservations across window
boundaries
---
.../unit/comment-media-quota.test.ts | 24 ++++++++++---------
1 file changed, 13 insertions(+), 11 deletions(-)
diff --git a/apps/web/__tests__/unit/comment-media-quota.test.ts b/apps/web/__tests__/unit/comment-media-quota.test.ts
index ee8e8e4ac5e..5174b94eac5 100644
--- a/apps/web/__tests__/unit/comment-media-quota.test.ts
+++ b/apps/web/__tests__/unit/comment-media-quota.test.ts
@@ -158,21 +158,23 @@ describe("comment media object quota", () => {
);
});
- it("keeps counting uploads that can still land and frees them after", async () => {
+ it("keeps counting a reservation past its token across window boundaries", async () => {
+ const W = COMMENT_MEDIA_RESERVATION_WINDOW_MS;
+ const lastMomentOfWindow = new Date(2 * W - 1);
await Promise.all(
- Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () => reserve()),
+ Array.from({ length: MAX_MEDIA_OBJECTS_PER_VIDEO }, () =>
+ reserve({ now: lastMomentOfWindow }),
+ ),
);
- await expect(reserve()).resolves.toBe(false);
- const nextWindow = new Date(
- now.getTime() + COMMENT_MEDIA_RESERVATION_WINDOW_MS,
- );
- await expect(reserve({ now: nextWindow })).resolves.toBe(false);
+ for (const at of [
+ lastMomentOfWindow.getTime() + 1,
+ lastMomentOfWindow.getTime() + W + 60_000,
+ 3 * W + W - 1,
+ ])
+ await expect(reserve({ now: new Date(at) })).resolves.toBe(false);
- const afterTokensExpire = new Date(
- now.getTime() + 2 * COMMENT_MEDIA_RESERVATION_WINDOW_MS,
- );
- await expect(reserve({ now: afterTokensExpire })).resolves.toBe(true);
+ await expect(reserve({ now: new Date(4 * W) })).resolves.toBe(true);
});
it("keeps each video's reservations separate", async () => {
From 2aa6a0c38a7e0fdda0398258dcf3942cc90cdecc Mon Sep 17 00:00:00 2001
From: Richie McIlroy
Date: Fri, 13 Nov 2026 08:14:15 -0500
Subject: [PATCH 5/6] test(web): prove the comment media quota against a real
database and bucket
---
.../comment-media-quota-database.test.ts | 281 ++++++++++++++++++
1 file changed, 281 insertions(+)
create mode 100644 apps/web/__tests__/integration/comment-media-quota-database.test.ts
diff --git a/apps/web/__tests__/integration/comment-media-quota-database.test.ts b/apps/web/__tests__/integration/comment-media-quota-database.test.ts
new file mode 100644
index 00000000000..c636d4c96bf
--- /dev/null
+++ b/apps/web/__tests__/integration/comment-media-quota-database.test.ts
@@ -0,0 +1,281 @@
+import { randomUUID } from "node:crypto";
+import {
+ CreateBucketCommand,
+ HeadObjectCommand,
+ ListObjectsV2Command,
+ PutObjectCommand,
+ S3Client,
+} from "@aws-sdk/client-s3";
+import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
+import { drizzle } from "drizzle-orm/mysql2";
+import { Effect, Layer, Option } from "effect";
+import { createPool, type Pool } from "mysql2/promise";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+
+// Drives the real /initiate handler against a real MySQL database and a real
+// S3-compatible bucket. Auth, the video lookup and the plan check are the only
+// stubs; the comment throttle, the rate limiter, the quota reservation and the
+// bucket listing all run for real.
+
+const fixture = vi.hoisted(() => ({
+ database: undefined as unknown,
+ s3: undefined as unknown,
+ bucket: "",
+ ownerId: "",
+ videoId: "",
+}));
+
+vi.mock("@cap/database", () => ({
+ db: () => {
+ if (!fixture.database) throw new Error("Test database is not connected.");
+ return fixture.database;
+ },
+}));
+vi.mock("@/app/api/utils", () => ({
+ withAuth: async (
+ c: {
+ req: { header: (name: string) => string | undefined };
+ set: (key: string, value: unknown) => void;
+ },
+ next: () => Promise,
+ ) => {
+ c.set("user", { id: c.req.header("x-test-user") });
+ await next();
+ },
+}));
+vi.mock("@cap/web-domain", () => ({
+ Comment: { CommentId: { make: (value: string) => value } },
+ Policy: { withPublicPolicy: () => (effect: unknown) => effect },
+ Video: {
+ VideoId: { make: (value: string) => value },
+ NotFoundError: class {},
+ },
+}));
+vi.mock("@cap/web-backend", () => {
+ const client = () => fixture.s3 as S3Client;
+ return {
+ createCommentMediaUploadToken: () => "upload-token",
+ verifyCommentMediaUploadToken: () => null,
+ makeCurrentUserLayer: () => Layer.empty,
+ provideOptionalAuth: (effect: unknown) => effect,
+ VideosRepo: Effect.succeed({
+ getById: () =>
+ Effect.succeed(
+ Option.some([{ id: fixture.videoId, ownerId: fixture.ownerId }]),
+ ),
+ }),
+ VideosPolicy: Effect.succeed({ canView: () => Effect.void }),
+ Storage: {
+ getAccessForVideo: () =>
+ Effect.succeed([
+ {
+ provider: "aws",
+ listObjects: (input: { prefix: string; maxKeys: number }) =>
+ Effect.promise(() =>
+ client().send(
+ new ListObjectsV2Command({
+ Bucket: fixture.bucket,
+ Prefix: input.prefix,
+ MaxKeys: input.maxKeys,
+ }),
+ ),
+ ),
+ createUploadTarget: (
+ key: string,
+ options: { contentType: string },
+ ) =>
+ Effect.promise(async () => ({
+ url: await getSignedUrl(
+ client(),
+ new PutObjectCommand({
+ Bucket: fixture.bucket,
+ Key: key,
+ ContentType: options.contentType,
+ }),
+ { expiresIn: 600 },
+ ),
+ method: "PUT",
+ })),
+ },
+ ]),
+ },
+ };
+});
+vi.mock("@/lib/media-comments", () => ({
+ canUseMediaComments: async () => true,
+}));
+vi.mock("@/lib/server", () => ({ runPromise: Effect.runPromise }));
+
+const { app } = await import("@/app/api/upload/[...route]/comment-media");
+
+const databaseUrl = process.env.CAP_INFRA_TEST_DATABASE_URL;
+const s3Endpoint = process.env.CAP_COMMENT_MEDIA_TEST_S3_ENDPOINT;
+const id = () => randomUUID().replaceAll("-", "").slice(0, 15);
+
+type Initiated = {
+ key: string;
+ upload: { url: string };
+ thumbnailUpload: { url: string } | null;
+};
+
+const initiate = (user: string, body: Record = {}) =>
+ app.request("/initiate", {
+ method: "POST",
+ headers: { "content-type": "application/json", "x-test-user": user },
+ body: JSON.stringify({
+ videoId: fixture.videoId,
+ kind: "video",
+ contentType: "video/webm",
+ mode: "single",
+ withThumbnail: true,
+ ...body,
+ }),
+ });
+
+const put = (url: string, contentType: string, bytes: Uint8Array) =>
+ fetch(url, {
+ method: "PUT",
+ headers: { "content-type": contentType },
+ body: bytes,
+ });
+
+const storedCount = async () => {
+ const listed = await (fixture.s3 as S3Client).send(
+ new ListObjectsV2Command({
+ Bucket: fixture.bucket,
+ Prefix: `${fixture.ownerId}/${fixture.videoId}/comments/`,
+ }),
+ );
+ return listed.KeyCount ?? 0;
+};
+
+const freshVideo = () => {
+ fixture.ownerId = `owner-${id()}`;
+ fixture.videoId = `video-${id()}`;
+};
+
+describe.runIf(Boolean(databaseUrl && s3Endpoint))(
+ "comment media initiate against a real database and bucket",
+ () => {
+ let pool: Pool;
+
+ beforeAll(async () => {
+ if (!databaseUrl || !s3Endpoint)
+ throw new Error("Missing isolated test database or bucket.");
+ const url = new URL(databaseUrl);
+ if (
+ url.protocol !== "mysql:" ||
+ !["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) ||
+ !/^\/cap_infra_[a-z0-9_]+$/.test(url.pathname)
+ )
+ throw new Error("These tests require a session-scoped local database.");
+ if (!["127.0.0.1", "localhost"].includes(new URL(s3Endpoint).hostname))
+ throw new Error("These tests require a local bucket.");
+ pool = createPool({ uri: databaseUrl, connectionLimit: 16 });
+ fixture.database = drizzle(pool);
+ fixture.s3 = new S3Client({
+ endpoint: s3Endpoint,
+ region: "us-east-1",
+ forcePathStyle: true,
+ credentials: {
+ accessKeyId: process.env.CAP_COMMENT_MEDIA_TEST_S3_KEY ?? "",
+ secretAccessKey: process.env.CAP_COMMENT_MEDIA_TEST_S3_SECRET ?? "",
+ },
+ });
+ fixture.bucket = `comment-media-test-${id()}`;
+ await (fixture.s3 as S3Client).send(
+ new CreateBucketCommand({ Bucket: fixture.bucket }),
+ );
+ });
+
+ afterAll(async () => {
+ await pool?.end();
+ });
+
+ it("uploads a normal video, thumbnail and audio comment end to end", async () => {
+ freshVideo();
+ const user = `viewer-${id()}`;
+
+ const video = await initiate(user);
+ expect(video.status).toBe(200);
+ const videoBody = (await video.json()) as Initiated;
+ expect(videoBody.thumbnailUpload).not.toBeNull();
+ expect(
+ (await put(videoBody.upload.url, "video/webm", new Uint8Array(4096)))
+ .status,
+ ).toBe(200);
+ expect(
+ (
+ await put(
+ videoBody.thumbnailUpload?.url ?? "",
+ "image/jpeg",
+ new Uint8Array(512),
+ )
+ ).status,
+ ).toBe(200);
+
+ const audio = await initiate(user, {
+ kind: "audio",
+ contentType: "audio/webm",
+ withThumbnail: false,
+ });
+ expect(audio.status).toBe(200);
+ const audioBody = (await audio.json()) as Initiated;
+ expect(audioBody.thumbnailUpload).toBeNull();
+ expect(
+ (await put(audioBody.upload.url, "audio/webm", new Uint8Array(2048)))
+ .status,
+ ).toBe(200);
+
+ const head = await (fixture.s3 as S3Client).send(
+ new HeadObjectCommand({ Bucket: fixture.bucket, Key: videoBody.key }),
+ );
+ expect(head.ContentLength).toBe(4096);
+ expect(await storedCount()).toBe(3);
+ });
+
+ it("never refuses a viewer well below the quota", async () => {
+ freshVideo();
+ const user = `viewer-${id()}`;
+ for (let index = 0; index < 30; index++) {
+ const response = await initiate(user);
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as Initiated;
+ expect(
+ (await put(body.upload.url, "video/webm", new Uint8Array(64))).status,
+ ).toBe(200);
+ }
+ expect(await storedCount()).toBe(30);
+ });
+
+ it("lets exactly one of many concurrent uploads take the last slots", async () => {
+ freshVideo();
+ const prefix = `${fixture.ownerId}/${fixture.videoId}/comments`;
+ // 398 objects already stored: room for one video plus its thumbnail.
+ for (let batch = 0; batch < 398; batch += 50)
+ await Promise.all(
+ Array.from({ length: Math.min(50, 398 - batch) }, (_, offset) =>
+ (fixture.s3 as S3Client).send(
+ new PutObjectCommand({
+ Bucket: fixture.bucket,
+ Key: `${prefix}/seed-${batch + offset}/media.webm`,
+ Body: new Uint8Array(1),
+ }),
+ ),
+ ),
+ );
+ expect(await storedCount()).toBe(398);
+
+ const responses = await Promise.all(
+ Array.from({ length: 8 }, () => initiate(`viewer-${id()}`)),
+ );
+ const statuses = responses.map((response) => response.status).sort();
+ expect(statuses).toEqual([200, 429, 429, 429, 429, 429, 429, 429]);
+ for (const response of responses)
+ if (response.status === 429)
+ expect(await response.json()).toEqual({
+ error: "This video has reached its media comment limit",
+ });
+ });
+ },
+);
From 38e7b6d4886096fc586276a0761a381aab5433ca Mon Sep 17 00:00:00 2001
From: Richie McIlroy
Date: Fri, 13 Nov 2026 08:33:33 -0500
Subject: [PATCH 6/6] test(web): send comment media test uploads as a Blob body
---
.../__tests__/integration/comment-media-quota-database.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/__tests__/integration/comment-media-quota-database.test.ts b/apps/web/__tests__/integration/comment-media-quota-database.test.ts
index c636d4c96bf..db357605314 100644
--- a/apps/web/__tests__/integration/comment-media-quota-database.test.ts
+++ b/apps/web/__tests__/integration/comment-media-quota-database.test.ts
@@ -136,7 +136,7 @@ const put = (url: string, contentType: string, bytes: Uint8Array) =>
fetch(url, {
method: "PUT",
headers: { "content-type": contentType },
- body: bytes,
+ body: new Blob([bytes as Uint8Array]),
});
const storedCount = async () => {