Skip to content

Cap mobile app#1845

Open
richiemcilroy wants to merge 20 commits into
mainfrom
mobile-app
Open

Cap mobile app#1845
richiemcilroy wants to merge 20 commits into
mainfrom
mobile-app

Conversation

@richiemcilroy
Copy link
Copy Markdown
Member

@richiemcilroy richiemcilroy commented May 19, 2026

Implements v1 of the Cap mobile app for iOS

Greptile Summary

This PR introduces the first version of the Cap mobile iOS app — an Expo/React Native application backed by a new Next.js API surface at /api/mobile. It adds OAuth and email-OTP sign-in, a cap library browser, video upload with progress tracking, playback, and cap management (titles, passwords, sharing, comments).

  • Mobile app (apps/mobile): Full Expo Router app with typed API client, secure-store session management, upload queue reducer, and comprehensive vitest coverage.
  • Web API (apps/web/app/api/mobile): 1,400-line Effect-based handler covering auth (session request, email OTP, revoke), bootstrap, cap CRUD, comments, reactions, and upload lifecycle.
  • Domain types (packages/web-domain/src/Mobile.ts): New Effect schemas defining the mobile API contract, with a matching contract test suite.

Confidence Score: 2/5

The mobile app code itself is well-structured and tested, but the new server-side API handler introduces multiple auth security gaps that should be addressed before this ships to users.

The web API route has three compounding auth issues: the OAuth session endpoint appends a permanent API key to any caller-supplied redirect URL with no scheme or domain validation; the email OTP verification deletes the token before checking expiry, allowing the same code to authenticate two concurrent requests simultaneously; and no attempt counter guards the 6-digit code endpoint against exhaustive search. All three affect the authentication path that every mobile user will go through on first sign-in.

apps/web/app/api/mobile/[...route]/route.ts needs the most attention — specifically the requestSession redirect, the verifyEmailSession token deletion order, and the missing rate-limit guard on OTP verification.

Security Review

  • Open redirect / API key leakage (apps/web/app/api/mobile/[...route]/route.ts, requestSession handler): The redirectUri query parameter is appended with api_key and user_id without any allowlist validation. A crafted link can redirect an authenticated user's permanent API key to an attacker-controlled URL.
  • TOCTOU race on OTP verification (verifyEmailSession): The verification token is deleted from verificationTokens before the expiry timestamp is checked. Two simultaneous requests carrying the same valid code can both succeed and both receive independent API keys.
  • No OTP brute-force protection (verifyEmailSession): No per-email attempt counter or back-off is implemented against the 6-digit code endpoint, making exhaustive search feasible.

Important Files Changed

Filename Overview
apps/web/app/api/mobile/[...route]/route.ts New 1,400-line mobile API handler — contains an open redirect that leaks API keys via unvalidated redirectUri, a TOCTOU race in OTP verification, and no brute-force protection on the 6-digit code endpoint.
packages/web-domain/src/Mobile.ts New Effect-schema API contract definitions for the mobile endpoints; types look correct and well-structured.
apps/mobile/src/auth/AuthContext.tsx New auth context using expo-secure-store and OAuth browser session; session lifecycle management looks solid.
apps/mobile/src/api/mobile.ts Mobile API client with typed fetch wrappers and native file upload support via expo-file-system; well-structured.
apps/mobile/src/uploads/uploadQueue.ts Pure reducer for upload queue state; well-tested and correct.
apps/mobile/src/uploads/runMobileUpload.ts Orchestrates file upload to S3 target with progress reporting; looks correct.
apps/web/app/(org)/login/form.tsx Adds mobile OAuth auto-trigger via useEffect; the Google sign-in effect can fire multiple times if searchParams identity changes during redirect.
apps/mobile/src/caps/passwordActions.ts iOS-specific password action sheet UI; correct and safe.
apps/web/tests/unit/mobile-api-contract.test.ts Schema contract tests for mobile API types; good coverage of shape validation.

Comments Outside Diff (3)

  1. apps/web/app/api/mobile/[...route]/route.ts, line 1586-1591 (link)

    P1 security Unvalidated redirectUri leaks API key to arbitrary URLs

    The requestSession handler appends the freshly-minted api_key and user_id to whatever redirectUri the caller provides, with no allowlist check. An attacker who tricks an authenticated user into opening https://cap.so/api/mobile/session/request?redirectUri=https://attacker.com will receive that user's permanent API key in the redirect. The redirectUri should be validated against the app's registered deep-link scheme (e.g., only cap:// or the deployment-specific scheme from app.config.js) before any credentials are appended.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/app/api/mobile/[...route]/route.ts
    Line: 1586-1591
    
    Comment:
    **Unvalidated `redirectUri` leaks API key to arbitrary URLs**
    
    The `requestSession` handler appends the freshly-minted `api_key` and `user_id` to whatever `redirectUri` the caller provides, with no allowlist check. An attacker who tricks an authenticated user into opening `https://cap.so/api/mobile/session/request?redirectUri=https://attacker.com` will receive that user's permanent API key in the redirect. The `redirectUri` should be validated against the app's registered deep-link scheme (e.g., only `cap://` or the deployment-specific scheme from `app.config.js`) before any credentials are appended.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. apps/web/app/api/mobile/[...route]/route.ts, line 1413-1450 (link)

    P2 getPlayback has no ownership check on the video

    getCapById enforces eq(Db.videos.ownerId, user.id), but getPlayback bypasses it and calls videos.getByIdForViewing directly. If getByIdForViewing returns records for public or org-shared videos that do not belong to the authenticated user, any authenticated mobile user can retrieve signed playback URLs for those videos. Confirm whether this broader access is intentional for the mobile app, or whether an ownership/membership check should be added here.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/app/api/mobile/[...route]/route.ts
    Line: 1413-1450
    
    Comment:
    **`getPlayback` has no ownership check on the video**
    
    `getCapById` enforces `eq(Db.videos.ownerId, user.id)`, but `getPlayback` bypasses it and calls `videos.getByIdForViewing` directly. If `getByIdForViewing` returns records for public or org-shared videos that do not belong to the authenticated user, any authenticated mobile user can retrieve signed playback URLs for those videos. Confirm whether this broader access is intentional for the mobile app, or whether an ownership/membership check should be added here.
    
    How can I resolve this? If you propose a fix, please make it concise.
  3. apps/web/app/(org)/login/form.tsx, line 143-162 (link)

    P2 Auto-triggered Google sign-in fires on every searchParams identity change

    The new useEffect calls handleGoogleSignIn() whenever mobileProvider=google is present in the query string. Because searchParams is a new reference on every navigation update, this effect can fire multiple times during the session redirect lifecycle, potentially triggering extra signIn("google", ...) calls. Guard the effect with a useRef flag (or router-replace the URL after the first trigger) so it fires exactly once per page load.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/app/(org)/login/form.tsx
    Line: 143-162
    
    Comment:
    **Auto-triggered Google sign-in fires on every `searchParams` identity change**
    
    The new `useEffect` calls `handleGoogleSignIn()` whenever `mobileProvider=google` is present in the query string. Because `searchParams` is a new reference on every navigation update, this effect can fire multiple times during the session redirect lifecycle, potentially triggering extra `signIn("google", ...)` calls. Guard the effect with a `useRef` flag (or router-replace the URL after the first trigger) so it fires exactly once per page load.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 5 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 5
apps/web/app/api/mobile/[...route]/route.ts:1586-1591
**Unvalidated `redirectUri` leaks API key to arbitrary URLs**

The `requestSession` handler appends the freshly-minted `api_key` and `user_id` to whatever `redirectUri` the caller provides, with no allowlist check. An attacker who tricks an authenticated user into opening `https://cap.so/api/mobile/session/request?redirectUri=https://attacker.com` will receive that user's permanent API key in the redirect. The `redirectUri` should be validated against the app's registered deep-link scheme (e.g., only `cap://` or the deployment-specific scheme from `app.config.js`) before any credentials are appended.

### Issue 2 of 5
apps/web/app/api/mobile/[...route]/route.ts:856-869
**TOCTOU race: same OTP accepted twice**

The verification token is deleted from the database _before_ the expiry check. Two concurrent requests carrying the same valid code will both `SELECT` the token (both rows are returned), both issue the `DELETE` (the second silently deletes 0 rows, not an error), both pass the expiry check, and both receive a freshly-minted API key. Moving the `DELETE` to _after_ the expiry check (or wrapping both in a transaction / using `DELETE … RETURNING` in a single statement) closes the window.

### Issue 3 of 5
apps/web/app/api/mobile/[...route]/route.ts:824-877
**No brute-force protection on the OTP endpoint**

`verifyEmailSession` accepts any 6-digit code (10⁶ possibilities) with no per-email attempt counter or exponential back-off. An attacker who receives the `requestEmailCode` confirmation for a target email address can cycle through all ~10⁶ codes within minutes. Middleware-level rate limiting alone is often insufficient here; a counter stored alongside the token record (or simply deleting the token on any failed attempt) would make brute-forcing impractical.

### Issue 4 of 5
apps/web/app/api/mobile/[...route]/route.ts:1413-1450
**`getPlayback` has no ownership check on the video**

`getCapById` enforces `eq(Db.videos.ownerId, user.id)`, but `getPlayback` bypasses it and calls `videos.getByIdForViewing` directly. If `getByIdForViewing` returns records for public or org-shared videos that do not belong to the authenticated user, any authenticated mobile user can retrieve signed playback URLs for those videos. Confirm whether this broader access is intentional for the mobile app, or whether an ownership/membership check should be added here.

### Issue 5 of 5
apps/web/app/(org)/login/form.tsx:143-162
**Auto-triggered Google sign-in fires on every `searchParams` identity change**

The new `useEffect` calls `handleGoogleSignIn()` whenever `mobileProvider=google` is present in the query string. Because `searchParams` is a new reference on every navigation update, this effect can fire multiple times during the session redirect lifecycle, potentially triggering extra `signIn("google", ...)` calls. Guard the effect with a `useRef` flag (or router-replace the URL after the first trigger) so it fires exactly once per page load.

Reviews (1): Last reviewed commit: "chore(web): refresh workflow step manife..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

@superagent-security superagent-security Bot added the contributor:verified Contributor passed trust analysis. label May 19, 2026
@polarityinc
Copy link
Copy Markdown

polarityinc Bot commented May 19, 2026

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.

@socket-security
Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​expo/​metro-runtime@​55.0.117410076100100
Added@​expo/​config-plugins@​55.0.97610084100100

View full report

Comment on lines +856 to +869
{ concurrency: 5 },
);
});

const getCapDetail = Effect.fn("Mobile.getCapDetail")(function* (
videoId: Video.VideoId,
) {
const { row, cap } = yield* getCapById(videoId);
const metadata = getMetadataRecord(row.metadata);
const comments = yield* getComments(videoId);

return {
cap,
summary: getMetadataString(metadata, "summary"),
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.

P1 security TOCTOU race: same OTP accepted twice

The verification token is deleted from the database before the expiry check. Two concurrent requests carrying the same valid code will both SELECT the token (both rows are returned), both issue the DELETE (the second silently deletes 0 rows, not an error), both pass the expiry check, and both receive a freshly-minted API key. Moving the DELETE to after the expiry check (or wrapping both in a transaction / using DELETE … RETURNING in a single statement) closes the window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/mobile/[...route]/route.ts
Line: 856-869

Comment:
**TOCTOU race: same OTP accepted twice**

The verification token is deleted from the database _before_ the expiry check. Two concurrent requests carrying the same valid code will both `SELECT` the token (both rows are returned), both issue the `DELETE` (the second silently deletes 0 rows, not an error), both pass the expiry check, and both receive a freshly-minted API key. Moving the `DELETE` to _after_ the expiry check (or wrapping both in a transaction / using `DELETE … RETURNING` in a single statement) closes the window.

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

Comment on lines +824 to +877
.from(Db.comments)
.leftJoin(Db.users, eq(Db.comments.authorId, Db.users.id))
.where(eq(Db.comments.videoId, videoId))
.orderBy(Db.comments.createdAt),
);

return yield* Effect.forEach(
rows,
(row) =>
Effect.gen(function* () {
const imageUrl = row.authorImage
? yield* imageUploads
.resolveImageUrl(row.authorImage)
.pipe(Effect.catchAll(() => Effect.succeed(null)))
: null;

return {
id: row.id,
videoId: row.videoId,
type: row.type,
content: row.content,
timestamp: row.timestamp,
parentCommentId: row.parentCommentId,
createdAt: toIsoString(row.createdAt),
updatedAt: toIsoString(row.updatedAt),
author: {
id: row.authorId,
name: row.authorName,
imageUrl,
},
};
}),
{ concurrency: 5 },
);
});

const getCapDetail = Effect.fn("Mobile.getCapDetail")(function* (
videoId: Video.VideoId,
) {
const { row, cap } = yield* getCapById(videoId);
const metadata = getMetadataRecord(row.metadata);
const comments = yield* getComments(videoId);

return {
cap,
summary: getMetadataString(metadata, "summary"),
chapters: getMetadataChapters(metadata),
transcriptionStatus: row.transcriptionStatus,
comments,
shareUrl: `${serverEnv().WEB_URL}/s/${videoId}`,
};
});

const createMobileComment = Effect.fn("Mobile.createComment")(function* ({
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.

P1 security No brute-force protection on the OTP endpoint

verifyEmailSession accepts any 6-digit code (10⁶ possibilities) with no per-email attempt counter or exponential back-off. An attacker who receives the requestEmailCode confirmation for a target email address can cycle through all ~10⁶ codes within minutes. Middleware-level rate limiting alone is often insufficient here; a counter stored alongside the token record (or simply deleting the token on any failed attempt) would make brute-forcing impractical.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/api/mobile/[...route]/route.ts
Line: 824-877

Comment:
**No brute-force protection on the OTP endpoint**

`verifyEmailSession` accepts any 6-digit code (10⁶ possibilities) with no per-email attempt counter or exponential back-off. An attacker who receives the `requestEmailCode` confirmation for a target email address can cycle through all ~10⁶ codes within minutes. Middleware-level rate limiting alone is often insufficient here; a counter stored alongside the token record (or simply deleting the token on any failed attempt) would make brute-forcing impractical.

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

const session = yield* createMobileApiKey(user.value.id);

if (urlParams.redirectUri) {
const redirectUrl = new URL(urlParams.redirectUri);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

redirectUri here is effectively untrusted input. As-is, a logged-in user hitting /api/mobile/session/request?redirectUri=https://evil.com would get redirected with api_key in the query string.

Consider allowlisting schemes/hosts before appending secrets:

Suggested change
const redirectUrl = new URL(urlParams.redirectUri);
if (urlParams.redirectUri) {
const redirectUrl = new URL(urlParams.redirectUri);
if (
redirectUrl.protocol !== "cap:" &&
redirectUrl.protocol !== "exp+cap:"
) {
return yield* Effect.fail(new HttpApiError.BadRequest());
}
redirectUrl.searchParams.set("api_key", session.apiKey);
redirectUrl.searchParams.set("user_id", user.value.id);
return HttpServerResponse.redirect(redirectUrl.toString());
}

.digest("hex");

const sendMobileEmailCode = async (email: string, code: string) => {
if (!serverEnv().RESEND_API_KEY) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Logging OTP codes to server logs is risky if RESEND_API_KEY is accidentally unset in prod. I’d consider failing hard in production (and only logging in non-prod):

Suggested change
if (!serverEnv().RESEND_API_KEY) {
if (!serverEnv().RESEND_API_KEY) {
if (process.env.NODE_ENV === "production") {
throw new Error("RESEND_API_KEY is required to send mobile email codes");
}
console.log("");
console.log("Cap mobile verification code");
console.log(`Email: ${email}`);
console.log(`Code: ${code}`);
console.log("Expires in: 10 minutes");
console.log("");
return;
}

}
};

const parseJson = async (response: Response) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

parseJson will throw if the backend ever returns non-JSON (common for proxies / HTML error pages), which bypasses your MobileApiError path. Small hardening to keep errors actionable:

Suggested change
const parseJson = async (response: Response) => {
const parseJson = async (response: Response) => {
const text = await response.text();
if (text.length === 0) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
};

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant