342 lines
9.4 KiB
TypeScript
342 lines
9.4 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;
|
|
}
|
|
|
|
// 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<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) with organization and project details
|
|
*/
|
|
async listKeys(): Promise<ApiKeyWithDetails[]> {
|
|
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<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;
|
|
}
|
|
}
|