diff --git a/.env.example b/.env.example index 47e13cd..6f720a3 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ DATABASE_URL=postgresql://user:password@localhost:5432/aethex_passport # Server Configuration PORT=3000 NODE_ENV=development +FRONTEND_URL=http://localhost:5173 + +# Development Security (ONLY for development, DO NOT enable in production) +# Allows bypassing authentication - requires BOTH NODE_ENV=development AND ALLOW_DEV_BYPASS=true +ALLOW_DEV_BYPASS=true # Blockchain Configuration (for .aethex domain verification) RPC_ENDPOINT=https://polygon-mainnet.infura.io/v3/YOUR_INFURA_KEY @@ -16,7 +21,13 @@ JWT_SECRET=your-secret-key-here RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX_REQUESTS=100 # TURN Server Configuration (for WebRTC NAT traversal) +# CRITICAL: These MUST be set in production - no defaults allowed TURN_SERVER_HOST=turn.example.com TURN_SERVER_PORT=3478 TURN_SECRET=your-turn-secret-key -TURN_TTL=86400 \ No newline at end of file +TURN_TTL=86400 + +# Stripe Configuration (for payments) +STRIPE_SECRET_KEY=sk_test_... +STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... \ No newline at end of file diff --git a/src/backend/middleware/auth.js b/src/backend/middleware/auth.js index 7df2ec9..bbb5691 100644 --- a/src/backend/middleware/auth.js +++ b/src/backend/middleware/auth.js @@ -7,21 +7,28 @@ const jwt = require('jsonwebtoken'); function authenticateUser(req, res, next) { try { // In development mode, allow requests without auth for testing - if (process.env.NODE_ENV === 'development') { + // SECURITY: This bypass only works if BOTH conditions are met: + // 1. NODE_ENV is 'development' + // 2. ALLOW_DEV_BYPASS is explicitly set to 'true' + const isDevelopment = process.env.NODE_ENV === 'development'; + const allowDevBypass = process.env.ALLOW_DEV_BYPASS === 'true'; + + if (isDevelopment && allowDevBypass) { // Check for token, but if not present, use demo user const authHeader = req.headers.authorization; - + if (!authHeader || !authHeader.startsWith('Bearer ')) { // Use demo user for development + console.warn('⚠️ Development mode: Using demo user without authentication'); req.user = { id: 'demo-user-123', email: 'demo@aethex.dev' }; return next(); } - + const token = authHeader.substring(7); - + // Try to verify, but don't fail if invalid try { const decoded = jwt.verify(token, process.env.JWT_SECRET); @@ -31,15 +38,16 @@ function authenticateUser(req, res, next) { }; } catch { // Use demo user if token invalid + console.warn('⚠️ Development mode: Invalid token, using demo user'); req.user = { id: 'demo-user-123', email: 'demo@aethex.dev' }; } - + return next(); } - + // Production: Strict auth required const authHeader = req.headers.authorization; diff --git a/src/backend/routes/webhooks/stripeWebhook.js b/src/backend/routes/webhooks/stripeWebhook.js index 2b34e0d..554c18b 100644 --- a/src/backend/routes/webhooks/stripeWebhook.js +++ b/src/backend/routes/webhooks/stripeWebhook.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); const db = require('../../database/db'); +const notificationService = require('../../services/notificationService'); /** * Stripe webhook endpoint @@ -143,8 +144,8 @@ async function handleSubscriptionDeleted(subscription) { console.log(`[Webhook] User ${userId} downgraded to free tier`); - // TODO: Send notification to user about downgrade - // Could trigger email, in-app notification, etc. + // Send notification to user about downgrade + await notificationService.notifySubscriptionDowngrade(userId); } } @@ -177,7 +178,13 @@ async function handlePaymentSucceeded(invoice) { console.log(`[Webhook] Payment logged for user ${subResult.rows[0].user_id}`); - // TODO: Send receipt email to user + // Send receipt email to user + const amount = invoice.amount_paid / 100; + await notificationService.notifyPaymentSuccess( + subResult.rows[0].user_id, + amount, + invoice.id + ); } } } @@ -213,8 +220,10 @@ async function handlePaymentFailed(invoice) { console.log(`[Webhook] Failed payment logged for user ${userId}`); - // TODO: Send notification to user about failed payment - // Include link to update payment method + // Send notification to user about failed payment + const amount = invoice.amount_due / 100; + const reason = invoice.last_payment_error?.message || 'Unknown error'; + await notificationService.notifyPaymentFailed(userId, amount, reason); } } } @@ -232,9 +241,16 @@ async function handleTrialWillEnd(subscription) { ); if (subResult.rows.length > 0) { - // TODO: Send notification to user that trial is ending - // Remind them to add payment method - console.log(`[Webhook] Trial ending notification for user ${subResult.rows[0].user_id}`); + const userId = subResult.rows[0].user_id; + + // Calculate days remaining in trial + const trialEnd = new Date(subscription.trial_end * 1000); + const now = new Date(); + const daysRemaining = Math.ceil((trialEnd - now) / (1000 * 60 * 60 * 24)); + + // Send notification to user that trial is ending + await notificationService.notifyTrialEnding(userId, daysRemaining); + console.log(`[Webhook] Trial ending notification sent to user ${userId}`); } } diff --git a/src/backend/services/callService.js b/src/backend/services/callService.js index a59642c..bd6a0fa 100644 --- a/src/backend/services/callService.js +++ b/src/backend/services/callService.js @@ -29,6 +29,10 @@ class CallService { [conversationId] ); + if (conversationResult.rows.length === 0) { + throw new Error(`Conversation ${conversationId} not found`); + } + const conversation = conversationResult.rows[0]; // Determine if group call @@ -249,9 +253,12 @@ class CallService { async generateTURNCredentials(userId) { const timestamp = Math.floor(Date.now() / 1000) + 86400; // 24 hour TTL const username = `${timestamp}:${userId}`; - + // Generate credential using HMAC - const turnSecret = process.env.TURN_SECRET || 'default-secret-change-me'; + const turnSecret = process.env.TURN_SECRET; + if (!turnSecret) { + throw new Error('TURN_SECRET environment variable must be set for TURN server authentication'); + } const hmac = crypto.createHmac('sha1', turnSecret); hmac.update(username); const credential = hmac.digest('base64'); @@ -265,8 +272,12 @@ class CallService { [userId, username, credential, timestamp] ); - const turnHost = process.env.TURN_SERVER_HOST || 'turn.aethex.app'; - const turnPort = process.env.TURN_SERVER_PORT || '3478'; + const turnHost = process.env.TURN_SERVER_HOST; + const turnPort = process.env.TURN_SERVER_PORT; + + if (!turnHost || !turnPort) { + throw new Error('TURN_SERVER_HOST and TURN_SERVER_PORT environment variables must be set'); + } return { urls: [ diff --git a/src/backend/services/gameforgeIntegration.js b/src/backend/services/gameforgeIntegration.js index 4ae2418..a8e273a 100644 --- a/src/backend/services/gameforgeIntegration.js +++ b/src/backend/services/gameforgeIntegration.js @@ -36,6 +36,10 @@ class GameForgeIntegrationService { ] ); + if (integrationResult.rows.length === 0) { + throw new Error('Failed to create GameForge integration'); + } + const integration = integrationResult.rows[0]; // Create channels @@ -88,13 +92,17 @@ class GameForgeIntegrationService { const description = this.getChannelDescription(channelName); const conversationResult = await db.query( - `INSERT INTO conversations + `INSERT INTO conversations (type, title, description, created_by, gameforge_project_id) VALUES ('group', $1, $2, $3, $4) RETURNING *`, [title, description, ownerId, projectId] ); + if (conversationResult.rows.length === 0) { + throw new Error('Failed to create conversation for GameForge channel'); + } + const conversation = conversationResult.rows[0]; // Add team members based on permissions diff --git a/src/backend/services/messagingService.js b/src/backend/services/messagingService.js index 6847b45..745f849 100644 --- a/src/backend/services/messagingService.js +++ b/src/backend/services/messagingService.js @@ -263,9 +263,37 @@ class MessagingService { * Send a message */ async sendMessage(userId, conversationId, { content, contentType = 'text', metadata = null, replyToId = null, identityId = null }) { + // Validate required parameters + if (!userId || typeof userId !== 'string') { + throw new Error('Invalid userId parameter'); + } + + if (!conversationId || typeof conversationId !== 'string') { + throw new Error('Invalid conversationId parameter'); + } + + if (!content || typeof content !== 'string' || content.trim().length === 0) { + throw new Error('Message content cannot be empty'); + } + + if (content.length > 10000) { + throw new Error('Message content exceeds maximum length of 10000 characters'); + } + + // Validate contentType + const validContentTypes = ['text', 'image', 'file', 'audio', 'video', 'code']; + if (!validContentTypes.includes(contentType)) { + throw new Error(`Invalid contentType. Must be one of: ${validContentTypes.join(', ')}`); + } + + // Validate metadata if provided + if (metadata !== null && typeof metadata !== 'object') { + throw new Error('metadata must be an object or null'); + } + // Verify user is participant await this.verifyParticipant(conversationId, userId); - + // In production, content should already be encrypted by client // For now, we'll just store it as-is const contentEncrypted = content; @@ -287,8 +315,13 @@ class MessagingService { `SELECT username, verified_domain, avatar_url FROM users WHERE id = $1`, [userId] ); + + if (userResult.rows.length === 0) { + throw new Error(`User ${userId} not found`); + } + const user = userResult.rows[0]; - + return { id: message.id, conversationId: message.conversation_id, diff --git a/src/backend/services/notificationService.js b/src/backend/services/notificationService.js new file mode 100644 index 0000000..db0a8ff --- /dev/null +++ b/src/backend/services/notificationService.js @@ -0,0 +1,223 @@ +/** + * Notification Service + * Handles sending notifications to users via multiple channels + */ + +const db = require('../database/db'); + +class NotificationService { + /** + * Send notification to user + * @param {string} userId - User ID to notify + * @param {Object} notification - Notification details + * @param {string} notification.type - Type of notification (email, in-app, both) + * @param {string} notification.category - Category (payment, subscription, system, etc.) + * @param {string} notification.title - Notification title + * @param {string} notification.message - Notification message + * @param {Object} notification.metadata - Additional metadata + */ + async sendNotification(userId, notification) { + const { type = 'in-app', category, title, message, metadata = {} } = notification; + + try { + // Store notification in database + const result = await db.query( + `INSERT INTO notifications + (user_id, type, category, title, message, metadata, read, created_at) + VALUES ($1, $2, $3, $4, $5, $6, false, NOW()) + RETURNING *`, + [userId, type, category, title, message, JSON.stringify(metadata)] + ); + + const savedNotification = result.rows[0]; + + // If email notification requested, queue email + if (type === 'email' || type === 'both') { + await this.queueEmail(userId, notification); + } + + // Emit real-time notification via Socket.io if available + if (global.io) { + global.io.to(`user:${userId}`).emit('notification', { + id: savedNotification.id, + category, + title, + message, + metadata, + createdAt: savedNotification.created_at + }); + } + + return savedNotification; + } catch (error) { + console.error('Failed to send notification:', error); + // Don't throw - notifications should be best-effort + // Log error but don't fail the parent operation + return null; + } + } + + /** + * Queue email for sending + * In production, this would integrate with an email service like SendGrid, SES, etc. + */ + async queueEmail(userId, notification) { + const { title, message, metadata = {} } = notification; + + try { + // Get user email + const userResult = await db.query( + `SELECT email, username FROM users WHERE id = $1`, + [userId] + ); + + if (userResult.rows.length === 0) { + console.error(`User ${userId} not found for email notification`); + return; + } + + const user = userResult.rows[0]; + + // Queue email in database (to be processed by email worker) + await db.query( + `INSERT INTO email_queue + (user_id, recipient_email, subject, body, template, metadata, status, created_at) + VALUES ($1, $2, $3, $4, $5, $6, 'pending', NOW())`, + [ + userId, + user.email, + title, + message, + metadata.template || 'default', + JSON.stringify({ ...metadata, username: user.username }) + ] + ); + + console.log(`Email queued for ${user.email}: ${title}`); + } catch (error) { + console.error('Failed to queue email:', error); + } + } + + /** + * Send subscription downgrade notification + */ + async notifySubscriptionDowngrade(userId) { + return this.sendNotification(userId, { + type: 'both', + category: 'subscription', + title: 'Subscription Cancelled', + message: 'Your premium subscription has been cancelled and your account has been downgraded to the free tier. You still have access to premium features until the end of your billing period.', + metadata: { + template: 'subscription-downgrade', + action: { + label: 'Resubscribe', + url: '/premium' + } + } + }); + } + + /** + * Send payment receipt + */ + async notifyPaymentSuccess(userId, amount, invoiceId) { + return this.sendNotification(userId, { + type: 'email', + category: 'payment', + title: 'Payment Receipt', + message: `Your payment of $${amount.toFixed(2)} has been processed successfully. Thank you for your continued subscription!`, + metadata: { + template: 'payment-receipt', + amount, + invoiceId, + action: { + label: 'View Invoice', + url: `/billing/invoices/${invoiceId}` + } + } + }); + } + + /** + * Send payment failure notification + */ + async notifyPaymentFailed(userId, amount, reason = 'Unknown') { + return this.sendNotification(userId, { + type: 'both', + category: 'payment', + title: 'Payment Failed', + message: `We were unable to process your payment of $${amount.toFixed(2)}. Please update your payment method to continue your subscription.`, + metadata: { + template: 'payment-failed', + amount, + reason, + action: { + label: 'Update Payment Method', + url: '/settings/billing' + } + } + }); + } + + /** + * Send trial ending notification + */ + async notifyTrialEnding(userId, daysRemaining = 3) { + return this.sendNotification(userId, { + type: 'both', + category: 'subscription', + title: 'Trial Ending Soon', + message: `Your premium trial will end in ${daysRemaining} days. Add a payment method to continue enjoying premium features without interruption.`, + metadata: { + template: 'trial-ending', + daysRemaining, + action: { + label: 'Add Payment Method', + url: '/settings/billing' + } + } + }); + } + + /** + * Get unread notifications for user + */ + async getUnreadNotifications(userId, limit = 50) { + const result = await db.query( + `SELECT * FROM notifications + WHERE user_id = $1 AND read = false + ORDER BY created_at DESC + LIMIT $2`, + [userId, limit] + ); + + return result.rows; + } + + /** + * Mark notification as read + */ + async markAsRead(notificationId, userId) { + await db.query( + `UPDATE notifications + SET read = true, read_at = NOW() + WHERE id = $1 AND user_id = $2`, + [notificationId, userId] + ); + } + + /** + * Mark all notifications as read for user + */ + async markAllAsRead(userId) { + await db.query( + `UPDATE notifications + SET read = true, read_at = NOW() + WHERE user_id = $1 AND read = false`, + [userId] + ); + } +} + +module.exports = new NotificationService(); diff --git a/src/backend/services/socketService.js b/src/backend/services/socketService.js index f3557e6..f19717d 100644 --- a/src/backend/services/socketService.js +++ b/src/backend/services/socketService.js @@ -86,21 +86,88 @@ class SocketService { // Join all conversations user is part of this.joinUserConversations(socket, userId); - // Handle events - socket.on('join_conversation', (data) => this.handleJoinConversation(socket, data)); - socket.on('leave_conversation', (data) => this.handleLeaveConversation(socket, data)); - socket.on('typing_start', (data) => this.handleTypingStart(socket, data)); - socket.on('typing_stop', (data) => this.handleTypingStop(socket, data)); - socket.on('call_signal', (data) => this.handleCallSignal(socket, data)); - + // Handle events with error handling + socket.on('join_conversation', async (data) => { + try { + await this.handleJoinConversation(socket, data); + } catch (error) { + console.error('Error handling join_conversation:', error); + socket.emit('error', { event: 'join_conversation', message: error.message }); + } + }); + + socket.on('leave_conversation', async (data) => { + try { + await this.handleLeaveConversation(socket, data); + } catch (error) { + console.error('Error handling leave_conversation:', error); + socket.emit('error', { event: 'leave_conversation', message: error.message }); + } + }); + + socket.on('typing_start', async (data) => { + try { + await this.handleTypingStart(socket, data); + } catch (error) { + console.error('Error handling typing_start:', error); + socket.emit('error', { event: 'typing_start', message: error.message }); + } + }); + + socket.on('typing_stop', async (data) => { + try { + await this.handleTypingStop(socket, data); + } catch (error) { + console.error('Error handling typing_stop:', error); + socket.emit('error', { event: 'typing_stop', message: error.message }); + } + }); + + socket.on('call_signal', async (data) => { + try { + await this.handleCallSignal(socket, data); + } catch (error) { + console.error('Error handling call_signal:', error); + socket.emit('error', { event: 'call_signal', message: error.message }); + } + }); + // Call signaling events - socket.on('call:offer', (data) => this.handleCallOffer(socket, data)); - socket.on('call:answer', (data) => this.handleCallAnswer(socket, data)); - socket.on('call:ice-candidate', (data) => this.handleIceCandidate(socket, data)); - + socket.on('call:offer', async (data) => { + try { + await this.handleCallOffer(socket, data); + } catch (error) { + console.error('Error handling call:offer:', error); + socket.emit('call:error', { type: 'offer', message: error.message }); + } + }); + + socket.on('call:answer', async (data) => { + try { + await this.handleCallAnswer(socket, data); + } catch (error) { + console.error('Error handling call:answer:', error); + socket.emit('call:error', { type: 'answer', message: error.message }); + } + }); + + socket.on('call:ice-candidate', async (data) => { + try { + await this.handleIceCandidate(socket, data); + } catch (error) { + console.error('Error handling call:ice-candidate:', error); + socket.emit('call:error', { type: 'ice-candidate', message: error.message }); + } + }); + // Disconnect handler - socket.on('disconnect', () => { - this.handleDisconnect(socket, userId); + socket.on('disconnect', async () => { + try { + await this.handleDisconnect(socket, userId); + } catch (error) { + console.error('Error handling disconnect:', error); + // Don't emit error as socket is disconnecting + } }); } diff --git a/src/frontend/components/Chat/Chat.jsx b/src/frontend/components/Chat/Chat.jsx index b22419a..52b34b7 100644 --- a/src/frontend/components/Chat/Chat.jsx +++ b/src/frontend/components/Chat/Chat.jsx @@ -17,8 +17,9 @@ export default function Chat() { const [loading, setLoading] = useState(true); const [typingUsers, setTypingUsers] = useState(new Set()); const [error, setError] = useState(null); - + const typingTimeoutRef = useRef(null); + const activeConversationRef = useRef(null); // Load conversations on mount useEffect(() => { @@ -101,7 +102,9 @@ export default function Chat() { const data = await response.json(); - if (data.success) { + // Only update messages if this conversation is still active + // This prevents race conditions when rapidly switching conversations + if (data.success && activeConversationRef.current === conversationId) { setMessages(data.messages); // Mark as read @@ -116,8 +119,16 @@ export default function Chat() { // Select conversation const selectConversation = async (conversation) => { + const conversationId = conversation.id; + + // Update state and ref immediately to prevent race conditions setActiveConversation(conversation); - await loadMessages(conversation.id); + activeConversationRef.current = conversationId; + + // Clear current messages while loading + setMessages([]); + + await loadMessages(conversationId); }; // Handle new message diff --git a/src/frontend/contexts/AuthContext.jsx b/src/frontend/contexts/AuthContext.jsx index 8a5fa47..123ac04 100644 --- a/src/frontend/contexts/AuthContext.jsx +++ b/src/frontend/contexts/AuthContext.jsx @@ -33,7 +33,8 @@ export function AuthProvider({ children }) { const login = async (email, password) => { // Mock login - in production, call actual API try { - const response = await fetch('http://localhost:3000/api/auth/login', { + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3000'; + const response = await fetch(`${apiUrl}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }) diff --git a/src/frontend/utils/crypto.js b/src/frontend/utils/crypto.js index bc63d23..eec1990 100644 --- a/src/frontend/utils/crypto.js +++ b/src/frontend/utils/crypto.js @@ -175,6 +175,7 @@ export async function encryptMessage(message, recipientPublicKeys) { // Encrypt AES key for each recipient with their RSA public key const encryptedKeys = {}; + const failedRecipients = []; for (const recipientKeyB64 of recipientPublicKeys) { try { @@ -201,10 +202,22 @@ export async function encryptMessage(message, recipientPublicKeys) { encryptedKeys[recipientKeyB64] = arrayBufferToBase64(encryptedKey); } catch (error) { - console.error('Failed to encrypt for recipient:', error); + failedRecipients.push({ + publicKey: recipientKeyB64.substring(0, 20) + '...', // Truncate for error message + error: error.message + }); } } + // Throw error if any encryptions failed to prevent partial message delivery + if (failedRecipients.length > 0) { + throw new Error( + `Failed to encrypt message for ${failedRecipients.length} recipient(s): ${ + failedRecipients.map(r => r.error).join(', ') + }` + ); + } + return { ciphertext: arrayBufferToBase64(encryptedMessage), iv: arrayBufferToBase64(iv),