Skip to content

fix(web): require canView on transcript and AI metadata endpoints#1926

Merged
richiemcilroy merged 1 commit into
mainfrom
security/transcript-access-control
Jun 19, 2026
Merged

fix(web): require canView on transcript and AI metadata endpoints#1926
richiemcilroy merged 1 commit into
mainfrom
security/transcript-access-control

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jun 19, 2026

Copy link
Copy Markdown
Member

Gates the transcript, translate-transcript, available-translations and AI-metadata endpoints behind the same canView policy used elsewhere, so private and password-protected videos' content is no longer readable by unauthorized callers. Also rate-limits the translation endpoint.

Greptile Summary

This PR closes an authorization gap by wrapping the transcript, translate-transcript, available-translations, and AI-metadata endpoints in the same canView policy already used elsewhere, and adds Vercel Firewall rate limiting to the translation endpoint.

  • All four endpoints now use Policy.withPublicPolicy(videosPolicy.canView(videoId)) via provideOptionalAuth; policy failures and not-found cases both return the same opaque message to avoid leaking video existence for private/password-protected content.
  • apps/web/lib/rate-limit.ts introduces a shared isRateLimited helper backed by @vercel/firewall; it is best-effort (fails open on error) and no-ops outside production, matching the pattern already established in actions/collections/password.ts.
  • The RATE_LIMIT_IDS constants beyond TRANSLATE_TRANSCRIPT are placeholders for future rules; only the translation endpoint is wired in this PR.

Confidence Score: 5/5

Safe to merge — the change only adds access-control gates and a rate-limit check; existing authorized callers are unaffected.

The authorization fix is applied consistently across all four endpoints using the same well-established pattern. The rate-limit helper is best-effort (fails open), so there is no availability risk. No logic regressions were found in the happy paths.

No files require special attention.

Important Files Changed

Filename Overview
apps/web/actions/videos/get-transcript.ts Adds canView policy gate via withPublicPolicy before the video DB fetch; access-denied and not-found cases both surface as the same "Video not found" response (information-safe).
apps/web/actions/videos/get-available-translations.ts Applies the same canView-gated Effect pattern; storage listing remains inside the try/catch so policy failures are cleanly separated from storage errors.
apps/web/actions/videos/translate-transcript.ts Adds both rate limiting (Vercel Firewall, IP-based) and canView policy before the expensive Groq translation path; ordering is correct (fast-reject first).
apps/web/app/api/video/ai/route.ts Adds canView policy on top of the existing auth guard; previously any authenticated user could read AI metadata for any video by ID — now restricted to users with view permission.
apps/web/lib/rate-limit.ts New shared Vercel Firewall rate-limit helper; best-effort (fails open), production-only, well-documented. Only TRANSLATE_TRANSCRIPT is wired up in this PR; the other IDs are reserved constants for future use.

Comments Outside Diff (1)

  1. apps/web/actions/videos/get-transcript.ts, line 17 (link)

    P2 Redundant session lookup alongside provideOptionalAuth

    getCurrentUser() is called here and the resulting user is stored, but the only thing it feeds downstream is the error-log field userId: user?.id on line 82. The actual access-control decision is now made by provideOptionalAuth inside the Effect pipeline, which performs its own independent session read. Every call to getTranscript therefore hits the session store twice. The user variable could be removed; the logging could rely on the ownerId already present on the video object, or provideOptionalAuth could be made to surface the resolved user if needed.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/actions/videos/get-transcript.ts
    Line: 17
    
    Comment:
    **Redundant session lookup alongside `provideOptionalAuth`**
    
    `getCurrentUser()` is called here and the resulting `user` is stored, but the only thing it feeds downstream is the error-log field `userId: user?.id` on line 82. The actual access-control decision is now made by `provideOptionalAuth` inside the Effect pipeline, which performs its own independent session read. Every call to `getTranscript` therefore hits the session store twice. The `user` variable could be removed; the logging could rely on the `ownerId` already present on the `video` object, or `provideOptionalAuth` could be made to surface the resolved user if needed.
    
    How can I resolve this? If you propose a fix, please make it concise.

Reviews (2): Last reviewed commit: "fix(web): require canView on transcript ..." | Re-trigger Greptile

@polarityinc

polarityinc Bot commented Jun 19, 2026

Copy link
Copy Markdown

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

@superagent-security

Copy link
Copy Markdown

Superagent didn't find any vulnerabilities or security issues in this PR.

Comment on lines +26 to +32
const exit = await Effect.gen(function* () {
const videosPolicy = yield* VideosPolicy;

return yield* Effect.promise(() =>
db().select({ video: videos }).from(videos).where(eq(videos.id, videoId)),
).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId)));
}).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Double DB round-trip for the same video row

VideosPolicy.canView calls repo.getById(videoId) internally (see buildCanView in packages/web-backend/src/Videos/VideosPolicy.ts), then the outer Effect.promise(() => db().select({ video: videos })…) fetches the exact same row a second time. The same pattern is present in get-available-translations.ts, translate-transcript.ts, and api/video/ai/route.ts. Since withPublicPolicy runs the policy before the wrapped effect, every request now incurs two sequential reads for the same video. If this becomes a measurable latency concern, the results from canView's internal fetch could be threaded out and reused instead of re-querying.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/actions/videos/get-transcript.ts
Line: 26-32

Comment:
**Double DB round-trip for the same video row**

`VideosPolicy.canView` calls `repo.getById(videoId)` internally (see `buildCanView` in `packages/web-backend/src/Videos/VideosPolicy.ts`), then the outer `Effect.promise(() => db().select({ video: videos })…)` fetches the exact same row a second time. The same pattern is present in `get-available-translations.ts`, `translate-transcript.ts`, and `api/video/ai/route.ts`. Since `withPublicPolicy` runs the policy *before* the wrapped effect, every request now incurs two sequential reads for the same video. If this becomes a measurable latency concern, the results from `canView`'s internal fetch could be threaded out and reused instead of re-querying.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +59 to +76
export const RATE_LIMIT_IDS = {
/** Email OTP verification attempts (brute-force guard). Suggested: 10 / 10m per key (email). */
AUTH_OTP_VERIFY: "rl_auth_otp_verify",
/** Email OTP / magic-link send (mailbomb + token-reseed guard). Suggested: 5 / 10m per key (email). */
AUTH_OTP_SEND: "rl_auth_otp_send",
/** Unauthed Loom download/convert (ffmpeg + memory DoS). Suggested: 10 / 1m per IP. */
LOOM_DOWNLOAD: "rl_loom_download",
/** Unauthed transcript translation (Groq cost). Suggested: 10 / 1m per IP. */
TRANSLATE_TRANSCRIPT: "rl_translate_transcript",
/** Anonymous support-chat messages (Groq + Supermemory cost). Suggested: 20 / 1m per IP. */
MESSENGER_MESSAGE: "rl_messenger_message",
/** Unauthed analytics view tracking (Tinybird ingest + notifications). Suggested: 60 / 1m per IP. */
ANALYTICS_TRACK: "rl_analytics_track",
/** Unauthed guest checkout (Stripe object/cost abuse). Suggested: 10 / 10m per IP. */
GUEST_CHECKOUT: "rl_guest_checkout",
/** Unauthed desktop log → Discord forwarding (spam). Suggested: 10 / 1m per IP. */
DESKTOP_LOGS: "rl_desktop_logs",
} as const;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 RATE_LIMIT_IDS entries without corresponding call-sites in this PR

Several IDs — AUTH_OTP_VERIFY, AUTH_OTP_SEND, LOOM_DOWNLOAD, MESSENGER_MESSAGE, ANALYTICS_TRACK, GUEST_CHECKOUT, DESKTOP_LOGS — are defined here but isRateLimited is only wired up for TRANSLATE_TRANSCRIPT in this PR. The doc-comment already explains that an ID with no matching Vercel Firewall dashboard rule fails open, so the constants themselves are harmless. That said, it is worth verifying the IDs are either already used elsewhere or are tracked for follow-up — a constant that silently provides no protection could be misleading.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/lib/rate-limit.ts
Line: 59-76

Comment:
**`RATE_LIMIT_IDS` entries without corresponding call-sites in this PR**

Several IDs — `AUTH_OTP_VERIFY`, `AUTH_OTP_SEND`, `LOOM_DOWNLOAD`, `MESSENGER_MESSAGE`, `ANALYTICS_TRACK`, `GUEST_CHECKOUT`, `DESKTOP_LOGS` — are defined here but `isRateLimited` is only wired up for `TRANSLATE_TRANSCRIPT` in this PR. The doc-comment already explains that an ID with no matching Vercel Firewall dashboard rule fails open, so the constants themselves are harmless. That said, it is worth verifying the IDs are either already used elsewhere or are tracked for follow-up — a constant that silently provides no protection could be misleading.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai please review the PR

).pipe(Policy.withPublicPolicy(videosPolicy.canView(videoId)));
}).pipe(provideOptionalAuth, EffectRuntime.runPromiseExit);

if (Exit.isFailure(exit)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this treats any failure (including unexpected DB/runtime errors) as "Video not found". If that’s intentional for the client response, it might still be worth logging the failure so real issues don’t get silently masked in production.

headers: headersList,
});

const { rateLimited } = await checkRateLimit(ruleId, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since checkRateLimit can fail open with error: "not-found", it feels easy to think you’re protected when the dashboard rule is missing. Consider warning when that happens (ideally once per ruleId) so misconfig doesn’t silently disable the limit.

Suggested change
const { rateLimited } = await checkRateLimit(ruleId, {
const { rateLimited, error } = await checkRateLimit(ruleId, {
request,
...(opts?.key ? { rateLimitKey: opts.key } : {}),
});
if (error === "not-found") {
console.warn(`Rate limit rule not found for "${ruleId}" (Vercel Firewall).`);
}
return rateLimited;

@richiemcilroy richiemcilroy merged commit 8d48642 into main Jun 19, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant