-
-
Notifications
You must be signed in to change notification settings - Fork 8
fix: resolve Clerk-Supabase auth synchronization and chat preview #710
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9c73575
25d43f4
85fcd36
1cf0251
765fee7
38a7100
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,3 @@ | ||
| DATABASE_URL="postgresql://user:password@host:port/db" | ||
| # POSTGRES_URL must be set in Vercel environment variables for production. | ||
| # For local development, add POSTGRES_URL to .env.local instead. | ||
| # POSTGRES_URL= |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -57,3 +57,5 @@ aef_index.csv | |
| .env.local | ||
| .env.production.local | ||
| *.log | ||
| deno.json | ||
| edge_function/ | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| # Clerk-Supabase Auth Synchronization Fix | ||
|
|
||
| ## Problem Summary | ||
|
|
||
| Authenticated users were unable to preview their chats in QCX because the Clerk authentication system and the QCX-BACKEND Supabase backend were not properly synchronized. The root cause was that the `clerk_user_id` column on the `public.users` table was **never being populated**, which caused all Clerk-aware Row Level Security (RLS) policies to fail — specifically, the `is_clerk_user()` function always returned `false`, blocking all authenticated users from accessing any data. | ||
|
|
||
| ## Root Cause Analysis | ||
|
|
||
| The synchronization failure stemmed from **three conflicting user creation paths** that each created user records differently without establishing the Clerk-to-Supabase ID bridge: | ||
|
|
||
| ### Path 1: Supabase `handle_new_user` Trigger | ||
| When a user signed up via Clerk, Clerk's JWT was passed to Supabase. The `handle_new_user` trigger fired on `auth.users` insert and created a `public.users` record with the Supabase-generated UUID as `id`, but it **never set `clerk_user_id`**. | ||
|
|
||
| ### Path 2: Next.js Webhook Route (`/api/clerk/webhook`) | ||
| The webhook received Clerk events and used Drizzle ORM to upsert into the **QCX app's own database** (a separate connection via `DATABASE_URL`). It created records with `clerk_user_id` set, but these records lived in the app's database, **not in QCX-BACKEND**. | ||
|
|
||
| ### Path 3: Supabase Edge Function (`database-access`) | ||
| The edge function received the same Clerk webhooks and performed a direct table upsert into QCX-BACKEND. However, it was using the Clerk ID (e.g., `user_abc123`) as the value for the `id` column — which is a `uuid` type — and it **never set `clerk_user_id`**. | ||
|
|
||
| ### The Critical Gap | ||
| All three paths operated in isolation. None of them linked the Supabase UUID to the Clerk ID in the `clerk_user_id` column. Since all RLS policies depend on `is_clerk_user(user_id)` which checks `clerk_user_id = clerk_id()`, and `clerk_user_id` was always `NULL`, **every RLS policy denied access**. | ||
|
|
||
| ## Fix Applied | ||
|
|
||
| ### QCX-BACKEND (Supabase) — Direct SQL/Migration Fixes | ||
|
|
||
| 1. **`sync_clerk_user()` RPC function**: A canonical function that handles all three cases — existing user by Clerk ID, existing user by email (linking), and new user creation. Returns the Supabase UUID for the record. | ||
|
|
||
| 2. **Updated `handle_new_user` trigger**: Now extracts the Clerk user ID from `auth.jwt()` metadata and stores it in `clerk_user_id`. | ||
|
|
||
| 3. **Updated `clerk_id()` function**: Correctly extracts the Clerk user ID from the JWT `sub` claim: `SELECT auth.jwt() ->> 'sub'`. | ||
|
|
||
| 4. **Updated `is_clerk_user(user_id)` function**: Properly checks that the user's `clerk_user_id` matches the current session's Clerk ID. | ||
|
|
||
| 5. **Updated all RLS policies** on 8 tables: `users`, `chats`, `messages`, `calendar_notes`, `chat_participants`, `locations`, `system_prompts`, `visualizations`. All policies now correctly use `is_clerk_user()` alongside `auth.uid()`. | ||
|
|
||
| 6. **`users_updated_at` trigger**: Ensures the `updated_at` timestamp is properly maintained on user records. | ||
|
|
||
| 7. **Deployed edge function v13**: The `database-access` edge function now calls `sync_clerk_user()` RPC instead of performing direct table upserts. | ||
|
|
||
| ### QCX (Next.js App) — Codebase Fixes | ||
|
|
||
| 1. **Updated webhook route** (`app/api/clerk/webhook/route.ts`): Now syncs users to **both** the app's database (via Drizzle) and QCX-BACKEND (via Supabase RPC). This ensures both systems have consistent user records with the correct `clerk_user_id`. | ||
|
|
||
| 2. **Updated schema** (`lib/db/schema.ts`): Added `first_name`, `last_name`, `avatar_url`, `created_at`, `updated_at`, `username`, `phone_number`, and `metadata` columns to match the QCX-BACKEND users table. | ||
|
|
||
| 3. **Updated `resolveClerkUserToDbUser`** (`lib/auth/get-current-user.ts`): Now syncs full profile data from Clerk (name, avatar) when resolving users, and properly links existing email-based records to their Clerk ID. | ||
|
|
||
| 4. **New Drizzle migration** (`drizzle/migrations/0003_clerk_auth_sync.sql`): Persists all the QCX-BACKEND SQL changes (functions, triggers, RLS policies) into the repo's tracked migration system. | ||
|
|
||
| ## Verification | ||
|
|
||
| All 6 critical components verified as active in QCX-BACKEND: | ||
| - `sync_clerk_user` function: active | ||
| - `clerk_id` function: active | ||
| - `is_clerk_user` function: active | ||
| - `clerk_user_id` column on `users`: present | ||
| - `on_auth_user_created` trigger: active | ||
| - `users_updated_at` trigger: active | ||
| - Edge function `database-access` v13: deployed and ACTIVE | ||
| - RLS policies: 12 policies across 8 tables, all using `is_clerk_user()` | ||
|
|
||
| ## How It Works After the Fix | ||
|
|
||
| When a user authenticates with Clerk: | ||
|
|
||
| 1. **Clerk** issues a JWT containing the Clerk user ID as the `sub` claim. | ||
| 2. The **Next.js app** uses Clerk middleware to validate the JWT and extract the Clerk ID. | ||
| 3. `resolveClerkUserToDbUser()` resolves the Clerk ID to the Supabase UUID, creating/linking the record if needed. | ||
| 4. The **webhook route** simultaneously syncs the user to QCX-BACKEND via `sync_clerk_user()`. | ||
| 5. When querying chats, RLS policies check `is_clerk_user(user_id)` which now correctly returns `true` because `clerk_user_id` is properly set. | ||
| 6. The user can now see their chat history. | ||
|
|
||
| ## Deployment Notes | ||
|
|
||
| - The Supabase changes are **already live** in QCX-BACKEND (project: `mofqapxwyphzjrqegjeq`). | ||
| - The edge function is deployed as version 13 and is ACTIVE. | ||
| - The Next.js codebase changes are committed and ready for deployment. | ||
| - The Drizzle migration (`0003_clerk_auth_sync.sql`) should be run via `npm run db:migrate` or `bun run db:migrate` (which sets `EXECUTE_MIGRATIONS=true`). | ||
| - The `edge_function/` directory contains the deployed edge function source for reference. | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import { WebhookEvent } from '@clerk/nextjs/server' | |||||||||||||||||||||||||||||||||
| import { db } from '@/lib/db' | ||||||||||||||||||||||||||||||||||
| import { users } from '@/lib/db/schema' | ||||||||||||||||||||||||||||||||||
| import { eq } from 'drizzle-orm' | ||||||||||||||||||||||||||||||||||
| import { createClient } from '@supabase/supabase-js' | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export async function POST(req: Request) { | ||||||||||||||||||||||||||||||||||
| // You can find this in the Clerk Dashboard -> Webhooks -> choose the endpoint | ||||||||||||||||||||||||||||||||||
|
|
@@ -50,50 +51,101 @@ export async function POST(req: Request) { | |||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Do something with the payload | ||||||||||||||||||||||||||||||||||
| // For this guide, you can log the payload to your console | ||||||||||||||||||||||||||||||||||
| const { id } = evt.data | ||||||||||||||||||||||||||||||||||
| const eventType = evt.type | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (eventType === 'user.created' || eventType === 'user.updated') { | ||||||||||||||||||||||||||||||||||
| const { id, email_addresses, first_name, last_name } = evt.data as any | ||||||||||||||||||||||||||||||||||
| const email = email_addresses[0]?.email_address | ||||||||||||||||||||||||||||||||||
| const { id: clerkId, email_addresses, first_name, last_name } = evt.data as any | ||||||||||||||||||||||||||||||||||
| const email = email_addresses?.[0]?.email_address || null | ||||||||||||||||||||||||||||||||||
| const firstName = first_name || null | ||||||||||||||||||||||||||||||||||
| const lastName = last_name || null | ||||||||||||||||||||||||||||||||||
| const avatarUrl = (evt.data as any).image_url || null | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (id) { | ||||||||||||||||||||||||||||||||||
| // Upsert user | ||||||||||||||||||||||||||||||||||
| const [existingUser] = await db.select() | ||||||||||||||||||||||||||||||||||
| if (clerkId) { | ||||||||||||||||||||||||||||||||||
| // ============================================================ | ||||||||||||||||||||||||||||||||||
| // SYNC TO QCX APP DATABASE (via Drizzle) | ||||||||||||||||||||||||||||||||||
| // ============================================================ | ||||||||||||||||||||||||||||||||||
| const [existingUser] = await db.select() | ||||||||||||||||||||||||||||||||||
| .from(users) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.clerkUserId, clerkId)) | ||||||||||||||||||||||||||||||||||
| .limit(1); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (existingUser) { | ||||||||||||||||||||||||||||||||||
| // Update existing user with latest Clerk data | ||||||||||||||||||||||||||||||||||
| await db.update(users) | ||||||||||||||||||||||||||||||||||
| .set({ | ||||||||||||||||||||||||||||||||||
| email, | ||||||||||||||||||||||||||||||||||
| ...(firstName && { firstName }), | ||||||||||||||||||||||||||||||||||
| ...(lastName && { lastName }), | ||||||||||||||||||||||||||||||||||
| ...(avatarUrl && { avatarUrl }), | ||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.clerkUserId, clerkId)); | ||||||||||||||||||||||||||||||||||
|
Comment on lines
+75
to
+82
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win Avoid overwriting
Suggested change await db.update(users)
.set({
- email,
+ ...(email && { email }),
...(firstName && { firstName }),
...(lastName && { lastName }),
...(avatarUrl && { avatarUrl }),
})
.where(eq(users.clerkUserId, clerkId));📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| // Check by email first to link existing Supabase-created user to Clerk | ||||||||||||||||||||||||||||||||||
| const [existingEmailUser] = await db.select() | ||||||||||||||||||||||||||||||||||
| .from(users) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.clerkUserId, id)) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.email, email)) | ||||||||||||||||||||||||||||||||||
| .limit(1); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (existingUser) { | ||||||||||||||||||||||||||||||||||
| if (existingEmailUser) { | ||||||||||||||||||||||||||||||||||
| // Link existing user to this Clerk ID | ||||||||||||||||||||||||||||||||||
| await db.update(users) | ||||||||||||||||||||||||||||||||||
| .set({ email }) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.clerkUserId, id)); | ||||||||||||||||||||||||||||||||||
| .set({ clerkUserId: clerkId }) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.id, existingEmailUser.id)); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| // Check by email first to link | ||||||||||||||||||||||||||||||||||
| const [existingEmailUser] = await db.select() | ||||||||||||||||||||||||||||||||||
| .from(users) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.email, email)) | ||||||||||||||||||||||||||||||||||
| .limit(1); | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (existingEmailUser) { | ||||||||||||||||||||||||||||||||||
| await db.update(users) | ||||||||||||||||||||||||||||||||||
| .set({ clerkUserId: id }) | ||||||||||||||||||||||||||||||||||
| .where(eq(users.id, existingEmailUser.id)); | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| await db.insert(users).values({ | ||||||||||||||||||||||||||||||||||
| clerkUserId: id, | ||||||||||||||||||||||||||||||||||
| email, | ||||||||||||||||||||||||||||||||||
| role: 'viewer' | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| // Create new user record | ||||||||||||||||||||||||||||||||||
| await db.insert(users).values({ | ||||||||||||||||||||||||||||||||||
| clerkUserId: clerkId, | ||||||||||||||||||||||||||||||||||
| email, | ||||||||||||||||||||||||||||||||||
| role: 'viewer', | ||||||||||||||||||||||||||||||||||
| ...(firstName && { firstName }), | ||||||||||||||||||||||||||||||||||
| ...(lastName && { lastName }), | ||||||||||||||||||||||||||||||||||
| ...(avatarUrl && { avatarUrl }), | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // ============================================================ | ||||||||||||||||||||||||||||||||||
| // SYNC TO QCX-BACKEND SUPABASE (via RPC) | ||||||||||||||||||||||||||||||||||
| // This ensures the edge function and RLS policies in QCX-BACKEND | ||||||||||||||||||||||||||||||||||
| // have the correct clerk_user_id for authentication. | ||||||||||||||||||||||||||||||||||
| // ============================================================ | ||||||||||||||||||||||||||||||||||
| const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL | ||||||||||||||||||||||||||||||||||
| const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (supabaseUrl && supabaseServiceKey) { | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| const supabase = createClient(supabaseUrl, supabaseServiceKey) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| // Call the sync_clerk_user RPC function in QCX-BACKEND | ||||||||||||||||||||||||||||||||||
| // This is the canonical way to create/link users across both systems | ||||||||||||||||||||||||||||||||||
| const { data: backendUserId, error: rpcError } = await supabase.rpc('sync_clerk_user', { | ||||||||||||||||||||||||||||||||||
| p_clerk_id: clerkId, | ||||||||||||||||||||||||||||||||||
| p_email: email, | ||||||||||||||||||||||||||||||||||
| p_first_name: firstName, | ||||||||||||||||||||||||||||||||||
| p_last_name: lastName, | ||||||||||||||||||||||||||||||||||
| p_avatar_url: avatarUrl, | ||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (rpcError) { | ||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When this RPC call fails, the handler logs and still returns HTTP 200. Clerk treats 2xx as success and won’t retry delivery, so transient Supabase failures can permanently leave Suggested fix: return non-2xx on backend sync failure (or enqueue a durable retry) so this critical sync path is eventually consistent. |
||||||||||||||||||||||||||||||||||
| console.error(`[Webhook] Failed to sync user ${clerkId} to QCX-BACKEND:`, rpcError) | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| console.log(`[Webhook] Successfully synced user ${clerkId} to QCX-BACKEND (UUID: ${backendUserId})`) | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||
| console.error(`[Webhook] Exception syncing user ${clerkId} to QCX-BACKEND:`, err) | ||||||||||||||||||||||||||||||||||
| // Don't fail the webhook — the app DB sync already succeeded | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||
| console.warn('[Webhook] Supabase credentials not configured, skipping QCX-BACKEND sync') | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| if (eventType === 'user.deleted') { | ||||||||||||||||||||||||||||||||||
| // Optionally handle user deletion | ||||||||||||||||||||||||||||||||||
| // For safety, we might not want to delete user data entirely | ||||||||||||||||||||||||||||||||||
| console.log('[Webhook] User deleted event received:', evt.data) | ||||||||||||||||||||||||||||||||||
|
Comment on lines
145
to
+148
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Avoid logging the full user payload (PII).
🔒️ Proposed fix if (eventType === 'user.deleted') {
// Optionally handle user deletion
// For safety, we might not want to delete user data entirely
- console.log('[Webhook] User deleted event received:', evt.data)
+ console.log('[Webhook] User deleted event received:', evt.data?.id)
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| return new Response('', { status: 200 }) | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Fix the verification count.
The section says “All 6 critical components,” but the list contains 8 items. That makes the verification summary internally inconsistent and can mislead readers about what was actually checked.
📝 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents