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; } 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) */ async listKeys(): Promise { return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt)); } /** * 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; } }