import { parseAbsoluteTimeMs } from "./parse.js"; import type { CronSchedule } from "./types.js"; const ONE_MINUTE_MS = 60 * 1000; const TEN_YEARS_MS = 10 * 365.25 * 24 * 60 * 60 * 1000; export type TimestampValidationError = { ok: false; message: string; }; export type TimestampValidationSuccess = { ok: true; }; export type TimestampValidationResult = TimestampValidationSuccess | TimestampValidationError; /** * Validates at timestamps in cron schedules. * Rejects timestamps that are: * - More than 1 minute in the past * - More than 10 years in the future */ export function validateScheduleTimestamp( schedule: CronSchedule, nowMs: number = Date.now(), ): TimestampValidationResult { if (schedule.kind !== "at") { return { ok: true }; } const atRaw = typeof schedule.at === "string" ? schedule.at.trim() : ""; const atMs = atRaw ? parseAbsoluteTimeMs(atRaw) : null; if (atMs === null || !Number.isFinite(atMs)) { return { ok: false, message: `Invalid schedule.at: expected ISO-8601 timestamp (got ${String(schedule.at)})`, }; } const diffMs = atMs - nowMs; // Check if timestamp is in the past (allow 1 minute grace period) if (diffMs < -ONE_MINUTE_MS) { const nowDate = new Date(nowMs).toISOString(); const atDate = new Date(atMs).toISOString(); const minutesAgo = Math.floor(-diffMs / ONE_MINUTE_MS); return { ok: false, message: `schedule.at is in the past: ${atDate} (${minutesAgo} minutes ago). Current time: ${nowDate}`, }; } // Check if timestamp is too far in the future if (diffMs > TEN_YEARS_MS) { const atDate = new Date(atMs).toISOString(); const yearsAhead = Math.floor(diffMs / (365.25 * 24 * 60 * 60 * 1000)); return { ok: false, message: `schedule.at is too far in the future: ${atDate} (${yearsAhead} years ahead). Maximum allowed: 10 years`, }; } return { ok: true }; }