@@ -4,9 +4,25 @@ import { z } from 'zod';
44const UlidSchema = z . string ( ) . ulid ( ) ;
55
66/**
7- * Default threshold for ULID timestamp validation (5 minutes in milliseconds).
7+ * Default threshold for ULID timestamps in the past (24 hours).
8+ *
9+ * Set to 24 hours to support the resilient start path: when start() fails to
10+ * create run_created, the queue carries the run input and the runtime creates
11+ * the run on run_started. VQS supports delayed messages up to 24 hours.
12+ */
13+ export const DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS = 24 * 60 * 60 * 1000 ;
14+
15+ /**
16+ * Default threshold for ULID timestamps in the future (5 minutes).
17+ *
18+ * Kept tight to prevent abuse from client-generated ULIDs with manipulated
19+ * future timestamps while still tolerating minor clock skew.
820 */
9- export const DEFAULT_TIMESTAMP_THRESHOLD_MS = 5 * 60 * 1000 ;
21+ export const DEFAULT_TIMESTAMP_THRESHOLD_FUTURE_MS = 5 * 60 * 1000 ;
22+
23+ /** @deprecated Use DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS instead */
24+ export const DEFAULT_TIMESTAMP_THRESHOLD_MS =
25+ DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS ;
1026
1127/**
1228 * Extracts a Date from a ULID string, or null if the string is not a valid ULID.
@@ -21,18 +37,22 @@ export function ulidToDate(maybeUlid: string): Date | null {
2137}
2238
2339/**
24- * Validates that a prefixed ULID's embedded timestamp is within an acceptable threshold
25- * of the current server time. This prevents client-generated ULIDs with manipulated timestamps.
40+ * Validates that a prefixed ULID's embedded timestamp is within acceptable thresholds
41+ * of the current server time. Uses asymmetric thresholds: 24h in the past (to support
42+ * resilient start with queue delays) and 5min in the future (to prevent abuse while
43+ * tolerating clock skew).
2644 *
2745 * @param prefixedUlid - The prefixed ULID to validate (e.g., "wrun_01ARYZ...")
2846 * @param prefix - The prefix to strip (e.g., "wrun_")
29- * @param thresholdMs - Maximum allowed drift in milliseconds (default: 5 minutes)
47+ * @param pastThresholdMs - Maximum allowed age in the past (default: 24 hours)
48+ * @param futureThresholdMs - Maximum allowed distance in the future (default: 5 minutes)
3049 * @returns null if valid, or an error message string if invalid
3150 */
3251export function validateUlidTimestamp (
3352 prefixedUlid : string ,
3453 prefix : string ,
35- thresholdMs : number = DEFAULT_TIMESTAMP_THRESHOLD_MS
54+ pastThresholdMs : number = DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS ,
55+ futureThresholdMs : number = DEFAULT_TIMESTAMP_THRESHOLD_FUTURE_MS
3656) : string | null {
3757 const raw = prefixedUlid . startsWith ( prefix )
3858 ? prefixedUlid . slice ( prefix . length )
@@ -44,13 +64,20 @@ export function validateUlidTimestamp(
4464 }
4565
4666 const serverTimestamp = new Date ( ) ;
47- const driftMs = Math . abs ( serverTimestamp . getTime ( ) - ulidTimestamp . getTime ( ) ) ;
67+ const diffMs = serverTimestamp . getTime ( ) - ulidTimestamp . getTime ( ) ;
4868
49- if ( driftMs <= thresholdMs ) {
50- return null ;
69+ // diffMs > 0 means the ULID is in the past; diffMs < 0 means it's in the future
70+ if ( diffMs > 0 && diffMs <= pastThresholdMs ) {
71+ return null ; // Within past threshold
72+ }
73+ if ( diffMs <= 0 && - diffMs <= futureThresholdMs ) {
74+ return null ; // Within future threshold
5175 }
5276
77+ const driftMs = Math . abs ( diffMs ) ;
5378 const driftSeconds = Math . round ( driftMs / 1000 ) ;
79+ const direction = diffMs > 0 ? 'past' : 'future' ;
80+ const thresholdMs = diffMs > 0 ? pastThresholdMs : futureThresholdMs ;
5481 const thresholdSeconds = Math . round ( thresholdMs / 1000 ) ;
55- return `Invalid runId timestamp: embedded timestamp differs from server time by ${ driftSeconds } s (threshold: ${ thresholdSeconds } s)` ;
82+ return `Invalid runId timestamp: embedded timestamp is ${ driftSeconds } s in the ${ direction } (threshold: ${ thresholdSeconds } s)` ;
5683}
0 commit comments