diff --git a/.env.example b/.env.example index a9348f0..a0ae1b9 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,27 @@ +# ============================================ +# PULSE CRM BACKEND - Environment Variables +# ============================================ + # Server PORT=8000 NODE_ENV=development # Database (PostgreSQL) -DATABASE_URL=postgres://user:password@localhost:5432/pulse_crm +DATABASE_URL=postgresql://pulse:password@localhost:5432/pulse_crm +DB_POOL_SIZE=10 -# JWT -JWT_SECRET=your-super-secret-jwt-key-min-32-chars -JWT_ACCESS_EXPIRES=15m -JWT_REFRESH_EXPIRES=7d +# JWT Secrets (use `openssl rand -base64 32` to generate) +JWT_SECRET=CHANGE_ME_IN_PRODUCTION_use_openssl_rand_base64_32 -# Email (Resend) -RESEND_API_KEY=re_xxxxxxxxxxxxx -EMAIL_FROM=noreply@pulse-crm.de +# CORS +CORS_ORIGINS=http://localhost:3000,https://crm.kronos-soulution.de -# App -APP_URL=https://crm.kronos-soulution.de -API_URL=https://api.crm.kronos-soulution.de +# Email (for verification, password reset) +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=noreply@example.com +SMTP_PASS=your-smtp-password +SMTP_FROM=Pulse CRM + +# Frontend URL (for email links) +FRONTEND_URL=https://crm.kronos-soulution.de diff --git a/deno.json b/deno.json index 029f738..04dad80 100644 --- a/deno.json +++ b/deno.json @@ -2,9 +2,12 @@ "name": "pulse-crm-backend", "version": "0.1.0", "tasks": { - "dev": "deno run --allow-net --allow-env --allow-read --watch src/main.ts", - "start": "deno run --allow-net --allow-env --allow-read src/main.ts", - "test": "deno test --allow-net --allow-env --allow-read", + "dev": "deno run --allow-net --allow-env --allow-read --allow-ffi --watch src/main.ts", + "start": "deno run --allow-net --allow-env --allow-read --allow-ffi src/main.ts", + "test": "deno test --allow-net --allow-env --allow-read --allow-ffi", + "check": "deno check src/main.ts", + "lint": "deno lint", + "fmt": "deno fmt", "db:migrate": "deno run --allow-net --allow-env --allow-read src/db/migrate.ts", "db:seed": "deno run --allow-net --allow-env --allow-read src/db/seed.ts" }, @@ -13,10 +16,20 @@ "@std/dotenv": "jsr:@std/dotenv@^0.225.0", "postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts", "zod": "https://deno.land/x/zod@v3.22.4/mod.ts", - "argon2": "https://deno.land/x/argon2@v0.9.2/mod.ts", + "argon2": "https://deno.land/x/argon2@v0.10.1/lib/mod.ts", "djwt": "https://deno.land/x/djwt@v3.0.2/mod.ts" }, "compilerOptions": { "strict": true + }, + "fmt": { + "indentWidth": 2, + "lineWidth": 100, + "singleQuote": false + }, + "lint": { + "rules": { + "exclude": ["no-explicit-any"] + } } } diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 0000000..056c43e --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,107 @@ +import { Pool } from "postgres"; + +// ============================================ +// DATABASE CONNECTION POOL +// ============================================ + +const DATABASE_URL = Deno.env.get("DATABASE_URL"); +const POOL_SIZE = parseInt(Deno.env.get("DB_POOL_SIZE") || "10"); + +if (!DATABASE_URL) { + console.error("❌ DATABASE_URL environment variable is required"); + Deno.exit(1); +} + +// Create connection pool +const pool = new Pool(DATABASE_URL, POOL_SIZE, true); + +/** + * Get a database connection from the pool + */ +export async function getConnection() { + return await pool.connect(); +} + +/** + * Execute a query and return results + */ +export async function query( + text: string, + args?: unknown[] +): Promise { + const connection = await pool.connect(); + try { + const result = await connection.queryObject(text, args); + return result.rows; + } finally { + connection.release(); + } +} + +/** + * Execute a query and return first result or null + */ +export async function queryOne( + text: string, + args?: unknown[] +): Promise { + const rows = await query(text, args); + return rows[0] || null; +} + +/** + * Execute a query and return the count of affected rows + */ +export async function execute( + text: string, + args?: unknown[] +): Promise { + const connection = await pool.connect(); + try { + const result = await connection.queryObject(text, args); + return result.rowCount || 0; + } finally { + connection.release(); + } +} + +/** + * Run a transaction + */ +export async function transaction( + fn: (connection: ReturnType extends Promise ? U : never) => Promise +): Promise { + const connection = await pool.connect(); + try { + await connection.queryObject("BEGIN"); + const result = await fn(connection); + await connection.queryObject("COMMIT"); + return result; + } catch (error) { + await connection.queryObject("ROLLBACK"); + throw error; + } finally { + connection.release(); + } +} + +/** + * Close all connections in the pool + */ +export async function closePool() { + await pool.end(); +} + +/** + * Check database health + */ +export async function checkHealth(): Promise { + try { + await query("SELECT 1"); + return true; + } catch { + return false; + } +} + +export { pool }; diff --git a/src/main.ts b/src/main.ts index fd6ba7b..0b2862d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,8 +8,12 @@ import { dealsRouter } from "./routes/deals.ts"; import { activitiesRouter } from "./routes/activities.ts"; import { pipelinesRouter } from "./routes/pipelines.ts"; +// Database +import { checkHealth as checkDbHealth } from "./db/connection.ts"; + const app = new Application(); const PORT = parseInt(Deno.env.get("PORT") || "8000"); +const NODE_ENV = Deno.env.get("NODE_ENV") || "development"; // ============================================ // MIDDLEWARE @@ -17,8 +21,15 @@ const PORT = parseInt(Deno.env.get("PORT") || "8000"); // CORS Middleware app.use(async (ctx, next) => { - const origin = ctx.request.headers.get("origin") || "*"; - ctx.response.headers.set("Access-Control-Allow-Origin", origin); + const allowedOrigins = Deno.env.get("CORS_ORIGINS")?.split(",") || ["*"]; + const origin = ctx.request.headers.get("origin"); + + if (origin && (allowedOrigins.includes("*") || allowedOrigins.includes(origin))) { + ctx.response.headers.set("Access-Control-Allow-Origin", origin); + } else if (allowedOrigins.includes("*")) { + ctx.response.headers.set("Access-Control-Allow-Origin", "*"); + } + ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH"); ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); ctx.response.headers.set("Access-Control-Allow-Credentials", "true"); @@ -32,7 +43,16 @@ app.use(async (ctx, next) => { await next(); }); -// Logger Middleware +// Security Headers +app.use(async (ctx, next) => { + ctx.response.headers.set("X-Content-Type-Options", "nosniff"); + ctx.response.headers.set("X-Frame-Options", "DENY"); + ctx.response.headers.set("X-XSS-Protection", "1; mode=block"); + ctx.response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + await next(); +}); + +// Request Logger app.use(async (ctx, next) => { const start = Date.now(); await next(); @@ -48,14 +68,18 @@ app.use(async (ctx, next) => { await next(); } catch (err) { console.error("Error:", err); - ctx.response.status = 500; + + const status = err.status || 500; + ctx.response.status = status; ctx.response.body = { success: false, error: { - code: "INTERNAL_ERROR", - message: Deno.env.get("NODE_ENV") === "development" + code: status === 500 ? "INTERNAL_ERROR" : "ERROR", + message: NODE_ENV === "development" ? err.message - : "An internal error occurred", + : status === 500 + ? "An internal error occurred" + : err.message, }, }; } @@ -65,20 +89,35 @@ app.use(async (ctx, next) => { // SYSTEM ROUTES // ============================================ -// Health Check +// Health Check (includes DB status) app.use(async (ctx, next) => { if (ctx.request.url.pathname === "/health") { + const dbHealthy = await checkDbHealth(); + + ctx.response.status = dbHealthy ? 200 : 503; ctx.response.body = { - status: "ok", + status: dbHealthy ? "ok" : "degraded", service: "pulse-crm-backend", version: "0.1.0", timestamp: new Date().toISOString(), + checks: { + database: dbHealthy ? "ok" : "error", + }, }; return; } await next(); }); +// Liveness probe (simple check) +app.use(async (ctx, next) => { + if (ctx.request.url.pathname === "/live") { + ctx.response.body = { status: "ok" }; + return; + } + await next(); +}); + // API Info app.use(async (ctx, next) => { if (ctx.request.url.pathname === "/api" || ctx.request.url.pathname === "/api/v1") { @@ -86,12 +125,17 @@ app.use(async (ctx, next) => { name: "Pulse CRM API", version: "1.0.0", description: "Der Herzschlag deines Business", + documentation: "/api/v1/docs", endpoints: { auth: { - "POST /api/v1/auth/register": "Register new user", + "POST /api/v1/auth/register": "Register new user & organization", "POST /api/v1/auth/login": "Login", - "POST /api/v1/auth/refresh": "Refresh token", - "POST /api/v1/auth/logout": "Logout", + "POST /api/v1/auth/refresh": "Refresh access token", + "POST /api/v1/auth/logout": "Logout (revoke token)", + "POST /api/v1/auth/logout-all": "Logout from all devices", + "POST /api/v1/auth/forgot-password": "Request password reset", + "POST /api/v1/auth/reset-password": "Reset password with token", + "POST /api/v1/auth/verify-email": "Verify email address", "GET /api/v1/auth/me": "Get current user", }, contacts: { @@ -100,6 +144,8 @@ app.use(async (ctx, next) => { "POST /api/v1/contacts": "Create contact", "PUT /api/v1/contacts/:id": "Update contact", "DELETE /api/v1/contacts/:id": "Delete contact", + "POST /api/v1/contacts/import": "Import contacts (CSV)", + "GET /api/v1/contacts/export": "Export contacts (DSGVO)", }, deals: { "GET /api/v1/deals": "List deals", @@ -173,6 +219,7 @@ console.log(" ===================="); console.log(` 📡 Server: http://localhost:${PORT}`); console.log(` 📚 API: http://localhost:${PORT}/api/v1`); console.log(` ❤️ Health: http://localhost:${PORT}/health`); +console.log(` 🔧 Mode: ${NODE_ENV}`); console.log(""); await app.listen({ port: PORT }); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..227af40 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,193 @@ +import { Context, Next } from "@oak/oak"; +import { verifyAccessToken } from "../services/jwt.ts"; +import type { AuthState, UserRole } from "../types/index.ts"; + +// ============================================ +// AUTHENTICATION MIDDLEWARE +// ============================================ + +/** + * Middleware to verify JWT token and populate ctx.state.user + */ +export async function requireAuth(ctx: Context, next: Next) { + const authHeader = ctx.request.headers.get("Authorization"); + + if (!authHeader) { + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "UNAUTHORIZED", + message: "Authorization header is required", + }, + }; + return; + } + + // Extract Bearer token + const match = authHeader.match(/^Bearer\s+(.+)$/i); + if (!match) { + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "UNAUTHORIZED", + message: "Invalid authorization format. Use: Bearer ", + }, + }; + return; + } + + const token = match[1]; + const payload = await verifyAccessToken(token); + + if (!payload) { + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "TOKEN_INVALID", + message: "Invalid or expired access token", + }, + }; + return; + } + + // Populate state with user info + ctx.state.user = { + id: payload.sub, + email: payload.email, + role: payload.role, + orgId: payload.orgId, + firstName: "", // Will be loaded from DB if needed + lastName: "", + isVerified: true, + twoFactorEnabled: false, + createdAt: "", + }; + ctx.state.orgId = payload.orgId; + + await next(); +} + +/** + * Optional auth - populates user if token present, but doesn't require it + */ +export async function optionalAuth(ctx: Context, next: Next) { + const authHeader = ctx.request.headers.get("Authorization"); + + if (authHeader) { + const match = authHeader.match(/^Bearer\s+(.+)$/i); + if (match) { + const token = match[1]; + const payload = await verifyAccessToken(token); + + if (payload) { + ctx.state.user = { + id: payload.sub, + email: payload.email, + role: payload.role, + orgId: payload.orgId, + firstName: "", + lastName: "", + isVerified: true, + twoFactorEnabled: false, + createdAt: "", + }; + ctx.state.orgId = payload.orgId; + } + } + } + + await next(); +} + +/** + * Role-based access control middleware + * Usage: requireRole("admin", "owner") + */ +export function requireRole(...allowedRoles: UserRole[]) { + return async (ctx: Context, next: Next) => { + // Ensure user is authenticated first + if (!ctx.state.user) { + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "UNAUTHORIZED", + message: "Authentication required", + }, + }; + return; + } + + // Check if user has required role + if (!allowedRoles.includes(ctx.state.user.role)) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "FORBIDDEN", + message: "Insufficient permissions", + requiredRoles: allowedRoles, + }, + }; + return; + } + + await next(); + }; +} + +/** + * Rate limiting state (simple in-memory, use Redis in production) + */ +const rateLimitStore = new Map(); + +/** + * Rate limiting middleware + * @param limit - Max requests per window + * @param windowMs - Time window in milliseconds + */ +export function rateLimit(limit: number, windowMs: number) { + return async (ctx: Context, next: Next) => { + const ip = ctx.request.ip || "unknown"; + const key = `${ip}:${ctx.request.url.pathname}`; + const now = Date.now(); + + const record = rateLimitStore.get(key); + + if (!record || now > record.resetAt) { + rateLimitStore.set(key, { count: 1, resetAt: now + windowMs }); + } else if (record.count >= limit) { + ctx.response.status = 429; + ctx.response.headers.set( + "Retry-After", + Math.ceil((record.resetAt - now) / 1000).toString() + ); + ctx.response.body = { + success: false, + error: { + code: "TOO_MANY_REQUESTS", + message: "Rate limit exceeded. Please try again later.", + retryAfter: Math.ceil((record.resetAt - now) / 1000), + }, + }; + return; + } else { + record.count++; + } + + await next(); + }; +} + +// Cleanup old rate limit entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, value] of rateLimitStore.entries()) { + if (now > value.resetAt) { + rateLimitStore.delete(key); + } + } +}, 60000); // Every minute diff --git a/src/repositories/organization.ts b/src/repositories/organization.ts new file mode 100644 index 0000000..dcdab41 --- /dev/null +++ b/src/repositories/organization.ts @@ -0,0 +1,83 @@ +import { query, queryOne } from "../db/connection.ts"; +import type { Organization } from "../types/index.ts"; + +// ============================================ +// ORGANIZATION REPOSITORY +// ============================================ + +/** + * Find organization by ID + */ +export async function findById(id: string): Promise { + return await queryOne( + `SELECT * FROM organizations WHERE id = $1 AND deleted_at IS NULL`, + [id] + ); +} + +/** + * Find organization by slug + */ +export async function findBySlug(slug: string): Promise { + return await queryOne( + `SELECT * FROM organizations WHERE slug = $1 AND deleted_at IS NULL`, + [slug] + ); +} + +/** + * Get user count for organization + */ +export async function getUserCount(orgId: string): Promise { + const result = await queryOne<{ count: string }>( + `SELECT COUNT(*) as count FROM users WHERE org_id = $1 AND deleted_at IS NULL`, + [orgId] + ); + return parseInt(result?.count || "0"); +} + +/** + * Check if organization can add more users + */ +export async function canAddUser(orgId: string): Promise { + const result = await queryOne<{ can_add: boolean }>( + `SELECT ( + SELECT COUNT(*) FROM users WHERE org_id = $1 AND deleted_at IS NULL + ) < o.max_users as can_add + FROM organizations o + WHERE o.id = $1`, + [orgId] + ); + return result?.can_add ?? false; +} + +/** + * Get all users in organization + */ +export async function getUsers(orgId: string) { + return await query( + `SELECT id, email, first_name, last_name, role, is_verified, + is_active, last_login_at, created_at + FROM users + WHERE org_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC`, + [orgId] + ); +} + +/** + * Update organization settings + */ +export async function updateSettings( + orgId: string, + settings: Record +): Promise { + const rows = await query( + `UPDATE organizations + SET settings = settings || $1::jsonb, updated_at = NOW() + WHERE id = $2 + RETURNING *`, + [JSON.stringify(settings), orgId] + ); + return rows[0] || null; +} diff --git a/src/repositories/user.ts b/src/repositories/user.ts new file mode 100644 index 0000000..6eb0c34 --- /dev/null +++ b/src/repositories/user.ts @@ -0,0 +1,246 @@ +import { query, queryOne, execute, transaction } from "../db/connection.ts"; +import type { User, Organization, RefreshTokenRecord } from "../types/index.ts"; + +// ============================================ +// USER REPOSITORY +// ============================================ + +/** + * Find user by email + */ +export async function findByEmail(email: string): Promise { + return await queryOne( + `SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`, + [email.toLowerCase()] + ); +} + +/** + * Find user by ID + */ +export async function findById(id: string): Promise { + return await queryOne( + `SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`, + [id] + ); +} + +/** + * Find user by verification token + */ +export async function findByVerificationToken(token: string): Promise { + return await queryOne( + `SELECT * FROM users WHERE verification_token = $1 AND deleted_at IS NULL`, + [token] + ); +} + +/** + * Find user by password reset token + */ +export async function findByResetToken(token: string): Promise { + return await queryOne( + `SELECT * FROM users + WHERE reset_token = $1 + AND reset_token_expires > NOW() + AND deleted_at IS NULL`, + [token] + ); +} + +/** + * Create a new user with organization + */ +export async function createUserWithOrg(data: { + email: string; + passwordHash: string; + firstName: string; + lastName: string; + orgName: string; + verificationToken: string; +}): Promise<{ user: User; organization: Organization }> { + return await transaction(async (conn) => { + // Generate slug from org name + const slug = data.orgName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 50); + + // Create organization + const orgResult = await conn.queryObject( + `INSERT INTO organizations (name, slug, plan, max_users, settings) + VALUES ($1, $2, 'free', 3, '{}') + RETURNING *`, + [data.orgName, slug + "-" + Date.now().toString(36)] + ); + const organization = orgResult.rows[0]; + + // Create user as owner + const userResult = await conn.queryObject( + `INSERT INTO users ( + org_id, email, password_hash, first_name, last_name, + role, is_verified, verification_token + ) + VALUES ($1, $2, $3, $4, $5, 'owner', false, $6) + RETURNING *`, + [ + organization.id, + data.email.toLowerCase(), + data.passwordHash, + data.firstName, + data.lastName, + data.verificationToken, + ] + ); + const user = userResult.rows[0]; + + return { user, organization }; + }); +} + +/** + * Create a user in an existing organization (invited user) + */ +export async function createUser(data: { + orgId: string; + email: string; + passwordHash: string; + firstName: string; + lastName: string; + role: "admin" | "manager" | "user"; + isVerified?: boolean; +}): Promise { + const rows = await query( + `INSERT INTO users ( + org_id, email, password_hash, first_name, last_name, + role, is_verified + ) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *`, + [ + data.orgId, + data.email.toLowerCase(), + data.passwordHash, + data.firstName, + data.lastName, + data.role, + data.isVerified ?? false, + ] + ); + return rows[0]; +} + +/** + * Update user's email verification status + */ +export async function verifyEmail(userId: string): Promise { + await execute( + `UPDATE users + SET is_verified = true, verification_token = NULL, updated_at = NOW() + WHERE id = $1`, + [userId] + ); +} + +/** + * Set password reset token + */ +export async function setResetToken( + userId: string, + token: string, + expiresAt: Date +): Promise { + await execute( + `UPDATE users + SET reset_token = $1, reset_token_expires = $2, updated_at = NOW() + WHERE id = $3`, + [token, expiresAt, userId] + ); +} + +/** + * Update password and clear reset token + */ +export async function updatePassword( + userId: string, + passwordHash: string +): Promise { + await execute( + `UPDATE users + SET password_hash = $1, reset_token = NULL, reset_token_expires = NULL, updated_at = NOW() + WHERE id = $2`, + [passwordHash, userId] + ); +} + +/** + * Update last login timestamp + */ +export async function updateLastLogin(userId: string): Promise { + await execute( + `UPDATE users SET last_login_at = NOW() WHERE id = $1`, + [userId] + ); +} + +/** + * Store refresh token hash + */ +export async function storeRefreshToken( + userId: string, + tokenHash: string, + expiresAt: Date +): Promise { + await execute( + `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) + VALUES ($1, $2, $3)`, + [userId, tokenHash, expiresAt] + ); +} + +/** + * Find refresh token by hash + */ +export async function findRefreshToken(tokenHash: string): Promise { + return await queryOne( + `SELECT * FROM refresh_tokens + WHERE token_hash = $1 + AND revoked = false + AND expires_at > NOW()`, + [tokenHash] + ); +} + +/** + * Revoke a refresh token + */ +export async function revokeRefreshToken(tokenHash: string): Promise { + await execute( + `UPDATE refresh_tokens + SET revoked = true, revoked_at = NOW() + WHERE token_hash = $1`, + [tokenHash] + ); +} + +/** + * Revoke all refresh tokens for a user (logout everywhere) + */ +export async function revokeAllUserTokens(userId: string): Promise { + await execute( + `UPDATE refresh_tokens + SET revoked = true, revoked_at = NOW() + WHERE user_id = $1 AND revoked = false`, + [userId] + ); +} + +/** + * Clean up expired refresh tokens + */ +export async function cleanupExpiredTokens(): Promise { + return await execute( + `DELETE FROM refresh_tokens WHERE expires_at < NOW()` + ); +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts index c5506d8..962ed10 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1,73 +1,321 @@ import { Router } from "@oak/oak"; +import { z } from "zod"; +import * as userRepo from "../repositories/user.ts"; +import * as orgRepo from "../repositories/organization.ts"; +import * as passwordService from "../services/password.ts"; +import * as jwtService from "../services/jwt.ts"; +import * as auditService from "../services/audit.ts"; +import { requireAuth, rateLimit } from "../middleware/auth.ts"; +import type { AuthState, UserPublic, OrganizationPublic } from "../types/index.ts"; -const router = new Router({ prefix: "/api/v1/auth" }); +const router = new Router({ prefix: "/api/v1/auth" }); + +// ============================================ +// VALIDATION SCHEMAS (Zod) +// ============================================ + +const registerSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(8, "Password must be at least 8 characters"), + firstName: z.string().min(1, "First name is required").max(50), + lastName: z.string().min(1, "Last name is required").max(50), + orgName: z.string().min(2, "Organization name is required").max(100), +}); + +const loginSchema = z.object({ + email: z.string().email("Invalid email address"), + password: z.string().min(1, "Password is required"), + twoFactorCode: z.string().optional(), +}); + +const refreshSchema = z.object({ + refreshToken: z.string().min(1, "Refresh token is required"), +}); + +const forgotPasswordSchema = z.object({ + email: z.string().email("Invalid email address"), +}); + +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"), +}); + +// ============================================ +// HELPER FUNCTIONS +// ============================================ + +function toUserPublic(user: { + id: string; + email: string; + first_name: string; + last_name: string; + role: string; + is_verified: boolean; + org_id: string; + two_factor_enabled: boolean; + created_at: Date; +}): UserPublic { + return { + id: user.id, + email: user.email, + firstName: user.first_name, + lastName: user.last_name, + role: user.role as UserPublic["role"], + isVerified: user.is_verified, + orgId: user.org_id, + twoFactorEnabled: user.two_factor_enabled, + createdAt: user.created_at.toISOString(), + }; +} + +function toOrgPublic(org: { + id: string; + name: string; + slug: string; + plan: string; +}): OrganizationPublic { + return { + id: org.id, + name: org.name, + slug: org.slug, + plan: org.plan as OrganizationPublic["plan"], + }; +} + +// ============================================ +// ROUTES +// ============================================ // POST /api/v1/auth/register -router.post("/register", async (ctx) => { +// Rate limit: 5 requests per minute per IP +router.post("/register", rateLimit(5, 60000), async (ctx) => { const body = await ctx.request.body.json(); - const { email, password, firstName, lastName, orgName } = body; + + // Validate input + const result = registerSchema.safeParse(body); + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid input", + details: result.error.errors, + }, + }; + return; + } - // TODO: Implement registration - // 1. Validate input (Zod) - // 2. Check if email exists - // 3. Hash password (Argon2) - // 4. Create organization - // 5. Create user - // 6. Send verification email - // 7. Return tokens + const { email, password, firstName, lastName, orgName } = result.data; + + // Check password strength + const passwordCheck = passwordService.validatePasswordStrength(password); + if (!passwordCheck.valid) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "WEAK_PASSWORD", + message: "Password does not meet requirements", + details: passwordCheck.errors, + }, + }; + return; + } + + // Check if email already exists + const existingUser = await userRepo.findByEmail(email); + if (existingUser) { + ctx.response.status = 409; + ctx.response.body = { + success: false, + error: { + code: "EMAIL_EXISTS", + message: "An account with this email already exists", + }, + }; + return; + } + + // Hash password + const passwordHash = await passwordService.hashPassword(password); + + // Generate verification token + const verificationToken = passwordService.generateSecureToken(); + + // Create user and organization + const { user, organization } = await userRepo.createUserWithOrg({ + email, + passwordHash, + firstName, + lastName, + orgName, + verificationToken, + }); + + // Generate tokens + const tokens = await jwtService.generateTokens({ + sub: user.id, + email: user.email, + role: user.role, + orgId: user.org_id, + }); + + // Store refresh token hash + const refreshTokenHash = await jwtService.hashToken(tokens.refreshToken); + const refreshExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + await userRepo.storeRefreshToken(user.id, refreshTokenHash, refreshExpiry); + + // Log registration + await auditService.logRegister( + organization.id, + user.id, + email, + ctx.request.ip + ); + + // TODO: Send verification email (implement email service) + console.log(`📧 Verification link: /verify-email?token=${verificationToken}`); ctx.response.status = 201; ctx.response.body = { success: true, - message: "Registration successful", + message: "Registration successful. Please verify your email.", data: { - user: { - id: "uuid", - email, - firstName, - lastName, - }, - organization: { - id: "uuid", - name: orgName, - }, - tokens: { - accessToken: "jwt_access_token", - refreshToken: "jwt_refresh_token", - }, + user: toUserPublic(user), + organization: toOrgPublic(organization), + tokens, }, }; }); // POST /api/v1/auth/login -router.post("/login", async (ctx) => { +// Rate limit: 10 requests per minute per IP +router.post("/login", rateLimit(10, 60000), async (ctx) => { const body = await ctx.request.body.json(); - const { email, password } = body; + + // Validate input + const result = loginSchema.safeParse(body); + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid input", + details: result.error.errors, + }, + }; + return; + } - // TODO: Implement login - // 1. Find user by email - // 2. Verify password (Argon2) - // 3. Generate tokens - // 4. Log login (audit) - // 5. Return user + tokens + const { email, password, twoFactorCode } = result.data; + + // Find user + const user = await userRepo.findByEmail(email); + if (!user) { + // Use same error message to prevent email enumeration + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "INVALID_CREDENTIALS", + message: "Invalid email or password", + }, + }; + return; + } + + // Check if account is active + if (!user.is_active) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "ACCOUNT_DISABLED", + message: "Your account has been disabled", + }, + }; + return; + } + + // Verify password + const validPassword = await passwordService.verifyPassword(password, user.password_hash); + if (!validPassword) { + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "INVALID_CREDENTIALS", + message: "Invalid email or password", + }, + }; + return; + } + + // Check 2FA if enabled + if (user.two_factor_enabled) { + if (!twoFactorCode) { + ctx.response.status = 403; + ctx.response.body = { + success: false, + error: { + code: "2FA_REQUIRED", + message: "Two-factor authentication code is required", + }, + }; + return; + } + // TODO: Verify 2FA code with TOTP + // For now, we'll skip actual verification + } + + // Get organization + const organization = await orgRepo.findById(user.org_id); + if (!organization) { + ctx.response.status = 500; + ctx.response.body = { + success: false, + error: { + code: "ORG_NOT_FOUND", + message: "Organization not found", + }, + }; + return; + } + + // Generate tokens + const tokens = await jwtService.generateTokens({ + sub: user.id, + email: user.email, + role: user.role, + orgId: user.org_id, + }); + + // Store refresh token hash + const refreshTokenHash = await jwtService.hashToken(tokens.refreshToken); + const refreshExpiry = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + await userRepo.storeRefreshToken(user.id, refreshTokenHash, refreshExpiry); + + // Update last login + await userRepo.updateLastLogin(user.id); + + // Log login + await auditService.logLogin( + user.org_id, + user.id, + ctx.request.ip, + ctx.request.headers.get("user-agent") || undefined + ); ctx.response.body = { success: true, message: "Login successful", data: { - user: { - id: "uuid", - email, - firstName: "Max", - lastName: "Mustermann", - role: "admin", - orgId: "org_uuid", - }, - tokens: { - accessToken: "jwt_access_token", - refreshToken: "jwt_refresh_token", - expiresIn: 900, // 15 minutes - }, + user: toUserPublic(user), + organization: toOrgPublic(organization), + tokens, }, }; }); @@ -75,29 +323,81 @@ router.post("/login", async (ctx) => { // POST /api/v1/auth/refresh router.post("/refresh", async (ctx) => { const body = await ctx.request.body.json(); - const { refreshToken } = body; + + // Validate input + const result = refreshSchema.safeParse(body); + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid input", + }, + }; + return; + } - // TODO: Implement token refresh - // 1. Validate refresh token - // 2. Check if revoked - // 3. Generate new access token - // 4. Optionally rotate refresh token + const { refreshToken } = result.data; + + // Verify refresh token + const payload = await jwtService.verifyRefreshToken(refreshToken); + if (!payload) { + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "INVALID_TOKEN", + message: "Invalid or expired refresh token", + }, + }; + return; + } + + // Check if token is in database and not revoked + const tokenHash = await jwtService.hashToken(refreshToken); + const storedToken = await userRepo.findRefreshToken(tokenHash); + if (!storedToken) { + ctx.response.status = 401; + ctx.response.body = { + success: false, + error: { + code: "TOKEN_REVOKED", + message: "Refresh token has been revoked", + }, + }; + return; + } + + // Generate new access token + const { accessToken, expiresIn } = await jwtService.refreshAccessToken(payload); ctx.response.body = { success: true, data: { - accessToken: "new_jwt_access_token", - expiresIn: 900, + accessToken, + expiresIn, }, }; }); // POST /api/v1/auth/logout -router.post("/logout", async (ctx) => { +router.post("/logout", requireAuth, async (ctx) => { const body = await ctx.request.body.json(); const { refreshToken } = body; - // TODO: Revoke refresh token + if (refreshToken) { + // Revoke the specific refresh token + const tokenHash = await jwtService.hashToken(refreshToken); + await userRepo.revokeRefreshToken(tokenHash); + } + + // Log logout + await auditService.logLogout( + ctx.state.orgId, + ctx.state.user.id, + ctx.request.ip + ); ctx.response.body = { success: true, @@ -105,49 +405,191 @@ router.post("/logout", async (ctx) => { }; }); -// POST /api/v1/auth/forgot-password -router.post("/forgot-password", async (ctx) => { - const body = await ctx.request.body.json(); - const { email } = body; - - // TODO: Send password reset email +// POST /api/v1/auth/logout-all +// Revoke all refresh tokens (logout from all devices) +router.post("/logout-all", requireAuth, async (ctx) => { + await userRepo.revokeAllUserTokens(ctx.state.user.id); ctx.response.body = { success: true, - message: "If the email exists, a reset link has been sent", + message: "Logged out from all devices", + }; +}); + +// POST /api/v1/auth/forgot-password +router.post("/forgot-password", rateLimit(3, 60000), async (ctx) => { + const body = await ctx.request.body.json(); + + const result = forgotPasswordSchema.safeParse(body); + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid email address", + }, + }; + return; + } + + const { email } = result.data; + const user = await userRepo.findByEmail(email); + + // Always return success to prevent email enumeration + if (user) { + // Generate reset token + const resetToken = passwordService.generateSecureToken(); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour + + await userRepo.setResetToken(user.id, resetToken, expiresAt); + + // Log reset request + await auditService.logPasswordResetRequest( + user.org_id, + user.id, + ctx.request.ip + ); + + // TODO: Send password reset email + console.log(`📧 Password reset link: /reset-password?token=${resetToken}`); + } + + ctx.response.body = { + success: true, + message: "If the email exists, a password reset link has been sent", }; }); // POST /api/v1/auth/reset-password -router.post("/reset-password", async (ctx) => { +router.post("/reset-password", rateLimit(5, 60000), async (ctx) => { const body = await ctx.request.body.json(); - const { token, password } = body; + + const result = resetPasswordSchema.safeParse(body); + if (!result.success) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "VALIDATION_ERROR", + message: "Invalid input", + details: result.error.errors, + }, + }; + return; + } - // TODO: Reset password + const { token, password } = result.data; + + // Check password strength + const passwordCheck = passwordService.validatePasswordStrength(password); + if (!passwordCheck.valid) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "WEAK_PASSWORD", + message: "Password does not meet requirements", + details: passwordCheck.errors, + }, + }; + return; + } + + // Find user by reset token + const user = await userRepo.findByResetToken(token); + if (!user) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "INVALID_TOKEN", + message: "Invalid or expired reset token", + }, + }; + return; + } + + // Hash new password and update + const passwordHash = await passwordService.hashPassword(password); + await userRepo.updatePassword(user.id, passwordHash); + + // Revoke all refresh tokens (force re-login) + await userRepo.revokeAllUserTokens(user.id); + + // Log password change + await auditService.logPasswordChange( + user.org_id, + user.id, + ctx.request.ip + ); ctx.response.body = { success: true, - message: "Password reset successful", + message: "Password reset successful. Please log in with your new password.", + }; +}); + +// POST /api/v1/auth/verify-email +router.post("/verify-email", async (ctx) => { + const body = await ctx.request.body.json(); + const { token } = body; + + if (!token) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "MISSING_TOKEN", + message: "Verification token is required", + }, + }; + return; + } + + const user = await userRepo.findByVerificationToken(token); + if (!user) { + ctx.response.status = 400; + ctx.response.body = { + success: false, + error: { + code: "INVALID_TOKEN", + message: "Invalid verification token", + }, + }; + return; + } + + await userRepo.verifyEmail(user.id); + + ctx.response.body = { + success: true, + message: "Email verified successfully", }; }); // GET /api/v1/auth/me -router.get("/me", async (ctx) => { - // TODO: Get current user from JWT +router.get("/me", requireAuth, async (ctx) => { + const user = await userRepo.findById(ctx.state.user.id); + if (!user) { + ctx.response.status = 404; + ctx.response.body = { + success: false, + error: { + code: "USER_NOT_FOUND", + message: "User not found", + }, + }; + return; + } + + const organization = await orgRepo.findById(user.org_id); ctx.response.body = { success: true, data: { - id: "uuid", - email: "user@example.com", - firstName: "Max", - lastName: "Mustermann", - role: "admin", - organization: { - id: "org_uuid", - name: "Demo Company", - plan: "pro", - }, + user: toUserPublic(user), + organization: organization ? toOrgPublic(organization) : null, }, }; }); diff --git a/src/services/audit.ts b/src/services/audit.ts new file mode 100644 index 0000000..905d5ab --- /dev/null +++ b/src/services/audit.ts @@ -0,0 +1,171 @@ +import { execute } from "../db/connection.ts"; +import type { AuditAction } from "../types/index.ts"; + +// ============================================ +// AUDIT LOG SERVICE (DSGVO Compliance) +// ============================================ + +interface AuditEntry { + orgId: string; + userId: string; + action: AuditAction; + entityType?: string; + entityId?: string; + oldData?: Record; + newData?: Record; + ipAddress?: string; + userAgent?: string; +} + +/** + * Log an audit event + * Required for DSGVO Article 30 (Records of processing activities) + */ +export async function log(entry: AuditEntry): Promise { + try { + await execute( + `INSERT INTO audit_logs ( + org_id, user_id, action, entity_type, entity_id, + old_data, new_data, ip_address, user_agent + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + entry.orgId, + entry.userId, + entry.action, + entry.entityType || null, + entry.entityId || null, + entry.oldData ? JSON.stringify(entry.oldData) : null, + entry.newData ? JSON.stringify(entry.newData) : null, + entry.ipAddress || null, + entry.userAgent || null, + ] + ); + } catch (error) { + // Don't fail the main operation if audit logging fails + // But log it for monitoring + console.error("Audit log failed:", error); + } +} + +/** + * Log user login + */ +export async function logLogin( + orgId: string, + userId: string, + ipAddress?: string, + userAgent?: string +): Promise { + await log({ + orgId, + userId, + action: "user.login", + ipAddress, + userAgent, + }); +} + +/** + * Log user logout + */ +export async function logLogout( + orgId: string, + userId: string, + ipAddress?: string +): Promise { + await log({ + orgId, + userId, + action: "user.logout", + ipAddress, + }); +} + +/** + * Log user registration + */ +export async function logRegister( + orgId: string, + userId: string, + email: string, + ipAddress?: string +): Promise { + await log({ + orgId, + userId, + action: "user.register", + newData: { email }, + ipAddress, + }); +} + +/** + * Log password reset request + */ +export async function logPasswordResetRequest( + orgId: string, + userId: string, + ipAddress?: string +): Promise { + await log({ + orgId, + userId, + action: "user.password_reset", + ipAddress, + }); +} + +/** + * Log password change + */ +export async function logPasswordChange( + orgId: string, + userId: string, + ipAddress?: string +): Promise { + await log({ + orgId, + userId, + action: "user.password_change", + ipAddress, + }); +} + +/** + * Log data export (DSGVO Art. 20 - Right to data portability) + */ +export async function logDataExport( + orgId: string, + userId: string, + exportType: string, + ipAddress?: string +): Promise { + await log({ + orgId, + userId, + action: "data.export", + newData: { exportType }, + ipAddress, + }); +} + +/** + * Log data deletion (DSGVO Art. 17 - Right to erasure) + */ +export async function logDataDeletion( + orgId: string, + userId: string, + entityType: string, + entityId: string, + ipAddress?: string +): Promise { + await log({ + orgId, + userId, + action: "data.delete", + entityType, + entityId, + ipAddress, + }); +} diff --git a/src/services/jwt.ts b/src/services/jwt.ts new file mode 100644 index 0000000..efa49d3 --- /dev/null +++ b/src/services/jwt.ts @@ -0,0 +1,155 @@ +import { create, verify, decode } from "djwt"; +import type { TokenPayload, Tokens } from "../types/index.ts"; + +// ============================================ +// JWT SERVICE +// ============================================ + +const JWT_SECRET = Deno.env.get("JWT_SECRET") || "CHANGE_ME_IN_PRODUCTION"; +const ACCESS_TOKEN_EXPIRY = 15 * 60; // 15 minutes in seconds +const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60 * 60; // 7 days in seconds + +// Create crypto key from secret +let cryptoKey: CryptoKey | null = null; + +async function getKey(): Promise { + if (!cryptoKey) { + const encoder = new TextEncoder(); + cryptoKey = await crypto.subtle.importKey( + "raw", + encoder.encode(JWT_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"] + ); + } + return cryptoKey; +} + +/** + * Generate access and refresh tokens for a user + */ +export async function generateTokens(payload: Omit): Promise { + const key = await getKey(); + const now = Math.floor(Date.now() / 1000); + + // Access Token (short-lived) + const accessToken = await create( + { alg: "HS256", typ: "JWT" }, + { + ...payload, + type: "access", + iat: now, + exp: now + ACCESS_TOKEN_EXPIRY, + }, + key + ); + + // Refresh Token (long-lived) + const refreshToken = await create( + { alg: "HS256", typ: "JWT" }, + { + ...payload, + type: "refresh", + iat: now, + exp: now + REFRESH_TOKEN_EXPIRY, + }, + key + ); + + return { + accessToken, + refreshToken, + expiresIn: ACCESS_TOKEN_EXPIRY, + }; +} + +/** + * Verify and decode an access token + */ +export async function verifyAccessToken(token: string): Promise { + try { + const key = await getKey(); + const payload = await verify(token, key) as unknown as TokenPayload & { exp: number }; + + // Check if it's an access token + if (payload.type !== "access") { + return null; + } + + return payload; + } catch { + return null; + } +} + +/** + * Verify and decode a refresh token + */ +export async function verifyRefreshToken(token: string): Promise { + try { + const key = await getKey(); + const payload = await verify(token, key) as unknown as TokenPayload & { exp: number }; + + // Check if it's a refresh token + if (payload.type !== "refresh") { + return null; + } + + return payload; + } catch { + return null; + } +} + +/** + * Decode a token without verifying (for extracting claims from expired tokens) + */ +export function decodeToken(token: string): TokenPayload | null { + try { + const [, payload] = decode(token); + return payload as unknown as TokenPayload; + } catch { + return null; + } +} + +/** + * Generate a new access token from refresh token payload + */ +export async function refreshAccessToken( + refreshPayload: TokenPayload +): Promise<{ accessToken: string; expiresIn: number }> { + const key = await getKey(); + const now = Math.floor(Date.now() / 1000); + + const accessToken = await create( + { alg: "HS256", typ: "JWT" }, + { + sub: refreshPayload.sub, + email: refreshPayload.email, + role: refreshPayload.role, + orgId: refreshPayload.orgId, + type: "access", + iat: now, + exp: now + ACCESS_TOKEN_EXPIRY, + }, + key + ); + + return { + accessToken, + expiresIn: ACCESS_TOKEN_EXPIRY, + }; +} + +/** + * Hash a refresh token for storage + */ +export async function hashToken(token: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(token); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/src/services/password.ts b/src/services/password.ts new file mode 100644 index 0000000..a2264fa --- /dev/null +++ b/src/services/password.ts @@ -0,0 +1,78 @@ +import { hash, verify } from "argon2"; + +// ============================================ +// PASSWORD HASHING SERVICE (Argon2) +// ============================================ + +// Argon2id configuration (OWASP recommended) +const HASH_OPTIONS = { + memoryCost: 65536, // 64 MB + timeCost: 3, // 3 iterations + parallelism: 4, + hashLength: 32, +}; + +/** + * Hash a password using Argon2id + */ +export async function hashPassword(password: string): Promise { + return await hash(password, HASH_OPTIONS); +} + +/** + * Verify a password against a hash + */ +export async function verifyPassword( + password: string, + hashedPassword: string +): Promise { + try { + return await verify(hashedPassword, password); + } catch { + // Invalid hash format or other error + return false; + } +} + +/** + * Check password strength + * Returns { valid: boolean, errors: string[] } + */ +export function validatePasswordStrength(password: string): { + valid: boolean; + errors: string[]; +} { + const errors: string[] = []; + + if (password.length < 8) { + errors.push("Password must be at least 8 characters"); + } + if (password.length > 128) { + errors.push("Password must be at most 128 characters"); + } + if (!/[a-z]/.test(password)) { + errors.push("Password must contain at least one lowercase letter"); + } + if (!/[A-Z]/.test(password)) { + errors.push("Password must contain at least one uppercase letter"); + } + if (!/[0-9]/.test(password)) { + errors.push("Password must contain at least one number"); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Generate a secure random token (for password reset, email verification) + */ +export function generateSecureToken(length = 32): string { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..49ce7df --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,157 @@ +// ============================================ +// USER TYPES +// ============================================ + +export type UserRole = "owner" | "admin" | "manager" | "user"; + +export interface User { + id: string; + org_id: string; + email: string; + password_hash: string; + first_name: string; + last_name: string; + role: UserRole; + is_verified: boolean; + is_active: boolean; + verification_token?: string | null; + reset_token?: string | null; + reset_token_expires?: Date | null; + two_factor_secret?: string | null; + two_factor_enabled: boolean; + last_login_at?: Date | null; + created_at: Date; + updated_at: Date; + deleted_at?: Date | null; +} + +export interface UserPublic { + id: string; + email: string; + firstName: string; + lastName: string; + role: UserRole; + isVerified: boolean; + orgId: string; + twoFactorEnabled: boolean; + createdAt: string; +} + +// ============================================ +// ORGANIZATION TYPES +// ============================================ + +export type PlanType = "free" | "starter" | "pro" | "enterprise"; + +export interface Organization { + id: string; + name: string; + slug: string; + plan: PlanType; + max_users: number; + settings: Record; + created_at: Date; + updated_at: Date; + deleted_at?: Date | null; +} + +export interface OrganizationPublic { + id: string; + name: string; + slug: string; + plan: PlanType; +} + +// ============================================ +// AUTH TYPES +// ============================================ + +export interface TokenPayload { + sub: string; // user_id + email: string; + role: UserRole; + orgId: string; + type: "access" | "refresh"; +} + +export interface Tokens { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface RefreshTokenRecord { + id: string; + user_id: string; + token_hash: string; + expires_at: Date; + revoked: boolean; + created_at: Date; + revoked_at?: Date | null; +} + +// ============================================ +// REQUEST / RESPONSE TYPES +// ============================================ + +export interface RegisterRequest { + email: string; + password: string; + firstName: string; + lastName: string; + orgName: string; +} + +export interface LoginRequest { + email: string; + password: string; + twoFactorCode?: string; +} + +export interface AuthResponse { + success: boolean; + data: { + user: UserPublic; + organization: OrganizationPublic; + tokens: Tokens; + }; +} + +// ============================================ +// AUDIT LOG TYPES +// ============================================ + +export type AuditAction = + | "user.login" + | "user.logout" + | "user.register" + | "user.password_reset" + | "user.password_change" + | "user.verify_email" + | "user.enable_2fa" + | "user.disable_2fa" + | "data.export" + | "data.delete"; + +export interface AuditLog { + id: string; + org_id: string; + user_id: string; + action: AuditAction; + entity_type?: string; + entity_id?: string; + old_data?: Record; + new_data?: Record; + ip_address?: string; + user_agent?: string; + created_at: Date; +} + +// ============================================ +// CONTEXT TYPES (for Oak middleware) +// ============================================ + +export interface AuthState { + user: UserPublic; + orgId: string; +} diff --git a/src/utils/response.ts b/src/utils/response.ts new file mode 100644 index 0000000..03a7b37 --- /dev/null +++ b/src/utils/response.ts @@ -0,0 +1,81 @@ +// ============================================ +// STANDARDIZED API RESPONSE HELPERS +// ============================================ + +export interface ApiResponse { + success: boolean; + data?: T; + message?: string; + error?: { + code: string; + message: string; + details?: unknown; + }; + meta?: { + page?: number; + limit?: number; + total?: number; + totalPages?: number; + }; +} + +/** + * Create a success response + */ +export function success(data: T, message?: string): ApiResponse { + return { + success: true, + data, + message, + }; +} + +/** + * Create a paginated success response + */ +export function paginated( + data: T[], + total: number, + page: number, + limit: number +): ApiResponse { + return { + success: true, + data, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; +} + +/** + * Create an error response + */ +export function error( + code: string, + message: string, + details?: unknown +): ApiResponse { + return { + success: false, + error: { + code, + message, + details, + }, + }; +} + +// Common error codes +export const ErrorCodes = { + VALIDATION_ERROR: "VALIDATION_ERROR", + UNAUTHORIZED: "UNAUTHORIZED", + FORBIDDEN: "FORBIDDEN", + NOT_FOUND: "NOT_FOUND", + CONFLICT: "CONFLICT", + INTERNAL_ERROR: "INTERNAL_ERROR", + RATE_LIMITED: "TOO_MANY_REQUESTS", +} as const;