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