import { DateTime } from 'luxon';
import { TimeConfig, isWorkday, isWithinWorkHours } from '../util/time.js';
export interface PolicyConfig {
timeConfig: TimeConfig;
allowlistCalendarIds: string[];
defaultCalendarId: string;
minDurationMinutes: number;
maxDurationMinutes: number;
}
export interface PolicyViolation {
code: string;
message: string;
details?: any;
}
export class PolicyEngine {
private config: PolicyConfig;
constructor(config: PolicyConfig) {
this.config = config;
}
validateCalendarAccess(calendarId: string): PolicyViolation | null {
if (!this.config.allowlistCalendarIds.includes(calendarId)) {
return {
code: 'FORBIDDEN',
message: `Calendar '${calendarId}' is not in the allowlist`,
details: { calendarId, allowlist: this.config.allowlistCalendarIds }
};
}
return null;
}
validateWorkingHours(
start: DateTime,
end: DateTime,
timezone: string
): PolicyViolation | null {
if (!isWorkday(start, this.config.timeConfig.workDays) ||
!isWorkday(end, this.config.timeConfig.workDays)) {
return {
code: 'FORBIDDEN',
message: 'Events must be scheduled on workdays only',
details: {
start: start.toISO(),
end: end.toISO(),
workDays: this.config.timeConfig.workDays
}
};
}
if (!isWithinWorkHours(start, end, this.config.timeConfig)) {
return {
code: 'FORBIDDEN',
message: 'Events must be scheduled within work hours',
details: {
start: start.toISO(),
end: end.toISO(),
workHours: {
start: this.config.timeConfig.workHoursStart,
end: this.config.timeConfig.workHoursEnd
}
}
};
}
return null;
}
validateDuration(
start: DateTime,
end: DateTime
): PolicyViolation | null {
const durationMinutes = end.diff(start, 'minutes').minutes;
if (durationMinutes < this.config.minDurationMinutes) {
return {
code: 'INVALID_ARGUMENT',
message: `Event duration must be at least ${this.config.minDurationMinutes} minutes`,
details: {
durationMinutes,
minDuration: this.config.minDurationMinutes
}
};
}
if (durationMinutes > this.config.maxDurationMinutes) {
return {
code: 'INVALID_ARGUMENT',
message: `Event duration must not exceed ${this.config.maxDurationMinutes} minutes`,
details: {
durationMinutes,
maxDuration: this.config.maxDurationMinutes
}
};
}
return null;
}
validateNoOverlap(
start: DateTime,
end: DateTime,
existingEvents: Array<{ start: DateTime; end: DateTime; id?: string }>,
excludeEventId?: string
): PolicyViolation | null {
const relevantEvents = excludeEventId
? existingEvents.filter(event => event.id !== excludeEventId)
: existingEvents;
for (const event of relevantEvents) {
if (this.eventsOverlap(start, end, event.start, event.end)) {
return {
code: 'CONFLICT',
message: 'Event conflicts with existing event',
details: {
newEvent: { start: start.toISO(), end: end.toISO() },
conflictingEvent: {
start: event.start.toISO(),
end: event.end.toISO(),
id: event.id
}
}
};
}
}
return null;
}
private eventsOverlap(
start1: DateTime,
end1: DateTime,
start2: DateTime,
end2: DateTime
): boolean {
return start1 < end2 && start2 < end1;
}
validateEventTitle(title: string): PolicyViolation | null {
if (!title || title.trim().length === 0) {
return {
code: 'INVALID_ARGUMENT',
message: 'Event title is required',
details: { title }
};
}
if (title.length > 200) {
return {
code: 'INVALID_ARGUMENT',
message: 'Event title must not exceed 200 characters',
details: { title, length: title.length, maxLength: 200 }
};
}
if (this.containsMaliciousContent(title)) {
return {
code: 'INVALID_ARGUMENT',
message: 'Event title contains invalid content',
details: { title }
};
}
return null;
}
validateAttendees(attendees: Array<{ email: string; required?: boolean }>): PolicyViolation | null {
if (!attendees || attendees.length === 0) {
return null;
}
for (const attendee of attendees) {
if (!attendee.email || !this.isValidEmail(attendee.email)) {
return {
code: 'INVALID_ARGUMENT',
message: 'Invalid attendee email address',
details: { attendee }
};
}
}
return null;
}
validateLocation(location?: string): PolicyViolation | null {
if (!location) {
return null;
}
if (location.length > 200) {
return {
code: 'INVALID_ARGUMENT',
message: 'Location must not exceed 200 characters',
details: { location, length: location.length, maxLength: 200 }
};
}
if (this.containsMaliciousContent(location)) {
return {
code: 'INVALID_ARGUMENT',
message: 'Location contains invalid content',
details: { location }
};
}
return null;
}
validateEvent(event: {
title: string;
start: DateTime;
end: DateTime;
calendarId: string;
attendees?: Array<{ email: string; required?: boolean }>;
location?: string;
}, existingEvents: Array<{ start: DateTime; end: DateTime; id?: string }> = []): PolicyViolation | null {
const calendarViolation = this.validateCalendarAccess(event.calendarId);
if (calendarViolation) return calendarViolation;
const hoursViolation = this.validateWorkingHours(event.start, event.end, event.start.zoneName);
if (hoursViolation) return hoursViolation;
const durationViolation = this.validateDuration(event.start, event.end);
if (durationViolation) return durationViolation;
const titleViolation = this.validateEventTitle(event.title);
if (titleViolation) return titleViolation;
const attendeesViolation = this.validateAttendees(event.attendees || []);
if (attendeesViolation) return attendeesViolation;
const locationViolation = this.validateLocation(event.location);
if (locationViolation) return locationViolation;
const overlapViolation = this.validateNoOverlap(event.start, event.end, existingEvents);
if (overlapViolation) return overlapViolation;
return null;
}
private containsMaliciousContent(content: string): boolean {
const maliciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/data:text\/html/i,
/vbscript:/i
];
return maliciousPatterns.some(pattern => pattern.test(content));
}
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
getConfig(): PolicyConfig {
return this.config;
}
updateConfig(newConfig: Partial<PolicyConfig>): void {
this.config = { ...this.config, ...newConfig };
}
}