UNPKG

@smartsamurai/krapi-sdk

Version:

KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)

1,588 lines (1,584 loc) 655 kB
import { AdminHttpClient, AuthHttpClient, BaseHttpClient, CollectionsHttpClient, EmailHttpClient, HealthHttpClient, KrapiClient, StorageHttpClient, client_default, normalizeEndpoint, validateEndpoint } from "./chunk-XFYSS6P6.mjs"; import { AdminService, logServiceOperationError } from "./chunk-ZGAJLJLC.mjs"; import { UsersService } from "./chunk-5QJZZZSI.mjs"; import { HttpError, KrapiError, __toCommonJS, createRequestId, enrichError, error_handler_exports, init_error_handler, init_http_error, init_krapi_error, normalizeError } from "./chunk-CUJMHNHY.mjs"; // src/krapi.ts init_krapi_error(); // src/krapi/connection-manager.ts init_krapi_error(); var ConnectionManager = class { constructor() { this.mode = null; this.config = null; this.logger = console; this.currentEndpoint = null; } /** * Connect to KRAPI (client or server mode) */ async connect(config) { if ("endpoint" in config) { const newEndpoint = config.endpoint; const validation = validateEndpoint(newEndpoint); if (!validation.valid) { throw KrapiError.validationError(`Invalid endpoint: ${validation.error}`, "endpoint"); } const normalizedEndpoint = normalizeEndpoint(newEndpoint, { warnOnBackendPort: true, autoAppendPath: true, logger: console }); const isReconnection = this.mode === "client" && this.currentEndpoint && this.currentEndpoint !== normalizedEndpoint; if (isReconnection) { this.logger.warn( `SDK reconnecting from ${this.currentEndpoint} to ${normalizedEndpoint}. All HTTP clients will be recreated with the new endpoint.` ); } this.config = { ...config, endpoint: normalizedEndpoint }; this.mode = "client"; this.logger = console; this.currentEndpoint = normalizedEndpoint; } else if ("database" in config) { this.mode = "server"; this.currentEndpoint = null; this.logger = config.logger || console; this.config = config; } else { throw KrapiError.validationError( "Either endpoint (for client) or database (for server) must be provided", "config" ); } this.logger.info(`KRAPI SDK initialized in ${this.mode} mode`); } /** * Get current mode */ getMode() { return this.mode; } /** * Get current configuration */ getConfig() { return this.config; } /** * Get current endpoint (client mode only) */ getEndpoint() { return this.currentEndpoint; } /** * Get logger */ getLogger() { return this.logger; } /** * Check if connected */ isConnected() { return this.mode !== null && this.config !== null; } /** * Clear connection state */ clear() { this.mode = null; this.config = null; this.currentEndpoint = null; } }; // src/activity-logger.ts import crypto2 from "crypto"; var ActivityLogger = class { constructor(dbConnection, logger = console) { this.dbConnection = dbConnection; this.logger = logger; this.initialized = false; } /** * Initialize the activity_logs table */ async initializeActivityTable() { if (this.initialized) { return; } try { try { await Promise.race([ Promise.all([ this.dbConnection.query("SELECT 1 FROM admin_users LIMIT 1"), this.dbConnection.query("SELECT 1 FROM projects LIMIT 1") ]), new Promise( (_, reject) => setTimeout(() => reject(new Error("Table check timeout")), 2e3) ) ]); } catch { } await this.dbConnection.query(` CREATE TABLE IF NOT EXISTS activity_logs ( id TEXT PRIMARY KEY, user_id TEXT, project_id TEXT, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT, details TEXT NOT NULL DEFAULT '{}', ip_address TEXT, user_agent TEXT, timestamp TEXT DEFAULT CURRENT_TIMESTAMP, severity TEXT NOT NULL DEFAULT 'info' CHECK (severity IN ('info', 'warning', 'error', 'critical')), metadata TEXT DEFAULT '{}', created_at TEXT DEFAULT CURRENT_TIMESTAMP ) `); await this.dbConnection.query(` CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id) `); await this.dbConnection.query(` CREATE INDEX IF NOT EXISTS idx_activity_logs_project_id ON activity_logs(project_id) `); await this.dbConnection.query(` CREATE INDEX IF NOT EXISTS idx_activity_logs_action ON activity_logs(action) `); await this.dbConnection.query(` CREATE INDEX IF NOT EXISTS idx_activity_logs_timestamp ON activity_logs(timestamp) `); await this.dbConnection.query(` CREATE INDEX IF NOT EXISTS idx_activity_logs_severity ON activity_logs(severity) `); this.initialized = true; this.logger.info("Activity logging table initialized"); } catch (error) { this.logger.error("Failed to initialize activity logging table:", error); } } /** * Ensure table is initialized before any operation * Uses a timeout to prevent hanging */ async ensureInitialized() { if (this.initialized) { return; } try { await Promise.race([ this.initializeActivityTable(), new Promise( (_, reject) => setTimeout(() => reject(new Error("Activity table initialization timeout")), 5e3) ) ]); } catch (error) { this.initialized = true; this.logger.warn("Activity table initialization failed or timed out, continuing without activity logging:", error); } } /** * Log an activity */ async log(activity) { await this.ensureInitialized(); try { try { await this.dbConnection.query("SELECT 1 FROM activity_logs LIMIT 1"); } catch { this.logger.warn("Activity table doesn't exist, skipping activity log"); return { id: crypto2.randomUUID(), user_id: activity.user_id || null, project_id: activity.project_id || null, action: activity.action, resource_type: activity.resource_type, resource_id: activity.resource_id || null, details: activity.details, ip_address: activity.ip_address || null, user_agent: activity.user_agent || null, timestamp: /* @__PURE__ */ new Date(), severity: activity.severity, metadata: activity.metadata || {}, created_at: /* @__PURE__ */ new Date() }; } const userId = activity.user_id && typeof activity.user_id === "string" && activity.user_id.includes("-") ? activity.user_id : null; const logId = crypto2.randomUUID(); const now = (/* @__PURE__ */ new Date()).toISOString(); await this.dbConnection.query( ` INSERT INTO activity_logs ( id, user_id, project_id, action, resource_type, resource_id, details, ip_address, user_agent, severity, metadata, timestamp, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) `, [ logId, userId, activity.project_id, activity.action, activity.resource_type, activity.resource_id, JSON.stringify(activity.details), activity.ip_address, activity.user_agent, activity.severity, JSON.stringify(activity.metadata || {}), now, now ] ); const result = await this.dbConnection.query( "SELECT * FROM activity_logs WHERE id = $1", [logId] ); const loggedActivity = result.rows?.[0]; this.logger.info( `Activity logged: ${activity.action} on ${activity.resource_type}` ); return loggedActivity; } catch (error) { this.logger.error("Failed to log activity:", error); return { id: crypto2.randomUUID(), user_id: activity.user_id || null, project_id: activity.project_id || null, action: activity.action, resource_type: activity.resource_type, resource_id: activity.resource_id || null, details: activity.details, ip_address: activity.ip_address || null, user_agent: activity.user_agent || null, timestamp: /* @__PURE__ */ new Date(), severity: activity.severity, metadata: activity.metadata || {}, created_at: /* @__PURE__ */ new Date() }; } } /** * Query activity logs */ async query(query) { await this.ensureInitialized(); try { try { await this.dbConnection.query("SELECT 1 FROM activity_logs LIMIT 1"); } catch { return { logs: [], total: 0 }; } let whereClause = "WHERE 1=1"; const params = []; let paramIndex = 1; if (query.user_id) { whereClause += ` AND user_id = $${paramIndex++}`; params.push(query.user_id); } if (query.project_id) { whereClause += ` AND project_id = $${paramIndex++}`; params.push(query.project_id); } if (query.action) { whereClause += ` AND action = $${paramIndex++}`; params.push(query.action); } if (query.resource_type) { whereClause += ` AND resource_type = $${paramIndex++}`; params.push(query.resource_type); } if (query.resource_id) { whereClause += ` AND resource_id = $${paramIndex++}`; params.push(query.resource_id); } if (query.severity) { whereClause += ` AND severity = $${paramIndex++}`; params.push(query.severity); } if (query.start_date) { whereClause += ` AND timestamp >= $${paramIndex++}`; params.push(query.start_date); } if (query.end_date) { whereClause += ` AND timestamp <= $${paramIndex++}`; params.push(query.end_date); } const countResult = await this.dbConnection.query( ` SELECT COUNT(*) FROM activity_logs ${whereClause} `, params ); const total = parseInt( (countResult.rows?.[0]).count || "0" ); const limit = query.limit || 100; const offset = query.offset || 0; const logsResult = await this.dbConnection.query( ` SELECT * FROM activity_logs ${whereClause} ORDER BY timestamp DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++} `, [...params, limit, offset] ); const logs = (logsResult.rows || []).map( (row) => { const rowData = row; return { ...rowData, timestamp: new Date(rowData.timestamp), details: typeof rowData.details === "string" ? JSON.parse(rowData.details) : rowData.details, metadata: typeof rowData.metadata === "string" ? JSON.parse(rowData.metadata) : rowData.metadata }; } ); return { logs, total }; } catch (error) { this.logger.error("Failed to query activity logs:", error); return { logs: [], total: 0 }; } } /** * Get recent activity for a user or project */ async getRecentActivity(userId, projectId, limit = 50) { await this.ensureInitialized(); const query = { limit }; if (userId) query.user_id = userId; if (projectId) query.project_id = projectId; const result = await this.query(query); return result.logs; } /** * Get activity statistics */ async getActivityStats(projectId, days = 30) { await this.ensureInitialized(); try { const cutoffDate = new Date(Date.now() - days * 24 * 60 * 60 * 1e3).toISOString(); let whereClause = "WHERE timestamp >= $1"; const params = [cutoffDate]; if (projectId) { whereClause += " AND project_id = $2"; params.push(projectId); } try { const checkResult = await this.dbConnection.query( `SELECT COUNT(*) as count FROM activity_logs LIMIT 1` ); const hasData = parseInt( checkResult.rows?.[0]?.count || "0" ) > 0; if (!hasData) { return { total_actions: 0, actions_by_type: {}, actions_by_severity: {}, actions_by_user: {} }; } } catch { return { total_actions: 0, actions_by_type: {}, actions_by_severity: {}, actions_by_user: {} }; } const totalResult = await this.dbConnection.query( ` SELECT COUNT(*) as count FROM activity_logs ${whereClause} `, params ); const total_actions = parseInt( (totalResult.rows?.[0]).count || "0" ); const typeResult = await this.dbConnection.query( ` SELECT action, COUNT(*) as count FROM activity_logs ${whereClause} GROUP BY action ORDER BY count DESC LIMIT 50 `, params ); const actions_by_type = {}; (typeResult.rows || []).forEach((row) => { const rowData = row; actions_by_type[rowData.action] = parseInt( rowData.count || "0" ); }); const severityResult = await this.dbConnection.query( ` SELECT severity, COUNT(*) as count FROM activity_logs ${whereClause} GROUP BY severity ORDER BY count DESC LIMIT 20 `, params ); const actions_by_severity = {}; (severityResult.rows || []).forEach((row) => { const rowData = row; actions_by_severity[rowData.severity] = parseInt( rowData.count || "0" ); }); const userResult = await this.dbConnection.query( ` SELECT user_id, COUNT(*) as count FROM activity_logs ${whereClause} AND user_id IS NOT NULL GROUP BY user_id ORDER BY count DESC LIMIT 10 `, params ); const actions_by_user = {}; (userResult.rows || []).forEach((row) => { const rowData = row; actions_by_user[rowData.user_id] = parseInt( rowData.count || "0" ); }); return { total_actions, actions_by_type, actions_by_severity, actions_by_user }; } catch (error) { this.logger.error("Failed to get activity statistics:", error); throw error; } } /** * Clean old activity logs */ async cleanOldLogs(daysToKeep = 90) { await this.ensureInitialized(); try { const cutoffDate = /* @__PURE__ */ new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); const result = await this.dbConnection.query( ` DELETE FROM activity_logs WHERE timestamp < $1 `, [cutoffDate.toISOString()] ); const deletedCount = result.rowCount || 0; this.logger.info(`Cleaned ${deletedCount} old activity logs`); return deletedCount; } catch (error) { this.logger.error("Failed to clean old activity logs:", error); throw error; } } /** * Cleanup old activity logs (alias for cleanOldLogs with different return format) * * @param daysToKeep - Number of days to keep (default: 90) * @returns Cleanup result with success status and deleted count */ async cleanup(daysToKeep = 90) { try { const deletedCount = await this.cleanOldLogs(daysToKeep); return { success: true, deleted_count: deletedCount }; } catch (error) { this.logger.error("Failed to cleanup activity logs:", error); return { success: false, deleted_count: 0 }; } } }; // src/auth-service.ts init_krapi_error(); init_error_handler(); import crypto3 from "crypto"; import bcrypt from "bcryptjs"; var AuthService = class { /** * Create a new AuthService instance * * @param {DatabaseConnection} databaseConnection - Database connection * @param {Logger} logger - Logger instance */ constructor(databaseConnection, logger) { this.db = databaseConnection; this.logger = logger; } /** * Get scopes for a given admin role * * Derives scopes from user role when permissions field is empty or null. * This ensures sessions always have appropriate scopes based on role. * * @param {string} role - User role (e.g., 'master_admin', 'admin', etc.) * @returns {string[]} Array of scope strings * * @example * const scopes = authService.getScopesForRole('master_admin'); * // Returns: ['master'] */ getScopesForRole(role) { const normalizedRole = role?.toLowerCase() || ""; switch (normalizedRole) { case "master_admin": case "super_admin": return ["MASTER"]; case "admin": return [ "admin:read", "admin:write", "admin:delete", "projects:read", "projects:write", "projects:delete", "collections:read", "collections:write", "collections:delete", "documents:read", "documents:write", "documents:delete", "storage:read", "storage:write", "storage:delete", "users:read", "users:write", "users:delete", "email:send", "email:read" ]; case "moderator": return [ "admin:read", "projects:read", "projects:write", "collections:read", "collections:write", "documents:read", "documents:write", "storage:read", "storage:write", "users:read", "email:read" ]; case "developer": return [ "projects:read", "projects:write", "collections:read", "collections:write", "collections:delete", "documents:read", "documents:write", "documents:delete", "storage:read", "storage:write", "functions:execute", "functions:write" ]; case "project_admin": return [ "projects:read", "projects:write", "collections:read", "collections:write", "collections:delete", "documents:read", "documents:write", "documents:delete", "storage:read", "storage:write", "storage:delete", "users:read", "users:write", "email:send" ]; case "limited_admin": return [ "admin:read", "projects:read", "collections:read", "documents:read", "storage:read", "users:read", "email:read" ]; default: return ["read"]; } } /** * Authenticate admin user * * Authenticates an admin user with username/email and password. * Creates a session and returns login response with token and user data. * * @param {LoginRequest} loginData - Login credentials * @param {string} [loginData.username] - Admin username * @param {string} [loginData.email] - Admin email * @param {string} loginData.password - Admin password * @param {string} [loginData.project_id] - Project ID (for project users) * @param {boolean} [loginData.remember_me] - Whether to remember session * @returns {Promise<LoginResponse>} Login response with token, user, and session info * @throws {Error} If username/email or password is missing * @throws {Error} If credentials are invalid * * @example * const result = await authService.authenticateAdmin({ * username: 'admin', * password: 'password' * }); */ async authenticateAdmin(loginData) { const { username, email, password } = loginData; try { if (!username && !email) { throw KrapiError.validationError( "Username or email is required", "username", username ); } if (!password) { throw KrapiError.validationError("Password is required", "password"); } let query = "SELECT * FROM admin_users WHERE is_active = true AND "; const params = []; if (username) { query += "username = $1"; params.push(username); } else { query += "email = $1"; params.push(email); } const result = await this.db.query(query, params); if (result.rows.length === 0) { throw KrapiError.authError("Invalid credentials", { operation: "authenticateAdmin", username, email }); } const rawUser = result.rows[0]; let permissions = []; if (rawUser.permissions) { if (typeof rawUser.permissions === "string") { try { permissions = JSON.parse(rawUser.permissions); } catch { permissions = []; } } else if (Array.isArray(rawUser.permissions)) { permissions = rawUser.permissions; } } const adminUser = { id: rawUser.id, username: rawUser.username, email: rawUser.email, password_hash: rawUser.password_hash, role: rawUser.role, access_level: rawUser.access_level, permissions, active: Boolean(rawUser.is_active ?? rawUser.active), created_at: rawUser.created_at, updated_at: rawUser.updated_at }; if (rawUser.last_login) { adminUser.last_login = rawUser.last_login; } if (rawUser.api_key) { adminUser.api_key = rawUser.api_key; } if (rawUser.login_count !== void 0) { adminUser.login_count = rawUser.login_count; } const isValidPassword = await this.validatePassword( password, adminUser.password_hash ); if (!isValidPassword) { throw KrapiError.authError("Invalid credentials", { operation: "authenticateAdmin", username, email, userId: adminUser.id }); } const scopes = permissions.length > 0 ? permissions : this.getScopesForRole(adminUser.role); const session = await this.createSession({ user_id: adminUser.id, user_type: "admin", scopes, remember_me: loginData.remember_me ?? false }); await this.updateLastLogin(adminUser.id, "admin"); const responseScopes = Array.isArray(session.scopes) ? session.scopes : typeof session.scopes === "string" ? (() => { try { return JSON.parse(session.scopes); } catch { return []; } })() : scopes; return { success: true, token: session.token, expires_at: session.expires_at, user: { ...adminUser, permissions: scopes // Return derived scopes in user object }, scopes: responseScopes, // Return as array session_id: session.id }; } catch (error) { this.logger.error("Admin authentication failed:", error); throw normalizeError(error, "UNAUTHORIZED", { operation: "authenticateAdmin", username, email }); } } /** * Register a new admin user * * Creates a new admin user account with the provided credentials. * * @param {Object} registerData - Registration data * @param {string} registerData.username - Username (required) * @param {string} registerData.email - Email address (required) * @param {string} registerData.password - Password (required) * @param {string} [registerData.role="user"] - User role * @param {string} [registerData.access_level="read"] - Access level * @param {string[]} [registerData.permissions=[]] - Permission scopes * @returns {Promise<{success: boolean, user: AdminUser}>} Registration result * @throws {Error} If user already exists or registration fails * * @example * const result = await authService.register({ * username: 'newuser', * email: 'user@example.com', * password: 'securepassword', * role: 'admin' * }); */ async register(registerData) { const { username, email, password, role = "user", access_level = "read", permissions = [] } = registerData; try { const existingUser = await this.db.query( "SELECT id FROM admin_users WHERE username = $1 OR email = $2", [username, email] ); if (existingUser.rows.length > 0) { throw KrapiError.conflict("User already exists", { resource: "admin_users", operation: "registerAdmin", username, email }); } const passwordHash = await this.hashPassword(password); const userId = crypto3.randomUUID(); const now = (/* @__PURE__ */ new Date()).toISOString(); await this.db.query( `INSERT INTO admin_users (id, username, email, password_hash, role, access_level, permissions, is_active, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ userId, username, email, passwordHash, role, access_level, permissions, 1, now, now ] ); const result = await this.db.query( "SELECT * FROM admin_users WHERE id = $1", [userId] ); if (result.rows.length === 0) { throw normalizeError( new Error("Failed to create user"), "INTERNAL_ERROR", { operation: "registerAdmin", username, email } ); } const newUser = result.rows[0]; return { success: true, user: newUser }; } catch (error) { this.logger.error("User registration failed:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "registerAdmin", username, email }); } } /** * Logout and revoke session * * Logs out a user by revoking their session token. * * @param {string} [sessionId] - Optional session ID to revoke (if not provided, revokes current session) * @returns {Promise<{success: boolean}>} Logout result * * @example * await authService.logout('session-id'); */ async logout(sessionId) { try { if (sessionId) { await this.revokeSession(sessionId); } return { success: true }; } catch (error) { this.logger.error("Logout failed:", error); return { success: true }; } } /** * Authenticate admin user with API key * * Authenticates an admin user using an API key instead of username/password. * * @param {ApiKeyAuthRequest} apiKeyData - API key authentication data * @param {string} apiKeyData.api_key - API key value * @returns {Promise<ApiKeyAuthResponse>} Authentication response with token and user * @throws {Error} If API key is invalid or expired * * @example * const result = await authService.authenticateAdminWithApiKey({ * api_key: 'ak_...' * }); */ async authenticateAdminWithApiKey(apiKeyData) { const { api_key } = apiKeyData; try { if (!api_key) { throw KrapiError.validationError("API key is required", "api_key"); } const result = await this.db.query( "SELECT * FROM admin_users WHERE api_key = $1 AND is_active = true", [api_key] ); if (result.rows.length === 0) { throw KrapiError.authError("Invalid API key", { operation: "authenticateAdminWithApiKey" }); } const rawUser = result.rows[0]; let permissions = []; if (rawUser.permissions) { if (typeof rawUser.permissions === "string") { try { permissions = JSON.parse(rawUser.permissions); } catch { permissions = []; } } else if (Array.isArray(rawUser.permissions)) { permissions = rawUser.permissions; } } const adminUser = { id: rawUser.id, username: rawUser.username, email: rawUser.email, password_hash: rawUser.password_hash, role: rawUser.role, access_level: rawUser.access_level, permissions, active: Boolean(rawUser.is_active ?? rawUser.active), created_at: rawUser.created_at, updated_at: rawUser.updated_at }; if (rawUser.last_login) { adminUser.last_login = rawUser.last_login; } if (rawUser.api_key) { adminUser.api_key = rawUser.api_key; } if (rawUser.login_count !== void 0) { adminUser.login_count = rawUser.login_count; } const scopes = permissions.length > 0 ? permissions : this.getScopesForRole(adminUser.role); const session = await this.createSession({ user_id: adminUser.id, user_type: "admin", scopes }); await this.updateLastLogin(adminUser.id, "admin"); const responseScopes = Array.isArray(session.scopes) ? session.scopes : typeof session.scopes === "string" ? (() => { try { return JSON.parse(session.scopes); } catch { return []; } })() : scopes; return { success: true, token: session.token, expires_at: session.expires_at, user: { ...adminUser, permissions: scopes // Return derived scopes in user object }, scopes: responseScopes, // Return as array session_id: session.id }; } catch (error) { this.logger.error("API key authentication failed:", error); throw normalizeError(error, "UNAUTHORIZED", { operation: "authenticateAdminWithApiKey" }); } } /** * Regenerate API key for admin user * * Generates a new API key for the authenticated admin user. * Note: This is a placeholder implementation. * * @param {unknown} _req - Request object (currently unused) * @returns {Promise<{success: boolean, data?: {apiKey: string}, error?: string}>} API key generation result * * @example * const result = await authService.regenerateApiKey(request); * if (result.success) { * console.log(`New API Key: ${result.data?.apiKey}`); * } */ async regenerateApiKey(_req) { try { const newApiKey = `ak_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Date.now()}`; return { success: true, data: { apiKey: newApiKey } }; } catch (error) { this.logger.error("Failed to regenerate API key:", error); return { success: false, error: "Failed to regenerate API key" }; } } /** * Authenticate project user * * Authenticates a project-specific user with username/email and password. * * @param {LoginRequest} loginData - Login credentials * @param {string} loginData.project_id - Project ID (required) * @param {string} [loginData.username] - Username * @param {string} [loginData.email] - Email * @param {string} loginData.password - Password * @param {boolean} [loginData.remember_me] - Whether to remember session * @returns {Promise<LoginResponse>} Login response with token and user * @throws {Error} If credentials are invalid or project ID missing * * @example * const result = await authService.authenticateProjectUser({ * project_id: 'project-id', * username: 'user', * password: 'password' * }); */ async authenticateProjectUser(loginData) { try { const { username, email, password, project_id } = loginData; if (!project_id) { throw KrapiError.validationError( "Project ID is required for project user authentication", "project_id" ); } if (!username && !email) { throw KrapiError.validationError( "Username or email is required", "username", username ); } if (!password) { throw KrapiError.validationError("Password is required", "password"); } let query = "SELECT * FROM project_users WHERE project_id = $1 AND is_active = true AND "; const params = [project_id]; if (username) { query += "username = $2"; params.push(username); } else { query += "email = $2"; params.push(email); } const result = await this.db.query(query, params); if (result.rows.length === 0) { throw KrapiError.authError("Invalid credentials", { operation: "login", username, email }); } const projectUser = result.rows[0]; if (!projectUser.password_hash) { throw KrapiError.authError("Invalid credentials", { operation: "login", username, email, userId: projectUser.id }); } const isValidPassword = await this.validatePassword( password, projectUser.password_hash ); if (!isValidPassword) { throw KrapiError.authError("Invalid credentials", { operation: "login", username, email, userId: projectUser.id }); } let scopes = []; if (projectUser.scopes && Array.isArray(projectUser.scopes) && projectUser.scopes.length > 0) { scopes = projectUser.scopes; } else if (projectUser.permissions && Array.isArray(projectUser.permissions) && projectUser.permissions.length > 0) { scopes = projectUser.permissions; } else { scopes = [ "projects:read", "projects:write", "collections:read", "collections:write", "documents:read", "documents:write" ]; } const session = await this.createSession({ user_id: projectUser.id, user_type: "project", project_id, scopes, remember_me: loginData.remember_me ?? false }); await this.updateLastLogin(projectUser.id, "project"); return { success: true, token: session.token, expires_at: session.expires_at, user: projectUser, scopes: session.scopes, session_id: session.id }; } catch (error) { this.logger.error("Project user authentication failed:", error); throw normalizeError(error, "UNAUTHORIZED", { operation: "login", username: loginData.username, email: loginData.email }); } } /** * Create a new session * * Creates a new authentication session for a user. * * @param {Object} sessionData - Session data * @param {string} sessionData.user_id - User ID * @param {"admin" | "project"} sessionData.user_type - User type * @param {string} [sessionData.project_id] - Project ID (for project users) * @param {string[]} sessionData.scopes - Permission scopes * @param {boolean} [sessionData.remember_me=false] - Whether to extend session (30 days vs 1 hour) * @param {string} [sessionData.ip_address] - Client IP address * @param {string} [sessionData.user_agent] - Client user agent * @returns {Promise<Session>} Created session * @throws {Error} If session creation fails * * @example * const session = await authService.createSession({ * user_id: 'user-id', * user_type: 'admin', * scopes: ['admin:read', 'admin:write'], * remember_me: true * }); */ async createSession(sessionData) { try { const sessionToken = this.generateSessionToken(); const expiresAt = /* @__PURE__ */ new Date(); if (sessionData.remember_me) { expiresAt.setDate(expiresAt.getDate() + 30); } else { expiresAt.setHours(expiresAt.getHours() + 1); } const sessionType = sessionData.user_type === "admin" ? "admin" : sessionData.user_type === "project" ? "project" : "user"; const scopesJson = JSON.stringify(sessionData.scopes); const sessionId = crypto3.randomUUID(); await this.db.query( `INSERT INTO sessions (id, user_id, user_type, type, project_id, token, scopes, expires_at, ip_address, user_agent) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ sessionId, sessionData.user_id, sessionData.user_type, sessionType, // Set type field explicitly (CRITICAL FIX) sessionData.project_id, sessionToken, scopesJson, // Store as JSON string expiresAt.toISOString(), sessionData.ip_address, sessionData.user_agent ] ); const result = await this.db.query( "SELECT * FROM sessions WHERE id = $1", [sessionId] ); const rawSession = result.rows[0]; let parsedScopes = []; if (rawSession.scopes) { if (typeof rawSession.scopes === "string") { try { parsedScopes = JSON.parse(rawSession.scopes); } catch { parsedScopes = sessionData.scopes; } } else if (Array.isArray(rawSession.scopes)) { parsedScopes = rawSession.scopes; } } else { parsedScopes = sessionData.scopes; } return { ...rawSession, scopes: parsedScopes }; } catch (error) { this.logger.error("Failed to create session:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createSession", user_id: sessionData.user_id, user_type: sessionData.user_type }); } } /** * Create session from API key * * Creates a session token from a valid API key. * * @param {string} apiKey - API key value * @returns {Promise<Object>} Session information * @returns {string} returns.session_token - Session token * @returns {string} returns.expires_at - Expiration timestamp * @returns {"admin" | "project"} returns.user_type - User type * @returns {string[]} returns.scopes - Permission scopes * @throws {Error} If API key is invalid or expired * * @example * const session = await authService.createSessionFromApiKey('ak_...'); * console.log(`Session token: ${session.session_token}`); */ async createSessionFromApiKey(apiKey) { try { const apiKeyResult = await this.db.query( `SELECT ak.*, au.username, au.role, au.permissions, au.id as user_id FROM api_keys ak JOIN admin_users au ON ak.owner_id = au.id WHERE ak.key = $1 AND ak.is_active = true AND ak.expires_at > CURRENT_TIMESTAMP`, [apiKey] ); if (apiKeyResult.rows.length === 0) { throw KrapiError.authError("Invalid or expired API key", { operation: "createSessionFromApiKey" }); } const rawApiKeyData = apiKeyResult.rows[0]; let userPermissions = []; if (rawApiKeyData.permissions) { if (typeof rawApiKeyData.permissions === "string") { try { userPermissions = JSON.parse(rawApiKeyData.permissions); } catch { userPermissions = []; } } else if (Array.isArray(rawApiKeyData.permissions)) { userPermissions = rawApiKeyData.permissions; } } const apiKeyData = { user_id: rawApiKeyData.user_id, role: rawApiKeyData.role, scopes: rawApiKeyData.scopes, permissions: userPermissions }; const userType = "admin"; let scopes = []; if (apiKeyData.scopes && Array.isArray(apiKeyData.scopes) && apiKeyData.scopes.length > 0) { scopes = apiKeyData.scopes; } else if (apiKeyData.permissions && Array.isArray(apiKeyData.permissions) && apiKeyData.permissions.length > 0) { scopes = apiKeyData.permissions; } else if (apiKeyData.role) { scopes = this.getScopesForRole(apiKeyData.role); } else { scopes = ["read"]; } const session = await this.createSession({ user_id: apiKeyData.user_id, user_type: userType, scopes, remember_me: false }); return { session_token: session.token, expires_at: session.expires_at, user_type: userType, scopes: session.scopes }; } catch (error) { this.logger.error("Failed to create session from API key", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createSessionFromApiKey" }); } } /** * Validate session token * * Validates a session token and returns the session if valid and not expired. * Updates the last_used_at timestamp. * * @param {string} token - Session token * @returns {Promise<Session | null>} Session if valid, null if invalid/expired * * @example * const session = await authService.validateSession('st_...'); * if (session) { * console.log(`User: ${session.user_id}, Scopes: ${session.scopes}`); * } */ async validateSession(token) { try { const result = await this.db.query( `SELECT * FROM sessions WHERE token = $1 AND is_active = true AND expires_at > CURRENT_TIMESTAMP`, [token] ); if (result.rows.length === 0) { return null; } const session = result.rows[0]; await this.db.query( "UPDATE sessions SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1", [session.id] ); return session; } catch (error) { this.logger.error("Failed to validate session:", error); return null; } } /** * Revoke a session * * Invalidates a session by marking it as inactive. * * @param {string} token - Session token to revoke * @returns {Promise<boolean>} True if session was revoked * @throws {Error} If revocation fails * * @example * const revoked = await authService.revokeSession('st_...'); */ async revokeSession(token) { try { const result = await this.db.query( "UPDATE sessions SET is_active = false WHERE token = $1", [token] ); return result.rowCount > 0; } catch (error) { this.logger.error("Failed to revoke session:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "revokeSession", token }); } } /** * Revoke all sessions for a user * * Invalidates all active sessions for a specific user. * * @param {string} userId - User ID * @param {"admin" | "project"} userType - User type * @returns {Promise<number>} Number of sessions revoked * @throws {Error} If revocation fails * * @example * const count = await authService.revokeAllUserSessions('user-id', 'admin'); * console.log(`Revoked ${count} sessions`); */ async revokeAllUserSessions(userId, userType) { try { const result = await this.db.query( "UPDATE sessions SET is_active = false WHERE user_id = $1 AND user_type = $2", [userId, userType] ); return result.rowCount; } catch (error) { this.logger.error("Failed to revoke user sessions:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "revokeAllUserSessions", userId, userType }); } } /** * Cleanup expired sessions * * Marks all expired sessions as inactive. * * @returns {Promise<number>} Number of sessions cleaned up * @throws {Error} If cleanup fails * * @example * const count = await authService.cleanupExpiredSessions(); * console.log(`Cleaned up ${count} expired sessions`); */ async cleanupExpiredSessions() { try { const result = await this.db.query( "UPDATE sessions SET is_active = false WHERE expires_at <= CURRENT_TIMESTAMP AND is_active = true" ); return result.rowCount; } catch (error) { this.logger.error("Failed to cleanup expired sessions:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "cleanupExpiredSessions" }); } } /** * Change user password * * Changes a user's password after validating the current password. * * @param {string} userId - User ID * @param {"admin" | "project"} userType - User type * @param {PasswordChangeRequest} passwordData - Password change data * @param {string} passwordData.current_password - Current password * @param {string} passwordData.new_password - New password * @returns {Promise<boolean>} True if password changed successfully * @throws {Error} If current password is incorrect or change fails * * @example * const changed = await authService.changePassword('user-id', 'admin', { * current_password: 'oldpass', * new_password: 'newpass' * }); */ async changePassword(userId, userType, passwordData) { try { const { current_password, new_password } = passwordData; const table = userType === "admin" ? "admin_users" : "project_users"; const result = await this.db.query( `SELECT password_hash FROM ${table} WHERE id = $1`, [userId] ); if (result.rows.length === 0) { throw KrapiError.notFound(`User '${userId}' not found`, { userId, operation: "changePassword", userType }); } const currentPasswordHash = result.rows[0].password_hash; const isValidPassword = await this.validatePassword( current_password, currentPasswordHash ); if (!isValidPassword) { throw KrapiError.authError("Current password is incorrect", { operation: "changePassword", userId, userType }); } const newPasswordHash = await this.hashPassword(new_password); const updateResult = await this.db.query( `UPDATE ${table} SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, [newPasswordHash, userId] ); return updateResult.rowCount > 0; } catch (error) { this.logger.error("Failed to change password:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "changePassword", userId, userType }); } } /** * Reset user password * * Initiates or completes a password reset process. * If no reset_token provided, generates and stores a reset token. * If reset_token provided, validates it and updates the password. * * @param {PasswordResetRequest} resetData - Password reset data * @param {string} resetData.email - User email * @param {string} [resetData.reset_token] - Reset token (for completing reset) * @param {string} [resetData.new_password] - New password (required when reset_token provided) * @returns {Promise<{success: boolean, reset_token?: string}>} Reset result * @throws {Error} If reset fails or token is invalid/expired * * @example * // Initiate reset * const { reset_token } = await authService.resetPassword({ email: 'user@example.com' }); * * // Complete reset * await authService.resetPassword({ * email: 'user@example.com', * reset_token: 'rt_...', * new_password: 'newpassword' * }); */ async resetPassword(resetData) { try { if (!resetData.reset_token) { const resetToken = this.generateResetToken(); await this.db.query( `INSERT INTO password_resets (email, reset_token, expires_at) VALUES ($1, $2, $3) ON CONFLICT (email) DO UPDATE SET reset_token = $2, expires_at = $3, created_at = CURRENT_TIMESTAMP`, [resetData.email, resetToken, new Date(Date.now() + 36e5)] // 1 hour expiry ); return { success: true, reset_token: resetToken }; } else { if (!resetData.new_password) { throw KrapiError.validationError( "New password is required", "new_password" ); } const result = await this.db.query( `SELECT email FROM password_resets WHERE reset_token = $1 AND expires_at > CURRENT_TIMESTAMP`, [resetData.reset_token] ); if (result.rows.length === 0) { throw KrapiError.authError("Invalid or expired reset token", { operation: "resetPassword" }); } const email = result.rows[0].email; const newPasswordHash = await this.hashPassword(resetData.new_password); await this.db.query( "UPDATE admin_users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE email = $2", [newPasswordHash, email] ); await this.db.query( "DELETE FROM password_resets WHERE reset_token = $1", [resetData.reset_token] ); return { success: true }; } } catch (error) { this.logger.error("Failed to reset password:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "resetPassword" }); } } // Utility Methods async hashPassword(password) { try { const saltRounds = 12; return await bcrypt.hash(password, saltRounds); } catch { return `hashed_${password}`; } } async validatePassword(password, hash) { try { return await bcrypt.compare(password, hash); } catch { return `hashed_${password}` === hash; } } generateSessionToken() { return `st_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}${Date.now()}`; } generateResetToken() { return `rt_${Math.random().toString(36