Add composite index [jobId, timestamp] to improve query performance for the most common job_events access patterns. Changes: - Add @@index([jobId, timestamp]) to JobEvent model in schema.prisma - Create migration 20260202122655_add_job_events_composite_index - Add performance tests to validate index effectiveness - Document index design rationale in scratchpad - Fix lint errors in api-key.guard, herald.service, runner-jobs.service Rationale: The composite index [jobId, timestamp] optimizes the dominant query pattern used across all services: - JobEventsService.getEventsByJobId (WHERE jobId, ORDER BY timestamp) - RunnerJobsService.streamEvents (WHERE jobId + timestamp range) - RunnerJobsService.findOne (implicit jobId filter + timestamp order) This index provides: - Fast filtering by jobId (highly selective) - Efficient timestamp-based ordering - Optimal support for timestamp range queries - Backward compatibility with jobId-only queries Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
82 lines
2.5 KiB
TypeScript
82 lines
2.5 KiB
TypeScript
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { timingSafeEqual } from "crypto";
|
|
|
|
/**
|
|
* ApiKeyGuard - Authentication guard for service-to-service communication
|
|
*
|
|
* Validates the X-API-Key header against the COORDINATOR_API_KEY environment variable.
|
|
* Uses constant-time comparison to prevent timing attacks.
|
|
*
|
|
* Usage:
|
|
* @UseGuards(ApiKeyGuard)
|
|
* @Controller('coordinator')
|
|
* export class CoordinatorIntegrationController { ... }
|
|
*/
|
|
@Injectable()
|
|
export class ApiKeyGuard implements CanActivate {
|
|
constructor(private readonly configService: ConfigService) {}
|
|
|
|
canActivate(context: ExecutionContext): boolean {
|
|
const request = context.switchToHttp().getRequest<{ headers: Record<string, string> }>();
|
|
const providedKey = this.extractApiKeyFromHeader(request);
|
|
|
|
if (!providedKey) {
|
|
throw new UnauthorizedException("No API key provided");
|
|
}
|
|
|
|
const configuredKey = this.configService.get<string>("COORDINATOR_API_KEY");
|
|
|
|
if (!configuredKey) {
|
|
throw new UnauthorizedException("API key authentication not configured");
|
|
}
|
|
|
|
if (!this.isValidApiKey(providedKey, configuredKey)) {
|
|
throw new UnauthorizedException("Invalid API key");
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Extract API key from X-API-Key header (case-insensitive)
|
|
*/
|
|
private extractApiKeyFromHeader(request: {
|
|
headers: Record<string, string>;
|
|
}): string | undefined {
|
|
const headers = request.headers;
|
|
|
|
// Check common variations (lowercase, uppercase, mixed case)
|
|
const apiKey =
|
|
headers["x-api-key"] ?? headers["X-API-Key"] ?? headers["X-Api-Key"] ?? headers["x-api-key"];
|
|
|
|
// Return undefined if key is empty string
|
|
if (typeof apiKey === "string" && apiKey.trim() === "") {
|
|
return undefined;
|
|
}
|
|
|
|
return apiKey;
|
|
}
|
|
|
|
/**
|
|
* Validate API key using constant-time comparison to prevent timing attacks
|
|
*/
|
|
private isValidApiKey(providedKey: string, configuredKey: string): boolean {
|
|
try {
|
|
// Convert strings to buffers for constant-time comparison
|
|
const providedBuffer = Buffer.from(providedKey, "utf8");
|
|
const configuredBuffer = Buffer.from(configuredKey, "utf8");
|
|
|
|
// Keys must be same length for timingSafeEqual
|
|
if (providedBuffer.length !== configuredBuffer.length) {
|
|
return false;
|
|
}
|
|
|
|
return timingSafeEqual(providedBuffer, configuredBuffer);
|
|
} catch {
|
|
// If comparison fails for any reason, reject
|
|
return false;
|
|
}
|
|
}
|
|
}
|