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