From 80e08223ea620f8ab99ca31b88e26c6588c7a860 Mon Sep 17 00:00:00 2001 From: otdoges Date: Tue, 11 Nov 2025 18:54:12 -0600 Subject: [PATCH 1/2] Add cleanup utility for orphaned projects with invalid userId references - Add cleanupOrphanedProjects mutation to identify and remove projects with userId references that don't exist in the users table - Add cleanupOrphanedProjectsAction for HTTP-accessible cleanup - Add cleanup-orphaned-projects.ts admin script for running cleanup from CLI - Add comprehensive documentation in SCHEMA_VALIDATION_FIX.md - Fixes schema validation errors that occur after Clerk to Better Auth migration --- convex/importData.ts | 93 ++++++++++++ explanations/SCHEMA_VALIDATION_FIX.md | 207 ++++++++++++++++++++++++++ scripts/cleanup-orphaned-projects.ts | 59 ++++++++ 3 files changed, 359 insertions(+) create mode 100644 explanations/SCHEMA_VALIDATION_FIX.md create mode 100644 scripts/cleanup-orphaned-projects.ts diff --git a/convex/importData.ts b/convex/importData.ts index 6ebaf0e1..1fd1299a 100644 --- a/convex/importData.ts +++ b/convex/importData.ts @@ -275,6 +275,83 @@ export const clearAllData = internalMutation({ }, }); +/** + * Clean up orphaned projects and related data (projects with invalid userId references) + * This fixes schema validation errors when users table is missing references + */ +export const cleanupOrphanedProjects = internalMutation({ + args: {}, + handler: async (ctx) => { + const allProjects = await ctx.db.query("projects").collect(); + const allUsers = await ctx.db.query("users").collect(); + const validUserIds = new Set(allUsers.map((u) => u._id)); + + let cleanedCount = 0; + const orphanedProjectIds: string[] = []; + + // Find all orphaned projects (userId doesn't reference a valid user) + for (const project of allProjects) { + if (!validUserIds.has(project.userId)) { + orphanedProjectIds.push(project._id); + } + } + + // Delete all data related to orphaned projects in reverse dependency order + for (const projectId of orphanedProjectIds) { + // Delete attachments for messages in this project + const messages = await ctx.db + .query("messages") + .withIndex("by_projectId", (q) => q.eq("projectId", projectId as any)) + .collect(); + + for (const message of messages) { + const attachments = await ctx.db + .query("attachments") + .withIndex("by_messageId", (q) => q.eq("messageId", message._id)) + .collect(); + + for (const attachment of attachments) { + await ctx.db.delete(attachment._id); + } + + // Delete fragments for this message + const fragments = await ctx.db + .query("fragments") + .withIndex("by_messageId", (q) => q.eq("messageId", message._id)) + .collect(); + + for (const fragment of fragments) { + await ctx.db.delete(fragment._id); + } + + // Delete the message itself + await ctx.db.delete(message._id); + } + + // Delete fragment drafts for this project + const drafts = await ctx.db + .query("fragmentDrafts") + .withIndex("by_projectId", (q) => q.eq("projectId", projectId as any)) + .collect(); + + for (const draft of drafts) { + await ctx.db.delete(draft._id); + } + + // Delete the project itself + await ctx.db.delete(projectId as any); + cleanedCount++; + } + + return { + success: true, + message: `Cleaned up ${cleanedCount} orphaned projects and related data`, + cleanedProjectCount: cleanedCount, + orphanedProjectIds, + }; + }, +}); + // Public action wrappers for HTTP client access export const importProjectAction = action({ args: { @@ -390,3 +467,19 @@ export const importUsageAction = action({ return await ctx.runMutation(internal.importData.importUsage, args); }, }); + +/** + * Public action to clean up orphaned projects (admin function) + * Removes all projects with invalid userId references + */ +export const cleanupOrphanedProjectsAction = action({ + args: {}, + handler: async (ctx): Promise<{ + success: boolean; + message: string; + cleanedProjectCount: number; + orphanedProjectIds: string[]; + }> => { + return await ctx.runMutation(internal.importData.cleanupOrphanedProjects); + }, +}); diff --git a/explanations/SCHEMA_VALIDATION_FIX.md b/explanations/SCHEMA_VALIDATION_FIX.md new file mode 100644 index 00000000..fa855b5d --- /dev/null +++ b/explanations/SCHEMA_VALIDATION_FIX.md @@ -0,0 +1,207 @@ +# Schema Validation Error Fix: Orphaned Projects Cleanup + +## Problem + +You encountered this error: +``` +Schema validation failed. +Document with ID "jn7034wkypcnpdyvkn0vd7r8n57tnrhf" in table "projects" does not match the schema: Value does not match validator. +Path: .userId +Value: "user_30xqHm23FRYopIgyfPPYsnMGqAq" +Validator: v.id("users") +``` + +This error occurs when the `projects` table contains documents with `userId` values that don't reference valid user IDs in the `users` table. This typically happens after migrating from Clerk to Better Auth, where old user IDs may not be properly linked to the new user records. + +## Root Cause + +During the Clerk → Better Auth migration, some projects in the database may still reference user IDs from the old authentication system. Convex's schema enforces referential integrity by default, which means: + +1. **Project documents** have a `userId` field defined as `v.id("users")` +2. **Convex validates** that every referenced ID actually exists in the referenced table +3. **Orphaned documents** with invalid references fail validation + +## Solution + +We've created a cleanup utility that: +1. ✅ Identifies all projects with invalid `userId` references +2. ✅ Cascades deletion of all related data (messages, fragments, attachments) +3. ✅ Removes the orphaned project documents +4. ✅ Restores database integrity + +## How to Run the Cleanup + +### Prerequisites + +1. **Start Convex Dev Server** (in a separate terminal) + ```bash + bun run convex:dev + ``` + Keep this running! The cleanup script needs it. + +2. **Set Environment Variables** + After starting `convex:dev`, you'll see output like: + ``` + ✓ Convex dev server is running! + To open the dashboard, run: + > npx convex dashboard --url + ``` + + Copy the URL and add to your `.env`: + ```bash + NEXT_PUBLIC_CONVEX_URL= + ``` + +### Run the Cleanup + +In a new terminal: +```bash +bun scripts/cleanup-orphaned-projects.ts +``` + +You'll see output like: +``` +🚀 Initializing Convex client... + URL: https://your-deployment.convex.cloud + +🧹 Starting cleanup of orphaned projects... + +✅ Cleaned up 1 orphaned projects and related data + Cleaned projects: 1 + +📋 Removed project IDs: + - jn7034wkypcnpdyvkn0vd7r8n57tnrhf + +✅ Cleanup completed successfully! +``` + +## What Gets Deleted + +The cleanup uses a cascade deletion approach to maintain data integrity: + +``` +Orphaned Project +├── Messages +│ ├── Attachments (deleted) +│ ├── Fragments (deleted) +│ └── Message itself (deleted) +├── Fragment Drafts (deleted) +└── Project itself (deleted) +``` + +**Important**: Only projects with invalid `userId` references are removed. All valid projects remain untouched. + +## Verification + +After running the cleanup, you can verify the fix: + +### 1. Check Dashboard +```bash +bun run convex:dev +# Then visit: https://dashboard.convex.dev +``` + +Navigate to the **Data** tab and verify: +- Projects with valid `userId` references remain +- No documents remain with orphaned references + +### 2. Run the App +```bash +# Terminal 1: Convex dev server +bun run convex:dev + +# Terminal 2: Next.js dev server +bun run dev +``` + +The app should now work without schema validation errors. + +### 3. Test Project Creation +1. Sign up or sign in at http://localhost:3000 +2. Create a new project +3. Verify it saves correctly to the database + +## Code Changes + +### Files Modified +- `convex/importData.ts` - Added `cleanupOrphanedProjects` mutation and action + +### New Functions + +**`cleanupOrphanedProjects` (internal mutation)** +- Finds all projects with invalid `userId` references +- Cascades deletion of related data +- Returns count of cleaned projects + +**`cleanupOrphanedProjectsAction` (public action)** +- HTTP-accessible wrapper for the cleanup function +- Used by the cleanup script + +**`scripts/cleanup-orphaned-projects.ts` (admin script)** +- CLI tool to run the cleanup from the command line +- Provides user-friendly output + +## When to Run This + +Run this cleanup when you see the schema validation error. It's safe to run multiple times: + +- ✅ Safe to run multiple times (idempotent) +- ✅ Only removes orphaned documents +- ✅ Won't affect valid user projects +- ✅ Can be run during development or production + +## Troubleshooting + +### Error: "NEXT_PUBLIC_CONVEX_URL is not set" +**Solution**: +1. Run `bun run convex:dev` first +2. Copy the URL from the output +3. Add to `.env`: `NEXT_PUBLIC_CONVEX_URL=` + +### Error: "Cannot connect to Convex" +**Solution**: +1. Make sure `bun run convex:dev` is still running +2. Verify the URL in `.env` matches the dev server output +3. Check internet connection + +### Cleanup shows 0 cleaned projects but error still occurs +**Possible causes**: +1. The orphaned data was already removed +2. The validation error is from a different table (check the error message) +3. New data was inserted after you ran the cleanup + +**Next steps**: Check the Convex dashboard directly to find the problem data. + +## Additional Notes + +### Data Safety +- ✅ Original data is not modified before deletion +- ✅ No data is moved or transformed +- ✅ Only orphaned documents are removed +- ✅ Can be re-run if needed + +### Performance +- For small databases: < 1 second +- For large databases: up to a few seconds depending on orphaned count + +### Future Prevention +To prevent this issue in the future: + +1. **During migration**: Ensure all user references are updated to valid Convex user IDs +2. **In validation**: Test that all `userId` references exist before creating projects +3. **In imports**: Always validate foreign key references before inserting data + +## Related Documentation + +- [BETTER_AUTH_POLAR_SETUP.md](./BETTER_AUTH_POLAR_SETUP.md) - Setup guide for Better Auth +- [DATA_MIGRATION_GUIDE.md](./DATA_MIGRATION_GUIDE.md) - PostgreSQL to Convex migration +- [MIGRATION_CLERK_TO_BETTER_AUTH.md](../MIGRATION_CLERK_TO_BETTER_AUTH.md) - Clerk to Better Auth migration + +## Questions? + +If you encounter issues with the cleanup: + +1. Check the error message carefully - it indicates what went wrong +2. Review the troubleshooting section above +3. Check Convex logs: `bun run convex logs` +4. Verify your Convex deployment status diff --git a/scripts/cleanup-orphaned-projects.ts b/scripts/cleanup-orphaned-projects.ts new file mode 100644 index 00000000..ae2e47b8 --- /dev/null +++ b/scripts/cleanup-orphaned-projects.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +/** + * Admin script to clean up orphaned projects from Convex database + * + * This script removes all projects that have userId references pointing to + * non-existent users in the users table. This fixes schema validation errors + * that occur when the database has stale references. + * + * Usage: + * bun scripts/cleanup-orphaned-projects.ts + * + * Prerequisites: + * 1. Run `bun run convex:dev` in a separate terminal + * 2. Set NEXT_PUBLIC_CONVEX_URL in .env from the convex dev server output + */ + +import { ConvexClient } from "convex/browser"; +import { api } from "../convex/_generated/api"; + +async function cleanupOrphanedProjects() { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL; + + if (!convexUrl) { + console.error("❌ Error: NEXT_PUBLIC_CONVEX_URL is not set"); + console.error(" Please ensure you've run `bun run convex:dev` and set the URL in .env"); + process.exit(1); + } + + console.log("🚀 Initializing Convex client..."); + console.log(` URL: ${convexUrl}`); + + const client = new ConvexClient(convexUrl); + + try { + console.log("\n🧹 Starting cleanup of orphaned projects...\n"); + + const result = await client.action(api.importData.cleanupOrphanedProjectsAction, {}); + + console.log(`✅ ${result.message}`); + console.log(` Cleaned projects: ${result.cleanedProjectCount}`); + + if (result.orphanedProjectIds.length > 0) { + console.log("\n📋 Removed project IDs:"); + for (const id of result.orphanedProjectIds) { + console.log(` - ${id}`); + } + } else { + console.log("\n✨ No orphaned projects found!"); + } + + console.log("\n✅ Cleanup completed successfully!"); + process.exit(0); + } catch (error) { + console.error("❌ Error during cleanup:", error); + process.exit(1); + } +} + +cleanupOrphanedProjects(); From 0b8c72453041688118c3236a1402178837ba2303 Mon Sep 17 00:00:00 2001 From: otdoges Date: Tue, 11 Nov 2025 19:32:24 -0600 Subject: [PATCH 2/2] changes --- convex/importData.ts | 68 +- convex/schema.ts | 8 +- env.example | 2 +- .../BETTER_AUTH_CONVEX_INTEGRATION.md | 667 ++++++++++++++++++ .../BETTER_AUTH_CONVEX_QUICK_REFERENCE.md | 402 +++++++++++ explanations/CORS_FIX_SUMMARY.md | 222 ++++++ explanations/DOMAIN_CONFIGURATION_GUIDE.md | 218 ++++++ next.config.mjs | 17 + scripts/cleanup-orphaned-projects.ts | 15 +- src/app/api/auth/[...all]/route.ts | 80 ++- src/lib/auth.ts | 9 +- 11 files changed, 1676 insertions(+), 32 deletions(-) create mode 100644 explanations/BETTER_AUTH_CONVEX_INTEGRATION.md create mode 100644 explanations/BETTER_AUTH_CONVEX_QUICK_REFERENCE.md create mode 100644 explanations/CORS_FIX_SUMMARY.md create mode 100644 explanations/DOMAIN_CONFIGURATION_GUIDE.md diff --git a/convex/importData.ts b/convex/importData.ts index 1fd1299a..ab73c39b 100644 --- a/convex/importData.ts +++ b/convex/importData.ts @@ -14,7 +14,7 @@ export const importProject = internalMutation({ args: { oldId: v.string(), // Original PostgreSQL UUID name: v.string(), - userId: v.id("users"), // Changed from v.string() to v.id("users") + userId: v.id("users"), // References users table framework: v.union( v.literal("NEXTJS"), v.literal("ANGULAR"), @@ -202,7 +202,7 @@ export const importAttachment = internalMutation({ export const importUsage = internalMutation({ args: { key: v.string(), // Original key like "rlflx:user_XXX" - userId: v.id("users"), // Changed from v.string() to v.id("users") + userId: v.id("users"), // References users table points: v.number(), expire: v.optional(v.string()), // ISO date string }, @@ -282,16 +282,19 @@ export const clearAllData = internalMutation({ export const cleanupOrphanedProjects = internalMutation({ args: {}, handler: async (ctx) => { - const allProjects = await ctx.db.query("projects").collect(); const allUsers = await ctx.db.query("users").collect(); - const validUserIds = new Set(allUsers.map((u) => u._id)); + const validUserIds = new Set(allUsers.map((u) => u._id as string)); - let cleanedCount = 0; + let cleanedProjectsCount = 0; + let cleanedUsageCount = 0; + let cleanedOAuthCount = 0; + let cleanedImportsCount = 0; const orphanedProjectIds: string[] = []; - // Find all orphaned projects (userId doesn't reference a valid user) + // 1. Find all orphaned projects + const allProjects = await ctx.db.query("projects").collect(); for (const project of allProjects) { - if (!validUserIds.has(project.userId)) { + if (!validUserIds.has(project.userId as any)) { orphanedProjectIds.push(project._id); } } @@ -340,13 +343,46 @@ export const cleanupOrphanedProjects = internalMutation({ // Delete the project itself await ctx.db.delete(projectId as any); - cleanedCount++; + cleanedProjectsCount++; + } + + // 2. Clean up orphaned usage records + const allUsage = await ctx.db.query("usage").collect(); + for (const usage of allUsage) { + if (!validUserIds.has(usage.userId as any)) { + await ctx.db.delete(usage._id); + cleanedUsageCount++; + } + } + + // 3. Clean up orphaned oauthConnections + const allOAuth = await ctx.db.query("oauthConnections").collect(); + for (const oauth of allOAuth) { + if (!validUserIds.has(oauth.userId as any)) { + await ctx.db.delete(oauth._id); + cleanedOAuthCount++; + } + } + + // 4. Clean up orphaned imports + const allImports = await ctx.db.query("imports").collect(); + for (const importRecord of allImports) { + if (!validUserIds.has(importRecord.userId as any)) { + await ctx.db.delete(importRecord._id); + cleanedImportsCount++; + } } + const totalCleaned = cleanedProjectsCount + cleanedUsageCount + cleanedOAuthCount + cleanedImportsCount; + return { success: true, - message: `Cleaned up ${cleanedCount} orphaned projects and related data`, - cleanedProjectCount: cleanedCount, + message: `Cleaned up ${totalCleaned} orphaned records (${cleanedProjectsCount} projects, ${cleanedUsageCount} usage, ${cleanedOAuthCount} oauth, ${cleanedImportsCount} imports)`, + cleanedProjectCount: cleanedProjectsCount, + cleanedUsageCount, + cleanedOAuthCount, + cleanedImportsCount, + totalCleaned, orphanedProjectIds, }; }, @@ -357,7 +393,7 @@ export const importProjectAction = action({ args: { oldId: v.string(), name: v.string(), - userId: v.id("users"), // Changed from v.string() to v.id("users") + userId: v.id("users"), // References users table framework: v.union( v.literal("NEXTJS"), v.literal("ANGULAR"), @@ -459,7 +495,7 @@ export const importAttachmentAction = action({ export const importUsageAction = action({ args: { key: v.string(), - userId: v.id("users"), // Changed from v.string() to v.id("users") + userId: v.id("users"), // References users table points: v.number(), expire: v.optional(v.string()), }, @@ -469,8 +505,8 @@ export const importUsageAction = action({ }); /** - * Public action to clean up orphaned projects (admin function) - * Removes all projects with invalid userId references + * Public action to clean up ALL orphaned data (admin function) + * Removes all projects, usage, oauthConnections, imports with invalid userId references */ export const cleanupOrphanedProjectsAction = action({ args: {}, @@ -478,6 +514,10 @@ export const cleanupOrphanedProjectsAction = action({ success: boolean; message: string; cleanedProjectCount: number; + cleanedUsageCount: number; + cleanedOAuthCount: number; + cleanedImportsCount: number; + totalCleaned: number; orphanedProjectIds: string[]; }> => { return await ctx.runMutation(internal.importData.cleanupOrphanedProjects); diff --git a/convex/schema.ts b/convex/schema.ts index 347ae1f3..17c86396 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -110,7 +110,7 @@ export default defineSchema({ // Projects table projects: defineTable({ name: v.string(), - userId: v.id("users"), // Changed to reference users table + userId: v.id("users"), // References users table framework: frameworkEnum, modelPreference: v.optional(v.string()), // User's preferred AI model (e.g., "auto", "anthropic/claude-haiku-4.5", "openai/gpt-4o") createdAt: v.optional(v.number()), // timestamp @@ -175,7 +175,7 @@ export default defineSchema({ // OAuth Connections table - for storing encrypted OAuth tokens oauthConnections: defineTable({ - userId: v.id("users"), // Changed to reference users table + userId: v.id("users"), // References users table provider: oauthProviderEnum, accessToken: v.string(), // Encrypted token refreshToken: v.optional(v.string()), @@ -190,7 +190,7 @@ export default defineSchema({ // Imports table - tracking import history and status imports: defineTable({ - userId: v.id("users"), // Changed to reference users table + userId: v.id("users"), // References users table projectId: v.id("projects"), messageId: v.optional(v.id("messages")), source: importSourceEnum, @@ -209,7 +209,7 @@ export default defineSchema({ // Usage table - rate limiting and credit tracking usage: defineTable({ - userId: v.id("users"), // Changed to reference users table + userId: v.id("users"), // References users table points: v.number(), // Remaining credits expire: v.optional(v.number()), // Expiration timestamp planType: v.optional(v.union(v.literal("free"), v.literal("pro"))), // Track plan type diff --git a/env.example b/env.example index c925a2e0..6f5cabf1 100644 --- a/env.example +++ b/env.example @@ -8,7 +8,7 @@ CONVEX_DEPLOYMENT="" # Better Auth BETTER_AUTH_SECRET="" # Generate with: openssl rand -base64 32 -BETTER_AUTH_URL="http://localhost:3000" # Use production URL outside of local dev +BETTER_AUTH_URL="http://localhost:3000" # IMPORTANT: Must match your deployment domain (e.g., https://zapdev.link) SESSION_COOKIE_PREFIX="zapdev" # Optional Better Auth cookie prefix override SESSION_COOKIE_NAME="" # Optional full cookie name override (defaults to ".session_token") diff --git a/explanations/BETTER_AUTH_CONVEX_INTEGRATION.md b/explanations/BETTER_AUTH_CONVEX_INTEGRATION.md new file mode 100644 index 00000000..a5395317 --- /dev/null +++ b/explanations/BETTER_AUTH_CONVEX_INTEGRATION.md @@ -0,0 +1,667 @@ +# Better Auth + Convex Integration Guide + +This guide explains how Better Auth and Convex are linked together in ZapDev, and what each component does. + +## Overview + +Better Auth is a modern authentication framework that needs a database to store users, sessions, and OAuth accounts. Instead of using a traditional SQL database, we use **Convex** (a real-time database) as the backend. + +The connection is made through a **Custom Database Adapter** that implements Better Auth's `DatabaseAdapter` interface, routing all auth operations to Convex mutations and queries. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend (React/Next.js) │ +│ - authClient.useSession() │ +│ - authClient.signIn() / signUp() / signOut() │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Routes (/api/auth/[...all]/route.ts) │ +│ - POST: Handle sign-up, sign-in, OAuth callbacks │ +│ - GET: OAuth provider redirects │ +│ - Rate limiting enabled │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Better Auth (src/lib/auth.ts) │ +│ - Email/password authentication │ +│ - OAuth providers (Google, GitHub) │ +│ - Session management (7 days default) │ +│ - Database: createConvexAdapter() │ +└────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Convex Adapter (src/lib/auth-adapter-convex.ts) │ +│ - Implements DatabaseAdapter interface │ +│ - Maps Better Auth calls to Convex operations │ +│ - OAuth token refresh logic │ +└────────────────────────┬────────────────────────────────────┘ + │ + ┌────────────────┼────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Users Module │ │ Sessions │ │ Accounts │ +│ (users.ts) │ │ Module │ │ Module │ +│ │ │ (sessions.ts)│ │ (accounts.ts)│ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + └─────────────────┼─────────────────┘ + │ + ▼ + ┌────────────────────────────────┐ + │ Convex Database Tables │ + │ - users │ + │ - sessions │ + │ - accounts (OAuth) │ + │ - emailVerifications │ + └────────────────────────────────┘ +``` + +## Database Tables + +### 1. Users Table (`users`) + +Stores user account information and subscription data. + +```typescript +{ + email: string; // Unique email + emailVerified: boolean; // Email verification status + name?: string; // User's full name + image?: string; // Avatar URL + + // Polar.sh subscription fields + polarCustomerId?: string; // Linked to Polar account + subscriptionId?: string; // Active subscription ID + subscriptionStatus?: string; // "active", "canceled", "past_due", etc. + plan?: "free" | "pro"; // Current plan tier + + createdAt: number; // Timestamp + updatedAt: number; // Timestamp +} + +// Indexes +.index("by_email", ["email"]) +.index("by_polarCustomerId", ["polarCustomerId"]) +``` + +**When records are created**: +- User signs up with email/password +- User signs in with OAuth (Google/GitHub) +- Better Auth automatically creates/updates via adapter + +### 2. Sessions Table (`sessions`) + +Manages user login sessions with expiration and renewal. + +```typescript +{ + userId: Id<"users">; // Reference to user + expiresAt: number; // Session expiration timestamp + token: string; // Session token (httpOnly cookie) + ipAddress?: string; // Client IP for security + userAgent?: string; // Browser info +} + +// Indexes +.index("by_userId", ["userId"]) +.index("by_token", ["token"]) +``` + +**Session lifecycle**: +1. User logs in → `sessions.create()` called +2. Session stored with 7-day expiration +3. Each request updates `updatedAt` +4. If `updatedAt + updateAge < now`, session is refreshed automatically +5. Expired sessions returned as `null` by `getByToken()` +6. User logs out → `sessions.deleteByToken()` called + +### 3. Accounts Table (`accounts`) + +Stores OAuth provider connections (Google, GitHub, etc.). + +```typescript +{ + userId: Id<"users">; // User this account belongs to + provider: string; // "google" or "github" + providerAccountId: string; // Provider's user ID + accessToken?: string; // OAuth access token + refreshToken?: string; // OAuth refresh token + expiresAt?: number; // Token expiration + tokenType?: string; // "Bearer", etc. + scope?: string; // Permission scope + idToken?: string; // OpenID Connect ID token +} + +// Indexes +.index("by_provider_accountId", ["provider", "providerAccountId"]) +.index("by_userId", ["userId"]) +``` + +**OAuth flow**: +1. User clicks "Sign in with Google" +2. Redirected to Google consent screen +3. Google redirects back with authorization code +4. Better Auth exchanges code for tokens +5. Adapter stores in `accounts` table +6. On next request, tokens are checked for expiration +7. If expired, automatically refreshed using `refreshToken` + +### 4. Email Verifications Table (`emailVerifications`) + +Optional: Stores email verification tokens. + +```typescript +{ + userId: Id<"users">; + email: string; + token: string; // Unique verification token + expiresAt: number; // Token expiration + verified: boolean; // Verification status + createdAt: number; +} + +// Indexes +.index("by_token", ["token"]) +.index("by_userId", ["userId"]) +.index("by_email", ["email"]) +``` + +Currently **email verification is disabled** in `src/lib/auth.ts`: +```typescript +emailAndPassword: { + enabled: true, + requireEmailVerification: false, // ← Set to true to enable +} +``` + +## Convex Modules + +### users.ts - User Account Management + +**Key functions**: + +| Function | Purpose | When Used | +|----------|---------|-----------| +| `createOrUpdate(email, name, image, emailVerified)` | Create new user or update existing | Sign-up, OAuth login | +| `getByEmail(email)` | Find user by email | Login lookup | +| `getById(userId)` | Get user by ID | Profile fetch | +| `update(userId, updates)` | Update user profile | Change name/avatar | +| `deleteUser(userId)` | Delete user and cascade | Account deletion | +| `linkPolarCustomer(userId, polarCustomerId)` | Link Polar subscription | Subscription created | +| `unlinkPolarCustomer(userId, expectedPolarCustomerId)` | Unlink Polar | Subscription canceled | +| `updateSubscription(polarCustomerId, subscriptionId, status, plan)` | Update subscription | Polar webhook | + +### sessions.ts - Session Lifecycle + +**Key functions**: + +| Function | Purpose | When Used | +|----------|---------|-----------| +| `create(userId, expiresAt, token, ipAddress, userAgent)` | Create session | Successful login | +| `getByToken(token)` | Get session by token | Auth middleware | +| `getByUserId(userId)` | Get all active sessions | Admin, device list | +| `updateByToken(token, expiresAt)` | Refresh session | Session renewal | +| `deleteByToken(token)` | Delete session | Logout | +| `deleteByUserId(userId)` | Delete all sessions | Account deletion | +| `cleanupExpired()` | Remove expired sessions | Scheduled job | + +### accounts.ts - OAuth Integration + +**Key functions**: + +| Function | Purpose | When Used | +|----------|---------|-----------| +| `create(userId, provider, providerAccountId, tokens)` | Store OAuth account | OAuth sign-up/login | +| `getByProvider(provider, providerAccountId)` | Retrieve OAuth account | Token refresh check | +| `getByUserId(userId)` | Get all OAuth accounts | Connected apps list | +| `update(provider, providerAccountId, tokens)` | Update tokens | Token refresh | +| `deleteOAuth(provider, providerAccountId)` | Disconnect provider | Revoke OAuth | +| `deleteByUserId(userId)` | Delete all accounts | Account deletion | + +## Better Auth Configuration + +### src/lib/auth.ts + +Main Better Auth instance: + +```typescript +export const auth = betterAuth({ + // Base URL for auth endpoints + baseURL: "http://localhost:3000", // dev + // or "https://zapdev-mu.vercel.app" // prod + + // Use Convex as database + database: createConvexAdapter(), + + // Email & password authentication + emailAndPassword: { + enabled: true, + requireEmailVerification: false, // Change to true to enable + }, + + // Social providers (configured from env vars) + socialProviders: { + google: { clientId, clientSecret }, + github: { clientId, clientSecret } + }, + + // Session configuration + session: { + expiresIn: 7 * 24 * 60 * 60, // 7 days + updateAge: 24 * 60 * 60, // Refresh after 1 day + cookieCache: { + enabled: true, + maxAge: 60 * 60 // Cache for 1 hour + } + }, + + // Advanced options + advanced: { + cookiePrefix: "zapdev.", + disableCSRFProtection: false, + }, + + plugins: [nextCookies()] // Next.js cookie plugin +}); +``` + +**Key settings explained**: + +| Setting | Default | Purpose | +|---------|---------|---------| +| `expiresIn` | 7 days | Session validity period | +| `updateAge` | 1 day | Refresh threshold (if last update > 1 day, refresh) | +| `cookieCache.enabled` | true | Cache session in memory for faster lookups | +| `cookieCache.maxAge` | 1 hour | Invalidate cache after 1 hour | +| `cookiePrefix` | "zapdev." | Cookie name prefix (e.g., "zapdev.session_token") | +| `requireEmailVerification` | false | Require email verification before login | + +## Database Adapter + +### src/lib/auth-adapter-convex.ts + +This file is the **bridge** between Better Auth and Convex. It implements the `DatabaseAdapter` interface with these methods: + +#### User Operations +```typescript +async createUser(user): Promise +async getUser(id): Promise +async getUserByEmail(email): Promise +async updateUser(id, updates): Promise +async deleteUser(id): Promise +``` + +Maps to Convex mutations: +- `api.users.createOrUpdate` +- `api.users.getById` +- `api.users.getByEmail` +- `api.users.update` +- `api.users.deleteUser` + +#### Session Operations +```typescript +async createSession(session): Promise +async getSession(token): Promise +async updateSession(token, updates): Promise +async deleteSession(token): Promise +``` + +Maps to Convex mutations: +- `api.sessions.create` +- `api.sessions.getByToken` +- `api.sessions.updateByToken` +- `api.sessions.deleteByToken` + +#### OAuth Operations +```typescript +async createAccount(account): Promise +async getAccount(provider, providerAccountId): Promise +async updateAccount(provider, providerAccountId, updates): Promise +async deleteAccount(provider, providerAccountId): Promise +``` + +Maps to Convex mutations: +- `api.accounts.create` +- `api.accounts.getByProvider` +- `api.accounts.update` +- `api.accounts.deleteOAuth` + +**Special feature**: The `getAccount()` method automatically detects expired OAuth tokens and refreshes them using the `refreshToken`: + +```typescript +// In getAccount(): +if (isOAuthTokenExpired(expiresAt) && refreshToken) { + const refreshResult = await refreshOAuthTokenForProvider( + provider, + refreshToken, + clientId, + clientSecret + ); + // Update database with new token + await this.updateAccount(provider, providerAccountId, { + accessToken: refreshResult.accessToken, + expiresAt: Date.now() + refreshResult.expiresIn * 1000 + }); +} +``` + +## API Routes + +### src/app/api/auth/[...all]/route.ts + +Handles all authentication HTTP requests: + +```typescript +export async function POST(request: Request) { + // Rate limiting check + const rateLimitResult = await checkRateLimit(request); + if (!rateLimitResult.success) { + return rateLimitResult.response; + } + + // Route to Better Auth handler + const handlers = toNextJsHandler(auth); + return handlers.POST(request); +} + +export async function GET(request: Request) { + // OAuth callbacks don't need rate limiting + const handlers = toNextJsHandler(auth); + return handlers.GET(request); +} +``` + +**Endpoints created automatically by Better Auth**: + +| Endpoint | Method | Purpose | +|----------|--------|---------| +| `/api/auth/sign-up` | POST | Register with email/password | +| `/api/auth/sign-in` | POST | Login with email/password | +| `/api/auth/sign-out` | POST | Logout (invalidate session) | +| `/api/auth/session` | GET | Get current session | +| `/api/auth/signin/google` | GET | Start Google OAuth | +| `/api/auth/callback/google` | GET | Handle Google redirect | +| `/api/auth/signin/github` | GET | Start GitHub OAuth | +| `/api/auth/callback/github` | GET | Handle GitHub redirect | + +**Rate limiting** is applied to sign-up/login endpoints to prevent brute force attacks. + +## Client Integration + +### src/lib/auth-client.ts + +Frontend auth client: + +```typescript +export const authClient = createAuthClient({ + baseURL: "http://localhost:3000" +}); + +export const { + signIn, + signUp, + signOut, + useSession, +} = authClient; +``` + +**Usage in React components**: + +```typescript +import { useSession, signIn, signOut } from "@/lib/auth-client"; + +function Profile() { + const { data: session } = useSession(); + + if (!session) { + return
Not logged in
; + } + + return ( +
+

Welcome, {session.user.name}!

+ +
+ ); +} +``` + +## Data Flow Examples + +### 1. Email/Password Sign-Up + +``` +User clicks "Sign Up" + ↓ +Form submits email & password + ↓ +POST /api/auth/sign-up + ↓ +Better Auth validates + ↓ +Convex Adapter: createUser() + ↓ +Convex Mutation: users.createOrUpdate() + ↓ +Insert into users table + ↓ +Convex Adapter: createSession() + ↓ +Convex Mutation: sessions.create() + ↓ +Insert into sessions table + ↓ +Response: { token, user } + ↓ +Client stores in httpOnly cookie + ↓ +React re-renders with useSession() +``` + +### 2. OAuth Sign-In (Google) + +``` +User clicks "Sign in with Google" + ↓ +GET /api/auth/signin/google + ↓ +Better Auth: redirect to Google consent screen + ↓ +User authorizes + ↓ +Google redirects to /api/auth/callback/google?code=... + ↓ +Better Auth exchanges code for access token + ↓ +Convex Adapter: getUserByEmail() + ↓ +If user exists: + ├─ Convex Adapter: getAccount() + ├─ Check if OAuth token is expired + ├─ If expired, refresh automatically + ├─ Convex Adapter: getSession() (existing session) + └─ Return existing session + ↓ +If user not found: + ├─ Convex Adapter: createUser() + ├─ Convex Adapter: createAccount() (store OAuth tokens) + ├─ Convex Adapter: createSession() + └─ Return new session + ↓ +Client receives session token in httpOnly cookie +``` + +### 3. Session Validation on Page Load + +``` +User loads app + ↓ +Browser sends request with session cookie + ↓ +Frontend calls useSession() + ↓ +Better Auth reads cookie + ↓ +Convex Adapter: getSession(token) + ↓ +Query sessions table + ↓ +If expiresAt < now: return null (expired) + ↓ +If expiresAt > now && (updatedAt + updateAge < now): + ├─ Convex Adapter: updateSession() + ├─ Set new expiresAt + └─ Return updated session + ↓ +Client receives session with user data +``` + +## Environment Variables + +Required for Better Auth + Convex to work: + +```bash +# Application URL +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Better Auth +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 + +# Convex +NEXT_PUBLIC_CONVEX_URL= +CONVEX_DEPLOYMENT= + +# OAuth Providers (optional) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Polar.sh (billing) +POLAR_ACCESS_TOKEN= +POLAR_ORGANIZATION_ID= +NEXT_PUBLIC_POLAR_PRODUCT_ID_PRO= +POLAR_WEBHOOK_SECRET= +``` + +See `.env.example` and `explanations/BETTER_AUTH_POLAR_SETUP.md` for complete setup. + +## Advanced: Email Verification + +To enable email verification: + +1. **Update Better Auth config** (`src/lib/auth.ts`): +```typescript +emailAndPassword: { + enabled: true, + requireEmailVerification: true // ← Change this +} +``` + +2. **Setup email provider** (nodemailer, SendGrid, Resend, etc.): +```typescript +import { sendEmailVerificationEmail } from "@/lib/email"; + +// In auth config +emailAndPassword: { + enabled: true, + requireEmailVerification: true, + sendVerificationEmail: async (email, token) => { + await sendEmailVerificationEmail(email, token); + } +} +``` + +3. **Verify email endpoint**: +```typescript +// POST /api/auth/verify-email?token=... +// Better Auth handles this automatically +``` + +The `convex/emailVerifications.ts` module provides: +- `createVerification()` - Generate token +- `getByToken()` - Retrieve verification +- `markVerified()` - Mark email as verified +- `deleteExpired()` - Cleanup old tokens + +## Troubleshooting + +### Session not persisting across page reload +**Cause**: Session token not in httpOnly cookie +**Fix**: Check `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` match exactly + +### OAuth token refresh not working +**Cause**: OAuth credentials missing or incorrect +**Fix**: Verify `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, etc. in environment + +### "User not found" errors +**Cause**: Convex adapter failing to create user +**Fix**: Check network tab → `/api/auth/sign-up` response for error details + +### Sessions expiring too quickly +**Cause**: `expiresIn` set to low value +**Fix**: Adjust in `src/lib/auth.ts` → `session.expiresIn` + +### Rate limiting blocking legitimate requests +**Cause**: Too many auth attempts from same IP +**Fix**: Whitelist IP or adjust `checkRateLimit()` threshold in rate-limit.ts + +## Testing the Integration + +### Manual test: Email/Password Sign-Up +```bash +# 1. Start dev server +bun run dev +bun run convex:dev # in another terminal + +# 2. Open http://localhost:3000 +# 3. Click "Sign Up" +# 4. Enter email & password +# 5. Check Convex dashboard: +# - users table should have new entry +# - sessions table should have new session +# 6. Refresh page - session should persist +``` + +### Manual test: Google OAuth +```bash +# 1. Configure GOOGLE_CLIENT_ID & GOOGLE_CLIENT_SECRET in .env.local +# 2. Start dev server +# 3. Click "Sign in with Google" +# 4. Authorize +# 5. Check Convex dashboard: +# - users table has new user +# - accounts table has Google connection +# 6. Sign out and sign in again - should use existing session +``` + +### Manual test: Session Refresh +```bash +# 1. Sign in +# 2. Keep browser open for > 1 day (or manually set expiresAt to 1 day ago) +# 3. Make a request (refresh page) +# 4. Check Convex: sessions table expiresAt should update to future date +``` + +## Security Considerations + +1. **Session tokens**: Stored in httpOnly cookies (secure by default) +2. **CSRF protection**: Enabled by default in Better Auth +3. **Password hashing**: Better Auth uses bcrypt automatically +4. **OAuth tokens**: Never exposed in client-side code +5. **Rate limiting**: POST endpoints protected against brute force +6. **Email verification**: Optional but recommended for production + +## References + +- [Better Auth Documentation](https://www.better-auth.com) +- [Convex Documentation](https://docs.convex.dev) +- [Next.js Authentication Best Practices](https://nextjs.org/docs/authentication) diff --git a/explanations/BETTER_AUTH_CONVEX_QUICK_REFERENCE.md b/explanations/BETTER_AUTH_CONVEX_QUICK_REFERENCE.md new file mode 100644 index 00000000..5e17e9a4 --- /dev/null +++ b/explanations/BETTER_AUTH_CONVEX_QUICK_REFERENCE.md @@ -0,0 +1,402 @@ +# Better Auth + Convex: Quick Reference + +A cheat sheet for working with authentication in ZapDev. + +## File Map + +| File | Purpose | +|------|---------| +| `src/lib/auth.ts` | Better Auth instance config | +| `src/lib/auth-client.ts` | Frontend auth hooks | +| `src/lib/auth-adapter-convex.ts` | Bridge between Better Auth & Convex | +| `src/app/api/auth/[...all]/route.ts` | API endpoint handler | +| `convex/schema.ts` | Database tables | +| `convex/users.ts` | User CRUD functions | +| `convex/sessions.ts` | Session management | +| `convex/accounts.ts` | OAuth connections | +| `convex/emailVerifications.ts` | Email verification (optional) | + +## Frontend Usage + +### Get Current Session +```typescript +import { useSession } from "@/lib/auth-client"; + +function Component() { + const { data: session, isPending } = useSession(); + + if (isPending) return
Loading...
; + if (!session) return
Not logged in
; + + return
Welcome {session.user.name}!
; +} +``` + +### Sign In +```typescript +import { signIn } from "@/lib/auth-client"; + +async function LoginForm() { + const { data, error } = await signIn.email({ + email: "user@example.com", + password: "password123", + onSuccess: () => { + // Redirect to dashboard + window.location.href = "/dashboard"; + }, + }); + + if (error) console.error(error.message); +} +``` + +### Sign Up +```typescript +import { signUp } from "@/lib/auth-client"; + +async function RegisterForm() { + const { data, error } = await signUp.email({ + email: "newuser@example.com", + password: "securepassword", + name: "John Doe", + onSuccess: () => { + window.location.href = "/dashboard"; + }, + }); + + if (error) console.error(error.message); +} +``` + +### Sign Out +```typescript +import { signOut } from "@/lib/auth-client"; + +async function LogoutButton() { + const { error } = await signOut(); + if (error) console.error(error); +} +``` + +### OAuth Sign-In +```typescript +import { signIn } from "@/lib/auth-client"; + +async function GoogleLoginButton() { + // Automatically redirects to Google + await signIn.social({ + provider: "google", + }); +} + +async function GitHubLoginButton() { + // Automatically redirects to GitHub + await signIn.social({ + provider: "github", + }); +} +``` + +## Backend Usage + +### Get Current User (Server-Side) +```typescript +import { auth } from "@/lib/auth"; + +export async function GET(request: Request) { + const session = await auth.api.getSession({ headers: request.headers }); + + if (!session) { + return new Response("Unauthorized", { status: 401 }); + } + + return Response.json({ user: session.user }); +} +``` + +### Get User by Email (Convex) +```typescript +import { fetchQuery } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; + +const user = await fetchQuery(api.users.getByEmail, { + email: "user@example.com" +}); +``` + +### Check if User Has OAuth Account +```typescript +const { data: session } = useSession(); +const accounts = await fetchQuery(api.accounts.getByUserId, { + userId: session.user.id +}); + +const hasGoogle = accounts.some(a => a.provider === "google"); +``` + +### Get Session by Token +```typescript +const session = await fetchQuery(api.sessions.getByToken, { + token: "session_token_here" +}); + +if (session && session.expiresAt < Date.now()) { + console.log("Session expired"); +} +``` + +## Convex Patterns + +### List All Users (Dangerous - For Admin Only!) +```typescript +import { query } from "./_generated/server"; + +export const getAllUsers = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("users").collect(); + } +}); +``` + +### Check if Email is Taken +```typescript +import { query } from "./_generated/server"; + +export const isEmailTaken = query({ + args: { email: v.string() }, + handler: async (ctx, args) => { + const user = await ctx.db + .query("users") + .withIndex("by_email", q => q.eq("email", args.email)) + .first(); + + return !!user; + } +}); +``` + +### Update User Profile +```typescript +import { fetchMutation } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; + +const userId = session.user.id; +await fetchMutation(api.users.update, { + userId, + name: "New Name", + image: "https://example.com/avatar.jpg" +}); +``` + +### Disconnect OAuth Provider +```typescript +import { fetchMutation } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; + +await fetchMutation(api.accounts.deleteOAuth, { + provider: "google", + providerAccountId: "1234567890" +}); +``` + +### Delete User Account +```typescript +import { fetchMutation } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; + +await fetchMutation(api.users.deleteUser, { + userId: session.user.id +}); +``` + +## Common Queries + +### Get All Sessions for Current User +```typescript +import { fetchQuery } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; + +const { data: session } = useSession(); +const sessions = await fetchQuery(api.sessions.getByUserId, { + userId: session.user.id +}); +``` + +### Get All Connected OAuth Accounts +```typescript +import { fetchQuery } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; + +const { data: session } = useSession(); +const accounts = await fetchQuery(api.accounts.getByUserId, { + userId: session.user.id +}); + +// Example: ["google", "github"] +const providers = accounts.map(a => a.provider); +``` + +### Check Subscription Status +```typescript +import { fetchQuery } from "convex/nextjs"; +import { api } from "@/convex/_generated/api"; + +const { data: session } = useSession(); +const subscriptionStatus = await fetchQuery(api.users.getSubscriptionStatus, { + userId: session.user.id +}); + +console.log(subscriptionStatus.plan); // "free" | "pro" +``` + +## Configuration + +### Session Expiration +In `src/lib/auth.ts`: +```typescript +session: { + expiresIn: 7 * 24 * 60 * 60, // 7 days (change this) + updateAge: 24 * 60 * 60, // Refresh after 1 day +} +``` + +### Disable OAuth Provider +In `src/lib/auth.ts`: +```typescript +socialProviders: { + google: { + enabled: false // ← Disable + } +} +``` + +### Enable Email Verification +In `src/lib/auth.ts`: +```typescript +emailAndPassword: { + enabled: true, + requireEmailVerification: true // ← Change to true +} +``` + +### Change Cookie Prefix +In `src/lib/auth.ts`: +```typescript +advanced: { + cookiePrefix: "myapp." // Cookie names: myapp.session_token, etc. +} +``` + +## Environment Variables + +Create `.env.local`: +```bash +# Required +NEXT_PUBLIC_CONVEX_URL=https://xyz.convex.cloud +NEXT_PUBLIC_APP_URL=http://localhost:3000 +BETTER_AUTH_SECRET= + +# OAuth (Optional) +GOOGLE_CLIENT_ID=... +GOOGLE_CLIENT_SECRET=... +GITHUB_CLIENT_ID=... +GITHUB_CLIENT_SECRET=... + +# Billing +POLAR_ACCESS_TOKEN=... +POLAR_WEBHOOK_SECRET=... +``` + +## Debugging + +### Check Session in Browser Console +```javascript +// Get session token from cookie +document.cookie.split(';').find(c => c.includes('session_token')) +``` + +### View Convex Data +1. Open [Convex Dashboard](https://dashboard.convex.dev) +2. Select project +3. View `users`, `sessions`, `accounts` tables + +### Check API Responses +```typescript +// In browser console while testing +await fetch("/api/auth/session").then(r => r.json()).then(console.log) +``` + +### View OAuth Token Expiration +```typescript +const { data: session } = useSession(); +const accounts = await fetchQuery(api.accounts.getByUserId, { + userId: session.user.id +}); + +accounts.forEach(acc => { + const isExpired = acc.expiresAt < Date.now(); + console.log(`${acc.provider}: ${isExpired ? "EXPIRED" : "VALID"}`); +}); +``` + +## Error Handling + +### Graceful Fallback +```typescript +const { data: session, error } = useSession(); + +if (error) { + console.error("Failed to fetch session:", error); + return
Unable to load user session
; +} + +if (!session) { + return
Please log in
; +} +``` + +### Check OAuth Token Validity +```typescript +import { isOAuthTokenExpired } from "@/lib/oauth-token-refresh"; + +const account = // ... fetch from Convex +if (isOAuthTokenExpired(account.expiresAt)) { + // Token is expired - attempt refresh or prompt user to re-authenticate +} +``` + +## Testing Checklist + +- [ ] Email/password sign-up works +- [ ] Email/password sign-in works +- [ ] Session persists on page reload +- [ ] Logout clears session +- [ ] Google OAuth sign-in works +- [ ] GitHub OAuth sign-in works +- [ ] OAuth tokens refresh automatically +- [ ] Account deletion removes all related data +- [ ] Rate limiting blocks brute force attempts +- [ ] User profile can be updated +- [ ] OAuth providers can be disconnected + +## Performance Tips + +1. **Cache session in React Query**: Already done via `useSession()` +2. **Use indexes**: Queries already use `.withIndex()` for common lookups +3. **Batch operations**: Use `Promise.all()` for parallel Convex calls +4. **Avoid N+1 queries**: Load related data in single query +5. **Clean up expired sessions**: Run `sessions.cleanupExpired()` periodically + +## Migration from Other Auth Systems + +If migrating from Clerk, Auth0, or Supabase: + +1. **Export existing users** to CSV/JSON +2. **Hash passwords** with bcrypt (if needed) +3. **Create migration script** in `convex/` +4. **Map user IDs** to Convex IDs (string format: "hashXXX") +5. **Update all references** in database +6. **Test thoroughly** before production + +See `explanations/MIGRATION_CONVEX_FROM_POSTGRESQL.md` for example. diff --git a/explanations/CORS_FIX_SUMMARY.md b/explanations/CORS_FIX_SUMMARY.md new file mode 100644 index 00000000..b41721c2 --- /dev/null +++ b/explanations/CORS_FIX_SUMMARY.md @@ -0,0 +1,222 @@ +# CORS Fix Summary + +## Problem +The application at `zapdev.link` was experiencing CORS errors when trying to make API requests to the authentication endpoints: + +``` +Access to fetch at 'https://zapdev-mu.vercel.app/api/auth/get-session' +from origin 'https://www.zapdev.link' has been blocked by CORS policy +``` + +**Root Cause**: The server-side auth configuration had a hardcoded URL pointing to `zapdev-mu.vercel.app` instead of using the actual deployment domain. + +## Solution Implemented + +### 1. Fixed Server-Side Auth Configuration +**File**: `src/lib/auth.ts` + +**Before**: +```typescript +const getBaseURL = (): string => { + if (process.env.NODE_ENV === "development") { + return "http://localhost:3000"; + } + return "https://zapdev-mu.vercel.app"; // ❌ Hardcoded +}; +``` + +**After**: +```typescript +const getBaseURL = (): string => { + // Use environment variable first (production/staging) + if (process.env.BETTER_AUTH_URL) { + return process.env.BETTER_AUTH_URL; + } + // Development fallback + if (process.env.NODE_ENV === "development") { + return "http://localhost:3000"; + } + // Last resort: use NEXT_PUBLIC_APP_URL + return process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; +}; +``` + +### 2. Added CORS Headers to Auth API Routes +**File**: `src/app/api/auth/[...all]/route.ts` + +Added: +- `getAllowedOrigin()` function to validate and allow requests from the app's domain +- `addCorsHeaders()` function to add proper CORS headers to all responses +- `OPTIONS` handler for CORS preflight requests +- CORS header support for both `GET` and `POST` handlers + +Features: +- ✅ Supports both `www.zapdev.link` and `zapdev.link` +- ✅ Allows credentials (cookies) to be sent with requests +- ✅ Handles preflight OPTIONS requests +- ✅ Validates origin against environment variables +- ✅ Development-friendly (allows localhost in dev mode) + +### 3. Updated Next.js Global Headers +**File**: `next.config.mjs` + +Added CORS headers specifically for auth API routes: +```javascript +{ + source: '/api/auth/:path*', + headers: [ + { + key: 'Access-Control-Allow-Credentials', + value: 'true' + }, + { + key: 'Access-Control-Allow-Methods', + value: 'GET, POST, PUT, DELETE, OPTIONS' + }, + { + key: 'Access-Control-Allow-Headers', + value: 'Content-Type, Authorization, Cookie' + }, + ] +} +``` + +### 4. Updated Documentation +**Files Updated**: +- `env.example` - Added important note about `BETTER_AUTH_URL` +- `explanations/DOMAIN_CONFIGURATION_GUIDE.md` - Complete setup guide (NEW) +- `explanations/CORS_FIX_SUMMARY.md` - This file (NEW) + +## What You Need to Do + +### Step 1: Set Environment Variables in Vercel + +Go to your Vercel project settings and add/update: + +```bash +BETTER_AUTH_URL=https://zapdev.link +NEXT_PUBLIC_APP_URL=https://zapdev.link +``` + +**Important**: +- Use your actual domain (not `zapdev-mu.vercel.app`) +- Include `https://` protocol +- No trailing slashes +- Both variables should match + +### Step 2: Redeploy + +After setting the environment variables, trigger a new deployment: + +```bash +git add . +git commit -m "Fix CORS issues with dynamic domain configuration" +git push +``` + +Or trigger a redeploy in Vercel dashboard. + +### Step 3: Update OAuth Providers (if using) + +If you're using Google or GitHub OAuth, update their redirect URIs: + +**Google OAuth**: +- Authorized redirect URIs: `https://zapdev.link/api/auth/callback/google` +- Authorized JavaScript origins: `https://zapdev.link` + +**GitHub OAuth**: +- Authorization callback URL: `https://zapdev.link/api/auth/callback/github` +- Homepage URL: `https://zapdev.link` + +### Step 4: Update Polar.sh Webhook (if using) + +Update your Polar.sh webhook URL to: +``` +https://zapdev.link/api/polar/webhooks +``` + +### Step 5: Test + +1. Clear browser cache and cookies +2. Visit `https://zapdev.link` +3. Try signing in +4. Check browser console - no CORS errors should appear +5. All API requests should go to `zapdev.link`, not `zapdev-mu.vercel.app` + +## How It Works + +### Development (localhost) +``` +Client (localhost:3000) → Auth API (localhost:3000) +✅ Same origin, no CORS needed +``` + +### Production (before fix) +``` +Client (zapdev.link) → Auth API (zapdev-mu.vercel.app) +❌ Different origins, CORS error +``` + +### Production (after fix) +``` +Client (zapdev.link) → Auth API (zapdev.link) +✅ Same origin, CORS headers present +``` + +## Technical Details + +### CORS Headers Added +- `Access-Control-Allow-Origin`: Dynamically set to match request origin +- `Access-Control-Allow-Credentials`: `true` (allows cookies) +- `Access-Control-Allow-Methods`: `GET, POST, PUT, DELETE, OPTIONS` +- `Access-Control-Allow-Headers`: `Content-Type, Authorization, Cookie` +- `Access-Control-Max-Age`: `86400` (24 hours cache for preflight) + +### Security Features +- Origin validation against environment variables +- Support for both www and non-www subdomains +- Development mode allows localhost +- Credentials (cookies) only allowed for validated origins + +## Troubleshooting + +If you still see CORS errors after deploying: + +1. **Check environment variables are set in Vercel** + - Go to Settings → Environment Variables + - Verify `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` are set correctly + +2. **Hard refresh your browser** + - Chrome/Firefox: `Ctrl+Shift+R` (Windows) or `Cmd+Shift+R` (Mac) + - Or try incognito/private mode + +3. **Verify deployment used new environment variables** + - Redeploy if environment variables were added after last deployment + - Check deployment logs for any errors + +4. **Check for www vs non-www mismatch** + - Ensure you're accessing the same domain set in environment variables + - Consider setting up a redirect from one to the other in Vercel + +5. **Clear all cookies for your domain** + - Old session cookies might cause issues + - Sign out completely and sign in again + +## Files Changed + +- ✅ `src/lib/auth.ts` - Dynamic base URL configuration +- ✅ `src/app/api/auth/[...all]/route.ts` - CORS headers and OPTIONS handler +- ✅ `next.config.mjs` - Global CORS headers for auth routes +- ✅ `env.example` - Updated documentation +- ✅ `explanations/DOMAIN_CONFIGURATION_GUIDE.md` - Complete setup guide +- ✅ `explanations/CORS_FIX_SUMMARY.md` - This summary + +## Next Steps + +1. Deploy the changes to production +2. Set the required environment variables +3. Test authentication flows +4. Update OAuth provider settings +5. Monitor for any remaining CORS issues + +For detailed setup instructions, see: `explanations/DOMAIN_CONFIGURATION_GUIDE.md` diff --git a/explanations/DOMAIN_CONFIGURATION_GUIDE.md b/explanations/DOMAIN_CONFIGURATION_GUIDE.md new file mode 100644 index 00000000..f99a7439 --- /dev/null +++ b/explanations/DOMAIN_CONFIGURATION_GUIDE.md @@ -0,0 +1,218 @@ +# Domain Configuration Guide + +This guide will help you properly configure ZapDev to work with your custom domain and fix CORS issues. + +## The Problem + +CORS errors occur when the application's frontend and backend use different domains. For example: +- Frontend served from: `https://zapdev.link` +- Backend API calls going to: `https://zapdev-mu.vercel.app` + +This causes browser security to block the requests with CORS policy errors. + +## The Solution + +### 1. Configure Environment Variables + +Set these environment variables in your production deployment (Vercel, etc.): + +```bash +# Your actual domain (must match what users see in their browser) +BETTER_AUTH_URL=https://zapdev.link +NEXT_PUBLIC_APP_URL=https://zapdev.link +``` + +**Important Notes:** +- Use `https://` for production (not `http://`) +- Don't include trailing slashes +- Both variables should have the same value +- If using `www.zapdev.link`, set it to that instead + +### 2. Vercel Configuration Steps + +If deploying to Vercel: + +1. Go to your project settings: https://vercel.com/[your-username]/[project-name]/settings/environment-variables + +2. Add/Update these variables: + - `BETTER_AUTH_URL` → `https://zapdev.link` + - `NEXT_PUBLIC_APP_URL` → `https://zapdev.link` + +3. **Important**: Redeploy after changing environment variables + ```bash + # Trigger a new deployment + git commit --allow-empty -m "Trigger redeploy for env vars" + git push + ``` + +### 3. Domain Setup + +#### Option A: Use Root Domain Only (Recommended) +- Primary domain: `zapdev.link` +- Redirect `www.zapdev.link` → `zapdev.link` + +In Vercel: +1. Go to Settings → Domains +2. Add `zapdev.link` as primary +3. Add `www.zapdev.link` and set it to redirect to `zapdev.link` + +Set environment variables: +```bash +BETTER_AUTH_URL=https://zapdev.link +NEXT_PUBLIC_APP_URL=https://zapdev.link +``` + +#### Option B: Use WWW Subdomain +- Primary domain: `www.zapdev.link` +- Redirect `zapdev.link` → `www.zapdev.link` + +Set environment variables: +```bash +BETTER_AUTH_URL=https://www.zapdev.link +NEXT_PUBLIC_APP_URL=https://www.zapdev.link +``` + +### 4. OAuth Provider Updates + +After changing your domain, update OAuth redirect URIs: + +#### Google OAuth +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Navigate to: APIs & Services → Credentials +3. Edit your OAuth 2.0 Client ID +4. Update Authorized redirect URIs: + ``` + https://zapdev.link/api/auth/callback/google + ``` +5. Update Authorized JavaScript origins: + ``` + https://zapdev.link + ``` + +#### GitHub OAuth +1. Go to [GitHub Settings](https://github.com/settings/developers) +2. Edit your OAuth App +3. Update Authorization callback URL: + ``` + https://zapdev.link/api/auth/callback/github + ``` +4. Update Homepage URL: + ``` + https://zapdev.link + ``` + +### 5. Polar.sh Webhook URL + +Update your Polar.sh webhook endpoint: + +1. Go to [Polar.sh Dashboard](https://polar.sh/) +2. Navigate to Settings → Webhooks +3. Update webhook URL to: + ``` + https://zapdev.link/api/polar/webhooks + ``` + +## Testing the Fix + +### 1. Check Environment Variables +After deployment, verify variables are set correctly: + +```bash +# In your browser console on https://zapdev.link +console.log(window.location.origin); // Should show https://zapdev.link +``` + +### 2. Test Authentication +1. Clear browser cookies and cache +2. Go to your domain: `https://zapdev.link` +3. Try signing in with email/password +4. Check browser console for any CORS errors +5. Try OAuth sign-in (Google/GitHub) + +### 3. Verify API Requests +In browser DevTools Network tab: +- All API requests should go to `https://zapdev.link/api/*` +- No requests should go to `zapdev-mu.vercel.app` +- Response headers should include `Access-Control-Allow-Origin: https://zapdev.link` + +## Common Issues + +### Issue: Still seeing old domain in requests + +**Solution**: +- Hard refresh: `Ctrl+Shift+R` (Windows/Linux) or `Cmd+Shift+R` (Mac) +- Clear browser cache completely +- Try incognito/private window +- Verify environment variables in Vercel dashboard + +### Issue: OAuth redirects to wrong domain + +**Solution**: +- Double-check OAuth provider settings match your domain exactly +- Ensure no typos in redirect URIs +- Wait a few minutes for OAuth provider changes to propagate + +### Issue: CORS errors persist + +**Solution**: +1. Verify both `BETTER_AUTH_URL` and `NEXT_PUBLIC_APP_URL` are identical +2. Check they match your actual domain (no www mismatch) +3. Ensure you redeployed after setting environment variables +4. Clear browser cache and cookies + +### Issue: Session cookies not working + +**Solution**: +- Check that your domain supports secure cookies (HTTPS) +- Verify `BETTER_AUTH_SECRET` is set in production +- Clear all cookies for your domain +- Try signing in again + +## Development vs Production + +### Development (localhost:3000) +```bash +BETTER_AUTH_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 +``` + +### Production (zapdev.link) +```bash +BETTER_AUTH_URL=https://zapdev.link +NEXT_PUBLIC_APP_URL=https://zapdev.link +``` + +## Verification Checklist + +Before going live, verify: + +- [ ] `BETTER_AUTH_URL` environment variable set in production +- [ ] `NEXT_PUBLIC_APP_URL` environment variable set in production +- [ ] Both variables use the same domain +- [ ] Domain uses HTTPS (not HTTP) +- [ ] No trailing slashes in URLs +- [ ] Vercel domain settings configured correctly +- [ ] OAuth providers updated with new redirect URIs +- [ ] Polar.sh webhook URL updated +- [ ] Tested sign-in flow (email + OAuth) +- [ ] No CORS errors in browser console +- [ ] All API requests go to correct domain + +## Need Help? + +If you're still experiencing issues: + +1. Check browser console for specific error messages +2. Review Vercel deployment logs +3. Verify all environment variables are set correctly +4. Test in incognito mode to rule out caching issues + +## Technical Details + +The CORS fix implemented includes: + +1. **Dynamic base URL** (`src/lib/auth.ts`): Uses environment variables instead of hardcoded domain +2. **CORS headers** (`src/app/api/auth/[...all]/route.ts`): Adds proper CORS headers to all auth endpoints +3. **Preflight support**: Handles OPTIONS requests for CORS preflight checks +4. **WWW subdomain support**: Automatically allows both www and non-www variants +5. **Next.js headers** (`next.config.mjs`): Global CORS headers for API routes diff --git a/next.config.mjs b/next.config.mjs index 74587b97..a8341265 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -24,6 +24,23 @@ const nextConfig = { /* config options here */ headers: async () => { return [ + { + source: '/api/auth/:path*', + headers: [ + { + key: 'Access-Control-Allow-Credentials', + value: 'true' + }, + { + key: 'Access-Control-Allow-Methods', + value: 'GET, POST, PUT, DELETE, OPTIONS' + }, + { + key: 'Access-Control-Allow-Headers', + value: 'Content-Type, Authorization, Cookie' + }, + ] + }, { source: '/:path*', headers: [ diff --git a/scripts/cleanup-orphaned-projects.ts b/scripts/cleanup-orphaned-projects.ts index ae2e47b8..5de630c5 100644 --- a/scripts/cleanup-orphaned-projects.ts +++ b/scripts/cleanup-orphaned-projects.ts @@ -37,15 +37,22 @@ async function cleanupOrphanedProjects() { const result = await client.action(api.importData.cleanupOrphanedProjectsAction, {}); console.log(`✅ ${result.message}`); - console.log(` Cleaned projects: ${result.cleanedProjectCount}`); + console.log(` Total cleaned: ${result.totalCleaned}`); + console.log(` - Projects: ${result.cleanedProjectCount}`); + console.log(` - Usage records: ${result.cleanedUsageCount}`); + console.log(` - OAuth connections: ${result.cleanedOAuthCount}`); + console.log(` - Imports: ${result.cleanedImportsCount}`); if (result.orphanedProjectIds.length > 0) { - console.log("\n📋 Removed project IDs:"); - for (const id of result.orphanedProjectIds) { + console.log(`\n📋 Removed ${result.orphanedProjectIds.length} orphaned project IDs (showing first 10):`); + for (const id of result.orphanedProjectIds.slice(0, 10)) { console.log(` - ${id}`); } + if (result.orphanedProjectIds.length > 10) { + console.log(` ... and ${result.orphanedProjectIds.length - 10} more`); + } } else { - console.log("\n✨ No orphaned projects found!"); + console.log("\n✨ No orphaned data found!"); } console.log("\n✅ Cleanup completed successfully!"); diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 66e912cd..ffffafdb 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -11,38 +11,104 @@ function getHandlers() { return handlers; } -// Wrap POST handler with rate limiting +// Get allowed origins for CORS +function getAllowedOrigin(request: Request): string { + const origin = request.headers.get("origin"); + const appUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.BETTER_AUTH_URL || "http://localhost:3000"; + + // Allow requests from the app's own domain + if (origin && (origin === appUrl || origin === `${appUrl}`.replace(/\/$/, ""))) { + return origin; + } + + // Also allow www subdomain variant + const wwwVariant = appUrl.replace("://", "://www."); + const nonWwwVariant = appUrl.replace("://www.", "://"); + + if (origin && (origin === wwwVariant || origin === nonWwwVariant)) { + return origin; + } + + // For development, allow localhost + if (process.env.NODE_ENV === "development" && origin?.includes("localhost")) { + return origin; + } + + // Default to app URL + return appUrl; +} + +// Add CORS headers to response +function addCorsHeaders(response: Response, origin: string): Response { + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", origin); + headers.set("Access-Control-Allow-Credentials", "true"); + headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, Cookie"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +// Handle preflight OPTIONS requests for CORS +export async function OPTIONS(request: Request) { + const origin = getAllowedOrigin(request); + + return new Response(null, { + status: 204, + headers: { + 'Access-Control-Allow-Origin': origin, + 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Cookie', + 'Access-Control-Allow-Credentials': 'true', + 'Access-Control-Max-Age': '86400', // 24 hours + }, + }); +} + +// Wrap POST handler with rate limiting and CORS export async function POST(request: Request) { + const origin = getAllowedOrigin(request); + try { // Check rate limit before processing auth request const rateLimitResult = await checkRateLimit(request); if (!rateLimitResult.success && rateLimitResult.response) { - return rateLimitResult.response; + return addCorsHeaders(rateLimitResult.response, origin); } // Continue with original handler const h = getHandlers(); - return h.POST(request); + const response = await h.POST(request); + return addCorsHeaders(response, origin); } catch (error) { console.error("Auth POST handler error:", error); - return new Response(JSON.stringify({ error: "Authentication failed" }), { + const errorResponse = new Response(JSON.stringify({ error: "Authentication failed" }), { status: 500, headers: { "Content-Type": "application/json" }, }); + return addCorsHeaders(errorResponse, origin); } } -// GET requests don't need rate limiting (mostly for OAuth callbacks) +// GET requests with CORS support (mostly for OAuth callbacks and session checks) export async function GET(request: Request) { + const origin = getAllowedOrigin(request); + try { const h = getHandlers(); - return h.GET(request); + const response = await h.GET(request); + return addCorsHeaders(response, origin); } catch (error) { console.error("Auth GET handler error:", error); - return new Response(JSON.stringify({ error: "Authentication failed" }), { + const errorResponse = new Response(JSON.stringify({ error: "Authentication failed" }), { status: 500, headers: { "Content-Type": "application/json" }, }); + return addCorsHeaders(errorResponse, origin); } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 3635a946..081f0301 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -32,11 +32,16 @@ const makeSocialProviders = () => { // Determine base URL based on environment const getBaseURL = (): string => { + // Use environment variable first (production/staging) + if (process.env.BETTER_AUTH_URL) { + return process.env.BETTER_AUTH_URL; + } + // Development fallback if (process.env.NODE_ENV === "development") { return "http://localhost:3000"; } - // Production URL - use hardcoded domain - return "https://zapdev-mu.vercel.app"; + // Last resort: use NEXT_PUBLIC_APP_URL + return process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; }; const baseURL = getBaseURL();