feat(auth): Implementiere vollständiges Auth-System

- JWT Access + Refresh Tokens mit djwt
- Argon2 Password Hashing (OWASP konfig)
- Rate Limiting für Auth-Endpoints
- Rollen-basierte Zugriffskontrolle (owner, admin, manager, user)
- DSGVO Audit Logging
- Email-Verifizierung (Struktur)
- Passwort-Reset Flow
- Multi-Device Logout

Neue Dateien:
- src/types/index.ts - TypeScript Interfaces
- src/db/connection.ts - PostgreSQL Pool
- src/services/password.ts - Argon2 Hashing
- src/services/jwt.ts - Token Generation
- src/services/audit.ts - DSGVO Audit Log
- src/middleware/auth.ts - Auth Middleware
- src/repositories/user.ts - User DB Queries
- src/repositories/organization.ts - Org DB Queries
- src/utils/response.ts - API Response Helpers

Task: #8 Authentifizierung & Benutzerverwaltung
This commit is contained in:
2026-02-11 10:30:37 +00:00
parent cc74d66fad
commit d0f1c242a3
13 changed files with 1888 additions and 107 deletions

View File

@@ -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 <noreply@example.com>
# Frontend URL (for email links)
FRONTEND_URL=https://crm.kronos-soulution.de

View File

@@ -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"]
}
}
}

107
src/db/connection.ts Normal file
View File

@@ -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<T>(
text: string,
args?: unknown[]
): Promise<T[]> {
const connection = await pool.connect();
try {
const result = await connection.queryObject<T>(text, args);
return result.rows;
} finally {
connection.release();
}
}
/**
* Execute a query and return first result or null
*/
export async function queryOne<T>(
text: string,
args?: unknown[]
): Promise<T | null> {
const rows = await query<T>(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<number> {
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<T>(
fn: (connection: ReturnType<Pool["connect"]> extends Promise<infer U> ? U : never) => Promise<T>
): Promise<T> {
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<boolean> {
try {
await query("SELECT 1");
return true;
} catch {
return false;
}
}
export { pool };

View File

@@ -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 });

193
src/middleware/auth.ts Normal file
View File

@@ -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<AuthState>, 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 <token>",
},
};
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<AuthState>, 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<AuthState>, 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<string, { count: number; resetAt: number }>();
/**
* 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

View File

@@ -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<Organization | null> {
return await queryOne<Organization>(
`SELECT * FROM organizations WHERE id = $1 AND deleted_at IS NULL`,
[id]
);
}
/**
* Find organization by slug
*/
export async function findBySlug(slug: string): Promise<Organization | null> {
return await queryOne<Organization>(
`SELECT * FROM organizations WHERE slug = $1 AND deleted_at IS NULL`,
[slug]
);
}
/**
* Get user count for organization
*/
export async function getUserCount(orgId: string): Promise<number> {
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<boolean> {
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<string, unknown>
): Promise<Organization | null> {
const rows = await query<Organization>(
`UPDATE organizations
SET settings = settings || $1::jsonb, updated_at = NOW()
WHERE id = $2
RETURNING *`,
[JSON.stringify(settings), orgId]
);
return rows[0] || null;
}

246
src/repositories/user.ts Normal file
View File

@@ -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<User | null> {
return await queryOne<User>(
`SELECT * FROM users WHERE email = $1 AND deleted_at IS NULL`,
[email.toLowerCase()]
);
}
/**
* Find user by ID
*/
export async function findById(id: string): Promise<User | null> {
return await queryOne<User>(
`SELECT * FROM users WHERE id = $1 AND deleted_at IS NULL`,
[id]
);
}
/**
* Find user by verification token
*/
export async function findByVerificationToken(token: string): Promise<User | null> {
return await queryOne<User>(
`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<User | null> {
return await queryOne<User>(
`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<Organization>(
`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<User>(
`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<User> {
const rows = await query<User>(
`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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<RefreshTokenRecord | null> {
return await queryOne<RefreshTokenRecord>(
`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<void> {
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<void> {
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<number> {
return await execute(
`DELETE FROM refresh_tokens WHERE expires_at < NOW()`
);
}

View File

@@ -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<AuthState>({ 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,
},
};
});

171
src/services/audit.ts Normal file
View File

@@ -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<string, unknown>;
newData?: Record<string, unknown>;
ipAddress?: string;
userAgent?: string;
}
/**
* Log an audit event
* Required for DSGVO Article 30 (Records of processing activities)
*/
export async function log(entry: AuditEntry): Promise<void> {
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<void> {
await log({
orgId,
userId,
action: "user.login",
ipAddress,
userAgent,
});
}
/**
* Log user logout
*/
export async function logLogout(
orgId: string,
userId: string,
ipAddress?: string
): Promise<void> {
await log({
orgId,
userId,
action: "user.logout",
ipAddress,
});
}
/**
* Log user registration
*/
export async function logRegister(
orgId: string,
userId: string,
email: string,
ipAddress?: string
): Promise<void> {
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<void> {
await log({
orgId,
userId,
action: "user.password_reset",
ipAddress,
});
}
/**
* Log password change
*/
export async function logPasswordChange(
orgId: string,
userId: string,
ipAddress?: string
): Promise<void> {
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<void> {
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<void> {
await log({
orgId,
userId,
action: "data.delete",
entityType,
entityId,
ipAddress,
});
}

155
src/services/jwt.ts Normal file
View File

@@ -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<CryptoKey> {
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<TokenPayload, "type">): Promise<Tokens> {
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<TokenPayload | null> {
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<TokenPayload | null> {
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<string> {
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("");
}

78
src/services/password.ts Normal file
View File

@@ -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<string> {
return await hash(password, HASH_OPTIONS);
}
/**
* Verify a password against a hash
*/
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
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("");
}

157
src/types/index.ts Normal file
View File

@@ -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<string, unknown>;
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<string, unknown>;
new_data?: Record<string, unknown>;
ip_address?: string;
user_agent?: string;
created_at: Date;
}
// ============================================
// CONTEXT TYPES (for Oak middleware)
// ============================================
export interface AuthState {
user: UserPublic;
orgId: string;
}

81
src/utils/response.ts Normal file
View File

@@ -0,0 +1,81 @@
// ============================================
// STANDARDIZED API RESPONSE HELPERS
// ============================================
export interface ApiResponse<T = unknown> {
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<T>(data: T, message?: string): ApiResponse<T> {
return {
success: true,
data,
message,
};
}
/**
* Create a paginated success response
*/
export function paginated<T>(
data: T[],
total: number,
page: number,
limit: number
): ApiResponse<T[]> {
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<never> {
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;