widgetdc-cortex / apps /backend /src /services /HumanApprovalService.ts
Kraft102's picture
Initial deployment - WidgeTDC Cortex Backend v2.1.0
529090e
/**
* Human-in-the-Loop Approval Service
*
* SIKKERHED: Ingen handlinger der muterer data kan udføres uden eksplicit menneskelig godkendelse.
* Dette inkluderer:
* - Data ingestion (Facebook, email, external APIs)
* - Social media posting
* - Database mutations
* - File system operations
* - External API calls
*/
import { EventEmitter } from 'events';
export enum ApprovalStatus {
PENDING = 'pending',
APPROVED = 'approved',
REJECTED = 'rejected',
EXPIRED = 'expired',
}
export enum ApprovalRiskLevel {
LOW = 'low', // Read-only operations
MEDIUM = 'medium', // Data ingestion, internal mutations
HIGH = 'high', // External posts, payments, deletions
CRITICAL = 'critical' // System-level changes
}
export interface ApprovalRequest {
id: string;
operation: string;
description: string;
riskLevel: ApprovalRiskLevel;
requestedBy: string; // System component requesting approval
requestedAt: number;
expiresAt: number;
status: ApprovalStatus;
metadata: Record<string, any>;
approvedBy?: string;
approvedAt?: number;
rejectedBy?: string;
rejectedAt?: number;
rejectionReason?: string;
}
class HumanApprovalServiceImpl extends EventEmitter {
private pendingRequests: Map<string, ApprovalRequest> = new Map();
private readonly DEFAULT_EXPIRY = 300000; // 5 minutes
private cleanupInterval: NodeJS.Timeout;
constructor() {
super();
// Cleanup expired requests every minute
this.cleanupInterval = setInterval(() => {
this.cleanupExpiredRequests();
}, 60000);
}
/**
* Request approval for an operation
* Returns approval request ID
*/
async requestApproval(
operation: string,
description: string,
riskLevel: ApprovalRiskLevel,
requestedBy: string,
metadata: Record<string, any> = {},
expiryMs?: number
): Promise<string> {
const id = this.generateId();
const now = Date.now();
const request: ApprovalRequest = {
id,
operation,
description,
riskLevel,
requestedBy,
requestedAt: now,
expiresAt: now + (expiryMs || this.DEFAULT_EXPIRY),
status: ApprovalStatus.PENDING,
metadata,
};
this.pendingRequests.set(id, request);
// Emit event for real-time UI updates
this.emit('approval-requested', request);
console.log(`🔐 [APPROVAL REQUIRED] ${operation}`);
console.log(` Risk: ${riskLevel.toUpperCase()}`);
console.log(` Description: ${description}`);
console.log(` Request ID: ${id}`);
return id;
}
/**
* Wait for approval (blocking)
* Throws if rejected or expired
*/
async waitForApproval(requestId: string, timeoutMs: number = 300000): Promise<void> {
return new Promise((resolve, reject) => {
const checkInterval = setInterval(() => {
const request = this.pendingRequests.get(requestId);
if (!request) {
clearInterval(checkInterval);
reject(new Error('Approval request not found'));
return;
}
if (request.status === ApprovalStatus.APPROVED) {
clearInterval(checkInterval);
resolve();
return;
}
if (request.status === ApprovalStatus.REJECTED) {
clearInterval(checkInterval);
reject(new Error(`Approval rejected: ${request.rejectionReason || 'No reason provided'}`));
return;
}
if (Date.now() > request.expiresAt) {
clearInterval(checkInterval);
request.status = ApprovalStatus.EXPIRED;
this.emit('approval-expired', request);
reject(new Error('Approval request expired'));
return;
}
}, 500);
// Failsafe timeout
setTimeout(() => {
clearInterval(checkInterval);
const request = this.pendingRequests.get(requestId);
if (request && request.status === ApprovalStatus.PENDING) {
request.status = ApprovalStatus.EXPIRED;
this.emit('approval-expired', request);
reject(new Error('Approval timeout'));
}
}, timeoutMs);
});
}
/**
* Approve a request
*/
approve(requestId: string, approvedBy: string): boolean {
const request = this.pendingRequests.get(requestId);
if (!request) {
console.error(`❌ Approval request ${requestId} not found`);
return false;
}
if (request.status !== ApprovalStatus.PENDING) {
console.error(`❌ Approval request ${requestId} is not pending (status: ${request.status})`);
return false;
}
if (Date.now() > request.expiresAt) {
request.status = ApprovalStatus.EXPIRED;
console.error(`❌ Approval request ${requestId} has expired`);
return false;
}
request.status = ApprovalStatus.APPROVED;
request.approvedBy = approvedBy;
request.approvedAt = Date.now();
this.emit('approval-granted', request);
console.log(`✅ [APPROVED] ${request.operation}`);
console.log(` Approved by: ${approvedBy}`);
return true;
}
/**
* Reject a request
*/
reject(requestId: string, rejectedBy: string, reason: string): boolean {
const request = this.pendingRequests.get(requestId);
if (!request) {
console.error(`❌ Approval request ${requestId} not found`);
return false;
}
if (request.status !== ApprovalStatus.PENDING) {
console.error(`❌ Approval request ${requestId} is not pending (status: ${request.status})`);
return false;
}
request.status = ApprovalStatus.REJECTED;
request.rejectedBy = rejectedBy;
request.rejectedAt = Date.now();
request.rejectionReason = reason;
this.emit('approval-rejected', request);
console.log(`🚫 [REJECTED] ${request.operation}`);
console.log(` Rejected by: ${rejectedBy}`);
console.log(` Reason: ${reason}`);
return true;
}
/**
* Get all pending requests
*/
getPendingRequests(): ApprovalRequest[] {
return Array.from(this.pendingRequests.values())
.filter(r => r.status === ApprovalStatus.PENDING && Date.now() <= r.expiresAt)
.sort((a, b) => {
// Sort by risk level (high to low), then by time (old to new)
const riskOrder = { critical: 4, high: 3, medium: 2, low: 1 };
const riskDiff = (riskOrder[b.riskLevel] || 0) - (riskOrder[a.riskLevel] || 0);
if (riskDiff !== 0) return riskDiff;
return a.requestedAt - b.requestedAt;
});
}
/**
* Get request by ID
*/
getRequest(requestId: string): ApprovalRequest | null {
return this.pendingRequests.get(requestId) || null;
}
/**
* Get request history
*/
getHistory(limit: number = 50): ApprovalRequest[] {
return Array.from(this.pendingRequests.values())
.sort((a, b) => b.requestedAt - a.requestedAt)
.slice(0, limit);
}
/**
* Cleanup expired requests
*/
private cleanupExpiredRequests(): void {
const now = Date.now();
let cleaned = 0;
for (const [id, request] of this.pendingRequests.entries()) {
if (request.status === ApprovalStatus.PENDING && now > request.expiresAt) {
request.status = ApprovalStatus.EXPIRED;
this.emit('approval-expired', request);
cleaned++;
}
// Remove old completed requests (keep for 1 hour)
if (request.status !== ApprovalStatus.PENDING && now - request.requestedAt > 3600000) {
this.pendingRequests.delete(id);
}
}
if (cleaned > 0) {
console.log(`🧹 Cleaned up ${cleaned} expired approval requests`);
}
}
/**
* Generate unique ID
*/
private generateId(): string {
return `approval_${Date.now()}_${Math.random().toString(36).substring(7)}`;
}
/**
* Shutdown cleanup
*/
shutdown(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
// Singleton instance
export const humanApprovalService = new HumanApprovalServiceImpl();