@@ -3,9 +3,10 @@ import { IDVerification } from '../models/idVerification.entity';
33import { authenticate } from '../middleware/auth' ;
44import { hasPermissionSync } from '../middleware/authorize' ;
55import { User } from '../models/user.entity' ;
6- import { canPerformIdVerification } from '../utils/eu' ;
6+ import { canPerformIdVerification , getMinimumAgeForCountry } from '../utils/eu' ;
77import { encryptBuffer } from '../utils/crypto' ;
88import { encryptBufferWithWorker } from '../workers/cryptoWorker' ;
9+ import { estimateAgeFromSelfie } from '../services/faceApiService' ;
910import path from 'path' ;
1011import fs from 'fs' ;
1112import { pipeline } from 'stream/promises' ;
@@ -85,6 +86,123 @@ export async function idVerificationRoutes(app: any, prefix = '') {
8586 detail : { summary : 'Submit ID verification' , description : 'User submits scanned ID and selfie for manual review.' , tags : [ 'Identity' ] }
8687 } ) ;
8788
89+ app . post ( prefix + '/id-verification/age-selfie' , async ( ctx : any ) => {
90+ const user = ctx . user as User ;
91+ if ( ! user ) {
92+ ctx . set . status = 401 ;
93+ return { error : 'Unauthorized' } ;
94+ }
95+
96+ const body = ( ctx . body || { } ) as any ;
97+ const selfie = Array . isArray ( body . selfie ) ? body . selfie [ 0 ] : body . selfie ;
98+ if ( ! selfie ) {
99+ ctx . set . status = 400 ;
100+ return { error : 'selfie_required' , message : 'A selfie image is required for age verification.' } ;
101+ }
102+
103+ const settings = user . settings && typeof user . settings === 'object' ? { ...user . settings } : { } ;
104+ const attempts = Number ( settings . ageVerificationSelfieAttempts ?? 0 ) ;
105+ if ( attempts >= 3 ) {
106+ ctx . set . status = 403 ;
107+ return { error : 'selfie_attempts_exceeded' , message : 'Maximum selfie verification attempts reached.' } ;
108+ }
109+
110+ const dateOfBirth = body . dateOfBirth ? new Date ( String ( body . dateOfBirth ) ) : user . dateOfBirth ? new Date ( String ( user . dateOfBirth ) ) : null ;
111+ if ( ! dateOfBirth || isNaN ( dateOfBirth . getTime ( ) ) ) {
112+ ctx . set . status = 400 ;
113+ return { error : 'date_of_birth_required' , message : 'Your date of birth is required for selfie age verification.' } ;
114+ }
115+ const age = ( ( ) : number | null => {
116+ const now = new Date ( ) ;
117+ let calculated = now . getUTCFullYear ( ) - dateOfBirth . getUTCFullYear ( ) ;
118+ const monthDiff = now . getUTCMonth ( ) - dateOfBirth . getUTCMonth ( ) ;
119+ const dayDiff = now . getUTCDate ( ) - dateOfBirth . getUTCDate ( ) ;
120+ if ( monthDiff < 0 || ( monthDiff === 0 && dayDiff < 0 ) ) calculated -= 1 ;
121+ return Number . isFinite ( calculated ) ? calculated : null ;
122+ } ) ( ) ;
123+ if ( age === null ) {
124+ ctx . set . status = 400 ;
125+ return { error : 'invalid_date_of_birth' , message : 'dateOfBirth must be a valid date string in YYYY-MM-DD format.' } ;
126+ }
127+
128+ try {
129+ const data = await selfie . arrayBuffer ( ) ;
130+ const buffer = Buffer . from ( data ) ;
131+ const predictedAge = await estimateAgeFromSelfie ( buffer ) ;
132+ if ( predictedAge === null ) {
133+ ctx . set . status = 400 ;
134+ return { error : 'no_face_detected' , message : 'Could not detect a face in the selfie. Please try again.' } ;
135+ }
136+
137+ const effectiveCountry = typeof body . billingCountry === 'string' ? body . billingCountry : user . billingCountry ;
138+ const minimumAge = await getMinimumAgeForCountry ( effectiveCountry ) ;
139+ if ( age < minimumAge ) {
140+ await AppDataSource . getRepository ( User ) . save ( {
141+ id : user . id ,
142+ suspended : true ,
143+ fraudFlag : true ,
144+ fraudReason : `Underage account (<${ minimumAge } years)` ,
145+ fraudDetectedAt : new Date ( ) ,
146+ } ) ;
147+ ctx . set . status = 400 ;
148+ return { error : 'minimum_age' , message : `Users must be at least ${ minimumAge } years old.` } ;
149+ }
150+
151+ const maxDelta = 11 ;
152+ const difference = Math . abs ( predictedAge - age ) ;
153+ const remaining = Math . max ( 0 , 3 - attempts - 1 ) ;
154+
155+ if ( difference > maxDelta ) {
156+ settings . ageVerificationSelfieAttempts = attempts + 1 ;
157+ settings . ageVerificationSelfieLastAttemptAt = new Date ( ) . toISOString ( ) ;
158+ await AppDataSource . getRepository ( User ) . save ( { id : user . id , settings } ) ;
159+
160+ ctx . set . status = 400 ;
161+ return {
162+ error : 'age_mismatch' ,
163+ message : `Estimated age ${ predictedAge . toFixed ( 1 ) } does not match your DOB age ${ age } . Please ensure your face is clearly visible in the selfie and try again.` ,
164+ attempts : settings . ageVerificationSelfieAttempts ,
165+ remaining,
166+ } ;
167+ }
168+
169+ settings . ageVerificationSelfieAttempts = 0 ;
170+ settings . ageVerificationSelfieVerifiedAt = new Date ( ) . toISOString ( ) ;
171+
172+ const update : any = { id : user . id , settings } ;
173+ if ( ! user . dateOfBirth ) {
174+ update . dateOfBirth = dateOfBirth ;
175+ }
176+ await AppDataSource . getRepository ( User ) . save ( update ) ;
177+
178+ return {
179+ success : true ,
180+ age : predictedAge ,
181+ difference,
182+ maxError : maxDelta ,
183+ attempts : 0 ,
184+ remaining : 3 ,
185+ } ;
186+ } catch ( err : any ) {
187+ ctx . log . error ( { err : err ?. message || err } , 'Selfie age verification failed' ) ;
188+ ctx . set . status = 500 ;
189+ return {
190+ error : 'age_verification_failed' ,
191+ message : 'Failed to estimate age from the selfie. Please try again later.' ,
192+ details : String ( err ?. message || err || 'unknown error' ) ,
193+ } ;
194+ }
195+ } , { beforeHandle : authenticate ,
196+ body : t . Any ( ) ,
197+ response : {
198+ 200 : t . Object ( { success : t . Boolean ( ) , age : t . Number ( ) , difference : t . Number ( ) , maxError : t . Number ( ) , attempts : t . Number ( ) , remaining : t . Number ( ) } ) ,
199+ 400 : t . Object ( { error : t . String ( ) , message : t . String ( ) , details : t . Optional ( t . String ( ) ) } ) ,
200+ 401 : t . Object ( { error : t . String ( ) } ) ,
201+ 403 : t . Object ( { error : t . String ( ) , message : t . Optional ( t . String ( ) ) } ) ,
202+ } ,
203+ detail : { summary : 'Verify age from selfie' , description : 'Estimate a user age from selfie data and compare against the provided date of birth.' , tags : [ 'Identity' ] }
204+ } ) ;
205+
88206 app . get ( prefix + '/id-verification/:id' , async ( ctx : any ) => {
89207 const userId = Number ( ctx . params [ 'id' ] ) ;
90208 const requester = ctx . user ;
0 commit comments