@@ -145,6 +145,7 @@ const VALID_INTAKE_DATA_CLASSES = new Set(['public', 'internal', 'private', 'sen
145145const VALID_INTAKE_ASSISTANT_STATUSES = new Set ( [ 'not-applicable' , 'candidate' , 'ready' , 'submitted' , 'blocked' ] ) ;
146146const SECRET_EXPORT_PATTERN = / ( s e c r e t | t o k e n | p a s s w o r d | c r e d e n t i a l | c o o k i e | a u t h o r i z a t i o n | s i g n e d [ _ - ] ? u r l | a p i [ _ - ] ? k e y ) / i;
147147const SIGNED_URL_EXPORT_PATTERN = / ( X - A m z - S i g n a t u r e | X - A m z - C r e d e n t i a l | X - A m z - S e c u r i t y - T o k e n | s i g n a t u r e = | s i g = | a c c e s s _ t o k e n = | t o k e n = | p a s s w o r d = | s e c r e t = | c r e d e n t i a l = | a p i [ _ - ] ? k e y = ) / i;
148+ const RFC3339_INSTANT_PATTERN = / ^ (?< year > \d { 4 } ) - (?< month > \d { 2 } ) - (?< day > \d { 2 } ) [ T t ] (?< hour > \d { 2 } ) : (?< minute > \d { 2 } ) : (?< second > \d { 2 } ) (?: \. (?< fraction > \d { 1 , 3 } ) ) ? (?: (?< utcZone > [ Z z ] ) | (?< offsetSign > [ + - ] ) (?< offsetHours > \d { 2 } ) : (?< offsetMinutes > \d { 2 } ) ) $ / ;
148149
149150const ENTITY_SPECS : EntitySpec [ ] = [
150151 {
@@ -605,6 +606,13 @@ function sha256(content: string): string {
605606 return `sha256:${ crypto . createHash ( 'sha256' ) . update ( content ) . digest ( 'hex' ) } ` ;
606607}
607608
609+ let portableExportClock : ( ) => Date = ( ) => new Date ( ) ;
610+
611+ function resolveGeneratedAt ( value ?: string ) : string {
612+ if ( value === undefined ) return portableExportClock ( ) . toISOString ( ) ;
613+ return canonicalizeRfc3339Instant ( value ) ;
614+ }
615+
608616async function writePortableExport (
609617 client : DynamoDBDocumentClient ,
610618 outputDir : string ,
@@ -616,6 +624,7 @@ async function writePortableExport(
616624 generatedAt ?: string ;
617625 } = { }
618626) : Promise < PortableExportResult > {
627+ const generatedAt = resolveGeneratedAt ( options . generatedAt ) ;
619628 await fs . mkdir ( outputDir , { recursive : true } ) ;
620629
621630 const entityFiles : Record < string , string > = { } ;
@@ -624,7 +633,6 @@ async function writePortableExport(
624633
625634 for ( const spec of ENTITY_SPECS ) {
626635 const rawItems = await scanByPrefix ( client , spec . tableName , spec . prefix , spec . recordType , spec . prefixes ) ;
627- const generatedAt = options . generatedAt || new Date ( ) . toISOString ( ) ;
628636 const records = rawItems
629637 . filter ( ( item ) => (
630638 ( ! spec . filter || spec . filter ( item ) )
@@ -648,7 +656,7 @@ async function writePortableExport(
648656
649657 const manifest : Manifest = {
650658 schema_version : SCHEMA_VERSION ,
651- generated_at : options . generatedAt || new Date ( ) . toISOString ( ) ,
659+ generated_at : generatedAt ,
652660 source_environment : options . sourceEnvironment || process . env . DATAOPS_ENV || process . env . NODE_ENV || 'unknown' ,
653661 source_stack : options . sourceStack || process . env . AWS_STACK_NAME || 'unknown' ,
654662 source_region : options . sourceRegion || process . env . AWS_REGION || 'unknown' ,
@@ -1155,10 +1163,68 @@ function isIsoDate(value: string): boolean {
11551163 ) ;
11561164}
11571165
1166+ function isRfc3339Instant ( value : string , requireUtc = false ) : boolean {
1167+ const match = RFC3339_INSTANT_PATTERN . exec ( value ) ;
1168+ if ( ! match ?. groups ) return false ;
1169+
1170+ if ( requireUtc && ! match . groups . utcZone ) return false ;
1171+ if ( Number ( match . groups . second ) > 59 ) return false ;
1172+ if ( match . groups . offsetHours && Number ( match . groups . offsetHours ) > 23 ) return false ;
1173+ if ( match . groups . offsetMinutes && Number ( match . groups . offsetMinutes ) > 59 ) return false ;
1174+
1175+ // Reapplying the numeric offset recovers the supplied wall-clock fields and
1176+ // prevents JavaScript from rolling invalid dates such as February 30 forward.
1177+ const secondTimestamp = Date . parse ( rfc3339SecondBase ( value ) ) ;
1178+ if ( Number . isNaN ( secondTimestamp ) ) return false ;
1179+ const offsetSign = match . groups . offsetSign === '-' ? - 1 : 1 ;
1180+ const offsetMinutes = (
1181+ Number ( match . groups . offsetHours || 0 ) * 60
1182+ + Number ( match . groups . offsetMinutes || 0 )
1183+ ) * offsetSign ;
1184+ const wallClock = new Date ( secondTimestamp + offsetMinutes * 60_000 ) ;
1185+ return (
1186+ wallClock . getUTCFullYear ( ) === Number ( match . groups . year )
1187+ && wallClock . getUTCMonth ( ) + 1 === Number ( match . groups . month )
1188+ && wallClock . getUTCDate ( ) === Number ( match . groups . day )
1189+ && wallClock . getUTCHours ( ) === Number ( match . groups . hour )
1190+ && wallClock . getUTCMinutes ( ) === Number ( match . groups . minute )
1191+ && wallClock . getUTCSeconds ( ) === Number ( match . groups . second )
1192+ ) ;
1193+ }
1194+
1195+ function rfc3339SecondBase ( value : string ) : string {
1196+ const match = RFC3339_INSTANT_PATTERN . exec ( value ) ;
1197+ if ( ! match ?. groups ) throw new Error ( 'Value is not an RFC3339 instant' ) ;
1198+
1199+ const zone = match . groups . utcZone
1200+ ? 'Z'
1201+ : `${ match . groups . offsetSign } ${ match . groups . offsetHours } :${ match . groups . offsetMinutes } ` ;
1202+ return `${ match . groups . year } -${ match . groups . month } -${ match . groups . day } `
1203+ + `T${ match . groups . hour } :${ match . groups . minute } :${ match . groups . second } .000${ zone } ` ;
1204+ }
1205+
1206+ function canonicalizeRfc3339Instant ( value : string ) : string {
1207+ if ( ! isRfc3339Instant ( value ) ) {
1208+ throw new Error ( 'generatedAt must be an RFC3339 date-time with millisecond precision' ) ;
1209+ }
1210+
1211+ const match = RFC3339_INSTANT_PATTERN . exec ( value ) ;
1212+ if ( ! match ?. groups ) throw new Error ( 'generatedAt must be an RFC3339 date-time with millisecond precision' ) ;
1213+ const utcSeconds = new Date ( Date . parse ( rfc3339SecondBase ( value ) ) )
1214+ . toISOString ( )
1215+ . slice ( 0 , 19 ) ;
1216+ const fraction = ( match . groups . fraction || '' ) . padEnd ( 3 , '0' ) ;
1217+ return `${ utcSeconds } .${ fraction } Z` ;
1218+ }
1219+
11581220function isParseableDateOrTimestamp ( value : string ) : boolean {
11591221 return isIsoDate ( value ) || ! Number . isNaN ( Date . parse ( value ) ) ;
11601222}
11611223
1224+ function setPortableExportClockForTests ( clock ?: ( ) => Date ) : void {
1225+ portableExportClock = clock || ( ( ) => new Date ( ) ) ;
1226+ }
1227+
11621228function validateDateField (
11631229 record : JsonRecord ,
11641230 field : string ,
@@ -1234,8 +1300,8 @@ async function validatePortableExport(exportDir: string): Promise<ValidationResu
12341300 if ( manifest . export_format_version !== EXPORT_FORMAT_VERSION ) {
12351301 errors . push ( `manifest export_format_version must be ${ EXPORT_FORMAT_VERSION } ` ) ;
12361302 }
1237- if ( typeof manifest . generated_at !== 'string' || ! isParseableDateOrTimestamp ( manifest . generated_at ) ) {
1238- errors . push ( 'manifest generated_at must be a parseable date or timestamp ' ) ;
1303+ if ( typeof manifest . generated_at !== 'string' || ! isRfc3339Instant ( manifest . generated_at , true ) ) {
1304+ errors . push ( 'manifest generated_at must be a UTC RFC3339 date-time ' ) ;
12391305 }
12401306
12411307 const recordsByEntity : Partial < Record < ExportEntityName , JsonRecord [ ] > > = { } ;
@@ -1858,6 +1924,7 @@ export {
18581924 REDACTIONS ,
18591925 SCHEMA_VERSION ,
18601926 dryRunImport ,
1927+ setPortableExportClockForTests ,
18611928 validatePortableExport ,
18621929 writePortableExport ,
18631930} ;
0 commit comments