UNPKG

@smartsamurai/krapi-sdk

Version:

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

1,497 lines (1,492 loc) 47.8 kB
import { HttpError, KrapiError, init_error_handler, init_http_error, init_krapi_error, normalizeError } from "./chunk-CUJMHNHY.mjs"; // src/admin-service.ts init_krapi_error(); init_error_handler(); import crypto from "crypto"; // src/utils/error-logger.ts init_krapi_error(); init_http_error(); function logError(logger, error, context, level = "error") { const logEntry = createErrorLogEntry(error, context); switch (level) { case "error": logger.error(formatErrorMessage(logEntry), logEntry); break; case "warn": logger.warn(formatErrorMessage(logEntry), logEntry); break; case "info": logger.info(formatErrorMessage(logEntry), logEntry); break; case "debug": logger.debug(formatErrorMessage(logEntry), logEntry); break; } } function createErrorLogEntry(error, context) { const timestamp = (/* @__PURE__ */ new Date()).toISOString(); if (error instanceof HttpError) { const httpContext = { ...context || {}, ...error.method ? { httpMethod: error.method } : {}, ...error.url ? { httpUrl: error.url } : {}, ...error.status !== void 0 ? { httpStatus: error.status } : {}, ...error.requestHeaders ? { requestHeaders: error.requestHeaders } : {}, ...error.requestBody !== void 0 ? { requestBody: error.requestBody } : {}, ...error.requestQuery ? { requestQuery: error.requestQuery } : {}, ...error.responseData !== void 0 ? { responseData: error.responseData } : {}, ...error.responseHeaders ? { responseHeaders: error.responseHeaders } : {} }; const logEntry2 = { timestamp, level: "error", message: error.message, error: { code: error.code || "HTTP_ERROR", message: error.message, ...error.status !== void 0 ? { status: error.status } : {}, details: { isApiError: error.isApiError, isNetworkError: error.isNetworkError, isAuthError: error.isAuthError, isClientError: error.isClientError, isServerError: error.isServerError, originalError: error.originalError } }, stack: error.stack, context: httpContext }; return logEntry2; } if (error instanceof KrapiError) { const httpErrorDetails = error.details?.httpError; const enhancedContext = { ...error.requestId ? { requestId: error.requestId } : {}, ...context, // Extract HTTP context from error details ...httpErrorDetails ? { httpMethod: httpErrorDetails.method, httpUrl: httpErrorDetails.url, httpStatus: httpErrorDetails.status, requestBody: httpErrorDetails.requestBody, requestQuery: httpErrorDetails.requestQuery, requestHeaders: httpErrorDetails.requestHeaders, responseData: httpErrorDetails.responseData, responseHeaders: httpErrorDetails.responseHeaders } : {}, // Include all error details ...error.details ? { errorDetails: error.details } : {} }; const logEntry2 = { timestamp, level: getLogLevelFromError(error), message: error.message, error: { code: error.code, message: error.message, ...error.status !== void 0 ? { status: error.status } : {}, ...error.details ? { details: error.details } : {} }, stack: error.stack, context: enhancedContext }; return logEntry2; } if (error instanceof Error) { const logEntry2 = { timestamp, level: "error", message: error.message, error: { code: "UNKNOWN_ERROR", message: error.message, details: { errorName: error.name, originalError: error } }, stack: error.stack, ...context ? { context } : {} }; return logEntry2; } const errorString = String(error); const logEntry = { timestamp, level: "error", message: errorString, error: { code: "UNKNOWN_ERROR", message: errorString, details: { errorType: typeof error, errorValue: error } }, ...context ? { context } : {} }; return logEntry; } function formatErrorMessage(entry) { const parts = [ `[${entry.error.code}]`, entry.message ]; if (entry.error.status) { parts.push(`(HTTP ${entry.error.status})`); } if (entry.context?.operation) { parts.push(`[${entry.context.operation}]`); } if (entry.context?.requestId) { parts.push(`[Request: ${entry.context.requestId}]`); } return parts.join(" "); } function getLogLevelFromError(error) { if (error.status && error.status >= 500) { return "error"; } if (error.status && error.status >= 400 && error.status < 500) { if (error.code === "VALIDATION_ERROR" || error.code === "BAD_REQUEST") { return "warn"; } return "error"; } if (error.code === "UNAUTHORIZED" || error.code === "FORBIDDEN") { return "warn"; } if (error.code === "NOT_FOUND") { return "info"; } return "error"; } function logServiceOperationError(logger, error, service, operation, inputData, additionalContext) { const enhancedContext = { service, operation, inputData: inputData || {}, ...additionalContext || {} }; logError(logger, error, enhancedContext); } // src/admin-service.ts var AdminService = class { /** * Create a new AdminService instance * * @param {DatabaseConnection} databaseConnection - Database connection * @param {Logger} logger - Logger instance */ constructor(databaseConnection, logger) { this.db = databaseConnection; this.logger = logger; } /** * Set backup service (called from BackendSDK constructor) * * @param {BackupService} backupService - Backup service instance * @returns {void} */ setBackupService(backupService) { this._backupService = backupService; } /** * Get all admin users * * @param {Object} [options] - Query options * @param {number} [options.limit] - Maximum number of users * @param {number} [options.offset] - Number of users to skip * @param {string} [options.search] - Search term for username/email * @param {boolean} [options.active] - Filter by active status * @returns {Promise<AdminUser[]>} Array of admin users * @throws {Error} If query fails * * @example * const users = await adminService.getUsers({ limit: 10, active: true }); */ async getUsers(options) { try { let query = "SELECT * FROM admin_users WHERE 1=1"; const params = []; let paramCount = 0; if (options?.active !== void 0) { paramCount++; query += ` AND is_active = $${paramCount}`; params.push(options.active); } if (options?.search) { paramCount++; query += ` AND (username LIKE $${paramCount} OR email LIKE $${paramCount})`; params.push(`%${options.search}%`); } query += " ORDER BY created_at DESC"; if (options?.limit) { paramCount++; query += ` LIMIT $${paramCount}`; params.push(options.limit); } if (options?.offset) { paramCount++; query += ` OFFSET $${paramCount}`; params.push(options.offset); } const result = await this.db.query(query, params); return result.rows; } catch (error) { logServiceOperationError( this.logger, error, "AdminService", "getUsers", { options }, {} ); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUsers", options }); } } /** * Get admin user by ID * * @param {string} userId - User ID * @returns {Promise<AdminUser | null>} Admin user or null if not found * @throws {Error} If query fails * * @example * const user = await adminService.getUserById('user-id'); */ async getUserById(userId) { try { const result = await this.db.query( "SELECT * FROM admin_users WHERE id = $1", [userId] ); return result.rows.length > 0 ? result.rows[0] : null; } catch (error) { logServiceOperationError( this.logger, error, "AdminService", "getUserById", { userId }, {} ); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUserById", userId }); } } /** * Create a new admin user * * Creates a new admin user account with the provided data. * * @param {Omit<AdminUser, "id" | "created_at" | "updated_at">} userData - User creation data * @param {string} userData.username - Username (required) * @param {string} userData.email - Email address (required) * @param {string} userData.password_hash - Hashed password (required) * @param {string} userData.role - User role * @param {string} userData.access_level - Access level * @param {string[]} userData.permissions - Permission scopes * @param {boolean} userData.active - Whether user is active * @param {string} [userData.api_key] - Optional API key * @returns {Promise<AdminUser>} Created admin user * @throws {Error} If creation fails or user already exists * * @example * const user = await adminService.createUser({ * username: 'newadmin', * email: 'admin@example.com', * password_hash: 'hashed_password', * role: 'admin', * access_level: 'full', * permissions: ['admin:read', 'admin:write'], * active: true * }); */ async createUser(userData) { try { const userId = crypto.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, api_key, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ userId, userData.username, userData.email, userData.password_hash, userData.role, userData.access_level, JSON.stringify(userData.permissions), // SQLite stores arrays as JSON strings userData.active ? 1 : 0, // SQLite uses INTEGER 1/0 for booleans userData.api_key, now, now ] ); const result = await this.db.query( "SELECT * FROM admin_users WHERE id = $1", [userId] ); return result.rows[0]; } catch (error) { const inputData = { username: userData.username, email: userData.email, role: userData.role, access_level: userData.access_level, permissions: userData.permissions // Note: password is intentionally excluded for security }; logServiceOperationError( this.logger, error, "AdminService", "createUser", inputData, {} ); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createUser", username: userData.username, email: userData.email }); } } /** * Update an admin user * * Updates admin user information with the provided data. * * @param {string} userId - User ID * @param {Partial<AdminUser>} updates - User update data * @returns {Promise<AdminUser | null>} Updated user or null if not found * @throws {Error} If update fails * * @example * const updated = await adminService.updateUser('user-id', { * role: 'master_admin', * permissions: ['master'] * }); */ async updateUser(userId, updates) { try { const fields = []; const values = []; let paramCount = 1; if (updates.username !== void 0) { fields.push(`username = $${paramCount++}`); values.push(updates.username); } if (updates.email !== void 0) { fields.push(`email = $${paramCount++}`); values.push(updates.email); } if (updates.role !== void 0) { fields.push(`role = $${paramCount++}`); values.push(updates.role); } if (updates.access_level !== void 0) { fields.push(`access_level = $${paramCount++}`); values.push(updates.access_level); } if (updates.permissions !== void 0) { fields.push(`permissions = $${paramCount++}`); values.push(updates.permissions); } if (updates.active !== void 0) { fields.push(`is_active = $${paramCount++}`); values.push(updates.active); } if (updates.api_key !== void 0) { fields.push(`api_key = $${paramCount++}`); values.push(updates.api_key); } if (fields.length === 0) { return this.getUserById(userId); } fields.push(`updated_at = $${paramCount++}`); values.push((/* @__PURE__ */ new Date()).toISOString()); values.push(userId); await this.db.query( `UPDATE admin_users SET ${fields.join( ", " )} WHERE id = $${paramCount}`, values ); const result = await this.db.query( "SELECT * FROM admin_users WHERE id = $1", [userId] ); return result.rows.length > 0 ? result.rows[0] : null; } catch (error) { this.logger.error("Failed to update admin user:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "updateUser", userId }); } } /** * Delete an admin user * * Permanently deletes an admin user from the database. * * @param {string} userId - User ID * @returns {Promise<boolean>} True if deletion successful * @throws {Error} If deletion fails * * @example * const deleted = await adminService.deleteUser('user-id'); */ async deleteUser(userId) { try { const result = await this.db.query( "DELETE FROM admin_users WHERE id = $1", [userId] ); return result.rowCount > 0; } catch (error) { this.logger.error("Failed to delete admin user:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "deleteUser", userId }); } } /** * Toggle user active status * * Toggles a user's active/inactive status. * * @param {string} userId - User ID * @returns {Promise<AdminUser | null>} Updated user or null if not found * @throws {Error} If toggle fails * * @example * const user = await adminService.toggleUserStatus('user-id'); */ async toggleUserStatus(userId) { try { const now = (/* @__PURE__ */ new Date()).toISOString(); await this.db.query( `UPDATE admin_users SET is_active = CASE WHEN is_active = 1 THEN 0 ELSE 1 END, updated_at = $1 WHERE id = $2`, [now, userId] ); const result = await this.db.query( "SELECT * FROM admin_users WHERE id = $1", [userId] ); return result.rows.length > 0 ? result.rows[0] : null; } catch (error) { this.logger.error("Failed to toggle user status:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "toggleUserStatus", userId }); } } /** * Get all API keys for a user * * Retrieves all API keys owned by a specific admin user. * * @param {string} userId - User ID * @returns {Promise<ApiKey[]>} Array of API keys * @throws {Error} If query fails * * @example * const apiKeys = await adminService.getUserApiKeys('user-id'); */ async getUserApiKeys(userId) { try { const result = await this.db.query( "SELECT * FROM api_keys WHERE owner_id = $1 AND type = 'admin' ORDER BY created_at DESC", [userId] ); return result.rows; } catch (error) { this.logger.error("Failed to get user API keys:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getUserApiKeys", userId }); } } /** * Create an API key for a user * * Creates a new admin API key for a specific user. * * @param {string} userId - User ID * @param {Object} apiKeyData - API key data * @param {string} apiKeyData.name - API key name * @param {string} apiKeyData.key - API key value * @param {string[]} apiKeyData.scopes - Permission scopes * @param {string[]} [apiKeyData.project_ids] - Project IDs (for project-scoped keys) * @param {string} [apiKeyData.expires_at] - Expiration date * @returns {Promise<ApiKey>} Created API key * @throws {Error} If creation fails * * @example * const apiKey = await adminService.createUserApiKey('user-id', { * name: 'My API Key', * key: 'ak_...', * scopes: ['admin:read', 'admin:write'] * }); */ async createUserApiKey(userId, apiKeyData) { try { const keyId = crypto.randomUUID(); const now = (/* @__PURE__ */ new Date()).toISOString(); await this.db.query( `INSERT INTO api_keys (id, key, name, type, owner_id, scopes, project_ids, expires_at, created_at, is_active) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, [ keyId, apiKeyData.key, apiKeyData.name, "admin", userId, JSON.stringify(apiKeyData.scopes), // SQLite stores arrays as JSON strings JSON.stringify(apiKeyData.project_ids || []), // SQLite stores arrays as JSON strings apiKeyData.expires_at, now, 1 // is_active = true ] ); const result = await this.db.query( "SELECT * FROM api_keys WHERE id = $1", [keyId] ); return result.rows[0]; } catch (error) { this.logger.error("Failed to create user API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createUserApiKey", userId }); } } /** * Create an API key (with auto-generation) * * Creates a new admin API key with auto-generated key value. * * @param {string} userId - User ID * @param {Object} keyData - API key data * @param {string} keyData.name - API key name * @param {string[]} keyData.permissions - Permission scopes * @param {string} [keyData.expires_at] - Expiration date * @returns {Promise<{key: string, data: ApiKey}>} Generated key value and API key data * @throws {Error} If creation fails * * @example * const { key, data } = await adminService.createApiKey('user-id', { * name: 'My API Key', * permissions: ['admin:read'] * }); * console.log(`API Key: ${key}`); // Save this securely! */ async createApiKey(userId, keyData) { try { const apiKey = `ak_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`; const keyId = crypto.randomUUID(); const now = (/* @__PURE__ */ new Date()).toISOString(); await this.db.query( `INSERT INTO api_keys (id, key, name, type, owner_id, scopes, expires_at, created_at, is_active) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [ keyId, apiKey, keyData.name, "admin", userId, JSON.stringify(keyData.permissions), // SQLite stores arrays as JSON strings keyData.expires_at, now, 1 // is_active = true ] ); const result = await this.db.query( "SELECT * FROM api_keys WHERE id = $1", [keyId] ); const createdKey = result.rows[0]; return { key: apiKey, data: createdKey }; } catch (error) { this.logger.error("Failed to create API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createApiKey" }); } } /** * Create a master API key * * Creates a master API key with full system access. * * @returns {Promise<ApiKey>} Created master API key * @throws {Error} If creation fails * * @example * const masterKey = await adminService.createMasterApiKey(); * console.log(`Master API Key: ${masterKey.key}`); // Save this securely! */ async createMasterApiKey() { try { const masterKey = `mak_${Math.random().toString(36).substring(2, 15)}`; const keyId = crypto.randomUUID(); const now = (/* @__PURE__ */ new Date()).toISOString(); await this.db.query( `INSERT INTO api_keys (id, key, name, type, owner_id, scopes, created_at, is_active) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ keyId, masterKey, "Master API Key", "master", "system", // System owner for master key JSON.stringify(["master"]), // SQLite stores arrays as JSON strings now, 1 // is_active = true ] ); const result = await this.db.query( "SELECT * FROM api_keys WHERE id = $1", [keyId] ); return result.rows[0]; } catch (error) { this.logger.error("Failed to create master API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createMasterApiKey" }); } } /** * Delete an API key * * Soft deletes an API key by marking it as inactive. * * @param {string} keyId - API key ID * @returns {Promise<boolean>} True if deletion successful * @throws {Error} If deletion fails * * @example * const deleted = await adminService.deleteApiKey('key-id'); */ async deleteApiKey(keyId) { try { const result = await this.db.query( "UPDATE api_keys SET is_active = false WHERE id = $1", [keyId] ); return result.rowCount > 0; } catch (error) { this.logger.error("Failed to delete API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "deleteApiKey" }); } } /** * Get all API keys for a project * * Retrieves all API keys associated with a project. * * @param {string} projectId - Project ID * @returns {Promise<ApiKey[]>} Array of project API keys * @throws {Error} If query fails * * @example * const apiKeys = await adminService.getProjectApiKeys('project-id'); */ async getProjectApiKeys(projectId) { try { const result = await this.db.query( `SELECT * FROM api_keys WHERE ( project_ids LIKE $1 OR project_ids LIKE $2 OR project_ids LIKE $3 OR JSON_EXTRACT(project_ids, '$') IS NOT NULL AND EXISTS ( SELECT 1 FROM json_each(project_ids) WHERE json_each.value = $4 ) ) AND is_active = true ORDER BY created_at DESC`, [ `%"${projectId}"%`, `%"${projectId}",%`, `%,"${projectId}"%`, projectId ] ); if (result.rows.length === 0) { const fallbackResult = await this.db.query( `SELECT * FROM api_keys WHERE project_ids LIKE $1 AND is_active = true ORDER BY created_at DESC`, [`%${projectId}%`] ); return fallbackResult.rows; } return result.rows; } catch { try { const fallbackResult = await this.db.query( `SELECT * FROM api_keys WHERE project_ids LIKE $1 AND is_active = true ORDER BY created_at DESC`, [`%${projectId}%`] ); return fallbackResult.rows; } catch (fallbackError) { this.logger.error("Failed to get project API keys:", fallbackError); throw normalizeError(fallbackError, "INTERNAL_ERROR", { operation: "getProjectApiKeys" }); } } } /** * Create a project API key * * Creates a new API key for a project with auto-generated key value. * * @param {string} projectId - Project ID * @param {Object} keyData - API key data * @param {string} keyData.name - API key name * @param {string} [keyData.description] - API key description * @param {string[]} keyData.scopes - Permission scopes * @param {string} [keyData.expires_at] - Expiration date * @param {string} [keyData.created_by] - User ID who created * @returns {Promise<{key: string, data: ApiKey}>} Generated key value and API key data * @throws {Error} If creation fails * * @example * const { key, data } = await adminService.createProjectApiKey('project-id', { * name: 'Project API Key', * scopes: ['collections:read', 'documents:write'] * }); * console.log(`API Key: ${key}`); // Save this securely! */ async createProjectApiKey(projectId, keyData) { try { const apiKey = `pk_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`; let ownerId; if (keyData.created_by) { ownerId = keyData.created_by; } else { const adminResult = await this.db.query( "SELECT id FROM admin_users WHERE role = 'master_admin' AND is_active = true LIMIT 1" ); if (adminResult.rows.length === 0) { await this.createDefaultAdmin(); const newAdminResult = await this.db.query( "SELECT id FROM admin_users WHERE role = 'master_admin' AND is_active = true LIMIT 1" ); ownerId = newAdminResult.rows[0].id; } else { ownerId = adminResult.rows[0].id; } } const keyId = crypto.randomUUID(); const now = (/* @__PURE__ */ new Date()).toISOString(); await this.db.query( `INSERT INTO api_keys (id, key, name, type, owner_id, scopes, project_ids, expires_at, metadata, created_at, is_active) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [ keyId, apiKey, keyData.name, "project", ownerId, JSON.stringify(keyData.scopes), // SQLite stores arrays as JSON strings JSON.stringify([projectId]), // SQLite stores arrays as JSON strings keyData.expires_at, JSON.stringify({ description: keyData.description }), // SQLite stores objects as JSON strings now, 1 // is_active = true ] ); const result = await this.db.query( "SELECT * FROM api_keys WHERE id = $1", [keyId] ); const createdKey = result.rows[0]; return { key: apiKey, data: createdKey }; } catch (error) { this.logger.error("Failed to create project API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "createProjectApiKey" }); } } /** * Get a project API key by ID * * Retrieves a single project API key by its ID. * * @param {string} keyId - API key ID * @param {string} projectId - Project ID * @returns {Promise<ApiKey | null>} API key or null if not found * @throws {Error} If query fails * * @example * const apiKey = await adminService.getProjectApiKey('key-id', 'project-id'); */ async getProjectApiKey(keyId, projectId) { try { const result = await this.db.query( `SELECT * FROM api_keys WHERE id = $1 AND is_active = 1`, [keyId] ); if (result.rows.length === 0) { return null; } const keyData = result.rows[0]; let projectIds = []; if (keyData.project_ids) { try { projectIds = typeof keyData.project_ids === "string" ? JSON.parse(keyData.project_ids) : keyData.project_ids; } catch { projectIds = []; } } if (!projectIds.includes(projectId)) { return null; } return keyData; } catch (error) { this.logger.error("Failed to get project API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getProjectApiKey" }); } } /** * Update a project API key * * Updates project API key metadata, scopes, or expiration. * * @param {string} keyId - API key ID * @param {string} projectId - Project ID * @param {Object} updates - API key updates * @param {string} [updates.name] - New name * @param {string} [updates.description] - New description * @param {string[]} [updates.scopes] - Updated scopes * @param {string} [updates.expires_at] - Updated expiration * @param {boolean} [updates.is_active] - Active status * @returns {Promise<ApiKey | null>} Updated API key or null if not found * @throws {Error} If update fails * * @example * const updated = await adminService.updateProjectApiKey('key-id', 'project-id', { * scopes: ['collections:read', 'documents:read'] * }); */ async updateProjectApiKey(keyId, projectId, updates) { try { const currentKeyResult = await this.db.query( "SELECT * FROM api_keys WHERE id = $1", [keyId] ); if (currentKeyResult.rows.length === 0) { return null; } const currentKey = currentKeyResult.rows[0]; let projectIds = []; if (currentKey.project_ids) { try { projectIds = typeof currentKey.project_ids === "string" ? JSON.parse(currentKey.project_ids) : currentKey.project_ids; } catch { projectIds = []; } } if (!projectIds.includes(projectId)) { return null; } const setClause = []; const values = []; let paramIndex = 1; if (updates.name !== void 0) { setClause.push(`name = $${paramIndex++}`); values.push(updates.name); } if (updates.description !== void 0) { let currentMetadata = {}; if (currentKey.metadata) { try { currentMetadata = typeof currentKey.metadata === "string" ? JSON.parse(currentKey.metadata) : currentKey.metadata; } catch { currentMetadata = {}; } } currentMetadata.description = updates.description; setClause.push(`metadata = $${paramIndex++}`); values.push(JSON.stringify(currentMetadata)); } if (updates.scopes !== void 0) { setClause.push(`scopes = $${paramIndex++}`); values.push(JSON.stringify(updates.scopes)); } if (updates.expires_at !== void 0) { setClause.push(`expires_at = $${paramIndex++}`); values.push(updates.expires_at); } if (updates.is_active !== void 0) { setClause.push(`is_active = $${paramIndex++}`); values.push(updates.is_active ? 1 : 0); } if (setClause.length === 0) { return null; } setClause.push(`updated_at = $${paramIndex++}`); values.push((/* @__PURE__ */ new Date()).toISOString()); values.push(keyId); await this.db.query( `UPDATE api_keys SET ${setClause.join(", ")} WHERE id = $${paramIndex}`, values ); const result = await this.db.query( "SELECT * FROM api_keys WHERE id = $1", [keyId] ); return result.rows.length > 0 ? result.rows[0] : null; } catch (error) { this.logger.error("Failed to update project API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "updateProjectApiKey" }); } } /** * Delete a project API key * * Soft deletes a project API key by marking it as inactive. * * @param {string} keyId - API key ID * @param {string} projectId - Project ID * @returns {Promise<boolean>} True if deletion successful * @throws {Error} If deletion fails * * @example * const deleted = await adminService.deleteProjectApiKey('key-id', 'project-id'); */ async deleteProjectApiKey(keyId, projectId) { try { const keyResult = await this.db.query( "SELECT project_ids FROM api_keys WHERE id = $1", [keyId] ); if (keyResult.rows.length === 0) { return false; } const keyData = keyResult.rows[0]; let projectIds = []; if (keyData.project_ids) { try { projectIds = typeof keyData.project_ids === "string" ? JSON.parse(keyData.project_ids) : keyData.project_ids; } catch { projectIds = []; } } if (!projectIds.includes(projectId)) { return false; } const now = (/* @__PURE__ */ new Date()).toISOString(); const result = await this.db.query( `UPDATE api_keys SET is_active = 0, updated_at = $1 WHERE id = $2`, [now, keyId] ); return result.rowCount > 0; } catch (error) { this.logger.error("Failed to delete project API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "deleteProjectApiKey" }); } } /** * Regenerate a project API key * * Generates a new key value for an existing project API key, invalidating the old one. * * @param {string} keyId - API key ID * @param {string} projectId - Project ID * @returns {Promise<{key: string, data: ApiKey}>} New key value and updated API key data * @throws {Error} If regeneration fails or key not found * * @example * const { key, data } = await adminService.regenerateProjectApiKey('key-id', 'project-id'); * console.log(`New API Key: ${key}`); // Save this securely! */ async regenerateProjectApiKey(keyId, projectId) { try { const newApiKey = `pk_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`; const keyResult = await this.db.query( "SELECT * FROM api_keys WHERE id = $1 AND is_active = 1", [keyId] ); if (keyResult.rows.length === 0) { throw KrapiError.notFound(`API key '${keyId}' not found`, { keyId, projectId }); } const keyData = keyResult.rows[0]; let projectIds = []; if (keyData.project_ids) { try { projectIds = typeof keyData.project_ids === "string" ? JSON.parse(keyData.project_ids) : keyData.project_ids; } catch { projectIds = []; } } if (!projectIds.includes(projectId)) { throw KrapiError.notFound(`API key '${keyId}' not found for project`, { keyId, projectId }); } const now = (/* @__PURE__ */ new Date()).toISOString(); await this.db.query( `UPDATE api_keys SET key = $1, updated_at = $2 WHERE id = $3`, [newApiKey, now, keyId] ); const result = await this.db.query( "SELECT * FROM api_keys WHERE id = $1", [keyId] ); if (result.rows.length === 0) { throw KrapiError.notFound(`API key '${keyId}' not found`, { keyId, projectId }); } const updatedKey = result.rows[0]; return { key: newApiKey, data: updatedKey }; } catch (error) { this.logger.error("Failed to regenerate project API key:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "regenerateProjectApiKey", keyId, projectId }); } } /** * Get system statistics * * Retrieves comprehensive system statistics including user counts, project counts, * collection/document/file counts, storage usage, and uptime. * * @returns {Promise<SystemStats>} System statistics * @throws {Error} If query fails * * @example * const stats = await adminService.getSystemStats(); * console.log(`Total users: ${stats.totalUsers}`); * console.log(`Total projects: ${stats.totalProjects}`); */ async getSystemStats() { try { const [ usersResult, projectsResult, collectionsResult, documentsResult, filesResult ] = await Promise.all([ this.db.query("SELECT COUNT(*) FROM admin_users"), this.db.query("SELECT COUNT(*) FROM projects"), this.db.query("SELECT COUNT(*) FROM collections"), this.db.query("SELECT COUNT(*) FROM documents"), this.db.query("SELECT COUNT(*) FROM files") ]); const storageResult = await this.db.query( "SELECT COALESCE(SUM(storage_used), 0) as total_storage FROM projects" ); return { totalUsers: parseInt(usersResult.rows[0].count), totalProjects: parseInt(projectsResult.rows[0].count), totalCollections: parseInt( collectionsResult.rows[0].count ), totalDocuments: parseInt(documentsResult.rows[0].count), totalFiles: parseInt(filesResult.rows[0].count), storageUsed: parseInt( storageResult.rows[0].total_storage ), databaseSize: 0, // Would need to query database size uptime: process.uptime() }; } catch (error) { this.logger.error("Failed to get system stats:", error); throw normalizeError(error, "INTERNAL_ERROR", { operation: "getSystemStats" }); } } /** * Get activity logs * * Retrieves system activity logs with optional filtering. * * @param {Object} options - Query options * @param {number} options.limit - Maximum number of entries * @param {number} options.offset - Number of entries to skip * @param {Object} [options.filters] - Optional filters * @param {string} [options.filters.entity_type] - Filter by entity type * @param {string} [options.filters.action] - Filter by action * @param {string} [options.filters.performed_by] - Filter by user ID * @returns {Promise<ActivityLog[]>} Array of activity log entries * @throws {Error} If query fails * * @example * const logs = await adminService.getActivityLogs({ * limit: 50, * offset: 0, * filters: { action: 'created', entity_type: 'document' } * }); */ async getActivityLogs(options) { try { let query = "SELECT * FROM changelog WHERE 1=1"; const values = []; let paramCount = 0; if (options.filters?.entity_type) { paramCount++; query += ` AND entity_type = $${paramCount}`; values.push(options.filters.entity_type); } if (options.filters?.action) { paramCount++; query += ` AND action = $${paramCount}`; values.push(options.filters.action); } if (options.filters?.performed_by) { paramCount++; query += ` AND performed_by = $${paramCount}`; values.push(options.filters.performed_by); } query += ` ORDER BY created_at DESC LIMIT $${++paramCount} OFFSET $${++paramCount}`; values.push(options.limit, options.offset); try { const result = await this.db.query(query, values); const logs = (result.rows || []).map((row) => { const rowData = row; let details = {}; try { if (rowData.changes) { if (typeof rowData.changes === "string") { details = JSON.parse(rowData.changes); } else if (typeof rowData.changes === "object") { details = rowData.changes; } } } catch { details = {}; } const log = { id: rowData.id || "", action: rowData.action || "", resource_type: rowData.entity_type || "", details, timestamp: rowData.created_at ? new Date(rowData.created_at) : /* @__PURE__ */ new Date(), severity: "info", metadata: {} }; if (rowData.user_id !== void 0 && rowData.user_id !== null) { log.user_id = rowData.user_id; } if (rowData.project_id !== void 0 && rowData.project_id !== null) { log.project_id = rowData.project_id; } if (rowData.entity_id !== void 0 && rowData.entity_id !== null) { log.resource_id = rowData.entity_id; } return log; }); return logs; } catch (queryError) { const errorMessage = queryError instanceof Error ? queryError.message : String(queryError); if (errorMessage.includes("no such table") || errorMessage.includes("does not exist")) { this.logger.info("Changelog table not found, returning empty activity logs"); return []; } throw queryError; } } catch (error) { this.logger.error("Failed to get activity logs:", error); return []; } } /** * Get database health status * * Performs comprehensive database health checks including connection, * table existence, and default admin user verification. * * @returns {Promise<DatabaseHealth>} Database health status * * @example * const health = await adminService.getDatabaseHealth(); * if (health.status === 'unhealthy') { * console.log('Database issues detected:', health.checks); * } */ async getDatabaseHealth() { try { await this.db.query("SELECT 1"); const criticalTables = [ "admin_users", "projects", "collections", "documents", "sessions", "api_keys", "changelog" ]; const missingTables = []; for (const table of criticalTables) { const result = await this.db.query( "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = $1)", [table] ); if (!result.rows[0].exists) { missingTables.push(table); } } const adminResult = await this.db.query( "SELECT id, is_active FROM admin_users WHERE username = $1", ["admin"] ); const checks = { database: { status: true, message: "Connected" }, tables: { status: missingTables.length === 0, message: missingTables.length === 0 ? "All required tables exist" : `Missing tables: ${missingTables.join(", ")}`, missing: missingTables }, defaultAdmin: { status: adminResult.rows.length > 0 && adminResult.rows[0].is_active, message: adminResult.rows.length > 0 && adminResult.rows[0].is_active ? "Default admin exists and is active" : "Default admin missing or inactive" }, initialization: { status: true, message: "Database initialized" } }; const allHealthy = Object.values(checks).every((check) => check.status); return { status: allHealthy ? "healthy" : "unhealthy", checks, timestamp: (/* @__PURE__ */ new Date()).toISOString() }; } catch (error) { this.logger.error("Database health check failed:", error); return { status: "unhealthy", checks: { database: { status: false, message: `Connection failed: ${error}` }, tables: { status: false, message: "Unable to check tables" }, defaultAdmin: { status: false, message: "Unable to check admin" }, initialization: { status: false, message: "Unable to check initialization" } }, timestamp: (/* @__PURE__ */ new Date()).toISOString() }; } } /** * Repair database issues * * Automatically repairs common database issues including missing tables * and missing default admin user. * * @returns {Promise<{success: boolean, actions: string[]}>} Repair result with actions taken * * @example * const result = await adminService.repairDatabase(); * if (result.success) { * console.log('Repairs performed:', result.actions); * } */ async repairDatabase() { try { const actions = []; const health = await this.getDatabaseHealth(); if (health.status === "healthy") { return { success: true, actions: ["Database is healthy, no repairs needed"] }; } if (!health.checks.tables.status && health.checks.tables.missing) { for (const table of health.checks.tables.missing) { await this.createMissingTable(table); actions.push(`Created missing table: ${table}`); } } if (!health.checks.defaultAdmin.status) { await this.createDefaultAdmin(); actions.push("Created default admin user"); } return { success: true, actions }; } catch (error) { this.logger.error("Database repair failed:", error); return { success: false, actions: [] }; } } /** * Run system diagnostics * * Performs comprehensive system diagnostics and returns health status * with recommendations for fixing any issues. * * @returns {Promise<DiagnosticResult>} Diagnostic results with recommendations * * @example * const diagnostics = await adminService.runDiagnostics(); * if (!diagnostics.success) { * console.log('Issues found:', diagnostics.recommendations); * } */ async runDiagnostics() { try { const health = await this.getDatabaseHealth(); const recommendations = []; if (health.status !== "healthy") { recommendations.push("Run database repair to fix issues"); } if (!health.checks.tables.status) { recommendations.push( "Check database schema and recreate missing tables" ); } if (!health.checks.defaultAdmin.status) { recommendations.push("Ensure default admin user exists and is active"); } return { success: health.status === "healthy", message: health.status === "healthy" ? "System is healthy" : "System has issues", details: health, recommendations }; } catch (error) { this.logger.error("Diagnostics failed:", error); return { success: false, message: "Diagnostics failed", details: {}, recommendations: [ "Check database connection", "Verify database permissions" ] }; } } async createMissingTable(tableName) { this.logger.info(`Need to create table: ${tableName}`); } /** * Create default admin user * * Creates the default admin user (username: admin, password: admin123) * and generates a master API key. Used during system initialization. * * @returns {Promise<void>} * @throws {Error} If creation fails * * @example * await adminService.createDefaultAdmin(); */ async createDefaultAdmin() { try { const hashedPassword = await this.hashPassword("admin123"); const masterApiKey = `mak_${Math.random().toString(36).substring(2, 15)}`; const adminId = crypto.randomUUID(); await this.db.query( `INSERT INTO admin_users (id, username, email, password_hash, role, access_level, permissions, api_key) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [ adminId, "admin", "admin@krapi.com", hashedPassword, "master_admin", "full", JSON.stringify(["master"]), masterApiKey ] ); await this.db.query( `INSERT INTO api_keys (key, name, type, owner_id, scopes) VALUES ($1, $2, $3, $4, $5)`, [masterApiKey, "Master API Key", "master", adminId, ["master"]] ); } catch (error) { this.logger.error("Failed to create default admin:", error); throw error; } } async hashPassword(password) { return `hashed_${password}`; } }; export { logServiceOperationError, AdminService };