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
67 changes: 67 additions & 0 deletions src/app/api/invites/[token]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { workspaceInvites, workspaces, user } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { withErrorHandling } from "@/lib/api/workspace-helpers";

async function handleGET(
request: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;

if (!token) {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}

// Find the invite
const [invite] = await db
.select({
email: workspaceInvites.email,
expiresAt: workspaceInvites.expiresAt,
workspaceId: workspaceInvites.workspaceId,
inviterId: workspaceInvites.inviterId,
})
.from(workspaceInvites)
.where(eq(workspaceInvites.token, token))
.limit(1);

if (!invite) {
return NextResponse.json({ error: "Invite not found" }, { status: 404 });
}

// Check expiration
if (new Date(invite.expiresAt) < new Date()) {
return NextResponse.json({ error: "Invite expired" }, { status: 410 });
}

// Get workspace details
const [workspace] = await db
.select({
name: workspaces.name,
})
.from(workspaces)
.where(eq(workspaces.id, invite.workspaceId))
.limit(1);

// Get inviter details
const [inviter] = await db
.select({
name: user.name,
email: user.email,
image: user.image
})
.from(user)
.where(eq(user.id, invite.inviterId))
.limit(1);

return NextResponse.json({
email: invite.email,
workspaceName: workspace?.name || "Workspace",
inviterName: inviter?.name || inviter?.email || "Someone",
inviterImage: inviter?.image
});
}

export const GET = withErrorHandling(handleGET, "GET /api/invites/[token]");
88 changes: 88 additions & 0 deletions src/app/api/invites/claim/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { workspaceInvites, workspaceCollaborators, workspaces } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";
import { withErrorHandling, requireAuth } from "@/lib/api/workspace-helpers";
import { auth } from "@/lib/auth"; // Need to get full session to check email

async function handlePOST(request: NextRequest) {
// We need the email from the session, simpler requireAuth only returns userId.
// So we use auth.api.getSession directly
const session = await auth.api.getSession({
headers: request.headers,
});

if (!session || !session.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { token } = await request.json();

if (!token) {
return NextResponse.json({ error: "Token is required" }, { status: 400 });
}

// Find the invite
const [invite] = await db
.select()
.from(workspaceInvites)
.where(eq(workspaceInvites.token, token))
.limit(1);

if (!invite) {
return NextResponse.json({ error: "Invite not found" }, { status: 404 });
}

// Check expiration
if (new Date(invite.expiresAt) < new Date()) {
return NextResponse.json({ error: "Invite expired" }, { status: 410 });
}

// Verify email matches current user
if (!session.user.email || invite.email.toLowerCase() !== session.user.email.toLowerCase()) {
return NextResponse.json({
error: "Email mismatch",
message: `This invite is for ${invite.email}, but you are logged in as ${session.user.email}. Please log out and sign up/in with the invited email.`
}, { status: 403 });
}

// Check if already a collaborator - if so, just consume the invite
const [existing] = await db
.select()
.from(workspaceCollaborators)
.where(and(
eq(workspaceCollaborators.workspaceId, invite.workspaceId),
eq(workspaceCollaborators.userId, session.user.id)
))
.limit(1);

if (!existing) {
// Add to collaborators
await db.insert(workspaceCollaborators).values({
workspaceId: invite.workspaceId,
userId: session.user.id,
permissionLevel: invite.permissionLevel,
});
}

// Clean up used invite
await db
.delete(workspaceInvites)
.where(eq(workspaceInvites.id, invite.id));

Comment on lines +50 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate the file and verify its existence
find . -name "route.ts" -path "*/api/invites/claim*" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 99


🏁 Script executed:

# Search for the file in the repository
git ls-files | grep -i "invite" | grep -i "route"

Repository: ThinkEx-OSS/thinkex

Length of output: 134


🏁 Script executed:

# Get the repository structure to understand the project
ls -la | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 1235


🏁 Script executed:

# Read the file to see the code at lines 50-73
cat -n src/app/api/invites/claim/route.ts | sed -n '45,80p'

Repository: ThinkEx-OSS/thinkex

Length of output: 1523


🏁 Script executed:

# Find the database schema files to understand constraints
find . -type f -name "*.ts" -o -name "*.sql" | xargs grep -l "workspaceCollaborators\|workspaceInvites" | head -10

Repository: ThinkEx-OSS/thinkex

Length of output: 513


🏁 Script executed:

# Look for schema definitions
fd -t f "schema\|db\|types" --path "*db*" --path "*schema*" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 296


🏁 Script executed:

# Search for schema/database definitions
find . -type f \( -name "*.ts" -o -name "*.sql" \) | xargs grep -l "workspaceCollaborators" | head -10

Repository: ThinkEx-OSS/thinkex

Length of output: 497


🏁 Script executed:

# Look for Drizzle schema files
find . -path "*/schema*" -name "*.ts" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 962


🏁 Script executed:

# Search more broadly for db schema
rg "workspaceCollaborators" -t ts | grep -E "(table|primaryKey|unique)" | head -20

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Read the database schema file
cat -n src/lib/db/schema.ts | head -200

Repository: ThinkEx-OSS/thinkex

Length of output: 12031


🏁 Script executed:

# Search specifically for workspaceCollaborators definition
rg "workspaceCollaborators" src/lib/db/schema.ts -A 20

Repository: ThinkEx-OSS/thinkex

Length of output: 1485


🏁 Script executed:

# Check if the codebase uses db.transaction anywhere
rg "db\.transaction|\.transaction\(" src/ -A 5 | head -50

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Look for onConflictDoNothing usage in the codebase
rg "onConflictDoNothing|onConflict" src/ -A 3 -B 1

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Check package.json for Drizzle version
cat package.json | grep -A 5 -B 5 "drizzle"

Repository: ThinkEx-OSS/thinkex

Length of output: 1069


🌐 Web query:

Drizzle ORM 0.44.7 transaction support PostgreSQL

💡 Result:

Drizzle ORM v0.44.7 already has full PostgreSQL transaction support via db.transaction(...) (commit/rollback handled automatically). You can also pass PostgreSQL-specific transaction options like isolationLevel, accessMode, and deferrable. [1]

What changed in 0.44.7? Nothing PostgreSQL-specific—v0.44.7 is a small patch release that fixes a durable-sqlite transaction return value issue, not Postgres transactions. [2]

PostgreSQL transaction usage (v0.44.7):

await db.transaction(
  async (tx) => {
    await tx.update(accounts).set({ /* ... */ });
    await tx.insert(logs).values({ /* ... */ });
  },
  {
    isolationLevel: "read committed",
    accessMode: "read write",
    deferrable: true,
  }
);

[1]

Nested transactions: tx.transaction(...) works on PostgreSQL by using SAVEPOINTs under the hood (so nested blocks can roll back independently). [1]

Sources
[1] Drizzle ORM docs – Transactions https://orm.drizzle.team/docs/transactions
[2] Drizzle ORM site – v0.44.7 release note (durable sqlite fix) https://orm.drizzle.team/


🌐 Web query:

Drizzle ORM onConflictDoNothing composite unique key

💡 Result:

To use onConflictDoNothing with a composite unique key in Drizzle, pass an array of columns as the target, and make sure your schema actually creates a UNIQUE constraint (or composite PRIMARY KEY) on the same columns.

1) Define a composite UNIQUE constraint in the schema

import { pgTable, text, unique } from "drizzle-orm/pg-core";

export const users = pgTable("users", {
  firstName: text("first_name").notNull(),
  lastName: text("last_name").notNull(),
}, (t) => [
  unique().on(t.firstName, t.lastName),
]);

Drizzle supports composite unique constraints via unique().on(col1, col2). [1]

2) Insert with onConflictDoNothing targeting that composite key

await db.insert(users).values({ firstName: "John", lastName: "Doe" })
  .onConflictDoNothing({ target: [users.firstName, users.lastName] });

onConflictDoNothing supports an explicit target, and conflict targets are columns (or an array of columns for composite). [2]

SQLite note (common pitfall)

If you get ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint, it usually means your table has a unique index but not a UNIQUE constraint / PK matching the target (this has come up with SQLite in Drizzle). In that case either:

  • ensure the migration produces a real UNIQUE(col1, col2) table constraint (like the unique().on(...) example), or
  • use .onConflictDoNothing() without target to ignore conflicts on any unique constraint. [2][3]

Sources: Drizzle docs on constraints [1] and insert/onConflictDoNothing [2], plus SQLite-related conflict-target issue discussion [3].


Wrap collaborator insert and invite delete in a transaction to prevent race conditions.

The current code is vulnerable to concurrent requests: two simultaneous claims can both pass the existence check and attempt to insert, causing one to fail on the unique constraint workspace_collaborators_workspace_user_unique on (workspaceId, userId). Use db.transaction() to make both the insert and delete atomic, and handle conflicts with onConflictDoNothing() targeting the composite key.

Proposed transactional fix
-    if (!existing) {
-        // Add to collaborators
-        await db.insert(workspaceCollaborators).values({
-            workspaceId: invite.workspaceId,
-            userId: session.user.id,
-            permissionLevel: invite.permissionLevel,
-        });
-    }
-
-    // Clean up used invite
-    await db
-        .delete(workspaceInvites)
-        .where(eq(workspaceInvites.id, invite.id));
+    await db.transaction(async (tx) => {
+        if (!existing) {
+            await tx
+                .insert(workspaceCollaborators)
+                .values({
+                    workspaceId: invite.workspaceId,
+                    userId: session.user.id,
+                    permissionLevel: invite.permissionLevel,
+                })
+                .onConflictDoNothing({
+                    target: [
+                        workspaceCollaborators.workspaceId,
+                        workspaceCollaborators.userId,
+                    ],
+                });
+        }
+
+        await tx
+            .delete(workspaceInvites)
+            .where(eq(workspaceInvites.id, invite.id));
+    });
📝 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
// Check if already a collaborator - if so, just consume the invite
const [existing] = await db
.select()
.from(workspaceCollaborators)
.where(and(
eq(workspaceCollaborators.workspaceId, invite.workspaceId),
eq(workspaceCollaborators.userId, session.user.id)
))
.limit(1);
if (!existing) {
// Add to collaborators
await db.insert(workspaceCollaborators).values({
workspaceId: invite.workspaceId,
userId: session.user.id,
permissionLevel: invite.permissionLevel,
});
}
// Clean up used invite
await db
.delete(workspaceInvites)
.where(eq(workspaceInvites.id, invite.id));
// Check if already a collaborator - if so, just consume the invite
const [existing] = await db
.select()
.from(workspaceCollaborators)
.where(and(
eq(workspaceCollaborators.workspaceId, invite.workspaceId),
eq(workspaceCollaborators.userId, session.user.id)
))
.limit(1);
await db.transaction(async (tx) => {
if (!existing) {
await tx
.insert(workspaceCollaborators)
.values({
workspaceId: invite.workspaceId,
userId: session.user.id,
permissionLevel: invite.permissionLevel,
})
.onConflictDoNothing({
target: [
workspaceCollaborators.workspaceId,
workspaceCollaborators.userId,
],
});
}
await tx
.delete(workspaceInvites)
.where(eq(workspaceInvites.id, invite.id));
});
🤖 Prompt for AI Agents
In `@src/app/api/invites/claim/route.ts` around lines 50 - 73, Wrap the existence
check, potential insert into workspaceCollaborators, and delete from
workspaceInvites in a single db.transaction() so the claim is atomic; inside the
transaction attempt the insert of {workspaceId: invite.workspaceId, userId:
session.user.id, permissionLevel: invite.permissionLevel} but use
onConflictDoNothing() (targeting the composite key workspaceId+userId / the
workspace_collaborators_workspace_user_unique constraint) to avoid
unique-constraint errors from concurrent claims, then delete the invite
(workspaceInvites where id = invite.id) within the same transaction; keep the
prior existence check if desired but ensure the insert+delete run inside the
transaction obtained from db.transaction().

// Get workspace slug for redirection
const [workspace] = await db
.select({ slug: workspaces.slug, id: workspaces.id })
.from(workspaces)
.where(eq(workspaces.id, invite.workspaceId))
.limit(1);

return NextResponse.json({
success: true,
workspaceId: workspace?.id,
workspaceSlug: workspace?.slug
});
}

export const POST = withErrorHandling(handlePOST, "POST /api/invites/claim");
169 changes: 125 additions & 44 deletions src/app/api/workspaces/[id]/collaborators/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db/client";
import { workspaceCollaborators, workspaces, user } from "@/lib/db/schema";
import { workspaceCollaborators, workspaces, user, workspaceInvites } from "@/lib/db/schema";
import { eq, and } from "drizzle-orm";
import {
verifyWorkspaceAccess,
Expand Down Expand Up @@ -69,6 +69,19 @@ async function handleGET(
.leftJoin(user, eq(workspaceCollaborators.userId, user.id))
.where(eq(workspaceCollaborators.workspaceId, workspaceId));

// Get pending invites
const invites = await db
.select({
id: workspaceInvites.id,
email: workspaceInvites.email,
permissionLevel: workspaceInvites.permissionLevel,
createdAt: workspaceInvites.createdAt,
expiresAt: workspaceInvites.expiresAt,
inviterId: workspaceInvites.inviterId,
})
.from(workspaceInvites)
.where(eq(workspaceInvites.workspaceId, workspaceId));
Comment on lines +72 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Don’t expose pending invite emails to viewers.

This endpoint allows “viewer” access, but returning invites leaks invitee emails to users who cannot manage access. Consider only returning invites to owners/editors.

🔐 Suggested change (gate invite visibility)
-    const invites = await db
-        .select({
-            id: workspaceInvites.id,
-            email: workspaceInvites.email,
-            permissionLevel: workspaceInvites.permissionLevel,
-            createdAt: workspaceInvites.createdAt,
-            expiresAt: workspaceInvites.expiresAt,
-            inviterId: workspaceInvites.inviterId,
-        })
-        .from(workspaceInvites)
-        .where(eq(workspaceInvites.workspaceId, workspaceId));
+    let invites: Array<{
+        id: string;
+        email: string;
+        permissionLevel: string;
+        createdAt: string;
+        expiresAt: string;
+        inviterId: string;
+    }> = [];
+    try {
+        await verifyWorkspaceAccess(workspaceId, userId, "editor");
+        invites = await db
+            .select({
+                id: workspaceInvites.id,
+                email: workspaceInvites.email,
+                permissionLevel: workspaceInvites.permissionLevel,
+                createdAt: workspaceInvites.createdAt,
+                expiresAt: workspaceInvites.expiresAt,
+                inviterId: workspaceInvites.inviterId,
+            })
+            .from(workspaceInvites)
+            .where(eq(workspaceInvites.workspaceId, workspaceId));
+    } catch {
+        invites = [];
+    }

Also applies to: 99-99

🤖 Prompt for AI Agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts around lines 72 - 83, The
current code always queries and returns workspaceInvites (the invites variable),
which exposes invitee emails to viewers; update the route handler in route.ts to
first determine the requester's permission for the workspace (e.g., call or
implement a getWorkspacePermission(userId, workspaceId) or reuse existing
permission check) and only run the workspaceInvites query (the select from
workspaceInvites) when the permissionLevel is owner/editor; for viewer-level
users return an empty invites list or omit invites from the response. Ensure you
reference workspaceId, workspaceInvites, and the invites variable when gating
the database query so emails are never returned to viewers.


const ownerAsCollaborator = workspaceOwner ? {
id: `owner-${workspaceOwner.userId}`,
userId: workspaceOwner.userId,
Expand All @@ -83,7 +96,7 @@ async function handleGET(
? [ownerAsCollaborator, ...collaborators]
: collaborators;

return NextResponse.json({ collaborators: allCollaborators });
return NextResponse.json({ collaborators: allCollaborators, invites });
}

// POST /api/workspaces/[id]/collaborators
Expand Down Expand Up @@ -116,12 +129,8 @@ async function handlePOST(
.where(eq(user.email, email.trim().toLowerCase()))
.limit(1);

if (!invitedUser) {
return NextResponse.json(
{ message: "User not found. They need to sign up first." },
{ status: 404 }
);
}
// If user not found, we will create a pending invite later in the code
// The previous 404 block here was preventing that flow

// Check if already a collaborator
const [existing] = await db
Expand All @@ -130,7 +139,7 @@ async function handlePOST(
.where(
and(
eq(workspaceCollaborators.workspaceId, workspaceId),
eq(workspaceCollaborators.userId, invitedUser.id)
eq(workspaceCollaborators.userId, invitedUser?.id || "non-existent") // non-existent won't match
)
)
.limit(1);
Expand All @@ -142,15 +151,7 @@ async function handlePOST(
);
}

// Can't invite yourself
if (invitedUser.id === currentUser.userId) {
return NextResponse.json(
{ message: "You can't invite yourself" },
{ status: 400 }
);
}

// Get workspace to check if invitee is the owner and get slug for url
// Get workspace details for email and token generation
const [ws] = await db
.select({
userId: workspaces.userId,
Expand All @@ -161,31 +162,107 @@ async function handlePOST(
.where(eq(workspaces.id, workspaceId))
.limit(1);

if (ws && invitedUser.id === ws.userId) {
return NextResponse.json(
{ message: "Cannot invite workspace owner as collaborator" },
{ status: 400 }
);
// If user exists, add as collaborator directly
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if (invitedUser) {
// Can't invite yourself
if (invitedUser.id === currentUser.userId) {
return NextResponse.json(
{ message: "You can't invite yourself" },
{ status: 400 }
);
}

if (ws && invitedUser.id === ws.userId) {
return NextResponse.json(
{ message: "Cannot invite workspace owner as collaborator" },
{ status: 400 }
);
}

// Add collaborator
const [newCollaborator] = await db
.insert(workspaceCollaborators)
.values({
workspaceId,
userId: invitedUser.id,
permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor",
})
.returning();

// Send standard invitation email
try {
// Use slug if available, otherwise fallback to id
const identifier = ws.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
const { data, error } = await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>',
to: [email],
subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`,
react: InviteEmailTemplate({
inviterName: currentUser.name || 'A user',
workspaceName: ws.name || 'Workspace',
workspaceUrl,
}),
});
if (error) console.error("Failed to send invitation email:", error);
} catch (emailError) {
console.error("Error sending invitation email:", emailError);
}
Comment on lines +192 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent null check for ws.

Line 166 checks if (ws && ...) before accessing ws.userId, but line 186 accesses ws.slug without a null check. While verifyWorkspaceAccess should ensure the workspace exists, it's safer to be consistent.

🛡️ Proposed fix
         // Send standard invitation email
         try {
             // Use slug if available, otherwise fallback to id
-            const identifier = ws.slug || workspaceId;
+            const identifier = ws?.slug || workspaceId;
📝 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
// Send standard invitation email
try {
// Use slug if available, otherwise fallback to id
const identifier = ws.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
const { data, error } = await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>',
to: [email],
subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`,
react: InviteEmailTemplate({
inviterName: currentUser.name || 'A user',
workspaceName: ws.name || 'Workspace',
workspaceUrl,
}),
});
if (error) console.error("Failed to send invitation email:", error);
} catch (emailError) {
console.error("Error sending invitation email:", emailError);
}
// Send standard invitation email
try {
// Use slug if available, otherwise fallback to id
const identifier = ws?.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
const { data, error } = await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>',
to: [email],
subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`,
react: InviteEmailTemplate({
inviterName: currentUser.name || 'A user',
workspaceName: ws.name || 'Workspace',
workspaceUrl,
}),
});
if (error) console.error("Failed to send invitation email:", error);
} catch (emailError) {
console.error("Error sending invitation email:", emailError);
}
🤖 Prompt for AI Agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts around lines 183 - 201,
The code accesses ws.slug and ws.name without the null-check used earlier, which
can cause a runtime error if ws is null; update the invitation-email block (the
identifier/workspaceUrl construction and the InviteEmailTemplate props passed to
resend.emails.send) to safely use optional chaining or the same guard as before
(e.g., use ws?.slug ?? workspaceId and ws?.name ?? 'Workspace' and ws?.name ??
'a workspace'), and ensure all uses of ws in that try block are null-safe
(including building workspaceUrl and subject) so the send call never
dereferences a potentially null ws.


return NextResponse.json({ collaborator: newCollaborator }, { status: 201 });
}

// Add collaborator
const [newCollaborator] = await db
.insert(workspaceCollaborators)
.values({
workspaceId,
userId: invitedUser.id,
permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor",
})
.returning();
// User does NOT exist - Create Pending Invite

// Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only
// Simple secure random token generator
const generateToken = () => {
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
Comment on lines +220 to +229

@cubic-dev-ai cubic-dev-ai Bot Feb 5, 2026

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 fallback to Math.random() creates predictable tokens, which is a critical security vulnerability for invitation systems.

In Next.js 16 (Node.js 18+ or Edge Runtime), the Web Crypto API (crypto) is globally available. If crypto is missing for some reason, the application should fail securely rather than silently degrading to insecure token generation.

Remove the fallback and reliance on Math.random.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/workspaces/[id]/collaborators/route.ts, line 207:

<comment>The fallback to `Math.random()` creates predictable tokens, which is a critical security vulnerability for invitation systems. 

In Next.js 16 (Node.js 18+ or Edge Runtime), the Web Crypto API (`crypto`) is globally available. If `crypto` is missing for some reason, the application should fail securely rather than silently degrading to insecure token generation.

Remove the fallback and reliance on `Math.random`.</comment>

<file context>
@@ -208,9 +204,16 @@ async function handlePOST(
-        const array = new Uint8Array(24);
-        crypto.getRandomValues(array);
-        return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
+        // Use Web Crypto API which is available in Node.js global scope in newer versions
+        // or fall back to Math.random for older environments if critical
+        if (typeof crypto !== 'undefined') {
</file context>
Suggested change
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
Fix with Cubic

Comment on lines +217 to +229

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check the runtime configuration and examine the file context
rg -n "export const runtime" src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

# Also check the full structure of the file around the problematic code
head -n 220 src/app/api/workspaces/[id]/collaborators/route.ts | tail -n 30

Repository: ThinkEx-OSS/thinkex

Length of output: 1325


🏁 Script executed:

# Check if there are any existing imports from crypto
rg -n "import.*crypto" src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 45


🏁 Script executed:

wc -l src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 117


🏁 Script executed:

# Read the beginning of the file to see imports and understand the context
head -n 50 src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 1628


🏁 Script executed:

# Check if next.config file has any edge runtime configuration that might affect this route
find . -name "next.config.*" -type f | head -5

Repository: ThinkEx-OSS/thinkex

Length of output: 79


🏁 Script executed:

# Read the full route file to check for runtime configuration
cat -n src/app/api/workspaces/[id]/collaborators/route.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 11627


🏁 Script executed:

# Check next.config for any edge runtime settings
cat next.config.ts

Repository: ThinkEx-OSS/thinkex

Length of output: 1208


Remove the insecure Math.random fallback from invite token generation.

The fallback to Math.random() is cryptographically insecure and should never be used for generating security-sensitive tokens like invitations. Since this is a Node.js API route (no edge runtime is configured), use crypto.randomBytes() unconditionally instead of the defensive Web Crypto API check with unsafe fallback.

🔐 Suggested fix
+import { randomBytes } from "crypto";
...
-    const generateToken = () => {
-        // Use Web Crypto API which is available in Node.js global scope in newer versions
-        // or fall back to Math.random for older environments if critical
-        if (typeof crypto !== 'undefined') {
-            const array = new Uint8Array(24);
-            crypto.getRandomValues(array);
-            return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
-        } else {
-            // Fallback for environments without crypto global
-            return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
-        }
-    };
+    const generateToken = () => randomBytes(24).toString("hex");
📝 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
// Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only
// Simple secure random token generator
const generateToken = () => {
// Use Web Crypto API which is available in Node.js global scope in newer versions
// or fall back to Math.random for older environments if critical
if (typeof crypto !== 'undefined') {
const array = new Uint8Array(24);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} else {
// Fallback for environments without crypto global
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
import { randomBytes } from "crypto";
// Import nanoid dynamically or use a simple random string generator since nanoid might be ESM only
// Simple secure random token generator
const generateToken = () => randomBytes(24).toString("hex");
🤖 Prompt for AI Agents
In `@src/app/api/workspaces/`[id]/collaborators/route.ts around lines 204 - 216,
The generateToken function currently falls back to Math.random which is
insecure; replace its implementation to use Node's crypto.randomBytes
unconditionally (e.g., call crypto.randomBytes(24).toString('hex') or import
randomBytes from 'crypto') for invite token generation in the API route; remove
the Web Crypto + Math.random branch and ensure the code uses the Node crypto API
only so tokens are cryptographically secure.

Comment on lines +219 to +229

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.

Insecure invite token fallback

generateToken() falls back to Math.random() when crypto is undefined (lines in the 219-229 range). In any runtime where the global Web Crypto API isn’t present (or is blocked), this produces guessable invite tokens, which would allow unauthorized users to brute-force invite links. Since these tokens gate workspace access, the fallback should be removed and token generation should fail hard (or use Node’s crypto module) rather than degrading to non-cryptographic randomness.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/workspaces/[id]/collaborators/route.ts
Line: 219:229

Comment:
**Insecure invite token fallback**

`generateToken()` falls back to `Math.random()` when `crypto` is undefined (lines in the 219-229 range). In any runtime where the global Web Crypto API isn’t present (or is blocked), this produces guessable invite tokens, which would allow unauthorized users to brute-force invite links. Since these tokens gate workspace access, the fallback should be removed and token generation should fail hard (or use Node’s `crypto` module) rather than degrading to non-cryptographic randomness.

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

};
Comment on lines +219 to +230

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.

Using crypto.getRandomValues() in Node.js requires importing from node:crypto or using the global crypto object which may not be available in all environments. Verify this works in your deployment environment.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/workspaces/[id]/collaborators/route.ts
Line: 210:214

Comment:
Using `crypto.getRandomValues()` in Node.js requires importing from `node:crypto` or using the global `crypto` object which may not be available in all environments. Verify this works in your deployment environment.

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


const token = generateToken();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7); // 7 days expiry


// Implementation for Pending Invite
// 1. Delete existing invite to replace it
await db
.delete(workspaceInvites)
.where(
and(
eq(workspaceInvites.workspaceId, workspaceId),
eq(workspaceInvites.email, email.trim().toLowerCase())
)
);

// Send invitation email
// 2. Create new invite
await db.insert(workspaceInvites).values({
workspaceId,
email: email.trim().toLowerCase(),
token,
inviterId: currentUser.userId,
permissionLevel: permissionLevel === "viewer" ? "viewer" : "editor",
expiresAt: expiresAt.toISOString(),
});

// 3. Send Invite Email
try {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://thinkex.app';
// Use slug if available, otherwise fallback to id (though slug should always be present for access)
const identifier = ws.slug || workspaceId;
const workspaceUrl = `https://thinkex.app/workspace/${identifier}`;
const { data, error } = await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>', // Update this with your verified domain if available
// Append ?invite=token to the URL
const workspaceUrl = `https://thinkex.app/workspace/${identifier}?invite=${token}`;

await resend.emails.send({
from: 'ThinkEx <hello@thinkex.app>',
to: [email],
subject: `You've been invited to collaborate on ${ws.name || 'a workspace'}`,
react: InviteEmailTemplate({
Expand All @@ -194,15 +271,19 @@ async function handlePOST(
workspaceUrl,
}),
});

if (error) {
console.error("Failed to send invitation email:", error);
}
} catch (emailError) {
console.error("Error sending invitation email:", emailError);
console.error("Error sending pending invitation email:", emailError);
// We still return success as the invite was created
}

return NextResponse.json({ collaborator: newCollaborator }, { status: 201 });
return NextResponse.json(
{
message: "Invitation sent to new user",
pending: true,
email
},
{ status: 201 }
);
}

export const GET = withErrorHandling(handleGET, "GET /api/workspaces/[id]/collaborators");
Expand Down
Loading