1+ const mongoose = require ( 'mongoose' ) ;
12const { Project, Developer } = require ( '@urbackend/common/src/models' ) ;
23const { forwardToPythonService } = require ( '../utils/internalPythonClient' ) ;
34const { AppError, ApiResponse, getProjectAccessQuery, resolveEffectivePlan } = require ( '@urbackend/common' ) ;
@@ -13,7 +14,6 @@ const queryBuilder = async (req, res, next) => {
1314 const { projectId } = req . params ;
1415 const { collectionName, prompt } = req . body ;
1516
16- const mongoose = require ( 'mongoose' ) ;
1717 if ( ! mongoose . Types . ObjectId . isValid ( projectId ) ) {
1818 throw new AppError ( 400 , "Invalid project ID" ) ;
1919 }
@@ -36,11 +36,11 @@ const queryBuilder = async (req, res, next) => {
3636 throw new AppError ( 403 , "Cannot query the users collection via AI" ) ;
3737 }
3838
39- // 1. Load collection and project details
39+ // 1. Load collection and project details (including BYOK fields in a single query)
4040 const project = await Project . findOne ( {
4141 _id : projectId ,
4242 ...getProjectAccessQuery ( req . user . _id )
43- } ) ;
43+ } ) . select ( '+byok.groqKey.encrypted +byok.groqKey.iv +byok.groqKey.tag' ) ;
4444
4545 if ( ! project ) {
4646 throw new AppError ( 404 , "Project not found or access denied" ) ;
@@ -80,20 +80,20 @@ const queryBuilder = async (req, res, next) => {
8080 let resolvedKey = null ;
8181
8282 // 1. Check project-level BYOK
83- const projectByok = await Project . findOne ( {
84- _id : projectId ,
85- ...getProjectAccessQuery ( req . user . _id )
86- } )
87- . select ( '+byok.groqKey.encrypted +byok.groqKey.iv +byok.groqKey.tag' )
88- . lean ( ) ;
89- if ( projectByok ?. byok ?. groqKey ?. encrypted ) {
90- resolvedKey = decrypt ( projectByok . byok . groqKey ) ;
83+ if ( project . byok ?. groqKey ?. encrypted ) {
84+ resolvedKey = decrypt ( project . byok . groqKey ) ;
85+ if ( ! resolvedKey ) {
86+ console . warn ( `[QueryBuilder] Project-level BYOK key decryption failed for project ${ projectId } ` ) ;
87+ }
9188 }
9289
9390 // 2. Fallback to developer-level BYOK
9491 const dev = await Developer . findById ( req . user . _id ) . select ( '+byok' ) ;
9592 if ( ! resolvedKey && dev ?. byok ?. groqKey ?. encrypted ) {
9693 resolvedKey = decrypt ( dev . byok . groqKey ) ;
94+ if ( ! resolvedKey ) {
95+ console . warn ( `[QueryBuilder] Developer-level BYOK key decryption failed for developer ${ req . user . _id } ` ) ;
96+ }
9797 }
9898
9999 // 3. Encrypt for secure transit to Python
@@ -166,7 +166,33 @@ const ccSessionKey = (projectId, developerId) =>
166166const ccIterationsKey = ( projectId , developerId ) =>
167167 `ai:cc:iterations:${ projectId } :${ developerId } ` ;
168168
169+ const LUA_RESERVE_ITERATION = `
170+ local sessionExists = redis.call('EXISTS', KEYS[2])
171+ local current = redis.call('GET', KEYS[1])
172+ local count = tonumber(current) or 0
173+
174+ if sessionExists == 0 and count > 0 then
175+ count = 0
176+ redis.call('SET', KEYS[1], '0')
177+ end
178+
179+ if count >= tonumber(ARGV[1]) then
180+ return {0, count}
181+ end
182+
183+ local nextVal = redis.call('INCR', KEYS[1])
184+ redis.call('EXPIRE', KEYS[1], ARGV[2])
185+ if sessionExists == 1 then
186+ redis.call('EXPIRE', KEYS[2], ARGV[2])
187+ end
188+ return {1, nextVal}
189+ ` ;
190+
169191const collectionCreator = async ( req , res , next ) => {
192+ let reservedIteration = false ;
193+ let iterKey = null ;
194+ let hasByok = false ;
195+
170196 try {
171197 const { projectId } = req . params ;
172198 const { userMessage } = req . body ;
@@ -179,7 +205,6 @@ const collectionCreator = async (req, res, next) => {
179205 }
180206 const safeUserMessage = userMessage . trim ( ) ;
181207
182- const mongoose = require ( 'mongoose' ) ;
183208 if ( ! mongoose . Types . ObjectId . isValid ( projectId ) ) {
184209 throw new AppError ( 400 , "Invalid project ID" ) ;
185210 }
@@ -196,38 +221,54 @@ const collectionCreator = async (req, res, next) => {
196221
197222 // 2. Load Redis session
198223 const sessionKey = ccSessionKey ( projectId , req . user . _id ) ;
199- const iterKey = ccIterationsKey ( projectId , req . user . _id ) ;
200- const [ sessionStr , iterStr ] = await Promise . all ( [
201- redis . get ( sessionKey ) ,
202- redis . get ( iterKey )
203- ] ) ;
224+ iterKey = ccIterationsKey ( projectId , req . user . _id ) ;
225+ const sessionStr = await redis . get ( sessionKey ) ;
204226
205227 let session = { messages : [ ] , hasByok : false } ;
206228 try {
207229 if ( sessionStr ) session = JSON . parse ( sessionStr ) ;
208230 } catch ( e ) {
209231 console . warn ( "Invalid session data in Redis, resetting chat transcript" ) ;
210232 }
211- let iterations = iterStr ? parseInt ( iterStr , 10 ) : 0 ;
212233
213234 // 3. BYOK resolution
214235 let resolvedKey = null ;
215236 if ( project . byok ?. groqKey ?. encrypted ) {
216237 resolvedKey = decrypt ( project . byok . groqKey ) ;
238+ if ( ! resolvedKey ) {
239+ console . warn ( `[CollectionCreator] Project-level BYOK key decryption failed for project ${ projectId } ` ) ;
240+ }
217241 }
218242
219243 const dev = await Developer . findById ( req . user . _id ) . select ( '+byok +plan +trialEndsAt +subscriptionId' ) ;
220244 if ( ! resolvedKey && dev ?. byok ?. groqKey ?. encrypted ) {
221245 resolvedKey = decrypt ( dev . byok . groqKey ) ;
246+ if ( ! resolvedKey ) {
247+ console . warn ( `[CollectionCreator] Developer-level BYOK key decryption failed for developer ${ req . user . _id } ` ) ;
248+ }
222249 }
223250
224251 const encryptedByok = resolvedKey ? { groqKey : encryptForTransit ( resolvedKey ) } : null ;
225- const hasByok = ! ! encryptedByok ;
252+ hasByok = ! ! encryptedByok ;
226253
227- // 4. Iteration limit check
254+ // 4. Atomic iteration reservation before calling Python service
228255 const PLATFORM_ITERATION_LIMIT = 3 ;
229- if ( ! hasByok && iterations >= PLATFORM_ITERATION_LIMIT ) {
230- throw new AppError ( 403 , "AI iteration limit reached (3/3 on platform key). Add your Groq API key in Settings for unlimited usage." ) ;
256+ let iterations = 0 ;
257+
258+ if ( ! hasByok ) {
259+ const [ allowed , newCount ] = await redis . eval (
260+ LUA_RESERVE_ITERATION ,
261+ 2 ,
262+ iterKey ,
263+ sessionKey ,
264+ PLATFORM_ITERATION_LIMIT ,
265+ 7200
266+ ) ;
267+ if ( allowed === 0 ) {
268+ throw new AppError ( 403 , "AI iteration limit reached (3/3 on platform key). Add your Groq API key in Settings for unlimited usage." ) ;
269+ }
270+ reservedIteration = true ;
271+ iterations = newCount ;
231272 }
232273
233274 // 5. Update session
@@ -251,46 +292,67 @@ const collectionCreator = async (req, res, next) => {
251292 let safeSchema = null ;
252293
253294 if ( [ 'schema' , 'complete' ] . includes ( aiResponse . type ) && Array . isArray ( aiResponse . schema ) ) {
295+ const sanitizeFields = ( fields ) => {
296+ return ( fields || [ ] ) . filter ( f => f . name && allowedTypes . has ( f . type ) ) . map ( f => {
297+ const fieldDef = {
298+ name : f . name ,
299+ type : f . type ,
300+ required : ! ! f . required
301+ } ;
302+ if ( f . type === 'Ref' ) {
303+ fieldDef . ref = f . ref || 'users' ;
304+ } else if ( f . type === 'Array' ) {
305+ if ( f . items && typeof f . items === 'object' ) {
306+ const itemDef = { type : f . items . type || 'String' } ;
307+ if ( f . items . type === 'Ref' ) {
308+ itemDef . ref = f . items . ref || 'users' ;
309+ } else if ( f . items . type === 'Object' && Array . isArray ( f . items . fields ) && f . items . fields . length > 0 ) {
310+ itemDef . fields = sanitizeFields ( f . items . fields ) ;
311+ }
312+ fieldDef . items = itemDef ;
313+ } else {
314+ fieldDef . items = { type : typeof f . items === 'string' ? f . items : 'String' } ;
315+ }
316+ } else if ( f . type === 'Object' && Array . isArray ( f . fields ) && f . fields . length > 0 ) {
317+ fieldDef . fields = sanitizeFields ( f . fields ) ;
318+ }
319+ if ( f . unique !== undefined && ! [ 'Array' , 'Object' , 'Ref' ] . includes ( f . type ) ) {
320+ fieldDef . unique = ! ! f . unique ;
321+ }
322+ return fieldDef ;
323+ } ) ;
324+ } ;
325+
254326 safeSchema = aiResponse . schema
255327 . filter ( c => c . collection && c . collection . toLowerCase ( ) !== 'users' )
256- . map ( c => {
257- const sanitizedFields = ( c . fields || [ ] ) . filter ( f => f . name && allowedTypes . has ( f . type ) ) . map ( f => {
258- const fieldDef = {
259- name : f . name ,
260- type : f . type ,
261- required : ! ! f . required
262- } ;
263- if ( f . type === 'Ref' ) {
264- fieldDef . ref = f . ref || 'users' ;
265- } else if ( f . type === 'Array' ) {
266- fieldDef . items = f . items || { type : 'String' } ;
267- }
268- return fieldDef ;
269- } ) ;
270- return {
271- collection : c . collection ,
272- fields : sanitizedFields
273- } ;
274- } )
328+ . map ( c => ( {
329+ collection : c . collection ,
330+ fields : sanitizeFields ( c . fields )
331+ } ) )
275332 . filter ( c => c . fields . length > 0 ) ;
276333 }
277334
278- // 7. Save session
335+ // 7. Save session with TTL
279336 session . messages . push ( { role : "assistant" , content : aiResponse . message } ) ;
280- if ( ! hasByok ) {
281- iterations += 1 ;
282- await redis . set ( iterKey , iterations . toString ( ) ) ;
283- }
284337 await redis . set ( sessionKey , JSON . stringify ( session ) , 'EX' , 7200 ) ;
285338
286339 return new ApiResponse ( {
287340 type : aiResponse . type ,
288341 message : aiResponse . message ,
289342 schema : safeSchema ,
290- iterationsLeft : hasByok ? null : Math . max ( 0 , PLATFORM_ITERATION_LIMIT - iterations )
343+ iterationsLeft : hasByok ? null : Math . max ( 0 , PLATFORM_ITERATION_LIMIT - iterations ) ,
344+ iterationLimit : hasByok ? null : PLATFORM_ITERATION_LIMIT
291345 } , "Agent responded successfully" ) . send ( res ) ;
292346
293347 } catch ( error ) {
348+ // Rollback reserved iteration on error if platform key was used
349+ if ( ! hasByok && reservedIteration && iterKey ) {
350+ try {
351+ await redis . decr ( iterKey ) ;
352+ } catch ( revertErr ) {
353+ console . warn ( "[CollectionCreator] Failed to rollback reserved iteration counter" , revertErr ) ;
354+ }
355+ }
294356 if ( error instanceof AppError ) {
295357 return next ( error ) ;
296358 }
@@ -318,7 +380,10 @@ const collectionCreator = async (req, res, next) => {
318380const clearCollectionCreatorSession = async ( req , res , next ) => {
319381 try {
320382 const { projectId } = req . params ;
321- await redis . del ( ccSessionKey ( projectId , req . user . _id ) ) ;
383+ await Promise . all ( [
384+ redis . del ( ccSessionKey ( projectId , req . user . _id ) ) ,
385+ redis . del ( ccIterationsKey ( projectId , req . user . _id ) )
386+ ] ) ;
322387 return new ApiResponse ( { } , "Session cleared" ) . send ( res ) ;
323388 } catch ( err ) {
324389 console . error ( "clearCollectionCreatorSession error" , err ) ;
@@ -331,3 +396,4 @@ module.exports = {
331396 collectionCreator,
332397 clearCollectionCreatorSession
333398} ;
399+
0 commit comments