CloudPeople API
+Authentication endpoints at /api/auth/*
+diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..d8ad73e11 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Database (PostgreSQL) +DATABASE_URL="postgresql://user:password@localhost:5432/cloudpeople" + +# Node environment +NODE_ENV="development" + +# Application URL (for email links in production) +APP_URL="http://localhost:3000" + +# OAuth Providers (optional - not enabled by default) +# Uncomment and configure to enable OAuth + +# Google OAuth +# GOOGLE_CLIENT_ID="" +# GOOGLE_CLIENT_SECRET="" + +# GitHub OAuth +# GITHUB_CLIENT_ID="" +# GITHUB_CLIENT_SECRET="" diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..bffb357a7 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..c578d74b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Dependencies +node_modules/ + +# Next.js +.next/ +out/ + +# Environment +.env +.env.local +.env.*.local + +# Build +*.tsbuildinfo + +# IDE +.idea/ +.vscode/ + +# OS +.DS_Store + +# Logs +*.log +npm-debug.log* diff --git a/CP Build 1 b/CP Build 1 new file mode 100644 index 000000000..485212a35 --- /dev/null +++ b/CP Build 1 @@ -0,0 +1,855 @@ +# CloudPeople - Complete Technical Specification + +## Tech Stack + +### Frontend +- **Next.js 14+** (App Router) - SSR, API routes, file-based routing +- **shadcn/ui** - Modern, accessible components (inspired by auction houses like Sotheby's clean interfaces) +- **Tailwind CSS** - Utility-first styling +- **React Hook Form + Zod** - Form validation +- **TanStack Query** - Data fetching and caching +- **Socket.io Client** - Real-time auction updates +- **Framer Motion** - Smooth animations (dating app feel) + +### Backend +- **Next.js API Routes** - Serverless functions +- **Prisma ORM** - Type-safe database access +- **PostgreSQL** - Primary database (AWS RDS) +- **Socket.io** - WebSocket server for real-time bidding +- **NextAuth.js** - Authentication with role-based access +- **Stripe** - Payment processing +- **AWS S3** - File storage (profiles, portfolios) +- **Redis** (AWS ElastiCache) - Session storage, auction state caching + +### Infrastructure (AWS) +- **Vercel** - Frontend hosting (seamless Next.js deployment) +- **AWS RDS** - PostgreSQL database +- **AWS S3** - File storage +- **AWS ElastiCache** - Redis for sessions/caching +- **AWS SES** - Transactional emails +- **AWS CloudWatch** - Logging and monitoring + +--- + +## Database Schema + +```prisma +// schema.prisma + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum UserRole { + CANDIDATE + EMPLOYER + ADMIN +} + +enum CandidateStatus { + DRAFT + ACTIVE + PAUSED + CLOSED +} + +enum EmployerVerificationStatus { + PENDING + VERIFIED + REJECTED + SUSPENDED +} + +enum RequestStatus { + PENDING + ACCEPTED + DECLINED + EXPIRED + INTERVIEW_SCHEDULED + AUCTION_APPROVED +} + +enum AuctionStatus { + SCHEDULED + LIVE + ENDED + CLOSED + CANCELLED +} + +enum OutcomeStatus { + PENDING_ACCEPTANCE + ACCEPTED + DECLINED + CONTRACT_SIGNED + HIRED + FAILED_TO_CLOSE +} + +model User { + id String @id @default(cuid()) + email String @unique + emailVerified DateTime? + password String + role UserRole + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastLogin DateTime? + + candidate Candidate? + employer Employer? + + sessions Session[] + auditLogs AuditLog[] +} + +model Session { + id String @id @default(cuid()) + userId String + token String @unique + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model Candidate { + id String @id @default(cuid()) + userId String @unique + status CandidateStatus @default(DRAFT) + + // Profile basics + firstName String + lastName String + headline String? + bio String? + profileImageUrl String? + + // Professional details + currentTitle String? + yearsExperience Int? + skills String[] // Array of skill tags + domains String[] // Industry domains + seniority String? // Junior, Mid, Senior, Lead, Principal, etc. + + // Location & work + location String? + remotePreference String? // Remote, Hybrid, Onsite, Flexible + timezone String? + workAuthorization String[] // US, UK, EU, etc. + + // Salary & availability + minSalary Decimal // Auction floor + currency String @default("USD") + availableFrom DateTime? + noticePeriod Int? // Days + + // Visibility controls + isSearchable Boolean @default(true) + isPublic Boolean @default(false) + + // Attachments + portfolioUrl String? + githubUrl String? + linkedinUrl String? + resumeUrl String? + certifications String[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + auctionSlots AuctionSlot[] + receivedRequests Request[] @relation("CandidateRequests") + auctions Auction[] + matches Match[] +} + +model Employer { + id String @id @default(cuid()) + userId String @unique + verificationStatus EmployerVerificationStatus @default(PENDING) + + // Company details + companyName String + companyLogo String? + companyWebsite String? + companySize String? // 1-10, 11-50, 51-200, etc. + industry String? + headquarters String? + + // Verification docs + verificationDocs String[] + verifiedAt DateTime? + verifiedBy String? // Admin user ID + + // Billing + stripeCustomerId String? + depositAmount Decimal? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + seats EmployerSeat[] + jobOpportunities JobOpportunity[] + sentRequests Request[] @relation("EmployerRequests") + auctions AuctionParticipant[] + savedSearches SavedSearch[] +} + +model EmployerSeat { + id String @id @default(cuid()) + employerId String + email String + firstName String + lastName String + role String? // Recruiter, Hiring Manager, etc. + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + employer Employer @relation(fields: [employerId], references: [id], onDelete: Cascade) + + @@unique([employerId, email]) +} + +model JobOpportunity { + id String @id @default(cuid()) + employerId String + title String + description String + location String? + remoteOption String? + salaryRangeMin Decimal? + salaryRangeMax Decimal? + requiredSkills String[] + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + employer Employer @relation(fields: [employerId], references: [id], onDelete: Cascade) + + requests Request[] + auctions Auction[] +} + +model Match { + id String @id @default(cuid()) + candidateId String + jobOpportunityId String? + fitScore Float? // 0-100 computed match score + computedAt DateTime @default(now()) + + candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) +} + +model SavedSearch { + id String @id @default(cuid()) + employerId String + name String + filters Json // Stored filter criteria + createdAt DateTime @default(now()) + + employer Employer @relation(fields: [employerId], references: [id], onDelete: Cascade) +} + +model Request { + id String @id @default(cuid()) + employerId String + candidateId String + jobOpportunityId String? + status RequestStatus @default(PENDING) + + message String? + employerMessage String? + candidateResponse String? + + requestedAt DateTime @default(now()) + respondedAt DateTime? + expiresAt DateTime? + + employer Employer @relation("EmployerRequests", fields: [employerId], references: [id], onDelete: Cascade) + candidate Candidate @relation("CandidateRequests", fields: [candidateId], references: [id], onDelete: Cascade) + jobOpportunity JobOpportunity? @relation(fields: [jobOpportunityId], references: [id], onDelete: SetNull) + + conversations Conversation[] + interviews InterviewEvent[] +} + +model Conversation { + id String @id @default(cuid()) + requestId String + createdAt DateTime @default(now()) + + request Request @relation(fields: [requestId], references: [id], onDelete: Cascade) + messages Message[] +} + +model Message { + id String @id @default(cuid()) + conversationId String + senderId String // User ID + content String + attachments String[] + readAt DateTime? + createdAt DateTime @default(now()) + + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) +} + +model InterviewEvent { + id String @id @default(cuid()) + requestId String + scheduledAt DateTime + duration Int // Minutes + meetingUrl String? + notes String? + status String @default("SCHEDULED") // SCHEDULED, COMPLETED, CANCELLED + createdAt DateTime @default(now()) + + request Request @relation(fields: [requestId], references: [id], onDelete: Cascade) +} + +model AuctionSlot { + id String @id @default(cuid()) + candidateId String + startTime DateTime + endTime DateTime + isAvailable Boolean @default(true) + createdAt DateTime @default(now()) + + candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + auctions Auction[] +} + +model Auction { + id String @id @default(cuid()) + candidateId String + jobOpportunityId String? + auctionSlotId String? + status AuctionStatus @default(SCHEDULED) + + // Auction rules + floorSalary Decimal + currency String @default("USD") + minIncrement Decimal @default(1000) + + // Timing + scheduledStart DateTime + scheduledEnd DateTime + actualStart DateTime? + actualEnd DateTime? + + // Results + winningBid Decimal? + winnerId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + candidate Candidate @relation(fields: [candidateId], references: [id], onDelete: Cascade) + jobOpportunity JobOpportunity? @relation(fields: [jobOpportunityId], references: [id], onDelete: SetNull) + auctionSlot AuctionSlot? @relation(fields: [auctionSlotId], references: [id], onDelete: SetNull) + + participants AuctionParticipant[] + bids Bid[] + outcome Outcome? +} + +model AuctionParticipant { + id String @id @default(cuid()) + auctionId String + employerId String + depositPaid Boolean @default(false) + depositAmount Decimal? + joinedAt DateTime @default(now()) + + auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) + employer Employer @relation(fields: [employerId], references: [id], onDelete: Cascade) + + @@unique([auctionId, employerId]) +} + +model Bid { + id String @id @default(cuid()) + auctionId String + bidderId String // Employer ID + amount Decimal + isWinning Boolean @default(false) + createdAt DateTime @default(now()) + + auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) + + @@index([auctionId, createdAt]) +} + +model Outcome { + id String @id @default(cuid()) + auctionId String @unique + status OutcomeStatus @default(PENDING_ACCEPTANCE) + finalSalary Decimal + winnerId String // Employer ID + + acceptedAt DateTime? + hiredAt DateTime? + startDate DateTime? + + failureReason String? + failureDetails String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade) + payment Payment? +} + +model Payment { + id String @id @default(cuid()) + outcomeId String @unique + + // Platform fee + platformFee Decimal + candidateShare Decimal? + employerCharge Decimal + + stripePaymentId String? + stripePayout String? + + paidAt DateTime? + payoutAt DateTime? + + createdAt DateTime @default(now()) + + outcome Outcome @relation(fields: [outcomeId], references: [id], onDelete: Cascade) +} + +model AuditLog { + id String @id @default(cuid()) + userId String? + action String // BID_PLACED, AUCTION_STARTED, PROFILE_UPDATED, etc. + entityType String // Auction, Bid, User, etc. + entityId String + metadata Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([entityType, entityId]) + @@index([userId, createdAt]) +} + +model ModerationFlag { + id String @id @default(cuid()) + entityType String // User, Candidate, Employer, Message + entityId String + reason String + details String? + reportedBy String? // User ID + status String @default("PENDING") // PENDING, REVIEWED, ACTIONED, DISMISSED + reviewedBy String? // Admin user ID + reviewedAt DateTime? + createdAt DateTime @default(now()) + + @@index([status, createdAt]) +} +``` + +--- + +## API Endpoints (Next.js API Routes) + +### Authentication +- `POST /api/auth/register` - Create account (candidate/employer) +- `POST /api/auth/login` - Login +- `POST /api/auth/logout` - Logout +- `POST /api/auth/verify-email` - Email verification +- `POST /api/auth/reset-password` - Password reset + +### Candidates +- `GET /api/candidates/me` - Get own profile +- `PUT /api/candidates/me` - Update profile +- `POST /api/candidates/me/upload` - Upload files (resume, portfolio) +- `GET /api/candidates/search` - Search candidates (employer only) +- `GET /api/candidates/:id` - View candidate profile (employer only, if visible) +- `POST /api/candidates/auction-slots` - Create auction slot +- `DELETE /api/candidates/auction-slots/:id` - Remove slot + +### Employers +- `GET /api/employers/me` - Get employer profile +- `PUT /api/employers/me` - Update employer profile +- `POST /api/employers/verify` - Submit verification docs +- `GET /api/employers/seats` - List seats +- `POST /api/employers/seats` - Add seat +- `DELETE /api/employers/seats/:id` - Remove seat + +### Job Opportunities +- `GET /api/jobs` - List jobs (employer's own) +- `POST /api/jobs` - Create job +- `PUT /api/jobs/:id` - Update job +- `DELETE /api/jobs/:id` - Delete job + +### Requests +- `POST /api/requests` - Send request (employer → candidate) +- `GET /api/requests` - List requests (filtered by role) +- `GET /api/requests/:id` - Get request details +- `PUT /api/requests/:id/respond` - Candidate responds +- `PUT /api/requests/:id/cancel` - Cancel request + +### Conversations +- `GET /api/conversations/:requestId` - Get conversation +- `POST /api/conversations/:requestId/messages` - Send message +- `PUT /api/conversations/messages/:id/read` - Mark as read + +### Interviews +- `POST /api/interviews` - Schedule interview +- `GET /api/interviews/:id` - Get interview details +- `PUT /api/interviews/:id` - Update/cancel interview + +### Auctions +- `POST /api/auctions` - Create auction (candidate) +- `GET /api/auctions` - List auctions (filtered by role) +- `GET /api/auctions/:id` - Get auction details +- `POST /api/auctions/:id/join` - Employer joins auction +- `POST /api/auctions/:id/bids` - Place bid (employer) +- `GET /api/auctions/:id/bids` - Get bid history +- `PUT /api/auctions/:id/cancel` - Cancel auction + +### Outcomes +- `GET /api/outcomes/:auctionId` - Get outcome +- `PUT /api/outcomes/:auctionId/accept` - Accept offer (candidate) +- `PUT /api/outcomes/:auctionId/decline` - Decline offer +- `PUT /api/outcomes/:auctionId/close` - Mark as hired/failed + +### Payments +- `POST /api/payments/setup-intent` - Create Stripe setup intent +- `POST /api/payments/charge` - Process payment +- `GET /api/payments/:outcomeId` - Get payment status + +### Admin +- `GET /api/admin/verification-queue` - Pending verifications +- `PUT /api/admin/verify-employer/:id` - Approve/reject employer +- `GET /api/admin/moderation-flags` - Moderation queue +- `PUT /api/admin/moderation-flags/:id` - Resolve flag +- `GET /api/admin/analytics` - Platform analytics + +### Search +- `POST /api/search/candidates` - Advanced candidate search +- `POST /api/search/save` - Save search +- `GET /api/search/saved` - Get saved searches + +--- + +## Page Structure (Next.js App Router) + +``` +app/ +├── (auth)/ +│ ├── login/ +│ │ └── page.tsx +│ ├── register/ +│ │ └── page.tsx +│ └── verify-email/ +│ └── page.tsx +│ +├── (candidate)/ +│ ├── dashboard/ +│ │ └── page.tsx # Auction overview, requests, upcoming +│ ├── profile/ +│ │ ├── page.tsx # View/edit profile +│ │ └── edit/ +│ │ └── page.tsx +│ ├── requests/ +│ │ ├── page.tsx # Inbox +│ │ └── [id]/ +│ │ └── page.tsx # Request detail + chat +│ ├── auctions/ +│ │ ├── page.tsx # My auctions list +│ │ ├── create/ +│ │ │ └── page.tsx +│ │ └── [id]/ +│ │ └── page.tsx # Auction detail + live view +│ └── outcomes/ +│ ├── page.tsx +│ └── [id]/ +│ └── page.tsx # Post-auction workflow +│ +├── (employer)/ +│ ├── dashboard/ +│ │ └── page.tsx # Search, active requests, auctions +│ ├── search/ +│ │ └── page.tsx # Candidate search with filters +│ ├── candidates/ +│ │ └── [id]/ +│ │ └── page.tsx # Candidate profile view +│ ├── requests/ +│ │ ├── page.tsx # Sent requests +│ │ └── [id]/ +│ │ └── page.tsx # Request + conversation +│ ├── auctions/ +│ │ ├── page.tsx # Auctions joined/won +│ │ └── [id]/ +│ │ └── page.tsx # Live auction bidding UI +│ ├── jobs/ +│ │ ├── page.tsx +│ │ ├── create/ +│ │ │ └── page.tsx +│ │ └── [id]/ +│ │ └── page.tsx +│ └── settings/ +│ ├── page.tsx # Company profile +│ ├── verification/ +│ │ └── page.tsx +│ └── billing/ +│ └── page.tsx +│ +├── (admin)/ +│ ├── dashboard/ +│ │ └── page.tsx +│ ├── verification/ +│ │ └── page.tsx # Employer verification queue +│ ├── moderation/ +│ │ └── page.tsx +│ ├── auctions/ +│ │ └── page.tsx # All auctions monitor +│ └── analytics/ +│ └── page.tsx +│ +└── api/ + └── [...all API routes above] +``` + +--- + +## Key UI Components (shadcn/ui based) + +### Auction House Inspiration (Sotheby's/Christie's) +- Clean, minimal interface with focus on the item (candidate) +- High-quality imagery and presentation +- Live bidding counter with smooth animations +- Bid history sidebar with elegant typography +- Countdown timer with urgency indicators +- "Place Bid" button with confirmation modal +- Winner announcement with celebration animation + +### Dating App Inspiration (Tinder/Hinge) +- Swipeable candidate cards (for employer search) +- Profile photo carousel +- "Like/Pass" action buttons +- Match celebration animation +- Chat interface with typing indicators +- Scheduled date/time picker for interviews +- Profile completion percentage indicator +- Mutual interest notifications + +### Component List +1. **CandidateCard** - Tinder-style card with swipe +2. **AuctionTimer** - Countdown with visual urgency +3. **BiddingPanel** - Live bid interface +4. **BidHistory** - Elegant list with animations +5. **ChatInterface** - Messaging UI +6. **ProfileGallery** - Image carousel +7. **RequestInbox** - Message-style inbox +8. **InterviewScheduler** - Calendar picker +9. **VerificationBadge** - Trust indicator +10. **MatchAnimation** - Celebration overlay +11. **SearchFilters** - Advanced filter panel +12. **AuctionSlotPicker** - Time slot selector + +--- + +## Real-Time Architecture (Socket.io) + +### WebSocket Events + +**Client → Server:** +- `auction:join` - Join auction room +- `auction:leave` - Leave room +- `auction:bid` - Place bid +- `message:send` - Send chat message +- `message:typing` - Typing indicator + +**Server → Client:** +- `auction:started` - Auction went live +- `auction:bid_placed` - New bid (with bidder info) +- `auction:winning` - You're currently winning +- `auction:outbid` - You've been outbid +- `auction:ending_soon` - 5 min warning +- `auction:ended` - Auction finished +- `message:received` - New message +- `message:typing` - Someone typing +- `request:new` - New request received + +### Redis for Auction State +```typescript +// Auction state cached in Redis +{ + auctionId: string, + currentBid: number, + currentWinner: string, + bidCount: number, + participants: string[], + endsAt: timestamp, + status: 'live' | 'ended' +} +``` + +--- + +## Security & Compliance + +### Authentication +- Bcrypt password hashing +- JWT tokens with refresh mechanism +- Role-based middleware on all routes +- Email verification required + +### Auction Integrity +- Database transactions for bids +- Optimistic locking on auction records +- Idempotency keys for bid requests +- Rate limiting (max 1 bid per 5 seconds per user) +- Audit logging of all bids + +### Privacy (GDPR) +- Consent tracking for data processing +- Data export endpoint (`/api/me/export`) +- Data deletion endpoint (`/api/me/delete`) +- PII encryption at rest +- Minimal data exposure in search results + +### Fraud Prevention +- Employer verification required for bidding +- Deposit system for auction participation +- Ban/suspend mechanisms +- IP and device fingerprinting +- Anomaly detection (suspicious bid patterns) + +--- + +## Development Phases + +### Phase 1: Auth & Profiles (Week 1-2) +- User registration/login +- Candidate profile creation +- Employer profile + verification flow +- Basic role routing + +### Phase 2: Search & Requests (Week 3-4) +- Candidate search with filters +- Request sending/receiving +- Conversation/messaging +- Interview scheduling + +### Phase 3: Auctions - Basic (Week 5-6) +- Auction scheduling +- Auction participation +- Manual bidding (no real-time yet) +- Outcome tracking + +### Phase 4: Real-Time Bidding (Week 7-8) +- Socket.io integration +- Live auction UI +- Redis caching +- Bid validation & locking + +### Phase 5: Payments & Admin (Week 9-10) +- Stripe integration +- Payment processing +- Admin dashboard +- Moderation tools + +### Phase 6: Polish & Deploy (Week 11-12) +- UI/UX refinement +- Performance optimization +- AWS deployment +- Monitoring & logging + +--- + +## Deployment (AWS) + +### Services Required +1. **Vercel** - Host Next.js app +2. **AWS RDS (PostgreSQL)** - Primary database +3. **AWS ElastiCache (Redis)** - Session/auction cache +4. **AWS S3** - File uploads +5. **AWS SES** - Email sending +6. **AWS CloudWatch** - Logs & monitoring +7. **Stripe** - Payments + +### Environment Variables +```bash +DATABASE_URL=postgresql://... +REDIS_URL=redis://... +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY=... +AWS_S3_BUCKET=... +STRIPE_SECRET_KEY=... +STRIPE_WEBHOOK_SECRET=... +NEXTAUTH_SECRET=... +NEXTAUTH_URL=... +SOCKET_IO_URL=... +``` + +--- + +## Getting Started with Claude Code + +### Prompt for Claude Code: + +``` +Build CloudPeople using this specification: [paste this entire document] + +Start with Phase 1: Authentication and Profiles. + +Tech stack: +- Next.js 14 (App Router) +- TypeScript +- Prisma + PostgreSQL +- shadcn/ui components +- Tailwind CSS +- NextAuth.js + +Create the following in order: +1. Initialize Next.js project with TypeScript +2. Set up Prisma schema (use the provided schema) +3. Create database migration +4. Set up NextAuth.js with email/password +5. Create registration flow for candidates and employers +6. Build candidate profile creation wizard +7. Build employer profile + verification flow +8. Create role-based dashboard routing + +For UI: Use shadcn/ui components with a design inspired by: +- Sotheby's auction house (clean, minimal, elegant) +- Tinder/Hinge (swipeable cards, match interactions) + +After Phase 1 is complete and tested, I'll ask you to proceed with Phase 2. +``` + +--- + +## Notes for Iteration + +- Start with MVP features, polish later +- Use feature flags for gradual rollout +- Implement comprehensive error handling +- Add loading states and optimistic UI updates +- Write tests for auction logic (critical path) +- Monitor performance with Vercel Analytics +- Set up error tracking (Sentry) +- Create admin tools early for debugging + +--- + +This specification is comprehensive and ready for Claude Code to execute. The phased approach ensures you can test and iterate at each stage before moving forward. diff --git a/migrations/001_create_users.sql b/migrations/001_create_users.sql new file mode 100644 index 000000000..73959d1f6 --- /dev/null +++ b/migrations/001_create_users.sql @@ -0,0 +1,22 @@ +-- Migration: 001_create_users +-- Description: Create users table for authentication +-- Based on: schema/AUTHENTICATION_FLOWS.md and schema/users_auth_profiles.sql + +-- Create user_role enum +CREATE TYPE user_role AS ENUM ('CANDIDATE', 'EMPLOYER', 'ADMIN'); + +-- Create users table +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + email_verified TIMESTAMPTZ, + password TEXT NOT NULL, + role user_role NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login TIMESTAMPTZ +); + +-- Indexes for common queries +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_role ON users(role); diff --git a/migrations/002_create_user_profiles.sql b/migrations/002_create_user_profiles.sql new file mode 100644 index 000000000..5e03581c6 --- /dev/null +++ b/migrations/002_create_user_profiles.sql @@ -0,0 +1,15 @@ +-- Migration: 002_create_user_profiles +-- Description: Create user_profiles table for identity information + +CREATE TABLE user_profiles ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + display_name TEXT, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id); diff --git a/migrations/003_create_sessions.sql b/migrations/003_create_sessions.sql new file mode 100644 index 000000000..d4c64a680 --- /dev/null +++ b/migrations/003_create_sessions.sql @@ -0,0 +1,22 @@ +-- Migration: 003_create_sessions +-- Description: Create sessions table for database-backed session management +-- Based on: MVP recommendation in AUTHENTICATION_FLOWS.md + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + user_agent TEXT, + ip_address TEXT +); + +-- Index for token lookups (primary auth path) +CREATE INDEX idx_sessions_token_hash ON sessions(token_hash); + +-- Index for user session management +CREATE INDEX idx_sessions_user_id ON sessions(user_id); + +-- Index for cleanup of expired sessions +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); diff --git a/migrations/004_create_email_verification_tokens.sql b/migrations/004_create_email_verification_tokens.sql new file mode 100644 index 000000000..42e2889df --- /dev/null +++ b/migrations/004_create_email_verification_tokens.sql @@ -0,0 +1,14 @@ +-- Migration: 004_create_email_verification_tokens +-- Description: Create email verification tokens table +-- Tokens expire after 24 hours + +CREATE TABLE email_verification_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_email_verification_tokens_token_hash ON email_verification_tokens(token_hash); +CREATE INDEX idx_email_verification_tokens_user_id ON email_verification_tokens(user_id); diff --git a/migrations/005_create_password_reset_tokens.sql b/migrations/005_create_password_reset_tokens.sql new file mode 100644 index 000000000..48310bbfd --- /dev/null +++ b/migrations/005_create_password_reset_tokens.sql @@ -0,0 +1,15 @@ +-- Migration: 005_create_password_reset_tokens +-- Description: Create password reset tokens table +-- Tokens expire after 1 hour (per MVP recommendation) + +CREATE TABLE password_reset_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + used_at TIMESTAMPTZ +); + +CREATE INDEX idx_password_reset_tokens_token_hash ON password_reset_tokens(token_hash); +CREATE INDEX idx_password_reset_tokens_user_id ON password_reset_tokens(user_id); diff --git a/migrations/006_create_oauth_accounts.sql b/migrations/006_create_oauth_accounts.sql new file mode 100644 index 000000000..1d27b69c6 --- /dev/null +++ b/migrations/006_create_oauth_accounts.sql @@ -0,0 +1,20 @@ +-- Migration: 006_create_oauth_accounts +-- Description: Create oauth_accounts table for OAuth provider linking +-- Note: OAuth is scaffolding only, not enabled by default + +CREATE TABLE oauth_accounts ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + provider_account_id TEXT NOT NULL, + access_token TEXT, + refresh_token TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE(provider, provider_account_id) +); + +CREATE INDEX idx_oauth_accounts_user_id ON oauth_accounts(user_id); +CREATE INDEX idx_oauth_accounts_provider ON oauth_accounts(provider, provider_account_id); diff --git a/next.config.js b/next.config.js new file mode 100644 index 000000000..a843cbee0 --- /dev/null +++ b/next.config.js @@ -0,0 +1,6 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, +} + +module.exports = nextConfig diff --git a/package.json b/package.json new file mode 100644 index 000000000..7d6a30d1e --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "cloudpeople-auth", + "version": "1.0.0", + "description": "CloudPeople MVP Authentication Backend", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "typecheck": "tsc --noEmit", + "db:migrate": "node scripts/migrate.js" + }, + "dependencies": { + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "pg": "^8.11.0", + "bcrypt": "^5.1.1", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/node": "^20.12.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@types/pg": "^8.11.0", + "@types/bcrypt": "^5.0.2", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/schema/AUTHENTICATION_FLOWS.md b/schema/AUTHENTICATION_FLOWS.md new file mode 100644 index 000000000..ef9051f92 --- /dev/null +++ b/schema/AUTHENTICATION_FLOWS.md @@ -0,0 +1,322 @@ +# Authentication Flows Design Proposal + +Based on the merged `users` + `user_profiles` schema for CloudPeople. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Email/Password Authentication Flow](#emailpassword-authentication-flow) +3. [OAuth Authentication Flow (Optional)](#oauth-authentication-flow-optional) +4. [Session Management Options](#session-management-options) +5. [Trade-offs Summary](#trade-offs-summary) +6. [MVP Recommendation](#mvp-recommendation) + +--- + +## Overview + +This document proposes authentication flows that align with the existing schema: + +| Schema Element | Auth Relevance | +|----------------|----------------| +| `users.email` | Primary login identifier | +| `users.password` | Hashed credential storage | +| `users.email_verified` | Verification state tracking | +| `users.role` | Post-auth access control | +| `users.last_login` | Session activity tracking | + +**Guiding Principles:** +- No vendor lock-in (portable across providers) +- Standards-based protocols (OAuth 2.0, OpenID Connect) +- Schema-compatible (works with existing `users` table) +- Minimal infrastructure for MVP + +--- + +## Email/Password Authentication Flow + +### Registration Flow + +``` +User Application Database + | | | + |-- Submit email/password ->| | + | |-- Validate format | + | |-- Check email uniqueness ->| + | |<- Email available ---------| + | |-- Hash password | + | |-- Insert user record ----->| + | |<- User created ------------| + | |-- Generate verify token | + | |-- Send verification email | + |<- "Check your email" ----| | +``` + +**Key Decisions:** + +| Decision | Options | Trade-off | +|----------|---------|-----------| +| Password hashing | Argon2id, bcrypt, scrypt | Argon2id is newest/recommended; bcrypt has widest library support | +| Email verification | Required before login vs. Required for full access | Blocking login reduces friction but delays access | +| Rate limiting | IP-based, email-based, or both | IP-only can be bypassed; email-based may block legitimate retries | + +### Login Flow + +``` +User Application Database + | | | + |-- Submit email/password ->| | + | |-- Lookup user by email --->| + | |<- User record (or null) ---| + | |-- Verify password hash | + | |-- Check email_verified | + | |-- Update last_login ------>| + | |-- Create session/token | + |<- Session token + role --| | +``` + +**Key Decisions:** + +| Decision | Options | Trade-off | +|----------|---------|-----------| +| Failed login response | Generic "invalid credentials" vs. Specific errors | Generic prevents enumeration but frustrates users | +| Account lockout | Temporary lockout vs. CAPTCHA vs. Exponential delay | Lockout can enable DoS; CAPTCHA adds friction | +| Remember me | Extended session vs. Separate persistent token | Extended sessions increase window of compromise | + +### Password Reset Flow + +``` +User Application Database + | | | + |-- Request reset (email) ->| | + | |-- Lookup user by email --->| + | |<- User exists (silent) ----| + | |-- Generate reset token | + | |-- Store token (hashed) --->| + | |-- Send reset email | + |<- "Check your email" ----| | + | | | + |-- Click reset link ------>| | + | |-- Validate token --------->| + | |<- Token valid --------------| + |-- Submit new password --->| | + | |-- Hash new password | + | |-- Update password -------->| + | |-- Invalidate all sessions | + |<- Login with new password | | +``` + +**Key Decisions:** + +| Decision | Options | Trade-off | +|----------|---------|-----------| +| Token expiry | 15 min, 1 hour, 24 hours | Shorter = more secure but less convenient | +| Token storage | Database table vs. Signed JWT | DB requires lookup; JWT can't be revoked early | +| Session invalidation | Invalidate all vs. Current only | All sessions is safer but may frustrate multi-device users | + +--- + +## OAuth Authentication Flow (Optional) + +OAuth provides "Login with Google/GitHub/etc." without storing external passwords. + +### OAuth Registration/Login Flow + +``` +User Application OAuth Provider + | | | + |-- Click "Login with X" -->| | + | |-- Generate state token | + |<- Redirect to provider --| | + |-- Authorize at provider -----------------------> | + |<- Redirect with auth code ----------------------- | + | |-- Exchange code for token ->| + | |<- Access token + ID token --| + | |-- Fetch user profile ------>| + | |<- Email, name, avatar ------| + | | | + | |-- Lookup user by email --->| (Database) + | | (Link or Create) | + |<- Session established ---| | +``` + +### Account Linking Strategy + +When an OAuth user's email matches an existing account: + +| Strategy | Behavior | Trade-off | +|----------|----------|-----------| +| **Auto-link** | Automatically merge accounts | Convenient but risky if provider email isn't verified | +| **Prompt-link** | Ask user to confirm with password | Secure but adds friction | +| **Separate accounts** | Create distinct account | No risk but confusing for users | + +**Recommendation:** Prompt-link for security—require password confirmation before linking OAuth to existing email/password account. + +### Schema Consideration: OAuth Accounts Table + +OAuth requires storing provider-specific identifiers. The current schema doesn't include this. A future addition would be: + +| Table | Purpose | +|-------|---------| +| `oauth_accounts` | Links `user_id` to provider + provider_account_id | + +**Structure (conceptual, not schema):** +- `user_id` → references `users.id` +- `provider` → "google", "github", etc. +- `provider_account_id` → unique ID from provider +- `access_token` (optional) → for API access +- `refresh_token` (optional) → for token refresh + +### Provider Selection Trade-offs + +| Provider | Pros | Cons | +|----------|------|------| +| **Google** | Ubiquitous, reliable | Requires Google Cloud Console setup | +| **GitHub** | Developer-friendly, good for tech users | Limited to developers | +| **Microsoft** | Enterprise/Azure integration | Complex configuration | +| **Generic OIDC** | Portable, no vendor lock-in | Requires configuration per provider | + +**No Vendor Lock-in Approach:** Use OpenID Connect (OIDC) standard. Any compliant provider can be swapped without code changes—only configuration changes. + +--- + +## Session Management Options + +The schema comments mention three strategies. Here's the trade-off analysis: + +### Option A: Database-Backed Sessions + +| Aspect | Details | +|--------|---------| +| **Storage** | `sessions` table with token, user_id, expiry | +| **Verification** | Database lookup on each request | +| **Revocation** | Delete session row | +| **Scaling** | Requires shared database or sticky sessions | + +**Trade-offs:** + +| Pro | Con | +|-----|-----| +| Immediate revocation | Database hit per request | +| Full session control | Horizontal scaling complexity | +| Works with NextAuth.js | Additional table to maintain | +| Audit trail built-in | Session table can grow large | + +### Option B: Stateless JWT + +| Aspect | Details | +|--------|---------| +| **Storage** | Token contains claims, signed by secret | +| **Verification** | Signature check only (no DB) | +| **Revocation** | Not possible until expiry (without blocklist) | +| **Scaling** | Trivial—any server can verify | + +**Trade-offs:** + +| Pro | Con | +|-----|-----| +| No database lookup | Cannot revoke before expiry | +| Horizontally scalable | Token size in requests | +| Stateless/simple | Secret rotation requires re-auth | +| Works offline | Claims can become stale | + +### Option C: Hybrid (JWT + Redis) + +| Aspect | Details | +|--------|---------| +| **Storage** | JWT for auth, Redis for session state | +| **Verification** | Signature check + Redis lookup | +| **Revocation** | Delete from Redis | +| **Scaling** | Requires Redis cluster (ElastiCache per spec) | + +**Trade-offs:** + +| Pro | Con | +|-----|-----| +| Fast verification | Additional infrastructure (Redis) | +| Revocation possible | Two systems to maintain | +| Session data without DB | Redis adds latency vs. pure JWT | +| Spec mentions ElastiCache | Operational complexity | + +--- + +## Trade-offs Summary + +### Authentication Method Trade-offs + +| Method | Security | UX | Implementation | Vendor Lock-in | +|--------|----------|----|--------------------|----------------| +| **Email/Password** | Depends on implementation | Familiar but friction | Medium | None | +| **OAuth only** | Delegated to provider | Low friction | Medium | Provider-dependent | +| **Email/Password + OAuth** | Best coverage | Best UX | Higher | Minimal with OIDC | + +### Session Strategy Trade-offs + +| Strategy | Performance | Revocation | Complexity | Spec Alignment | +|----------|-------------|------------|------------|----------------| +| **Database Sessions** | Slower (DB hit) | Immediate | Low | Works with NextAuth | +| **Stateless JWT** | Fastest | Delayed | Lowest | Minimal infra | +| **Hybrid (JWT+Redis)** | Fast | Immediate | Highest | Uses ElastiCache | + +### Password Hashing Trade-offs + +| Algorithm | Security | Performance | Library Support | +|-----------|----------|-------------|-----------------| +| **Argon2id** | Best (memory-hard) | Configurable | Growing | +| **bcrypt** | Good (battle-tested) | Fixed cost | Universal | +| **scrypt** | Good (memory-hard) | Configurable | Moderate | + +--- + +## MVP Recommendation + +For the initial MVP, the following configuration balances security, simplicity, and future flexibility: + +### Recommended MVP Configuration + +| Aspect | Recommendation | Rationale | +|--------|----------------|-----------| +| **Primary Auth** | Email/Password | Universal, no external dependencies | +| **OAuth** | Defer to post-MVP | Adds complexity; email/password sufficient for launch | +| **Password Hashing** | bcrypt | Widest library support, battle-tested | +| **Session Strategy** | Database-backed sessions | Simple, immediate revocation, works with NextAuth.js | +| **Email Verification** | Required for full access, not login | Balances security with onboarding friction | +| **Password Reset** | 1-hour token expiry | Standard balance of security/convenience | + +### Why This Configuration? + +1. **Minimal Dependencies:** No Redis, no OAuth provider setup—just the existing PostgreSQL database. + +2. **No Vendor Lock-in:** Email/password is universal. bcrypt is standard across all languages. Database sessions work with any framework. + +3. **Clear Upgrade Path:** + - Add OAuth later by creating `oauth_accounts` table + - Switch to hybrid sessions by adding Redis when scaling requires it + - Migrate to Argon2id when library support matures + +4. **Matches Schema:** The existing `users` table supports this configuration without modification. + +### Post-MVP Enhancements (in priority order) + +| Priority | Enhancement | Trigger | +|----------|-------------|---------| +| 1 | Add OAuth (Google) | User feedback requesting social login | +| 2 | Add Redis session cache | Database session queries become bottleneck | +| 3 | Add MFA/2FA | Enterprise customers require it | +| 4 | Add passwordless (magic links) | UX research indicates password friction | + +--- + +## Summary + +| Question | Answer | +|----------|--------| +| Email/Password for MVP? | Yes—universal, no dependencies | +| OAuth for MVP? | No—defer to reduce complexity | +| Session strategy? | Database-backed—simple, revocable | +| Password hashing? | bcrypt—battle-tested, universal support | +| Vendor lock-in risk? | None—all choices are standards-based | + +This configuration provides a secure, maintainable authentication system that can evolve with product needs while avoiding premature optimization and external dependencies. diff --git a/schema/SCHEMA_PROPOSAL.md b/schema/SCHEMA_PROPOSAL.md new file mode 100644 index 000000000..f85b2d5e0 --- /dev/null +++ b/schema/SCHEMA_PROPOSAL.md @@ -0,0 +1,99 @@ +# Minimal PostgreSQL Schema Proposal +## Users, Authentication, User Profiles (Identity Only) + +Based on **CP Build 1** specification for CloudPeople. + +--- + +## Schema Overview + +| Table | Purpose | Key Fields | Relationships | +|-------|---------|------------|---------------| +| `users` | Core authentication account | `id`, `email`, `password`, `role` | 1:1 → `user_profiles` | +| `user_profiles` | Identity information | `user_id`, `first_name`, `last_name`, `avatar_url` | 1:1 → `users` | + +--- + +## Enum + +| Enum | Values | Purpose | +|------|--------|---------| +| `user_role` | `CANDIDATE`, `EMPLOYER`, `ADMIN` | Role-based access control | + +--- + +## Table Details & Rationale + +### 1. `users` + +**Purpose:** Core identity and authentication record. + +| Column | Type | Rationale | +|--------|------|-----------| +| `id` | TEXT | Spec uses Prisma's `@default(cuid())` | +| `email` | TEXT UNIQUE | Primary login identifier | +| `email_verified` | TIMESTAMPTZ | Tracks email verification (required per spec) | +| `password` | TEXT | Hashed password; algorithm decided at app layer | +| `role` | user_role | Determines profile type + route access | +| `last_login` | TIMESTAMPTZ | Session tracking, spec explicitly includes | + +**Why a separate `users` table?** +- Spec shows `User` as the core authentication entity +- Enables role-based middleware on all routes +- Single source of truth for credentials + +--- + +### 2. `user_profiles` + +**Purpose:** Minimal identity information for display purposes. + +| Column | Type | Rationale | +|--------|------|-----------| +| `user_id` | TEXT UNIQUE | 1:1 relationship with users | +| `first_name` | TEXT | Common identity field from spec's Candidate model | +| `last_name` | TEXT | Common identity field from spec's Candidate model | +| `display_name` | TEXT | Optional public-facing name | +| `avatar_url` | TEXT | Profile image reference (S3 per spec) | + +**Why minimal?** +- Contains only identity/display data +- Marketplace-specific fields (skills, salary, verification) belong in domain-specific tables added later + +--- + +## Authentication Strategy + +This schema **does not prescribe** a specific authentication strategy. The `users` table supports multiple approaches: + +| Option | Description | Storage | +|--------|-------------|---------| +| **A. Server-side Sessions** | Database-backed session tokens | Requires `sessions` table | +| **B. JWT Tokens** | Stateless, self-contained tokens | No table needed | +| **C. Hybrid** | JWT + Redis session cache | Uses ElastiCache per spec | + +See `users_auth_profiles.sql` for detailed pros/cons of each option. + +**Recommendation:** Defer strategy decision until authentication implementation phase. + +--- + +## What's Excluded (and why) + +| Excluded | Reason | +|----------|--------| +| `candidates` table | Marketplace-specific; add during Phase 2+ | +| `employers` table | Marketplace-specific; add during Phase 2+ | +| Skills, domains, seniority | Marketplace concepts | +| Salary, auction floor | Auction-specific | +| Visibility flags | Search/discovery feature | +| Verification status | Employer workflow feature | +| Concrete session table | Auth strategy not yet chosen | + +--- + +## Next Steps + +1. **Choose auth strategy** during implementation phase +2. **Add marketplace tables** (`candidates`, `employers`) when building Phase 2 +3. **Add audit logging** when implementing compliance features diff --git a/schema/users_auth_profiles.sql b/schema/users_auth_profiles.sql new file mode 100644 index 000000000..f4e2e7789 --- /dev/null +++ b/schema/users_auth_profiles.sql @@ -0,0 +1,108 @@ +-- ============================================================================= +-- CloudPeople: Minimal PostgreSQL Schema +-- Scope: Users, Authentication, User Profiles (Identity Only) +-- Based on: CP Build 1 Specification +-- ============================================================================= + +-- ----------------------------------------------------------------------------- +-- ENUM: user_role +-- From CP Build 1: UserRole enum defines access control boundaries +-- ----------------------------------------------------------------------------- + +CREATE TYPE user_role AS ENUM ( + 'CANDIDATE', + 'EMPLOYER', + 'ADMIN' +); + +-- ----------------------------------------------------------------------------- +-- TABLE: users +-- Core user account for authentication +-- Rationale: Matches the User model from CP Build 1 spec, containing only +-- fields necessary for identity and authentication - no marketplace logic. +-- ----------------------------------------------------------------------------- + +CREATE TABLE users ( + id TEXT PRIMARY KEY, -- cuid() generated in application layer + email TEXT NOT NULL UNIQUE, -- Primary login identifier + email_verified TIMESTAMPTZ, -- NULL = unverified, timestamp = when verified + password TEXT NOT NULL, -- Hashed password (algorithm chosen at app layer) + role user_role NOT NULL, -- Determines profile type + route access + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login TIMESTAMPTZ -- Tracks most recent authentication +); + +-- Index for login lookups +CREATE INDEX idx_users_email ON users(email); + +-- Index for role-based queries (e.g., admin listing all employers) +CREATE INDEX idx_users_role ON users(role); + +-- ----------------------------------------------------------------------------- +-- TABLE: user_profiles +-- Minimal identity information for any user type +-- Rationale: Extracted common identity fields from Candidate/Employer models. +-- Contains only display/identity data, no marketplace-specific attributes. +-- ----------------------------------------------------------------------------- + +CREATE TABLE user_profiles ( + id TEXT PRIMARY KEY, -- cuid() generated in application layer + user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + + -- Identity fields (common to all user types) + first_name TEXT NOT NULL, + last_name TEXT NOT NULL, + display_name TEXT, -- Optional public display name + avatar_url TEXT, -- Profile image (stored in S3 per spec) + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_user_profiles_user_id ON user_profiles(user_id); + +-- ============================================================================= +-- AUTHENTICATION STRATEGY OPTIONS +-- ============================================================================= +-- +-- CP Build 1 mentions multiple authentication approaches. This schema does NOT +-- prescribe a specific strategy. Options to consider: +-- +-- OPTION A: Server-side Sessions (Database-backed) +-- ------------------------------------------------ +-- CREATE TABLE sessions ( +-- id TEXT PRIMARY KEY, +-- user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, +-- token TEXT NOT NULL UNIQUE, +-- expires_at TIMESTAMPTZ NOT NULL, +-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +-- ); +-- +-- Pros: Easy revocation, server control, works with NextAuth.js database adapter +-- Cons: Database lookup on each request, horizontal scaling requires shared store +-- +-- OPTION B: JWT Tokens (Stateless) +-- -------------------------------- +-- No database table required. Tokens are self-contained and verified via signature. +-- Refresh tokens may optionally be stored for revocation. +-- +-- Pros: Stateless, scalable, no DB hit per request +-- Cons: Cannot revoke until expiry (unless using blocklist), token size in requests +-- +-- OPTION C: Hybrid (JWT + Redis Session Cache) +-- -------------------------------------------- +-- Short-lived JWTs for API auth, with session state cached in Redis (per spec: +-- "AWS ElastiCache - Redis for sessions/caching"). +-- +-- Pros: Best of both - stateless verification, centralized revocation +-- Cons: Additional infrastructure dependency +-- +-- RECOMMENDATION: Defer decision until authentication implementation phase. +-- The users table above supports all three options. +-- +-- ============================================================================= + +-- ============================================================================= +-- END OF SCHEMA +-- ============================================================================= diff --git a/scripts/migrate.js b/scripts/migrate.js new file mode 100644 index 000000000..70a2d5ece --- /dev/null +++ b/scripts/migrate.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +/** + * Simple migration runner for raw SQL files + * Usage: node scripts/migrate.js + * + * Runs all .sql files in /migrations in alphabetical order. + * Tracks applied migrations in a migrations table. + */ + +const { Client } = require('pg'); +const fs = require('fs'); +const path = require('path'); + +const MIGRATIONS_DIR = path.join(__dirname, '..', 'migrations'); + +async function run() { + const client = new Client({ + connectionString: process.env.DATABASE_URL, + }); + + try { + await client.connect(); + console.log('Connected to database'); + + // Create migrations tracking table if not exists + await client.query(` + CREATE TABLE IF NOT EXISTS _migrations ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `); + + // Get already applied migrations + const { rows: applied } = await client.query( + 'SELECT name FROM _migrations ORDER BY name' + ); + const appliedSet = new Set(applied.map(r => r.name)); + + // Get migration files + const files = fs.readdirSync(MIGRATIONS_DIR) + .filter(f => f.endsWith('.sql')) + .sort(); + + // Apply pending migrations + for (const file of files) { + if (appliedSet.has(file)) { + console.log(`Skipping ${file} (already applied)`); + continue; + } + + console.log(`Applying ${file}...`); + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf8'); + + await client.query('BEGIN'); + try { + await client.query(sql); + await client.query( + 'INSERT INTO _migrations (name) VALUES ($1)', + [file] + ); + await client.query('COMMIT'); + console.log(`Applied ${file}`); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } + } + + console.log('All migrations complete'); + } catch (err) { + console.error('Migration failed:', err.message); + process.exit(1); + } finally { + await client.end(); + } +} + +run(); diff --git a/src/app/api/auth/forgot-password/route.ts b/src/app/api/auth/forgot-password/route.ts new file mode 100644 index 000000000..8ed7d8a50 --- /dev/null +++ b/src/app/api/auth/forgot-password/route.ts @@ -0,0 +1,57 @@ +/** + * POST /api/auth/forgot-password + * + * Initiates password reset by sending token to email. + * Always returns success to prevent email enumeration. + */ + +import { query, queryOne, generateId } from '@/lib/db'; +import { forgotPasswordSchema, generateToken, hashToken, PASSWORD_RESET_DURATION_MS } from '@/lib/auth'; +import { success, validationError, errors } from '@/lib/api'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + + const parsed = forgotPasswordSchema.safeParse(body); + if (!parsed.success) { + return validationError(parsed.error); + } + + const { email } = parsed.data; + + // Find user (don't reveal if exists) + const user = await queryOne<{ id: string; email: string }>( + 'SELECT id, email FROM users WHERE email = $1', + [email] + ); + + if (user) { + // Delete existing tokens + await query('DELETE FROM password_reset_tokens WHERE user_id = $1', [user.id]); + + // Create new token + const token = generateToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + PASSWORD_RESET_DURATION_MS); + + await query( + 'INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)', + [generateId(), user.id, tokenHash, expiresAt] + ); + + // TODO: Send email with token + if (process.env.NODE_ENV === 'development') { + console.log(`[DEV] Password reset token for ${email}: ${token}`); + } + } + + // Always return success to prevent enumeration + return success({ + message: 'If an account exists with this email, a reset link has been sent.', + }); + } catch (err) { + console.error('Forgot password error:', err); + return errors.internal(); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 000000000..93551d241 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,77 @@ +/** + * POST /api/auth/login + * + * Authenticates user with email/password. + * Per AUTHENTICATION_FLOWS.md: Generic error to prevent enumeration, + * updates last_login, creates session with HttpOnly cookie. + */ + +import { NextRequest } from 'next/server'; +import { query, queryOne } from '@/lib/db'; +import { verifyPassword, loginSchema } from '@/lib/auth'; +import { createSession, setSessionCookie } from '@/lib/session'; +import { success, validationError, errors } from '@/lib/api'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate input + const parsed = loginSchema.safeParse(body); + if (!parsed.success) { + return validationError(parsed.error); + } + + const { email, password, rememberMe } = parsed.data; + + // Find user + const user = await queryOne<{ + id: string; + email: string; + password: string; + role: string; + email_verified: Date | null; + }>( + 'SELECT id, email, password, role, email_verified FROM users WHERE email = $1', + [email] + ); + + // Generic error to prevent email enumeration + if (!user) { + return errors.invalidCredentials(); + } + + // Verify password + const valid = await verifyPassword(password, user.password); + if (!valid) { + return errors.invalidCredentials(); + } + + // Update last_login + await query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]); + + // Create session + const userAgent = request.headers.get('user-agent') ?? undefined; + const ipAddress = request.headers.get('x-forwarded-for')?.split(',')[0] ?? undefined; + const sessionToken = await createSession(user.id, { + rememberMe, + userAgent, + ipAddress, + }); + + // Build response + const response = success({ + user: { + id: user.id, + email: user.email, + role: user.role, + emailVerified: user.email_verified !== null, + }, + }); + + return setSessionCookie(response, sessionToken, rememberMe); + } catch (err) { + console.error('Login error:', err); + return errors.internal(); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 000000000..1b0b93ae9 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,26 @@ +/** + * POST /api/auth/logout + * + * Terminates the current session. + * Deletes session from database and clears cookie. + */ + +import { NextRequest } from 'next/server'; +import { deleteSession, clearSessionCookie } from '@/lib/session'; +import { success, errors } from '@/lib/api'; + +export async function POST(request: NextRequest) { + try { + const token = request.cookies.get('session_token')?.value; + + if (token) { + await deleteSession(token); + } + + const response = success({ message: 'Logged out successfully' }); + return clearSessionCookie(response); + } catch (err) { + console.error('Logout error:', err); + return errors.internal(); + } +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 000000000..e3663fec6 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,69 @@ +/** + * GET /api/auth/me + * + * Returns the current authenticated user. + * Requires valid session. + */ + +import { queryOne } from '@/lib/db'; +import { getSession } from '@/lib/session'; +import { success, errors } from '@/lib/api'; + +export async function GET() { + try { + const session = await getSession(); + + if (!session) { + return errors.unauthorized(); + } + + // Get user with profile + const user = await queryOne<{ + id: string; + email: string; + role: string; + email_verified: Date | null; + created_at: Date; + last_login: Date | null; + first_name: string; + last_name: string; + display_name: string | null; + avatar_url: string | null; + }>( + `SELECT + u.id, u.email, u.role, u.email_verified, u.created_at, u.last_login, + p.first_name, p.last_name, p.display_name, p.avatar_url + FROM users u + LEFT JOIN user_profiles p ON p.user_id = u.id + WHERE u.id = $1`, + [session.user.id] + ); + + if (!user) { + return errors.unauthorized(); + } + + return success({ + user: { + id: user.id, + email: user.email, + role: user.role, + emailVerified: user.email_verified !== null, + createdAt: user.created_at, + lastLogin: user.last_login, + profile: { + firstName: user.first_name, + lastName: user.last_name, + displayName: user.display_name, + avatarUrl: user.avatar_url, + }, + }, + session: { + expiresAt: session.expiresAt, + }, + }); + } catch (err) { + console.error('Get me error:', err); + return errors.internal(); + } +} diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts new file mode 100644 index 000000000..c6975a177 --- /dev/null +++ b/src/app/api/auth/register/route.ts @@ -0,0 +1,99 @@ +/** + * POST /api/auth/register + * + * Creates a new user account with email/password. + * Per AUTHENTICATION_FLOWS.md: Registration creates user + profile, + * generates email verification token, and establishes session. + */ + +import { NextRequest } from 'next/server'; +import { query, queryOne, generateId } from '@/lib/db'; +import { hashPassword, registerSchema, generateToken, hashToken, EMAIL_VERIFICATION_DURATION_MS } from '@/lib/auth'; +import { createSession, setSessionCookie } from '@/lib/session'; +import { success, error, validationError, errors } from '@/lib/api'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate input + const parsed = registerSchema.safeParse(body); + if (!parsed.success) { + return validationError(parsed.error); + } + + const { email, password, role, firstName, lastName } = parsed.data; + + // Check if email exists + const existing = await queryOne<{ id: string }>( + 'SELECT id FROM users WHERE email = $1', + [email] + ); + if (existing) { + return errors.emailExists(); + } + + // Hash password + const passwordHash = await hashPassword(password); + + // Generate IDs + const userId = generateId(); + const profileId = generateId(); + + // Generate email verification token + const verifyToken = generateToken(); + const verifyTokenHash = hashToken(verifyToken); + const verifyExpiresAt = new Date(Date.now() + EMAIL_VERIFICATION_DURATION_MS); + + // Create user, profile, and verification token in transaction + await query('BEGIN'); + try { + await query( + `INSERT INTO users (id, email, password, role) + VALUES ($1, $2, $3, $4)`, + [userId, email, passwordHash, role] + ); + + await query( + `INSERT INTO user_profiles (id, user_id, first_name, last_name) + VALUES ($1, $2, $3, $4)`, + [profileId, userId, firstName, lastName] + ); + + await query( + `INSERT INTO email_verification_tokens (id, user_id, token_hash, expires_at) + VALUES ($1, $2, $3, $4)`, + [generateId(), userId, verifyTokenHash, verifyExpiresAt] + ); + + await query('COMMIT'); + } catch (err) { + await query('ROLLBACK'); + throw err; + } + + // Create session + const userAgent = request.headers.get('user-agent') ?? undefined; + const ipAddress = request.headers.get('x-forwarded-for')?.split(',')[0] ?? undefined; + const sessionToken = await createSession(userId, { userAgent, ipAddress }); + + // TODO: Send verification email with verifyToken + if (process.env.NODE_ENV === 'development') { + console.log(`[DEV] Verification token for ${email}: ${verifyToken}`); + } + + // Build response + const response = success( + { + user: { id: userId, email, role, emailVerified: false }, + message: 'Registration successful. Please verify your email.', + }, + 201 + ); + + return setSessionCookie(response, sessionToken); + } catch (err) { + console.error('Register error:', err); + return errors.internal(); + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 000000000..b6cdbb2cf --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,78 @@ +/** + * POST /api/auth/reset-password + * + * Completes password reset with token. + * Per AUTHENTICATION_FLOWS.md: 1-hour expiry, invalidates all sessions. + */ + +import { query, queryOne } from '@/lib/db'; +import { resetPasswordSchema, hashToken, hashPassword, isExpired } from '@/lib/auth'; +import { deleteAllUserSessions, clearSessionCookie } from '@/lib/session'; +import { success, validationError, errors, error } from '@/lib/api'; + +export async function POST(request: Request) { + try { + const body = await request.json(); + + const parsed = resetPasswordSchema.safeParse(body); + if (!parsed.success) { + return validationError(parsed.error); + } + + const { token, password } = parsed.data; + const tokenHash = hashToken(token); + + // Find token + const tokenRow = await queryOne<{ + id: string; + user_id: string; + expires_at: Date; + used_at: Date | null; + }>( + 'SELECT id, user_id, expires_at, used_at FROM password_reset_tokens WHERE token_hash = $1', + [tokenHash] + ); + + if (!tokenRow) { + return errors.invalidToken(); + } + + if (tokenRow.used_at) { + return errors.invalidToken(); + } + + if (isExpired(tokenRow.expires_at)) { + await query('DELETE FROM password_reset_tokens WHERE id = $1', [tokenRow.id]); + return error('TOKEN_EXPIRED', 'Reset token has expired', 400); + } + + // Hash new password + const passwordHash = await hashPassword(password); + + // Update password and mark token used + await query('BEGIN'); + try { + await query('UPDATE users SET password = $1, updated_at = NOW() WHERE id = $2', [ + passwordHash, + tokenRow.user_id, + ]); + await query('UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1', [tokenRow.id]); + await query('COMMIT'); + } catch (err) { + await query('ROLLBACK'); + throw err; + } + + // Invalidate all sessions + await deleteAllUserSessions(tokenRow.user_id); + + const response = success({ + message: 'Password reset successful. Please log in with your new password.', + }); + + return clearSessionCookie(response); + } catch (err) { + console.error('Reset password error:', err); + return errors.internal(); + } +} diff --git a/src/app/api/auth/verify-email/route.ts b/src/app/api/auth/verify-email/route.ts new file mode 100644 index 000000000..9c1d154dc --- /dev/null +++ b/src/app/api/auth/verify-email/route.ts @@ -0,0 +1,108 @@ +/** + * POST /api/auth/verify-email + * + * Verifies user's email address using token. + * Stub implementation - validates token structure only. + */ + +import { query, queryOne, generateId } from '@/lib/db'; +import { verifyEmailSchema, hashToken, isExpired, EMAIL_VERIFICATION_DURATION_MS, generateToken } from '@/lib/auth'; +import { getSession } from '@/lib/session'; +import { success, validationError, errors, error } from '@/lib/api'; + +/** + * POST - Verify email with token + */ +export async function POST(request: Request) { + try { + const body = await request.json(); + + const parsed = verifyEmailSchema.safeParse(body); + if (!parsed.success) { + return validationError(parsed.error); + } + + const { token } = parsed.data; + const tokenHash = hashToken(token); + + // Find token + const tokenRow = await queryOne<{ + id: string; + user_id: string; + expires_at: Date; + }>( + 'SELECT id, user_id, expires_at FROM email_verification_tokens WHERE token_hash = $1', + [tokenHash] + ); + + if (!tokenRow) { + return errors.invalidToken(); + } + + if (isExpired(tokenRow.expires_at)) { + await query('DELETE FROM email_verification_tokens WHERE id = $1', [tokenRow.id]); + return error('TOKEN_EXPIRED', 'Verification token has expired', 400); + } + + // Verify email and delete token + await query('BEGIN'); + try { + await query('UPDATE users SET email_verified = NOW() WHERE id = $1', [tokenRow.user_id]); + await query('DELETE FROM email_verification_tokens WHERE user_id = $1', [tokenRow.user_id]); + await query('COMMIT'); + } catch (err) { + await query('ROLLBACK'); + throw err; + } + + return success({ message: 'Email verified successfully' }); + } catch (err) { + console.error('Verify email error:', err); + return errors.internal(); + } +} + +/** + * GET - Resend verification email (requires auth) + */ +export async function GET() { + try { + const session = await getSession(); + if (!session) { + return errors.unauthorized(); + } + + // Check if already verified + const user = await queryOne<{ email_verified: Date | null; email: string }>( + 'SELECT email_verified, email FROM users WHERE id = $1', + [session.user.id] + ); + + if (user?.email_verified) { + return error('ALREADY_VERIFIED', 'Email is already verified', 400); + } + + // Delete existing tokens + await query('DELETE FROM email_verification_tokens WHERE user_id = $1', [session.user.id]); + + // Create new token + const token = generateToken(); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + EMAIL_VERIFICATION_DURATION_MS); + + await query( + 'INSERT INTO email_verification_tokens (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)', + [generateId(), session.user.id, tokenHash, expiresAt] + ); + + // TODO: Send email with token + if (process.env.NODE_ENV === 'development') { + console.log(`[DEV] Verification token for ${user?.email}: ${token}`); + } + + return success({ message: 'Verification email sent' }); + } catch (err) { + console.error('Resend verification error:', err); + return errors.internal(); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 000000000..007c4658e --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,16 @@ +export const metadata = { + title: 'CloudPeople API', + description: 'Authentication API', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + +
{children} + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 000000000..a05dd7356 --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +Authentication endpoints at /api/auth/*
+