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
40 changes: 40 additions & 0 deletions CLERK_SUPABASE_FIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,43 @@ When a Clerk-authenticated user makes a request:
- All 12 RLS policies re-applied with corrected function references.
- The migration is tracked in the repo's Drizzle migration system.
- Run via `npm run db:migrate` or `bun run db:migrate` to apply to local/dev environments.

---

# Clerk-Supabase Native Third-Party Auth (Clerk) Settings Setup Note

## Native Third-Party Auth Setup Steps (No Shared JWT Secret Needed)

Under the Clerk native Third-Party Auth integration with Supabase, there is **no need** for a shared JWT secret. Supabase validates tokens issued directly by Clerk using Clerk's JSON Web Key Set (JWKS) URL.

### 1. Clerk Dashboard Configuration
1. Go to your **Clerk Dashboard**.
2. Navigate to **JWT Templates** (or **Integrations** -> **Supabase** if using Clerk's legacy integration, but for Native Third-Party Auth, navigate to **Configure** -> **API Keys** -> **JWT Templates** if you wish to configure scopes/claims, or simply use Clerk's native JWT signature).
3. Under Clerk's Native Third-Party Auth, Supabase directly retrieves the JSON Web Key Set (JWKS) from Clerk to verify signature of any standard token issued by Clerk. Therefore, standard session tokens from Clerk can be sent directly to Supabase.

### 2. Supabase Dashboard Configuration
1. Go to your **Supabase Project Dashboard**.
2. Navigate to **Authentication** -> **Third-Party Auth**.
3. Enable **Clerk** Third-Party Auth integration.
4. Set the following fields:
- **Clerk JWKS URL**: Enter your Clerk JWKS URL (e.g., `https://clerk.<your-domain>.com/.well-known/jwks.json` or development URL like `https://<your-dev-domain>.clerk.accounts.dev/.well-known/jwks.json`).
5. Save the settings.

### 3. Application Integration
The application uses Supabase JS clients initialized with a custom `fetch` handler. The `fetch` handler intercepts out-going requests to Supabase and appends the dynamic Clerk session JWT in the `Authorization` header as `Bearer <clerkToken>`.

- **Client-Side (Browser)**: Retrieves token via Clerk's browser SDK: `window.Clerk.session.getToken()`.
- **Server-Side (Next.js server/API/Actions)**: Retrieves token via `@clerk/nextjs/server` `auth()` helper: `auth().getToken()`.

Both clients use `persistSession: false` since Clerk manages the session lifecycle independently. Tokens are sent without the `{ template: 'supabase' }` option because Supabase validates Clerk tokens directly against the Clerk JWKS.

---

## Dual-Write Consistency and Access Control Guarantees

In a multi-user, real-time collaboration environment, dual-write consistency issues can arise between the primary application database (Drizzle) and the auth/edge function sync in the Supabase backend (QCX-BACKEND). To prevent desynchronization from breaking RLS-authorized Realtime and Storage access, we implement several safety measures:

1. **Stamping Ownership on Individual Messages**: In `saveChat`, instead of stamping all messages with the chat owner's ID, each message is stamped with the `userId` of the individual user that sent it (`userId: msg.userId || effectiveUserId`). This prevents concurrent collaborators' messages from being rewritten or misattributed.
2. **Deterministic Deduplication**: Messages inside a batch transaction are deduplicated using their unique `id` and upserted via `onConflictDoUpdate` (`ON CONFLICT (id) DO UPDATE SET content = EXCLUDED.content, role = EXCLUDED.role`). This ensures concurrent writes from different collaborators do not conflict or cause duplicate messages.
3. **Graceful User Existence Check**: Webhook path (/api/clerk/webhook) syncs new users to BOTH the primary Drizzle database and the Supabase backend. Furthermore, `getCurrentUserIdOnServer()` uses `resolveClerkUserToDbUser` which deterministically creates or links profile-aligned user records on-demand whenever an action is executed. This guarantees a user is always represented in both stores before attempting database operations.
4. **Resilient Realtime Subscriptions**: Supabase Realtime subscriptions follow the exact same RLS policies deployed in QCX-BACKEND. By ensuring that `is_clerk_user()` and the `chat_participants` access checks are fully reactive, collaborators instantly receive changes they have permission to see, and are automatically unsubscribed/unauthorized on the fly if their access role changes.
Comment on lines +166 to +173

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not document consistency guarantees that saveChat does not provide.

The implementation trusts msg.userId, replaces duplicate IDs with random UUIDs, and performs an unrestricted conflict update by message ID. That permits misattribution, repeated-save duplication, and unrelated message overwrites rather than deterministic deduplication. Revise this section after fixing the persistence path.

🧰 Tools
🪛 LanguageTool

[style] ~171-~171: ‘exact same’ might be wordy. Consider a shorter alternative.
Context: ...abase Realtime subscriptions follow the exact same RLS policies deployed in QCX-BACKEND. B...

(EN_WORDINESS_PREMIUM_EXACT_SAME)

🤖 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 `@CLERK_SUPABASE_FIX.md` around lines 164 - 171, Revise the “Dual-Write
Consistency and Access Control Guarantees” section to match the actual saveChat
persistence behavior, removing claims about per-user ownership stamping,
deterministic ID-based deduplication, and unrestricted conflict updates unless
those guarantees are implemented. First fix saveChat to validate message
ownership, preserve stable IDs, and restrict conflict updates to authorized
records, then document only the guarantees it truly provides.

124 changes: 123 additions & 1 deletion app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
getAIState,
getMutableAIState
} from 'ai/rsc'
import { CoreMessage, ToolResultPart, TextPart, ImagePart } from 'ai'
import { CoreMessage, ToolResultPart, TextPart, ImagePart, embedMany } from 'ai'
import { openai } from '@ai-sdk/openai'
import { nanoid } from '@/lib/utils'
import type { FeatureCollection } from 'geojson'
import { Spinner } from '@/components/ui/spinner'
Expand All @@ -29,12 +30,26 @@ import RetrieveSection from '@/components/retrieve-section'
import { VideoSearchSection } from '@/components/video-search-section'
import { MapQueryHandler } from '@/components/map/map-query-handler'
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user'
import { createClient } from '@/lib/supabase/client'
import { db } from '@/lib/db'
import { documents, documentChunks } from '@/lib/db/schema'
import { eq } from 'drizzle-orm'

// Define the type for related queries
type RelatedQueries = {
items: { query: string }[]
}

function chunkText(text: string, chunkSize = 800, overlap = 100): string[] {
const chunks: string[] = []
let i = 0
while (i < text.length) {
chunks.push(text.slice(i, i + chunkSize))
i += chunkSize - overlap
}
return chunks
}

async function submit(formData?: FormData, skip?: boolean) {
'use server'

Expand Down Expand Up @@ -325,6 +340,97 @@ async function submit(formData?: FormData, skip?: boolean) {
}

const userId = await getCurrentUserIdOnServer()

// Handle document attachment if uploaded via browser client to Storage
const documentStoragePath = formData?.get('documentStoragePath') as string
const documentMime = formData?.get('documentMime') as string
const documentName = formData?.get('documentName') as string

if (documentStoragePath && userId) {
let docId: string | null = null
try {
const supabase = createClient()

// First, create the document row
const [docRow] = await db.insert(documents).values({
userId: userId,
chatId: aiState.get().chatId,
storagePath: documentStoragePath,
mime: documentMime,
status: 'processing'
}).returning({ id: documents.id })
docId = docRow.id

const { data: fileData, error: downloadError } = await supabase.storage
.from('chat-attachments')
.download(documentStoragePath)

if (downloadError) {
throw downloadError
}

if (!fileData) {
throw new Error('Downloaded file data is null')
}

// Validate the actual file: trust the downloaded content over the submitted MIME
const buffer = await fileData.arrayBuffer()
const byteLength = buffer.byteLength

// Reject oversized files (max 5 MB)
const MAX_FILE_SIZE = 5 * 1024 * 1024
if (byteLength > MAX_FILE_SIZE) {
throw new Error(`File too large: ${byteLength} bytes exceeds ${MAX_FILE_SIZE} byte limit`)
}

// Validate MIME type against allowed types
const ALLOWED_MIMES = ['text/plain', 'text/markdown', 'application/pdf', 'text/csv', 'application/json']
const actualMime = fileData.type || documentMime || ''
if (!ALLOWED_MIMES.includes(actualMime)) {
throw new Error(`Unsupported MIME type: ${actualMime}`)
}

const text = await fileData.text()

const chunks = chunkText(text)
// Cap chunks to prevent runaway embedding costs (max 50 chunks)
const MAX_CHUNKS = 50
const cappedChunks = chunks.slice(0, MAX_CHUNKS)

if (cappedChunks.length > 0) {
const { embeddings } = await embedMany({
model: openai.embedding('text-embedding-ada-002'),
values: cappedChunks,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const chunkRows = cappedChunks.map((chunk, idx) => ({
documentId: docRow.id,
chunkText: chunk,
embedding: embeddings[idx]
}))

await db.insert(documentChunks).values(chunkRows)
}

await db.update(documents)
.set({ status: 'complete' })
.where(eq(documents.id, docRow.id))

} catch (err) {
console.error('[Document Ingestion] Failed to ingest document:', err)
// Update the existing row to error status instead of inserting a duplicate
if (docId) {
try {
await db.update(documents)
.set({ status: 'error' })
.where(eq(documents.id, docId))
} catch (e) {
console.error('[Document Ingestion] Failed to update document to error status:', e)
}
}
}
}

const currentSystemPrompt = userId ? await getSystemPrompt(userId) : null
const maxMessages = 10
const messages = aiState.get().messages.map(message => ({
Expand Down Expand Up @@ -722,6 +828,22 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
),
isCollapsed: isCollapsed.value
}
case 'documentRetrieve': {
const adaptedResults = {
results: toolOutput.map((r: any) => ({
title: `Document Match (Similarity: ${(r.similarity * 100).toFixed(1)}%)`,
content: r.chunkText,
url: ''
})),
query: '',
images: []
}
return {
id,
component: <RetrieveSection data={adaptedResults} />,
isCollapsed: isCollapsed.value
}
}
default:
console.warn(
`Unhandled tool result in getUIStateFromAIState: ${name}`
Expand Down
88 changes: 88 additions & 0 deletions app/api/chats/[id]/participants/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { NextResponse, NextRequest } from 'next/server';
import { addParticipant, removeParticipant, listParticipants } from '@/lib/actions/chat';
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user';

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserIdOnServer();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id: chatId } = await params;
const result = await listParticipants(chatId);

if (result === null) {
// Access denied — user is not authorized to view this chat
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}

// result is an empty array means authorized but no collaborators
return NextResponse.json({ participants: result }, { status: 200 });
} catch (error) {
console.error('Error listing participants via API:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserIdOnServer();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id: chatId } = await params;
const { emailOrClerkId } = await request.json();

if (!emailOrClerkId) {
return NextResponse.json({ error: 'Missing emailOrClerkId' }, { status: 400 });
}

// Ignore untrusted role from request — always use 'collaborator'
const success = await addParticipant(chatId, emailOrClerkId, 'collaborator');
if (success) {
return NextResponse.json({ message: 'Participant added successfully' }, { status: 200 });
} else {
return NextResponse.json({ error: 'Failed to add participant (user not found or unauthorized)' }, { status: 404 });
}
} catch (error) {
console.error('Error adding participant via API:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const userId = await getCurrentUserIdOnServer();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const { id: chatId } = await params;
const { targetUserId } = await request.json();

if (!targetUserId) {
return NextResponse.json({ error: 'Missing targetUserId' }, { status: 400 });
}

const success = await removeParticipant(chatId, targetUserId);
if (success) {
return NextResponse.json({ message: 'Participant removed successfully' }, { status: 200 });
} else {
return NextResponse.json({ error: 'Failed to remove participant or unauthorized' }, { status: 404 });
}
} catch (error) {
console.error('Error removing participant via API:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
Loading