create-arktos 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,284 @@
1
+ import helmet from 'helmet';
2
+ import cors from 'cors';
3
+ import rateLimit from 'express-rate-limit';
4
+ import jwt from 'jsonwebtoken';
5
+ import { createErrorResponse } from '../utils/response.js';
6
+ import { ERROR_CODES } from '../constants/errorCodes.js';
7
+ import DatabaseService from '../services/database.service.js';
8
+ import logger from '../config/logger.js';
9
+
10
+ const dbService = DatabaseService.getInstance();
11
+ const prisma = dbService.getClient();
12
+
13
+ // Security middleware
14
+ export const securityMiddleware = [
15
+ helmet({
16
+ contentSecurityPolicy: {
17
+ directives: {
18
+ defaultSrc: ["'self'"],
19
+ styleSrc: ["'self'", "'unsafe-inline'"],
20
+ scriptSrc: ["'self'"],
21
+ imgSrc: ["'self'", 'data:', 'https:'],
22
+ },
23
+ },
24
+ crossOriginEmbedderPolicy: false,
25
+ }),
26
+ cors({
27
+ origin: process.env.FRONTEND_URL || 'http://localhost:3000',
28
+ credentials: true,
29
+ optionsSuccessStatus: 200,
30
+ }),
31
+ ];
32
+
33
+ // Rate limiters
34
+ export const rateLimiter = {
35
+ general: rateLimit({
36
+ windowMs: 15 * 60 * 1000, // 15 minutes
37
+ max: 100, // limit each IP to 100 requests per windowMs
38
+ message: createErrorResponse(
39
+ 'Too many requests, please try again later',
40
+ ERROR_CODES.RATE_LIMIT_EXCEEDED
41
+ ),
42
+ standardHeaders: true,
43
+ legacyHeaders: false,
44
+ }),
45
+
46
+ auth: rateLimit({
47
+ windowMs: 15 * 60 * 1000, // 15 minutes
48
+ max: 10, // limit each IP to 10 auth requests per windowMs
49
+ message: createErrorResponse(
50
+ 'Too many authentication attempts, please try again later',
51
+ ERROR_CODES.RATE_LIMIT_EXCEEDED
52
+ ),
53
+ standardHeaders: true,
54
+ legacyHeaders: false,
55
+ }),
56
+
57
+ upload: rateLimit({
58
+ windowMs: 15 * 60 * 1000, // 15 minutes
59
+ max: 20, // limit each IP to 20 upload requests per windowMs
60
+ message: createErrorResponse(
61
+ 'Too many upload requests, please try again later',
62
+ ERROR_CODES.RATE_LIMIT_EXCEEDED
63
+ ),
64
+ standardHeaders: true,
65
+ legacyHeaders: false,
66
+ }),
67
+
68
+ api: rateLimit({
69
+ windowMs: 15 * 60 * 1000, // 15 minutes
70
+ max: 200, // limit each IP to 200 API requests per windowMs
71
+ message: createErrorResponse(
72
+ 'Too many API requests, please try again later',
73
+ ERROR_CODES.RATE_LIMIT_EXCEEDED
74
+ ),
75
+ standardHeaders: true,
76
+ legacyHeaders: false,
77
+ }),
78
+ };
79
+
80
+ // Authentication middleware
81
+ export const authMiddleware = async (req, res, next) => {
82
+ try {
83
+ const authHeader = req.headers.authorization;
84
+
85
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
86
+ return res.status(401).json(createErrorResponse(
87
+ 'Authentication token required',
88
+ ERROR_CODES.AUTH_TOKEN_REQUIRED
89
+ ));
90
+ }
91
+
92
+ const token = authHeader.substring(7);
93
+
94
+ let decoded;
95
+ try {
96
+ decoded = jwt.verify(token, process.env.JWT_SECRET);
97
+ } catch (error) {
98
+ return res.status(401).json(createErrorResponse(
99
+ 'Invalid or expired token',
100
+ ERROR_CODES.AUTH_TOKEN_EXPIRED
101
+ ));
102
+ }
103
+
104
+ // Get user from database
105
+ const user = await prisma.user.findUnique({
106
+ where: { id: decoded.userId },
107
+ select: {
108
+ id: true,
109
+ email: true,
110
+ firstName: true,
111
+ lastName: true,
112
+ username: true,
113
+ avatar: true,
114
+ role: true,
115
+ isEmailVerified: true,
116
+ isActive: true,
117
+ lastLoginAt: true,
118
+ }
119
+ });
120
+
121
+ if (!user) {
122
+ return res.status(401).json(createErrorResponse(
123
+ 'User not found',
124
+ ERROR_CODES.AUTH_USER_NOT_FOUND
125
+ ));
126
+ }
127
+
128
+ if (!user.isActive) {
129
+ return res.status(403).json(createErrorResponse(
130
+ 'Account is deactivated',
131
+ ERROR_CODES.AUTH_ACCOUNT_DEACTIVATED
132
+ ));
133
+ }
134
+
135
+ req.user = user;
136
+ next();
137
+ } catch (error) {
138
+ logger.error('Authentication middleware error:', error);
139
+ return res.status(500).json(createErrorResponse(
140
+ 'Internal server error',
141
+ ERROR_CODES.INTERNAL_ERROR
142
+ ));
143
+ }
144
+ };
145
+
146
+ // Optional auth middleware (doesn't fail if no token)
147
+ export const optionalAuth = async (req, res, next) => {
148
+ try {
149
+ const authHeader = req.headers.authorization;
150
+
151
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
152
+ return next();
153
+ }
154
+
155
+ const token = authHeader.substring(7);
156
+
157
+ try {
158
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
159
+
160
+ const user = await prisma.user.findUnique({
161
+ where: { id: decoded.userId },
162
+ select: {
163
+ id: true,
164
+ email: true,
165
+ firstName: true,
166
+ lastName: true,
167
+ username: true,
168
+ avatar: true,
169
+ role: true,
170
+ isEmailVerified: true,
171
+ isActive: true,
172
+ lastLoginAt: true,
173
+ }
174
+ });
175
+
176
+ if (user && user.isActive) {
177
+ req.user = user;
178
+ }
179
+ } catch (error) {
180
+ // Ignore token errors in optional auth
181
+ }
182
+
183
+ next();
184
+ } catch (error) {
185
+ logger.error('Optional auth middleware error:', error);
186
+ next();
187
+ }
188
+ };
189
+
190
+ // Role-based access control
191
+ export const requireRole = (roles) => {
192
+ return (req, res, next) => {
193
+ if (!req.user) {
194
+ return res.status(401).json(createErrorResponse(
195
+ 'Authentication required',
196
+ ERROR_CODES.AUTH_TOKEN_REQUIRED
197
+ ));
198
+ }
199
+
200
+ const userRoles = Array.isArray(req.user.role) ? req.user.role : [req.user.role];
201
+ const requiredRoles = Array.isArray(roles) ? roles : [roles];
202
+
203
+ const hasRole = requiredRoles.some(role => userRoles.includes(role));
204
+
205
+ if (!hasRole) {
206
+ return res.status(403).json(createErrorResponse(
207
+ 'Insufficient permissions',
208
+ ERROR_CODES.AUTH_INSUFFICIENT_PERMISSIONS
209
+ ));
210
+ }
211
+
212
+ next();
213
+ };
214
+ };
215
+
216
+ // Admin only middleware
217
+ export const adminOnly = requireRole(['ADMIN']);
218
+
219
+ // Moderator or admin middleware
220
+ export const moderatorOrAdmin = requireRole(['MODERATOR', 'ADMIN']);
221
+
222
+ // Validation middleware
223
+ export const validation = {
224
+ request: (schema, data) => {
225
+ try {
226
+ return schema.parse(data);
227
+ } catch (error) {
228
+ const errorMessage = error.errors
229
+ ? error.errors.map(err => `${err.path.join('.')}: ${err.message}`).join(', ')
230
+ : 'Validation failed';
231
+
232
+ throw new Error(errorMessage);
233
+ }
234
+ }
235
+ };
236
+
237
+ // Error handling middleware
238
+ export const errorHandling = (error, _req, res, _next) => {
239
+ logger.error('Unhandled error:', error);
240
+
241
+ // Prisma errors
242
+ if (error.code === 'P2002') {
243
+ return res.status(409).json(createErrorResponse(
244
+ 'Duplicate entry',
245
+ ERROR_CODES.DATABASE_CONFLICT
246
+ ));
247
+ }
248
+
249
+ if (error.code === 'P2025') {
250
+ return res.status(404).json(createErrorResponse(
251
+ 'Record not found',
252
+ ERROR_CODES.DATABASE_NOT_FOUND
253
+ ));
254
+ }
255
+
256
+ // Validation errors
257
+ if (error.name === 'ValidationError' || error.name === 'ZodError') {
258
+ return res.status(400).json(createErrorResponse(
259
+ error.message || 'Validation failed',
260
+ ERROR_CODES.VALIDATION_ERROR
261
+ ));
262
+ }
263
+
264
+ // JWT errors
265
+ if (error.name === 'JsonWebTokenError') {
266
+ return res.status(401).json(createErrorResponse(
267
+ 'Invalid token',
268
+ ERROR_CODES.AUTH_TOKEN_INVALID
269
+ ));
270
+ }
271
+
272
+ if (error.name === 'TokenExpiredError') {
273
+ return res.status(401).json(createErrorResponse(
274
+ 'Token expired',
275
+ ERROR_CODES.AUTH_TOKEN_EXPIRED
276
+ ));
277
+ }
278
+
279
+ // Default error response
280
+ return res.status(500).json(createErrorResponse(
281
+ 'Internal server error',
282
+ ERROR_CODES.INTERNAL_ERROR
283
+ ));
284
+ };
@@ -0,0 +1,28 @@
1
+ import express from 'express';
2
+ import {
3
+ register,
4
+ login,
5
+ refreshToken,
6
+ logout,
7
+ verifyEmail,
8
+ getProfile,
9
+ updateProfile,
10
+ changePassword
11
+ } from '../controllers/auth.controller.js';
12
+ import { authMiddleware, optionalAuth, rateLimiter } from '../middleware/index.js';
13
+
14
+ const router = express.Router();
15
+
16
+ // Public routes with rate limiting
17
+ router.post('/register', rateLimiter.auth, register);
18
+ router.post('/login', rateLimiter.auth, login);
19
+ router.post('/refresh-token', rateLimiter.auth, refreshToken);
20
+ router.get('/verify-email/:token', verifyEmail);
21
+
22
+ // Protected routes
23
+ router.post('/logout', authMiddleware, logout);
24
+ router.get('/profile', authMiddleware, getProfile);
25
+ router.put('/profile', authMiddleware, updateProfile);
26
+ router.put('/change-password', authMiddleware, changePassword);
27
+
28
+ export default router;
@@ -0,0 +1,18 @@
1
+ import express from 'express';
2
+ import authRoutes from './auth.routes.js';
3
+
4
+ const router = express.Router();
5
+
6
+ // Mount auth routes
7
+ router.use('/auth', authRoutes);
8
+
9
+ // Health check for API
10
+ router.get('/health', (req, res) => {
11
+ res.json({
12
+ status: 'ok',
13
+ timestamp: new Date().toISOString(),
14
+ message: 'API is running'
15
+ });
16
+ });
17
+
18
+ export default router;
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+
3
+ // Environment validation schema
4
+ export const envSchema = z.object({
5
+ NODE_ENV: z.string().default('development'),
6
+ PORT: z.string().transform(Number).default(3001),
7
+ DATABASE_URL: z.string().min(1, 'Database URL is required'),
8
+ DIRECT_URL: z.string().optional(),
9
+ JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
10
+ JWT_REFRESH_SECRET: z.string().min(32, 'JWT refresh secret must be at least 32 characters'),
11
+ RESEND_API_KEY: z.string().optional(),
12
+ FRONTEND_URL: z.string().default('http://localhost:3000'),
13
+ BACKEND_URL: z.string().default('http://localhost:3001'),
14
+ BCRYPT_SALT_ROUNDS: z.string().transform(Number).default(12),
15
+ LOG_LEVEL: z.string().default('info'),
16
+ LOG_FILE: z.string().default('logs/app.log'),
17
+ });
18
+
19
+ // Auth schemas
20
+ export const registerSchema = z.object({
21
+ email: z.string().email('Invalid email format'),
22
+ password: z.string().min(8, 'Password must be at least 8 characters'),
23
+ firstName: z.string().min(1, 'First name is required'),
24
+ lastName: z.string().min(1, 'Last name is required'),
25
+ username: z.string().min(3, 'Username must be at least 3 characters').optional(),
26
+ });
27
+
28
+ export const loginSchema = z.object({
29
+ email: z.string().email('Invalid email format'),
30
+ password: z.string().min(1, 'Password is required'),
31
+ });
32
+
33
+ export const refreshTokenSchema = z.object({
34
+ refreshToken: z.string().min(1, 'Refresh token is required'),
35
+ });
36
+
37
+ export const resendVerificationSchema = z.object({
38
+ email: z.string().email('Invalid email format'),
39
+ });
40
+
41
+ export const forgotPasswordSchema = z.object({
42
+ email: z.string().email('Invalid email format'),
43
+ });
44
+
45
+ export const resetPasswordSchema = z.object({
46
+ token: z.string().min(1, 'Reset token is required'),
47
+ password: z.string().min(8, 'Password must be at least 8 characters'),
48
+ });
49
+
50
+ export const updateProfileSchema = z.object({
51
+ firstName: z.string().min(1, 'First name is required').optional(),
52
+ lastName: z.string().min(1, 'Last name is required').optional(),
53
+ username: z.string().min(3, 'Username must be at least 3 characters').optional(),
54
+ avatar: z.string().url('Invalid avatar URL').optional(),
55
+ });
56
+
57
+ export const changePasswordSchema = z.object({
58
+ currentPassword: z.string().min(1, 'Current password is required'),
59
+ newPassword: z.string().min(8, 'New password must be at least 8 characters'),
60
+ });
@@ -0,0 +1,89 @@
1
+ import { PrismaClient } from '@prisma/client';
2
+ import logger from '../config/logger.js';
3
+
4
+ export class DatabaseService {
5
+ static instance;
6
+ prisma;
7
+ isConnected = false;
8
+
9
+ constructor() {
10
+ this.prisma = new PrismaClient({
11
+ log: ['query', 'error', 'info', 'warn'],
12
+ });
13
+
14
+ // Set up event listeners
15
+ this.setupEventListeners();
16
+ }
17
+
18
+ static getInstance() {
19
+ if (!DatabaseService.instance) {
20
+ DatabaseService.instance = new DatabaseService();
21
+ }
22
+ return DatabaseService.instance;
23
+ }
24
+
25
+ setupEventListeners() {
26
+ // Event listeners can be added here if needed
27
+ // For now, we're using simple logging configuration
28
+ }
29
+
30
+ async connect() {
31
+ try {
32
+ await this.prisma.$connect();
33
+ this.isConnected = true;
34
+ logger.info('Database connected successfully');
35
+ } catch (error) {
36
+ this.isConnected = false;
37
+ logger.error('Failed to connect to database:', error);
38
+ throw error;
39
+ }
40
+ }
41
+
42
+ async disconnect() {
43
+ try {
44
+ await this.prisma.$disconnect();
45
+ this.isConnected = false;
46
+ logger.info('Database disconnected successfully');
47
+ } catch (error) {
48
+ logger.error('Failed to disconnect from database:', error);
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ getClient() {
54
+ if (!this.isConnected) {
55
+ logger.warn('Database client requested but not connected');
56
+ }
57
+ return this.prisma;
58
+ }
59
+
60
+ async healthCheck() {
61
+ try {
62
+ await this.prisma.$queryRaw`SELECT 1`;
63
+ return {
64
+ connected: true,
65
+ status: 'healthy',
66
+ timestamp: new Date().toISOString(),
67
+ };
68
+ } catch (error) {
69
+ logger.error('Database health check failed:', error);
70
+ return {
71
+ connected: false,
72
+ status: 'unhealthy',
73
+ error: error.message,
74
+ timestamp: new Date().toISOString(),
75
+ };
76
+ }
77
+ }
78
+
79
+ async executeTransaction(callback) {
80
+ try {
81
+ return await this.prisma.$transaction(callback);
82
+ } catch (error) {
83
+ logger.error('Database transaction failed:', error);
84
+ throw error;
85
+ }
86
+ }
87
+ }
88
+
89
+ export default DatabaseService;
@@ -0,0 +1,161 @@
1
+ import { Resend } from 'resend';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import logger from '../config/logger.js';
5
+
6
+ const resend = new Resend(process.env.RESEND_API_KEY);
7
+
8
+ class EmailService {
9
+ constructor() {
10
+ this.fromEmail = process.env.FROM_EMAIL || 'noreply@arktos.dev';
11
+ this.templatesPath = path.join(process.cwd(), 'src', 'views', 'emails');
12
+ }
13
+
14
+ async loadTemplate(templateName) {
15
+ try {
16
+ const templatePath = path.join(this.templatesPath, `${templateName}.html`);
17
+ return fs.readFileSync(templatePath, 'utf8');
18
+ } catch (error) {
19
+ logger.error(`Failed to load email template: ${templateName}`, error);
20
+ throw new Error(`Email template ${templateName} not found`);
21
+ }
22
+ }
23
+
24
+ replaceVariables(template, variables) {
25
+ let processedTemplate = template;
26
+
27
+ for (const [key, value] of Object.entries(variables)) {
28
+ const regex = new RegExp(`{{${key}}}`, 'g');
29
+ processedTemplate = processedTemplate.replace(regex, value || '');
30
+ }
31
+
32
+ return processedTemplate;
33
+ }
34
+
35
+ async sendEmail({ to, subject, template, variables = {}, attachments = [] }) {
36
+ try {
37
+ if (!process.env.RESEND_API_KEY) {
38
+ logger.warn('RESEND_API_KEY not configured. Email would be sent in production.');
39
+ logger.info(`Email would be sent to: ${to}, Subject: ${subject}`);
40
+ return { success: true, messageId: 'dev-mode' };
41
+ }
42
+
43
+ const htmlTemplate = await this.loadTemplate(template);
44
+ const html = this.replaceVariables(htmlTemplate, {
45
+ ...variables,
46
+ frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000',
47
+ backendUrl: process.env.BACKEND_URL || 'http://localhost:3001',
48
+ year: new Date().getFullYear(),
49
+ });
50
+
51
+ const emailData = {
52
+ from: this.fromEmail,
53
+ to: Array.isArray(to) ? to : [to],
54
+ subject,
55
+ html,
56
+ };
57
+
58
+ if (attachments.length > 0) {
59
+ emailData.attachments = attachments;
60
+ }
61
+
62
+ const result = await resend.emails.send(emailData);
63
+
64
+ logger.info(`Email sent successfully to ${to}: ${subject}`);
65
+ return { success: true, messageId: result.id };
66
+ } catch (error) {
67
+ logger.error('Failed to send email:', error);
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ async sendVerificationEmail(to, token, firstName) {
73
+ const verificationUrl = `${process.env.FRONTEND_URL}/verify-email/${token}`;
74
+
75
+ return this.sendEmail({
76
+ to,
77
+ subject: 'Verify your email address',
78
+ template: 'verification',
79
+ variables: {
80
+ firstName,
81
+ verificationUrl,
82
+ },
83
+ });
84
+ }
85
+
86
+ async sendWelcomeEmail(to, firstName) {
87
+ return this.sendEmail({
88
+ to,
89
+ subject: 'Welcome to Arktos!',
90
+ template: 'welcome',
91
+ variables: {
92
+ firstName,
93
+ },
94
+ });
95
+ }
96
+
97
+ async sendPasswordResetEmail(to, token, firstName) {
98
+ const resetUrl = `${process.env.FRONTEND_URL}/reset-password/${token}`;
99
+
100
+ return this.sendEmail({
101
+ to,
102
+ subject: 'Reset your password',
103
+ template: 'resetPassword',
104
+ variables: {
105
+ firstName,
106
+ resetUrl,
107
+ },
108
+ });
109
+ }
110
+
111
+ async sendNotificationEmail(to, subject, message, firstName) {
112
+ return this.sendEmail({
113
+ to,
114
+ subject,
115
+ template: 'notification',
116
+ variables: {
117
+ firstName,
118
+ message,
119
+ subject,
120
+ },
121
+ });
122
+ }
123
+
124
+ // Utility method to validate email addresses
125
+ validateEmail(email) {
126
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
127
+ return emailRegex.test(email);
128
+ }
129
+
130
+ // Bulk email sending (for newsletters, announcements, etc.)
131
+ async sendBulkEmail({ recipients, subject, template, variables = {} }) {
132
+ const results = [];
133
+
134
+ for (const recipient of recipients) {
135
+ try {
136
+ const result = await this.sendEmail({
137
+ to: recipient.email,
138
+ subject,
139
+ template,
140
+ variables: {
141
+ ...variables,
142
+ firstName: recipient.firstName || '',
143
+ lastName: recipient.lastName || '',
144
+ },
145
+ });
146
+
147
+ results.push({ email: recipient.email, success: true, messageId: result.messageId });
148
+
149
+ // Add small delay to avoid rate limiting
150
+ await new Promise(resolve => setTimeout(resolve, 100));
151
+ } catch (error) {
152
+ logger.error(`Failed to send bulk email to ${recipient.email}:`, error);
153
+ results.push({ email: recipient.email, success: false, error: error.message });
154
+ }
155
+ }
156
+
157
+ return results;
158
+ }
159
+ }
160
+
161
+ export default new EmailService();