Skip to content

Cap mobile app#1845

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

Cap mobile app#1845
richiemcilroy wants to merge 22 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 a full v1 iOS mobile app for Cap, along with the backend mobile API (/api/mobile/[...route]), a shared domain schema package, and mobile-aware changes to the web login form. The mobile client handles auth (email OTP, Google OAuth, WorkOS SSO), cap browsing/management, and video uploads via a queue-based system.

  • Backend API (route.ts, 1 452 lines): email OTP verification uses an atomic DELETE … AND token = token to prevent double-use; getPlayback adds an explicit ownerId guard; the requestSession redirect URI is now validated against an allowlist of custom-scheme protocols (cap:, exp+cap:).
  • Login form: adds auto-triggering of Google/WorkOS SSO for the mobile OAuth redirect flow; the Google path is protected by a useRef one-shot guard but the WorkOS path is currently missing an equivalent.
  • saveCapVideo.ts: downloads a cap to documentDirectory for photo-library saving but does not clean up the temporary file afterwards, causing permanent storage accumulation per download.

Confidence Score: 4/5

Safe to merge once the missing WorkOS idempotency guard and the temp-file cleanup in saveCapVideo are addressed.

The core security concerns from the first review round (redirect URI validation, OTP race) have been fixed. The remaining issues are a missing one-shot guard on the WorkOS SSO trigger and a permanent file-storage leak in the download-to-photos flow — both real defects, neither blocking auth or data integrity.

apps/mobile/src/caps/saveCapVideo.ts for the temp-file leak, and apps/web/app/(org)/login/form.tsx for the WorkOS guard gap.

Important Files Changed

Filename Overview
apps/web/app/api/mobile/[...route]/route.ts New 1452-line mobile API surface: ownership checks rely on a consistent getCapById + policy layer; getPlayback adds an explicit ownerId guard; redirect URI is now validated against an allowlist of custom-scheme protocols; OTP verification uses an atomic DELETE … AND token = token to close the original TOCTOU race.
apps/web/app/(org)/login/form.tsx Adds mobile OAuth redirect handling; Google path is correctly guarded with a useRef flag to prevent duplicate triggers, but the WorkOS SSO path is missing the equivalent guard.
apps/mobile/src/caps/saveCapVideo.ts Downloads cap video to documentDirectory and saves to photo library, but never deletes the temp file—each download permanently accumulates a full copy in the app's document storage.
apps/mobile/src/auth/AuthContext.tsx Auth context using expo-secure-store for key storage, expo-web-browser for OAuth session, and bootstrap refresh on sign-in — straightforward and safe.
apps/mobile/src/uploads/uploadQueue.ts Pure reducer managing upload queue state with well-typed actions; progress clamping and clampProgress helpers are correct.
apps/mobile/src/api/mobile.ts Mobile API client with typed request/response schemas; upload path correctly supports both native file URIs and XHR for non-native paths with progress callbacks.
packages/web-domain/src/Mobile.ts New Effect Schema domain model defining the mobile API contract — schemas look consistent with the implementation.

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 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
apps/web/app/(org)/login/form.tsx:50
The WorkOS SSO auto-trigger path is missing the same one-shot guard that was added for Google. The Google branch correctly uses `mobileGoogleSignInStarted.current` to prevent re-firing, but the WorkOS branch has no equivalent guard. Because `searchParams` is in the dependency array and React can create a new reference object for the same query string on a re-render (including StrictMode double-invocations in dev), `handleWorkosSignIn` may be called more than once for a single page load, triggering multiple `getOrganizationSSOData` fetches and `signIn("workos", ...)` calls.

```suggestion
	const mobileGoogleSignInStarted = useRef(false);
	const mobileWorkosSignInStarted = useRef(false);
```

### Issue 2 of 3
apps/mobile/src/caps/saveCapVideo.ts:26-29
The temporary file downloaded to `documentDirectory` is never deleted after it is saved to the photo library. Each `saveCapVideoToPhotos` call leaves a copy behind, so the app's documents folder grows unboundedly whenever users download caps.

```suggestion
	const result = await FileSystem.downloadAsync(download.url, target);
	try {
		await MediaLibrary.saveToLibraryAsync(result.uri);
	} finally {
		await FileSystem.deleteAsync(result.uri, { idempotent: true });
	}

	return download.fileName;
```

### Issue 3 of 3
apps/web/app/(org)/login/form.tsx:157-167
**WorkOS SSO auto-trigger missing idempotency guard**

The Google branch correctly sets `mobileGoogleSignInStarted.current = true` before calling `handleGoogleSignIn()`, preventing re-fires when `searchParams` gets a new reference on re-render. The WorkOS branch has no equivalent guard. Because `searchParams` is in the dependency array and React (especially in StrictMode dev mode) can create a new reference for the same query string, `handleWorkosSignIn` may be called more than once, triggering extra `getOrganizationSSOData` server fetches and multiple `signIn("workos", ...)` calls. Adding a `const mobileWorkosSignInStarted = useRef(false)` guard—identical to the Google pattern above—closes the gap.

Reviews (2): Last reviewed commit: "Mobile: handle non-JSON errors and redir..." | Re-trigger Greptile

Greptile also left 1 inline comment 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.

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;
}
};

@superagent-security superagent-security Bot removed the contributor:verified Contributor passed trust analysis. label May 28, 2026
.pipe(Effect.map(() => ({ success: true as const }))),
),
)
.handle("getPlayback", ({ path }) =>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

getPlayback returns signed URLs but doesn’t appear to enforce the same access checks as getCapById. If getByIdForViewing is broader than owner-only, this could let any authed user fetch playback for other users’ videos.

Suggested change
.handle("getPlayback", ({ path }) =>
.handle("getPlayback", ({ path }) =>
withMappedErrors(
Effect.gen(function* () {
yield* getCapById(path.id);
return yield* getPlayback(path.id);
}),
),
)

[next],
);

useEffect(() => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This effect can re-run multiple times because searchParams changes identity across navigations. For mobileProvider=google, that can trigger multiple signIn("google") calls.

One option is to remove the param right after triggering so it’s one-shot:

Suggested change
useEffect(() => {
useEffect(() => {
if (searchParams?.get("mobileProvider") === "google") {
handleGoogleSignIn();
const params = new URLSearchParams(searchParams.toString());
params.delete("mobileProvider");
router.replace(`?${params.toString()}`);
}
if (searchParams?.get("mobileProvider") !== "workos") return;
const mobileOrganizationId = searchParams.get("organizationId");
if (!mobileOrganizationId) {
setShowOrgInput(true);
return;
}
let active = true;
handleWorkosSignIn(mobileOrganizationId).catch(() => {
if (!active) return;
setOrganizationId(mobileOrganizationId);
setShowOrgInput(true);
toast.error("Organization not found or SSO not configured");
});
return () => {
active = false;
};
}, [handleGoogleSignIn, handleWorkosSignIn, router, searchParams]);

@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label May 28, 2026
@richiemcilroy
Copy link
Copy Markdown
Member Author

hey @greptileai please re-review the pr

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label May 28, 2026
Comment on lines +26 to +29
const result = await FileSystem.downloadAsync(download.url, target);
await MediaLibrary.saveToLibraryAsync(result.uri);

return download.fileName;
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 The temporary file downloaded to documentDirectory is never deleted after it is saved to the photo library. Each saveCapVideoToPhotos call leaves a copy behind, so the app's documents folder grows unboundedly whenever users download caps.

Suggested change
const result = await FileSystem.downloadAsync(download.url, target);
await MediaLibrary.saveToLibraryAsync(result.uri);
return download.fileName;
const result = await FileSystem.downloadAsync(download.url, target);
try {
await MediaLibrary.saveToLibraryAsync(result.uri);
} finally {
await FileSystem.deleteAsync(result.uri, { idempotent: true });
}
return download.fileName;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/mobile/src/caps/saveCapVideo.ts
Line: 26-29

Comment:
The temporary file downloaded to `documentDirectory` is never deleted after it is saved to the photo library. Each `saveCapVideoToPhotos` call leaves a copy behind, so the app's documents folder grows unboundedly whenever users download caps.

```suggestion
	const result = await FileSystem.downloadAsync(download.url, target);
	try {
		await MediaLibrary.saveToLibraryAsync(result.uri);
	} finally {
		await FileSystem.deleteAsync(result.uri, { idempotent: true });
	}

	return download.fileName;
```

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

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