From 6f09b4a8e179ed7dc5837ae9920055db0b2bc289 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 18:49:51 +0900 Subject: [PATCH 1/4] fix(web): gate /api/video/transcribe/status on video access policy The route authenticated the caller but then looked the video up by the caller-supplied videoId with no access check, so any signed-in user could read the transcription status of another user's private recording. The getVideoStatus server action returns this same field and already gates on VideosPolicy.canView; apply the same policy here. --- .../app/api/video/transcribe/status/route.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts index aff321848bb..1b997e65853 100644 --- a/apps/web/app/api/video/transcribe/status/route.ts +++ b/apps/web/app/api/video/transcribe/status/route.ts @@ -1,9 +1,12 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; -import type { Video } from "@cap/web-domain"; +import { provideOptionalAuth, VideosPolicy } from "@cap/web-backend"; +import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import { Effect, Exit } from "effect"; import type { NextRequest } from "next/server"; +import * as EffectRuntime from "@/lib/server"; export const dynamic = "force-dynamic"; @@ -23,7 +26,26 @@ export async function GET(request: NextRequest) { ); } - const video = await db().select().from(videos).where(eq(videos.id, videoId)); + // `videoId` is caller-supplied, so an authenticated session is not enough: + // without this any signed-in user could read the transcription status of + // someone else's private recording. The `getVideoStatus` server action, + // which returns this same field, already gates on `canView`. + const exit = await Effect.gen(function* () { + const videosPolicy = yield* VideosPolicy; + + return yield* Effect.promise(() => + db().select().from(videos).where(eq(videos.id, videoId)), + ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); + }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); + + if (Exit.isFailure(exit)) { + return Response.json( + { error: true, message: "Video does not exist" }, + { status: 404 }, + ); + } + + const video = exit.value; if (video.length === 0 || !video[0]) { return Response.json( From 9fdbada009ce5229bd08775ce7c2e82421d5cde7 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 18:55:54 +0900 Subject: [PATCH 2/4] perf: limit the transcribe status lookup to one row Addresses review feedback: videos.id is unique so the DB can stop early. --- apps/web/app/api/video/transcribe/status/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts index 1b997e65853..1ceab9f1858 100644 --- a/apps/web/app/api/video/transcribe/status/route.ts +++ b/apps/web/app/api/video/transcribe/status/route.ts @@ -34,7 +34,7 @@ export async function GET(request: NextRequest) { const videosPolicy = yield* VideosPolicy; return yield* Effect.promise(() => - db().select().from(videos).where(eq(videos.id, videoId)), + db().select().from(videos).where(eq(videos.id, videoId)).limit(1), ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); From 601fa1d8a8cd02c757376740fb287143be422a61 Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 19:03:33 +0900 Subject: [PATCH 3/4] perf: provide the already-loaded user instead of re-resolving it Same redundancy tembo flagged on #2029: the route already requires auth and holds the user, so provideOptionalAuth would re-run getServerSession() and re-query users to build the same layer. --- apps/web/app/api/video/transcribe/status/route.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts index 1ceab9f1858..330fb902a11 100644 --- a/apps/web/app/api/video/transcribe/status/route.ts +++ b/apps/web/app/api/video/transcribe/status/route.ts @@ -1,7 +1,7 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; -import { provideOptionalAuth, VideosPolicy } from "@cap/web-backend"; +import { makeCurrentUserLayer, VideosPolicy } from "@cap/web-backend"; import { Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; import { Effect, Exit } from "effect"; @@ -36,7 +36,13 @@ export async function GET(request: NextRequest) { return yield* Effect.promise(() => db().select().from(videos).where(eq(videos.id, videoId)).limit(1), ).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId))); - }).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit); + }).pipe( + // The route already required auth above, so provide the user we have + // rather than `provideOptionalAuth`, which would re-run + // getServerSession() and re-query `users`. + Effect.provide(makeCurrentUserLayer(user)), + EffectRuntime.runPromiseExit, + ); if (Exit.isFailure(exit)) { return Response.json( From 236db347808ac50f349ee26fd49d940ebe239dcc Mon Sep 17 00:00:00 2001 From: Jiho Lee Date: Sat, 25 Jul 2026 21:37:56 +0900 Subject: [PATCH 4/4] fix(web): distinguish a database failure from an access denial on transcribe status --- .../unit/transcribe-status-access.test.ts | 136 ++++++++++++++++++ .../app/api/video/transcribe/status/route.ts | 23 ++- 2 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 apps/web/__tests__/unit/transcribe-status-access.test.ts diff --git a/apps/web/__tests__/unit/transcribe-status-access.test.ts b/apps/web/__tests__/unit/transcribe-status-access.test.ts new file mode 100644 index 00000000000..eb68c851b79 --- /dev/null +++ b/apps/web/__tests__/unit/transcribe-status-access.test.ts @@ -0,0 +1,136 @@ +import { Exit, Layer } from "effect"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDbChain = { + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), +}; + +function resetDbChain() { + for (const fn of Object.values(mockDbChain)) fn.mockClear(); + mockDbChain.select.mockReturnValue(mockDbChain); + mockDbChain.from.mockReturnValue(mockDbChain); + mockDbChain.where.mockReturnValue(mockDbChain); + mockDbChain.limit.mockReturnValue( + Promise.resolve([{ id: "video-1", transcriptionStatus: "COMPLETE" }]), + ); +} + +vi.mock("@cap/database", () => ({ db: () => mockDbChain })); + +vi.mock("@cap/database/schema", () => ({ + videos: { id: "id" }, +})); + +const mockGetCurrentUser = vi.fn(); +vi.mock("@cap/database/auth/session", () => ({ + getCurrentUser: () => mockGetCurrentUser(), +})); + +const mockCanView = vi.fn(); +vi.mock("@cap/web-backend", () => ({ + makeCurrentUserLayer: () => Layer.empty, + VideosPolicy: { + key: "VideosPolicy", + }, +})); + +// The route runs its query through the Effect runtime; the parts under test are +// which failures reach the handler and how it maps them, so the runtime is +// replaced with one that returns the exit the policy would have produced. +vi.mock("@/lib/server", () => ({ + runPromiseExit: () => mockCanView(), +})); + +vi.mock("@cap/web-domain", async () => { + const actual = + await vi.importActual("@cap/web-domain"); + return { + ...actual, + Policy: { + ...actual.Policy, + withPublicPolicy: () => (self: unknown) => self, + }, + }; +}); + +const { GET } = await import("@/app/api/video/transcribe/status/route"); +const { DatabaseError, Policy } = await import("@cap/web-domain"); + +const request = (videoId: string) => + new Request( + `https://cap.test/api/video/transcribe/status?videoId=${videoId}`, + ) as never; + +describe("GET /api/video/transcribe/status", () => { + beforeEach(() => { + resetDbChain(); + mockGetCurrentUser.mockResolvedValue({ id: "user-1" }); + mockCanView.mockReset(); + }); + + it("returns 401 when there is no session", async () => { + mockGetCurrentUser.mockResolvedValue(null); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(401); + }); + + it("returns 400 when videoId is missing", async () => { + const res = await GET( + new Request("https://cap.test/api/video/transcribe/status") as never, + ); + + expect(res.status).toBe(400); + }); + + it("returns 404 when the access policy denies the caller", async () => { + mockCanView.mockResolvedValue( + Exit.fail(new Policy.PolicyDeniedError({ reason: "denied" })), + ); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(404); + await expect(res.json()).resolves.toMatchObject({ + message: "Video does not exist", + }); + }); + + it("returns 500, not 404, when the access check hits a database failure", async () => { + mockCanView.mockResolvedValue( + Exit.fail(new DatabaseError({ cause: new Error("connection reset") })), + ); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(500); + await expect(res.json()).resolves.toMatchObject({ + message: "Failed to fetch transcription status", + }); + }); + + it("returns the transcription status when access is granted", async () => { + mockCanView.mockResolvedValue( + Exit.succeed([{ id: "video-1", transcriptionStatus: "COMPLETE" }]), + ); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + transcriptionStatus: "COMPLETE", + }); + }); + + it("returns 404 when the video row is missing", async () => { + mockCanView.mockResolvedValue(Exit.succeed([])); + + const res = await GET(request("video-1")); + + expect(res.status).toBe(404); + }); +}); diff --git a/apps/web/app/api/video/transcribe/status/route.ts b/apps/web/app/api/video/transcribe/status/route.ts index 330fb902a11..01697777233 100644 --- a/apps/web/app/api/video/transcribe/status/route.ts +++ b/apps/web/app/api/video/transcribe/status/route.ts @@ -2,9 +2,9 @@ import { db } from "@cap/database"; import { getCurrentUser } from "@cap/database/auth/session"; import { videos } from "@cap/database/schema"; import { makeCurrentUserLayer, VideosPolicy } from "@cap/web-backend"; -import { Policy, type Video } from "@cap/web-domain"; +import { DatabaseError, Policy, type Video } from "@cap/web-domain"; import { eq } from "drizzle-orm"; -import { Effect, Exit } from "effect"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; import type { NextRequest } from "next/server"; import * as EffectRuntime from "@/lib/server"; @@ -45,6 +45,25 @@ export async function GET(request: NextRequest) { ); if (Exit.isFailure(exit)) { + const failure = Cause.failureOption(exit.cause); + + // The 404-for-denied behaviour below is deliberate, so this endpoint + // can't be used to probe which video IDs exist. A database failure is + // not a denial though: `canView` reads the video plus its org/space + // memberships, so an outage in any of those would otherwise be reported + // as "video does not exist" for a video the caller can actually see. + if (Option.isSome(failure) && Schema.is(DatabaseError)(failure.value)) { + console.error( + "[transcribe/status] database error while checking video access:", + failure.value.cause, + ); + + return Response.json( + { error: true, message: "Failed to fetch transcription status" }, + { status: 500 }, + ); + } + return Response.json( { error: true, message: "Video does not exist" }, { status: 404 },