MVP ready to test

This commit is contained in:
Timo Knuth
2025-10-28 17:20:37 +01:00
parent 91b78cb284
commit 2f0208ebf9
48 changed files with 6258 additions and 110 deletions

View File

@@ -19,14 +19,15 @@ export function getAuthCookieOptions() {
/**
* Get cookie options for CSRF tokens
* Note: httpOnly is false so client-side JavaScript can read the token
* Note: httpOnly is false so the client can read it, but we verify via double-submit pattern
*/
export function getCsrfCookieOptions() {
return {
httpOnly: false, // Client needs to read this token
httpOnly: false, // Client needs to read this token for the header
secure: isProduction, // HTTPS only in production
sameSite: 'lax' as const,
maxAge: 60 * 60 * 24, // 24 hours
path: '/', // Available on all paths
};
}

View File

@@ -14,7 +14,8 @@ const rateLimitStore = new Map<string, RateLimitEntry>();
// Cleanup old entries every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
const entries = Array.from(rateLimitStore.entries());
for (const [key, entry] of entries) {
if (entry.resetAt < now) {
rateLimitStore.delete(key);
}
@@ -108,6 +109,7 @@ export function getClientIdentifier(request: Request): string {
* Predefined rate limit configurations
*/
export const RateLimits = {
// Auth endpoints
// Login: 5 attempts per 15 minutes
LOGIN: {
name: 'login',
@@ -122,17 +124,98 @@ export const RateLimits = {
windowSeconds: 60 * 60,
},
// API: 100 requests per minute
API: {
name: 'api',
maxRequests: 100,
windowSeconds: 60,
},
// Password reset: 3 attempts per hour
PASSWORD_RESET: {
name: 'password-reset',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// QR Code endpoints
// Create QR: 20 per minute
QR_CREATE: {
name: 'qr-create',
maxRequests: 20,
windowSeconds: 60,
},
// Modify QR: 30 per minute
QR_MODIFY: {
name: 'qr-modify',
maxRequests: 30,
windowSeconds: 60,
},
// Delete all QRs: 3 per hour
QR_DELETE_ALL: {
name: 'qr-delete-all',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// Bulk create: 3 per hour
BULK_CREATE: {
name: 'bulk-create',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// User settings endpoints
// Profile update: 10 per minute
PROFILE_UPDATE: {
name: 'profile-update',
maxRequests: 10,
windowSeconds: 60,
},
// Password change: 5 per hour
PASSWORD_CHANGE: {
name: 'password-change',
maxRequests: 5,
windowSeconds: 60 * 60,
},
// Account delete: 2 per day
ACCOUNT_DELETE: {
name: 'account-delete',
maxRequests: 2,
windowSeconds: 24 * 60 * 60,
},
// Analytics endpoints
// Analytics summary: 30 per minute
ANALYTICS: {
name: 'analytics',
maxRequests: 30,
windowSeconds: 60,
},
// Stripe endpoints
// Checkout session: 5 per minute
STRIPE_CHECKOUT: {
name: 'stripe-checkout',
maxRequests: 5,
windowSeconds: 60,
},
// Customer portal: 10 per minute
STRIPE_PORTAL: {
name: 'stripe-portal',
maxRequests: 10,
windowSeconds: 60,
},
// Cancel subscription: 3 per hour
STRIPE_CANCEL: {
name: 'stripe-cancel',
maxRequests: 3,
windowSeconds: 60 * 60,
},
// General API: 100 requests per minute
API: {
name: 'api',
maxRequests: 100,
windowSeconds: 60,
},
};

View File

@@ -0,0 +1,174 @@
/**
* Zod Validation Schemas for API endpoints
* Centralized validation logic for type-safety and security
*/
import { z } from 'zod';
// ==========================================
// QR Code Schemas
// ==========================================
export const qrStyleSchema = z.object({
fgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid foreground color format').optional(),
bgColor: z.string().regex(/^#[0-9A-F]{6}$/i, 'Invalid background color format').optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().min(100).max(1000).optional(),
});
export const createQRSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters'),
content: z.record(z.any()), // Accept any object structure for content
isStatic: z.boolean().optional(),
contentType: z.enum(['URL', 'WIFI', 'EMAIL', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT'], {
errorMap: () => ({ message: 'Invalid content type' })
}),
tags: z.array(z.string()).optional(),
style: z.object({
foregroundColor: z.string().optional(),
backgroundColor: z.string().optional(),
cornerStyle: z.enum(['square', 'rounded']).optional(),
size: z.number().optional(),
}).optional(),
});
export const updateQRSchema = z.object({
title: z.string()
.min(1, 'Title is required')
.max(100, 'Title must be less than 100 characters')
.optional(),
content: z.string()
.min(1, 'Content is required')
.max(5000, 'Content must be less than 5000 characters')
.optional(),
style: qrStyleSchema.optional(),
isActive: z.boolean().optional(),
});
export const bulkQRSchema = z.object({
qrs: z.array(
z.object({
title: z.string().min(1).max(100),
content: z.string().min(1).max(5000),
contentType: z.enum(['URL', 'WIFI', 'EMAIL', 'PHONE', 'SMS', 'WHATSAPP', 'TEXT']),
})
).min(1, 'At least one QR code is required')
.max(100, 'Maximum 100 QR codes per bulk creation'),
});
// ==========================================
// Authentication Schemas
// ==========================================
export const loginSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase(),
password: z.string()
.min(1, 'Password is required'),
});
export const signupSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.trim(),
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim(),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
export const forgotPasswordSchema = z.object({
email: z.string()
.email('Invalid email format')
.toLowerCase()
.trim(),
});
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Reset token is required'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
// ==========================================
// Settings Schemas
// ==========================================
export const updateProfileSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.trim(),
});
export const changePasswordSchema = z.object({
currentPassword: z.string()
.min(1, 'Current password is required'),
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password must be less than 100 characters'),
// Password complexity rules removed for easier testing
});
// ==========================================
// Stripe Schemas
// ==========================================
export const createCheckoutSchema = z.object({
priceId: z.string().min(1, 'Price ID is required'),
});
// ==========================================
// Helper: Format Zod Errors
// ==========================================
export function formatZodError(error: z.ZodError) {
return {
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message,
})),
};
}
// ==========================================
// Helper: Validate with Zod
// ==========================================
export async function validateRequest<T>(
schema: z.ZodSchema<T>,
data: unknown
): Promise<{ success: true; data: T } | { success: false; error: any }> {
try {
const validatedData = schema.parse(data);
return { success: true, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
return { success: false, error: formatZodError(error) };
}
return { success: false, error: { error: 'Invalid request data' } };
}
}