import crypto from 'crypto'; import { db } from '../db'; import { apiKeys, organizations, projects, type ApiKey, type NewApiKey } from '@banatie/database'; import { eq, and, desc } from 'drizzle-orm'; // Extended API key type with slugs for storage paths export interface ApiKeyWithSlugs extends ApiKey { organizationSlug?: string; projectSlug?: string; } // Extended API key type with full organization and project details for admin listing export interface ApiKeyWithDetails extends ApiKey { organization: { id: string; slug: string; name: string; email: string; } | null; project: { id: string; slug: string; name: string; } | null; } export class ApiKeyService { /** * Generate a new API key * Format: bnt_{64_hex_chars} */ private generateKey(): { fullKey: string; keyHash: string; keyPrefix: string; } { const secret = crypto.randomBytes(32).toString('hex'); // 64 chars const keyPrefix = 'bnt_'; const fullKey = keyPrefix + secret; // Hash for storage (SHA-256) const keyHash = crypto.createHash('sha256').update(fullKey).digest('hex'); return { fullKey, keyHash, keyPrefix }; } /** * Create a master key (admin access, never expires) */ async createMasterKey( name?: string, createdBy?: string, ): Promise<{ key: string; metadata: ApiKey }> { const { fullKey, keyHash, keyPrefix } = this.generateKey(); const [newKey] = await db .insert(apiKeys) .values({ keyHash, keyPrefix, keyType: 'master', projectId: null, scopes: ['*'], // Full access name: name || 'Master Key', expiresAt: null, // Never expires createdBy: createdBy || null, }) .returning(); console.log( `[${new Date().toISOString()}] Master key created: ${newKey?.id} - ${newKey?.name}`, ); return { key: fullKey, metadata: newKey! }; } /** * Create a project key (expires in 90 days) */ async createProjectKey( projectId: string, organizationId?: string, name?: string, createdBy?: string, expiresInDays: number = 90, ): Promise<{ key: string; metadata: ApiKey }> { const { fullKey, keyHash, keyPrefix } = this.generateKey(); const expiresAt = new Date(); expiresAt.setDate(expiresAt.getDate() + expiresInDays); const [newKey] = await db .insert(apiKeys) .values({ keyHash, keyPrefix, keyType: 'project', projectId, organizationId: organizationId || null, scopes: ['generate', 'read'], name: name || `Project Key - ${projectId}`, expiresAt, createdBy: createdBy || null, }) .returning(); console.log( `[${new Date().toISOString()}] Project key created: ${newKey?.id} - ${projectId} - expires: ${expiresAt.toISOString()}`, ); return { key: fullKey, metadata: newKey! }; } /** * Validate an API key * Returns null if invalid/expired/revoked * Returns API key with organization and project slugs for storage paths */ async validateKey(providedKey: string): Promise { if (!providedKey || !providedKey.startsWith('bnt_')) { return null; } // Hash the provided key const keyHash = crypto.createHash('sha256').update(providedKey).digest('hex'); // Find in database with left joins to get slugs const [result] = await db .select({ // API key fields id: apiKeys.id, keyHash: apiKeys.keyHash, keyPrefix: apiKeys.keyPrefix, keyType: apiKeys.keyType, organizationId: apiKeys.organizationId, projectId: apiKeys.projectId, scopes: apiKeys.scopes, createdAt: apiKeys.createdAt, expiresAt: apiKeys.expiresAt, lastUsedAt: apiKeys.lastUsedAt, isActive: apiKeys.isActive, name: apiKeys.name, createdBy: apiKeys.createdBy, // Slug fields organizationSlug: organizations.slug, projectSlug: projects.slug, }) .from(apiKeys) .leftJoin(organizations, eq(apiKeys.organizationId, organizations.id)) .leftJoin(projects, eq(apiKeys.projectId, projects.id)) .where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true))) .limit(1); if (!result) { console.warn( `[${new Date().toISOString()}] Invalid API key attempt: ${providedKey.substring(0, 10)}...`, ); return null; } // Check expiration if (result.expiresAt && result.expiresAt < new Date()) { console.warn( `[${new Date().toISOString()}] Expired API key used: ${result.id} - expired: ${result.expiresAt.toISOString()}`, ); return null; } // Update last used timestamp (async, don't wait) db.update(apiKeys) .set({ lastUsedAt: new Date() }) .where(eq(apiKeys.id, result.id)) .execute() .catch((err) => console.error(`[${new Date().toISOString()}] Failed to update lastUsedAt:`, err), ); return result as ApiKeyWithSlugs; } /** * Revoke a key (soft delete) */ async revokeKey(keyId: string): Promise { const result = await db .update(apiKeys) .set({ isActive: false }) .where(eq(apiKeys.id, keyId)) .returning(); if (result.length > 0) { console.log(`[${new Date().toISOString()}] API key revoked: ${keyId}`); return true; } return false; } /** * List all keys (for admin) with organization and project details */ async listKeys(): Promise { const results = await db .select({ // API key fields id: apiKeys.id, keyHash: apiKeys.keyHash, keyPrefix: apiKeys.keyPrefix, keyType: apiKeys.keyType, organizationId: apiKeys.organizationId, projectId: apiKeys.projectId, scopes: apiKeys.scopes, createdAt: apiKeys.createdAt, expiresAt: apiKeys.expiresAt, lastUsedAt: apiKeys.lastUsedAt, isActive: apiKeys.isActive, name: apiKeys.name, createdBy: apiKeys.createdBy, // Organization fields orgId: organizations.id, orgSlug: organizations.slug, orgName: organizations.name, orgEmail: organizations.email, // Project fields projId: projects.id, projSlug: projects.slug, projName: projects.name, }) .from(apiKeys) .leftJoin(organizations, eq(apiKeys.organizationId, organizations.id)) .leftJoin(projects, eq(apiKeys.projectId, projects.id)) .orderBy(desc(apiKeys.createdAt)); // Transform flat results to nested structure return results.map((row) => ({ id: row.id, keyHash: row.keyHash, keyPrefix: row.keyPrefix, keyType: row.keyType, organizationId: row.organizationId, projectId: row.projectId, scopes: row.scopes, createdAt: row.createdAt, expiresAt: row.expiresAt, lastUsedAt: row.lastUsedAt, isActive: row.isActive, name: row.name, createdBy: row.createdBy, organization: row.orgId && row.orgSlug && row.orgName && row.orgEmail ? { id: row.orgId, slug: row.orgSlug, name: row.orgName, email: row.orgEmail, } : null, project: row.projId && row.projSlug && row.projName ? { id: row.projId, slug: row.projSlug, name: row.projName, } : null, })); } /** * Check if any keys exist (for bootstrap) */ async hasAnyKeys(): Promise { const keys = await db.select({ id: apiKeys.id }).from(apiKeys).limit(1); return keys.length > 0; } /** * Get or create organization by slug * If organization doesn't exist, create it with provided name (or use slug as name) */ async getOrCreateOrganization(slug: string, name?: string, email?: string): Promise { // Try to find existing organization const [existing] = await db .select({ id: organizations.id }) .from(organizations) .where(eq(organizations.slug, slug)) .limit(1); if (existing) { return existing.id; } // Create new organization const [newOrg] = await db .insert(organizations) .values({ slug, name: name || slug, email: email || `${slug}@placeholder.local`, }) .returning({ id: organizations.id }); console.log(`[${new Date().toISOString()}] Organization created: ${newOrg?.id} - ${slug}`); return newOrg!.id; } /** * Get or create project by slug within an organization * If project doesn't exist, create it with provided name (or use slug as name) */ async getOrCreateProject(organizationId: string, slug: string, name?: string): Promise { // Try to find existing project const [existing] = await db .select({ id: projects.id }) .from(projects) .where(and(eq(projects.organizationId, organizationId), eq(projects.slug, slug))) .limit(1); if (existing) { return existing.id; } // Create new project const [newProject] = await db .insert(projects) .values({ organizationId, slug, name: name || slug, }) .returning({ id: projects.id }); console.log( `[${new Date().toISOString()}] Project created: ${newProject?.id} - ${slug} (org: ${organizationId})`, ); return newProject!.id; } }