banatie-service/apps/api-service/src/services/ApiKeyService.ts

285 lines
7.3 KiB
TypeScript

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<ApiKeyWithSlugs | null> {
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<boolean> {
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<ApiKey[]> {
return db.select().from(apiKeys).orderBy(desc(apiKeys.createdAt));
}
/**
* Check if any keys exist (for bootstrap)
*/
async hasAnyKeys(): Promise<boolean> {
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<string> {
// 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<string> {
// 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;
}
}