/** * @swagger * components: * schemas: * ApiKey: * type: object * properties: * id: * type: integer * name: * type: string * key_prefix: * type: string * scopes: * type: string * rate_limit: * type: integer * expires_at: * type: string * format: date-time */ import { Router } from 'express'; import crypto from 'crypto'; import { dbGet, dbAll, dbRun } from '../../database/connection.js'; import { apiLogger } from '../../utils/logger.js'; import { success, created, notFound, badRequest, forbidden, error } from '../../utils/response.js'; const router = Router(); /** * Generate a secure API key * Format: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (32 random hex chars) */ function generateApiKey() { const randomPart = crypto.randomBytes(16).toString('hex'); return `sk-${randomPart}`; } /** * Hash API key for secure storage */ function hashApiKey(key) { return crypto.createHash('sha256').update(key).digest('hex'); } /** * Find api_keys_list table in System Data project of the space * @param {number} projectId - The current project ID * @returns {number|null} - Table ID or null if not found */ async function findApiKeysTable(projectId) { // First, get the space_id of the current project const project = await dbGet('SELECT space_id FROM projects WHERE id = ?', [projectId]); if (!project) return null; // Find System Data project in this space const systemDataProject = await dbGet( "SELECT id FROM projects WHERE space_id = ? AND name = 'System Data'", [project.space_id] ); if (!systemDataProject) return null; // Find api_keys_list table in System Data const apiKeysTable = await dbGet( "SELECT id FROM universal_tables WHERE project_id = ? AND name = 'api_keys_list'", [systemDataProject.id] ); return apiKeysTable?.id || null; } /** * Find or create api_keys_list table in System Data project of the space * @param {number} projectId - The current project ID * @returns {number|null} - Table ID or null if failed */ async function findOrCreateApiKeysTable(projectId) { // First try to find existing table const existingTable = await findApiKeysTable(projectId); if (existingTable) return existingTable; // Get the space_id of the current project const project = await dbGet('SELECT space_id FROM projects WHERE id = ?', [projectId]); if (!project) return null; // Find System Data project in this space let systemDataProject = await dbGet( "SELECT id FROM projects WHERE space_id = ? AND name = 'System Data'", [project.space_id] ); // If no System Data project, create one if (!systemDataProject) { const now = new Date().toISOString(); const result = await dbRun( "INSERT INTO projects (name, space_id, created_at, updated_at) VALUES ('System Data', ?, ?, ?)", [project.space_id, now, now] ); systemDataProject = { id: result.lastInsertRowid }; apiLogger.info(`[api-keys] Created System Data project ${systemDataProject.id} for space ${project.space_id}`); } // Create api_keys_list table const now = new Date().toISOString(); const tableResult = await dbRun( "INSERT INTO universal_tables (name, display_name, project_id, created_at, updated_at) VALUES ('api_keys_list', 'API Keys', ?, ?, ?)", [systemDataProject.id, now, now] ); const tableId = tableResult.lastInsertRowid; apiLogger.info(`[api-keys] Created api_keys_list table ${tableId} in project ${systemDataProject.id}`); // Create columns for the table const columns = [ { column_name: 'name', display_name: 'Name', type: 'text', order_index: 0, is_visible: 1, is_required: 1 }, { column_name: 'key_prefix', display_name: 'Key Prefix', type: 'text', order_index: 1, is_visible: 1, is_required: 0 }, { column_name: 'key_hash', display_name: 'Key Hash', type: 'text', order_index: 2, is_visible: 0, is_required: 0 }, { column_name: 'scopes', display_name: 'Scopes', type: 'text', order_index: 3, is_visible: 1, is_required: 0 }, { column_name: 'rate_limit', display_name: 'Rate Limit', type: 'number', order_index: 4, is_visible: 1, is_required: 0 }, { column_name: 'request_count', display_name: 'Requests', type: 'number', order_index: 5, is_visible: 1, is_required: 0 }, { column_name: 'last_used_at', display_name: 'Last Used', type: 'date', order_index: 6, is_visible: 1, is_required: 0 }, { column_name: 'expires_at', display_name: 'Expires At', type: 'date', order_index: 7, is_visible: 1, is_required: 0 }, { column_name: 'is_active', display_name: 'Active', type: 'checkbox', order_index: 8, is_visible: 1, is_required: 0 }, { column_name: 'created_at', display_name: 'Created At', type: 'date', order_index: 9, is_visible: 1, is_required: 0 }, { column_name: 'agent_id', display_name: 'Agent', type: 'link', order_index: 10, is_visible: 1, is_required: 0 }, { column_name: 'user_id', display_name: 'User', type: 'number', order_index: 11, is_visible: 0, is_required: 0 } ]; for (const col of columns) { await dbRun( `INSERT INTO table_columns (table_id, column_name, display_name, type, order_index, is_visible, is_required) VALUES (?, ?, ?, ?, ?, ?, ?)`, [tableId, col.column_name, col.display_name, col.type, col.order_index, col.is_visible, col.is_required] ); } apiLogger.info(`[api-keys] Created ${columns.length} columns for api_keys_list table`); return tableId; } /** * GET /api/v3/api-keys * List all API keys for a project (from api_keys_list table in System Data) * Query params: project_id (required) - filter by project * @swagger * /api/v3/api-keys: * get: * summary: List all API keys for a project * tags: [API Keys] * security: * - bearerAuth: [] * parameters: * - in: query * name: project_id * required: true * schema: * type: integer * responses: * 200: * description: List of API keys */ router.get('/', async (req, res) => { try { const { project_id } = req.query; if (!project_id) { return badRequest(res, 'project_id is required'); } // Find api_keys_list table for this space const tableId = await findApiKeysTable(project_id); if (!tableId) { apiLogger.debug(`[api-keys] api_keys_list table not found for project ${project_id}`); return success(res, []); } // Get all rows from api_keys_list const rows = await dbAll( 'SELECT id, data FROM table_rows WHERE table_id = ? ORDER BY created_at DESC', [tableId] ); // Parse data and return const keys = rows.map(row => { const data = typeof row.data === 'string' ? JSON.parse(row.data) : row.data; return { id: row.id, key_prefix: data.key_prefix || '', name: data.name || '', scopes: data.scopes ? (typeof data.scopes === 'string' ? JSON.parse(data.scopes) : data.scopes) : ['*'], rate_limit: data.rate_limit || 1000, request_count: data.request_count || 0, last_used_at: data.last_used_at || null, expires_at: data.expires_at || null, is_active: data.is_active !== false, created_at: data.created_at || null, agent_id: data.agent_id || null, user_id: data.user_id || null }; }); success(res, keys); } catch (err) { apiLogger.error('Error listing API keys:', err); error(res, 'INTERNAL_ERROR', 'Failed to list API keys', 500); } }); /** * POST /api/v3/api-keys * Create a new API key (stores in api_keys_list table in System Data) * @swagger * /api/v3/api-keys: * post: * summary: Create a new API key * tags: [API Keys] * security: * - bearerAuth: [] * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [name, project_id] * properties: * name: * type: string * project_id: * type: integer * scopes: * type: array * items: * type: string * expires_in_days: * type: integer * responses: * 201: * description: API key created */ router.post('/', async (req, res) => { try { apiLogger.debug('[api-keys POST] Request body:', JSON.stringify(req.body)); const { name, scopes = ['*'], expires_in_days, rate_limit = 1000, project_id, agent_id } = req.body; if (!name || name.trim().length === 0) { return badRequest(res, 'Name is required'); } if (!project_id) { return badRequest(res, 'project_id is required'); } // Find or create api_keys_list table for this space const tableId = await findOrCreateApiKeysTable(project_id); if (!tableId) { return error(res, 'INTERNAL_ERROR', 'Failed to create api_keys_list table in System Data.', 500); } // Generate API key const apiKey = generateApiKey(); const keyPrefix = apiKey.substring(0, 7); // sk-xxxx const keyHash = hashApiKey(apiKey); // Calculate expiration date if specified let expiresAt = null; if (expires_in_days && expires_in_days > 0) { const expDate = new Date(); expDate.setDate(expDate.getDate() + expires_in_days); expiresAt = expDate.toISOString(); } const now = new Date().toISOString(); // Create row data const rowData = { name: name.trim(), key_prefix: keyPrefix, key_hash: keyHash, scopes: JSON.stringify(scopes), rate_limit: rate_limit, request_count: 0, last_used_at: null, expires_at: expiresAt, is_active: true, created_at: now, agent_id: agent_id || null, user_id: req.user.id }; // Generate unique base_id for the row const baseId = `apikey_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`; // Insert into table_rows const result = await dbRun( 'INSERT INTO table_rows (table_id, base_id, data, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [tableId, baseId, JSON.stringify(rowData), req.user.id, now, now] ); // Return the full key ONLY on creation (never stored/shown again) created(res, { id: result.lastInsertRowid, key: apiKey, // ⚠️ IMPORTANT: Only shown once! key_prefix: keyPrefix, name: name.trim(), scopes, rate_limit, expires_at: expiresAt, created_at: now, message: '⚠️ Save this API key now! It will not be shown again.' }); } catch (err) { apiLogger.error('Error creating API key:', err); error(res, 'INTERNAL_ERROR', 'Failed to create API key', 500); } }); /** * PATCH /api/v3/api-keys/:id * Update API key (name, scopes, active status) */ router.patch('/:id', async (req, res) => { try { const { id } = req.params; const { name, scopes, is_active, rate_limit, project_id } = req.body; if (!project_id) { return badRequest(res, 'project_id is required'); } // Find api_keys_list table const tableId = await findApiKeysTable(project_id); if (!tableId) { return notFound(res, 'api_keys_list table'); } // Get existing row const existingRow = await dbGet( 'SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?', [id, tableId] ); if (!existingRow) { return notFound(res, 'API key'); } // Parse existing data const data = typeof existingRow.data === 'string' ? JSON.parse(existingRow.data) : existingRow.data; // Check ownership if (data.user_id !== req.user.id) { return forbidden(res, 'You can only edit your own API keys'); } // Update fields if (name !== undefined) data.name = name.trim(); if (scopes !== undefined) data.scopes = JSON.stringify(scopes); if (is_active !== undefined) data.is_active = is_active; if (rate_limit !== undefined) data.rate_limit = rate_limit; // Save await dbRun( 'UPDATE table_rows SET data = ?, updated_at = ? WHERE id = ?', [JSON.stringify(data), new Date().toISOString(), id] ); success(res, { id: parseInt(id), key_prefix: data.key_prefix, name: data.name, scopes: typeof data.scopes === 'string' ? JSON.parse(data.scopes) : data.scopes, rate_limit: data.rate_limit, is_active: data.is_active, expires_at: data.expires_at, created_at: data.created_at }); } catch (err) { apiLogger.error('Error updating API key:', err); error(res, 'INTERNAL_ERROR', 'Failed to update API key', 500); } }); /** * DELETE /api/v3/api-keys/:id * Delete (revoke) an API key */ router.delete('/:id', async (req, res) => { try { const { id } = req.params; const { project_id } = req.query; if (!project_id) { return badRequest(res, 'project_id is required'); } // Find api_keys_list table const tableId = await findApiKeysTable(project_id); if (!tableId) { return notFound(res, 'api_keys_list table'); } // Get existing row const existingRow = await dbGet( 'SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?', [id, tableId] ); if (!existingRow) { return notFound(res, 'API key'); } // Parse data and check ownership const data = typeof existingRow.data === 'string' ? JSON.parse(existingRow.data) : existingRow.data; if (data.user_id !== req.user.id) { return forbidden(res, 'You can only delete your own API keys'); } await dbRun('DELETE FROM table_rows WHERE id = ?', [id]); success(res, { message: `API key "${data.name}" has been revoked` }); } catch (err) { apiLogger.error('Error deleting API key:', err); error(res, 'INTERNAL_ERROR', 'Failed to delete API key', 500); } }); /** * POST /api/v3/api-keys/:id/regenerate * Regenerate an API key (creates new key, invalidates old) */ router.post('/:id/regenerate', async (req, res) => { try { const { id } = req.params; const { project_id } = req.body; if (!project_id) { return badRequest(res, 'project_id is required'); } // Find api_keys_list table const tableId = await findApiKeysTable(project_id); if (!tableId) { return notFound(res, 'api_keys_list table'); } // Get existing row const existingRow = await dbGet( 'SELECT id, data FROM table_rows WHERE id = ? AND table_id = ?', [id, tableId] ); if (!existingRow) { return notFound(res, 'API key'); } // Parse data and check ownership const data = typeof existingRow.data === 'string' ? JSON.parse(existingRow.data) : existingRow.data; if (data.user_id !== req.user.id) { return forbidden(res, 'You can only regenerate your own API keys'); } // Generate new key const apiKey = generateApiKey(); const keyPrefix = apiKey.substring(0, 7); const keyHash = hashApiKey(apiKey); // Update data data.key_prefix = keyPrefix; data.key_hash = keyHash; data.request_count = 0; await dbRun( 'UPDATE table_rows SET data = ?, updated_at = ? WHERE id = ?', [JSON.stringify(data), new Date().toISOString(), id] ); success(res, { id: parseInt(id), key: apiKey, // ⚠️ Only shown once! key_prefix: keyPrefix, name: data.name, scopes: typeof data.scopes === 'string' ? JSON.parse(data.scopes) : data.scopes, rate_limit: data.rate_limit, expires_at: data.expires_at, message: '⚠️ Save this new API key now! The old key has been invalidated.' }); } catch (err) { apiLogger.error('Error regenerating API key:', err); error(res, 'INTERNAL_ERROR', 'Failed to regenerate API key', 500); } }); export default router;