Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions src/app/api/session/[id]/signal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { NextRequest, NextResponse } from "next/server";

interface SignalMessage {
id: string;
sender: "seeker" | "expert";
type: "offer" | "answer" | "candidate" | "status";
data: any;
timestamp: number;
}

const globalSignals = global as unknown as {
sessionSignals?: Record<string, SignalMessage[]>;
};

if (!globalSignals.sessionSignals) {
globalSignals.sessionSignals = {};
}

// POST /api/session/[id]/signal
// Body: { sender: 'seeker' | 'expert', type: 'offer' | 'answer' | 'candidate' | 'status', data: any }
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
let body: any;

try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const { sender, type, data } = body;
if (!sender || !type || !data) {
return NextResponse.json(
{ error: "Missing required fields: sender, type, data" },
{ status: 400 }
);
}
Comment on lines +34 to +40

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

!data rejects legitimate falsy payloads and type is never validated.

type is written straight into the store without checking it against the union, so any arbitrary string is persisted and polled back to peers. Meanwhile !data rejects data: 0/false/"".

♻️ Tighter validation
-  const { sender, type, data } = body;
-  if (!sender || !type || !data) {
+  const { sender, type, data } = body;
+  const VALID_TYPES = ["offer", "answer", "candidate", "status"] as const;
+  if (!sender || !type || data === undefined || data === null) {
     return NextResponse.json(
       { error: "Missing required fields: sender, type, data" },
       { status: 400 }
     );
   }
+  if (!VALID_TYPES.includes(type)) {
+    return NextResponse.json({ error: "Invalid signal type" }, { status: 400 });
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { sender, type, data } = body;
if (!sender || !type || !data) {
return NextResponse.json(
{ error: "Missing required fields: sender, type, data" },
{ status: 400 }
);
}
const { sender, type, data } = body;
const VALID_TYPES = ["offer", "answer", "candidate", "status"] as const;
if (!sender || !type || data === undefined || data === null) {
return NextResponse.json(
{ error: "Missing required fields: sender, type, data" },
{ status: 400 }
);
}
if (!VALID_TYPES.includes(type)) {
return NextResponse.json({ error: "Invalid signal type" }, { status: 400 });
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/session/`[id]/signal/route.ts around lines 34 - 40, Update the
request validation around the body destructuring to validate type against the
supported signal-type union before writing it to the store, and treat data as
missing only when it is undefined or null so legitimate falsy payloads such as
0, false, and an empty string are accepted. Preserve the existing 400 response
for invalid or missing required fields.


if (sender !== "seeker" && sender !== "expert") {
return NextResponse.json(
{ error: "sender must be 'seeker' or 'expert'" },
{ status: 400 }
);
}
Comment on lines +21 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Peer identity originates from an unauthenticated URL query param and is never verified server-side. The role travels from ?role=VideoCall prop → sender in the signal body → the store, with no authentication anywhere along the path, so anyone with a session id can claim to be the expert and pick up the seeker's offer and media.

  • src/app/api/session/[id]/signal/route.ts#L21-L47: authenticate the caller, verify membership in session id, and derive sender from the server-side session/role rather than the request body; apply the same guard to GET and DELETE.
  • src/app/session/[id]/page.tsx#L93-L97: stop deriving userRole from searchParams; resolve the viewer's role from the authenticated session record for sessionId.
  • src/components/session/VideoCall.tsx#L63-L77: drop sender: role from the POST body once the server derives it, keeping role for local UI/offerer decisions only.
🧰 Tools
🪛 ESLint

[error] 26-26: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

📍 Affects 3 files
  • src/app/api/session/[id]/signal/route.ts#L21-L47 (this comment)
  • src/app/session/[id]/page.tsx#L93-L97
  • src/components/session/VideoCall.tsx#L63-L77
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/session/`[id]/signal/route.ts around lines 21 - 47, The session
signaling flow trusts an unauthenticated client-supplied role, allowing callers
to impersonate either participant. In src/app/api/session/[id]/signal/route.ts
lines 21-47, authenticate the caller, verify membership in session id, derive
sender from the server-side session role, and apply the same guard to GET and
DELETE; in src/app/session/[id]/page.tsx lines 93-97, resolve userRole from the
authenticated session record instead of searchParams; in
src/components/session/VideoCall.tsx lines 63-77, remove sender: role from the
signal POST body while retaining role for local UI and offerer decisions.


if (!globalSignals.sessionSignals) {
globalSignals.sessionSignals = {};
}

if (!globalSignals.sessionSignals[id]) {
globalSignals.sessionSignals[id] = [];
}

const newSignal: SignalMessage = {
id: Math.random().toString(36).substring(2, 9),
sender,
type,
data,
timestamp: Date.now(),
};

globalSignals.sessionSignals[id].push(newSignal);

// Keep array small to prevent memory leak
if (globalSignals.sessionSignals[id].length > 200) {
globalSignals.sessionSignals[id] = globalSignals.sessionSignals[id].slice(-200);
}

return NextResponse.json({ success: true, signal: newSignal }, { status: 200 });
}

// GET /api/session/[id]/signal?role=seeker|expert&since=timestamp
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const { searchParams } = new URL(request.url);
const role = searchParams.get("role");
const sinceStr = searchParams.get("since");
const since = sinceStr ? parseInt(sinceStr, 10) : 0;

if (!role || (role !== "seeker" && role !== "expert")) {
return NextResponse.json(
{ error: "role query param must be 'seeker' or 'expert'" },
{ status: 400 }
);
}

if (!globalSignals.sessionSignals || !globalSignals.sessionSignals[id]) {
return NextResponse.json({ signals: [] }, { status: 200 });
}

// Get signals sent by the OTHER peer after the since timestamp
const otherRole = role === "seeker" ? "expert" : "seeker";
const signals = globalSignals.sessionSignals[id].filter(
(sig) => sig.sender === otherRole && sig.timestamp > since
);
Comment on lines +83 to +101

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unvalidated since yields NaN and silently returns zero signals forever.

parseInt("abc")NaN, and sig.timestamp > NaN is always false, so a malformed since makes the peer never receive any signaling message with no error surfaced. Also, timestamp > since at millisecond granularity drops any signal written in the same millisecond as the cursor the client last saw — ICE candidates are bursty, so this is a realistic cause of stuck connections. Consider a monotonically increasing sequence number as the cursor.

🐛 Proposed fix for NaN handling
-  const since = sinceStr ? parseInt(sinceStr, 10) : 0;
+  const parsedSince = sinceStr ? Number.parseInt(sinceStr, 10) : 0;
+  if (Number.isNaN(parsedSince) || parsedSince < 0) {
+    return NextResponse.json(
+      { error: "since must be a non-negative integer" },
+      { status: 400 }
+    );
+  }
+  const since = parsedSince;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sinceStr = searchParams.get("since");
const since = sinceStr ? parseInt(sinceStr, 10) : 0;
if (!role || (role !== "seeker" && role !== "expert")) {
return NextResponse.json(
{ error: "role query param must be 'seeker' or 'expert'" },
{ status: 400 }
);
}
if (!globalSignals.sessionSignals || !globalSignals.sessionSignals[id]) {
return NextResponse.json({ signals: [] }, { status: 200 });
}
// Get signals sent by the OTHER peer after the since timestamp
const otherRole = role === "seeker" ? "expert" : "seeker";
const signals = globalSignals.sessionSignals[id].filter(
(sig) => sig.sender === otherRole && sig.timestamp > since
);
const sinceStr = searchParams.get("since");
const parsedSince = sinceStr ? Number.parseInt(sinceStr, 10) : 0;
if (Number.isNaN(parsedSince) || parsedSince < 0) {
return NextResponse.json(
{ error: "since must be a non-negative integer" },
{ status: 400 }
);
}
const since = parsedSince;
if (!role || (role !== "seeker" && role !== "expert")) {
return NextResponse.json(
{ error: "role query param must be 'seeker' or 'expert'" },
{ status: 400 }
);
}
if (!globalSignals.sessionSignals || !globalSignals.sessionSignals[id]) {
return NextResponse.json({ signals: [] }, { status: 200 });
}
// Get signals sent by the OTHER peer after the since timestamp
const otherRole = role === "seeker" ? "expert" : "seeker";
const signals = globalSignals.sessionSignals[id].filter(
(sig) => sig.sender === otherRole && sig.timestamp > since
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/session/`[id]/signal/route.ts around lines 83 - 101, Validate the
since query parameter in the signal route before filtering: reject non-numeric
or invalid values with a 400 response instead of allowing NaN. Update the
cursor/filtering logic around otherRole and sessionSignals to use a monotonic
per-signal sequence identifier, preserving all signals after the client’s
cursor, including signals sharing the same timestamp.


return NextResponse.json({ signals }, { status: 200 });
}

// DELETE /api/session/[id]/signal
// Clears all signals for this session
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;

if (globalSignals.sessionSignals && globalSignals.sessionSignals[id]) {
delete globalSignals.sessionSignals[id];
}

return NextResponse.json({ success: true }, { status: 200 });
}
86 changes: 62 additions & 24 deletions src/app/session/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { LiveCounter } from "@/components/session/LiveCounter";
import { SessionNotes } from "@/components/session/SessionNotes";
import { AppealFormModal } from "@/components/session/AppealForm";
Expand All @@ -12,6 +12,7 @@ import { Modal } from "@/components/ui/Modal";
import { useWallet } from "@/providers/WalletProvider";
import { CodeWorkspace } from "@/components/session/CodeWorkspace";
import { SessionChat } from "@/components/session/SessionChat";
import VideoCall from "@/components/session/VideoCall";
import {
User,
Wallet,
Expand Down Expand Up @@ -73,6 +74,7 @@ export default function SessionPage() {
const router = useRouter();
const wallet = useWallet();
const sessionId = params.id as string;
const searchParams = useSearchParams();

const [session] = useState<SessionData>(() => ({
...MOCK_SESSION,
Expand All @@ -88,6 +90,11 @@ export default function SessionPage() {
const [showCodeEditor, setShowCodeEditor] = useState(false);
const [showChat, setShowChat] = useState(false);
const [showAppealModal, setShowAppealModal] = useState(false);
const [isPictureInPicture, setIsPictureInPicture] = useState(false);

const roleParam = searchParams.get("role");
const isExpert = roleParam === "expert";
const userRole = isExpert ? "expert" : "seeker";

/**
* A resolved dispute attached to this session, if any.
Expand Down Expand Up @@ -187,31 +194,62 @@ export default function SessionPage() {

<div className={cn("grid gap-6", gridClassName)}>
<div className={cn("space-y-6 flex flex-col", mainColumnClass)}>
<Card
variant="glow"
className="relative overflow-hidden flex-1 min-h-[300px]"
>
<div className="absolute inset-0 bg-gradient-to-b from-primary/5 to-transparent pointer-events-none" />
<CardContent className="flex flex-col items-center justify-center h-full py-16">
<LiveCounter
ratePerSecond={session.ratePerSecond}
onTotalChange={setTotalStreamed}
remainingSeconds={remainingSeconds}
className="mb-8"
{isPictureInPicture ? (
<Card
variant="glow"
className="relative overflow-hidden flex-1 min-h-[300px]"
>
<div className="absolute inset-0 bg-gradient-to-b from-primary/5 to-transparent pointer-events-none" />
<CardContent className="flex flex-col items-center justify-center h-full py-16">
<LiveCounter
ratePerSecond={session.ratePerSecond}
onTotalChange={setTotalStreamed}
remainingSeconds={remainingSeconds}
className="mb-8"
/>

<div className="flex items-center gap-3 mb-4">
<Badge variant="success" className="text-xs">
<span className="size-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
Session Active
</Badge>
<Badge variant="info" className="text-xs">
<Zap className="size-3" />
Live
</Badge>
</div>
<p className="text-sm text-foreground/50">Video call is floating</p>
</CardContent>
</Card>
) : (
<div className="relative flex-1 min-h-[450px] rounded-2xl overflow-hidden border border-purple-500/30 bg-black">
<VideoCall
expertName={session.expertName}
seekerName="You"
expertAvatar={session.expertAvatar || "/assets/Avatar.svg"}
seekerAvatar="/assets/Avatar.svg"
onEndCall={handleEndSession}
isPictureInPicture={isPictureInPicture}
onTogglePIP={() => setIsPictureInPicture(!isPictureInPicture)}
sessionId={sessionId}
role={userRole}
/>
</div>
)}

<div className="flex items-center gap-3">
<Badge variant="success" className="text-xs">
<span className="size-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
Session Active
</Badge>
<Badge variant="info" className="text-xs">
<Zap className="size-3" />
Live
</Badge>
</div>
</CardContent>
</Card>
{isPictureInPicture && (
<VideoCall
expertName={session.expertName}
seekerName="You"
expertAvatar={session.expertAvatar || "/assets/Avatar.svg"}
seekerAvatar="/assets/Avatar.svg"
onEndCall={handleEndSession}
isPictureInPicture={isPictureInPicture}
onTogglePIP={() => setIsPictureInPicture(!isPictureInPicture)}
sessionId={sessionId}
role={userRole}
/>
)}
Comment on lines +197 to +252

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Two distinct VideoCall instances means toggling PiP destroys and re-establishes the call.

The non-PiP branch and the isPictureInPicture && ... block are separate elements in different tree positions, so React unmounts one and mounts the other on every toggle. VideoCall's cleanup stops the local tracks and closes the peer connection, then the new instance re-acquires media and (as seeker) posts a fresh offer — the remote peer sees the call drop. Render one instance and let it choose its own layout.

🐛 Single instance, layout-only branching
-            {isPictureInPicture ? (
-              <Card ...>
+            {isPictureInPicture && (
+              <Card
+                variant="glow"
+                className="relative overflow-hidden flex-1 min-h-[300px]"
+              >
                 ...
               </Card>
-            ) : (
-              <div className="relative flex-1 min-h-[450px] rounded-2xl overflow-hidden border border-purple-500/30 bg-black">
-                <VideoCall ... />
-              </div>
             )}
-
-            {isPictureInPicture && (
-              <VideoCall ... />
-            )}
+            <div
+              className={cn(
+                !isPictureInPicture &&
+                  "relative flex-1 min-h-[450px] rounded-2xl overflow-hidden border border-purple-500/30 bg-black",
+              )}
+            >
+              <VideoCall
+                expertName={session.expertName}
+                seekerName="You"
+                expertAvatar={session.expertAvatar || "/assets/Avatar.svg"}
+                seekerAvatar="/assets/Avatar.svg"
+                onEndCall={handleEndSession}
+                isPictureInPicture={isPictureInPicture}
+                onTogglePIP={() => setIsPictureInPicture((prev) => !prev)}
+                sessionId={sessionId}
+                role={userRole}
+              />
+            </div>

Note the PiP layout in VideoCall renders fixed-positioned markup, so it detaches from this container visually either way.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{isPictureInPicture ? (
<Card
variant="glow"
className="relative overflow-hidden flex-1 min-h-[300px]"
>
<div className="absolute inset-0 bg-gradient-to-b from-primary/5 to-transparent pointer-events-none" />
<CardContent className="flex flex-col items-center justify-center h-full py-16">
<LiveCounter
ratePerSecond={session.ratePerSecond}
onTotalChange={setTotalStreamed}
remainingSeconds={remainingSeconds}
className="mb-8"
/>
<div className="flex items-center gap-3 mb-4">
<Badge variant="success" className="text-xs">
<span className="size-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
Session Active
</Badge>
<Badge variant="info" className="text-xs">
<Zap className="size-3" />
Live
</Badge>
</div>
<p className="text-sm text-foreground/50">Video call is floating</p>
</CardContent>
</Card>
) : (
<div className="relative flex-1 min-h-[450px] rounded-2xl overflow-hidden border border-purple-500/30 bg-black">
<VideoCall
expertName={session.expertName}
seekerName="You"
expertAvatar={session.expertAvatar || "/assets/Avatar.svg"}
seekerAvatar="/assets/Avatar.svg"
onEndCall={handleEndSession}
isPictureInPicture={isPictureInPicture}
onTogglePIP={() => setIsPictureInPicture(!isPictureInPicture)}
sessionId={sessionId}
role={userRole}
/>
</div>
)}
<div className="flex items-center gap-3">
<Badge variant="success" className="text-xs">
<span className="size-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
Session Active
</Badge>
<Badge variant="info" className="text-xs">
<Zap className="size-3" />
Live
</Badge>
</div>
</CardContent>
</Card>
{isPictureInPicture && (
<VideoCall
expertName={session.expertName}
seekerName="You"
expertAvatar={session.expertAvatar || "/assets/Avatar.svg"}
seekerAvatar="/assets/Avatar.svg"
onEndCall={handleEndSession}
isPictureInPicture={isPictureInPicture}
onTogglePIP={() => setIsPictureInPicture(!isPictureInPicture)}
sessionId={sessionId}
role={userRole}
/>
)}
{isPictureInPicture && (
<Card
variant="glow"
className="relative overflow-hidden flex-1 min-h-[300px]"
>
<div className="absolute inset-0 bg-gradient-to-b from-primary/5 to-transparent pointer-events-none" />
<CardContent className="flex flex-col items-center justify-center h-full py-16">
<LiveCounter
ratePerSecond={session.ratePerSecond}
onTotalChange={setTotalStreamed}
remainingSeconds={remainingSeconds}
className="mb-8"
/>
<div className="flex items-center gap-3 mb-4">
<Badge variant="success" className="text-xs">
<span className="size-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
Session Active
</Badge>
<Badge variant="info" className="text-xs">
<Zap className="size-3" />
Live
</Badge>
</div>
<p className="text-sm text-foreground/50">Video call is floating</p>
</CardContent>
</Card>
)}
<div
className={cn(
!isPictureInPicture &&
"relative flex-1 min-h-[450px] rounded-2xl overflow-hidden border border-purple-500/30 bg-black",
)}
>
<VideoCall
expertName={session.expertName}
seekerName="You"
expertAvatar={session.expertAvatar || "/assets/Avatar.svg"}
seekerAvatar="/assets/Avatar.svg"
onEndCall={handleEndSession}
isPictureInPicture={isPictureInPicture}
onTogglePIP={() => setIsPictureInPicture((prev) => !prev)}
sessionId={sessionId}
role={userRole}
/>
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/session/`[id]/page.tsx around lines 197 - 252, Render a single
VideoCall instance in the session page instead of maintaining separate non-PiP
and isPictureInPicture branches. Consolidate the shared VideoCall props into one
render location and let its isPictureInPicture prop control the layout, while
preserving the surrounding Card content for PiP mode and the existing container
styling for normal mode.


<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 shrink-0">
{[
Expand Down
Loading
Loading