aap-agent-server 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/challenges.js CHANGED
@@ -1,11 +1,16 @@
1
1
  /**
2
- * @aap/server - Challenge Generator v2.5
2
+ * @aap/server - Challenge Generator v2.6
3
3
  *
4
4
  * "Burst Mode with Entropy Injection"
5
- * - 5 challenges in 8 seconds (humans cannot pass)
5
+ * - 7 challenges in 6 seconds (humans cannot pass)
6
6
  * - Salt injection prevents caching attacks
7
7
  * - Natural language instructions (requires LLM)
8
8
  *
9
+ * v2.6 Changes:
10
+ * - BATCH_SIZE: 5 → 7 (more problems = harder for humans)
11
+ * - MAX_RESPONSE_TIME_MS: 8000 → 6000 (tighter window)
12
+ * - Random chance reduced: (1/3)^7 = 0.05%
13
+ *
9
14
  * v2.5 Changes:
10
15
  * - BATCH_SIZE: 3 → 5
11
16
  * - MAX_RESPONSE_TIME_MS: 12000 → 8000
@@ -68,23 +73,32 @@ function generateSalt(nonce, offset = 0) {
68
73
  */
69
74
  export const CHALLENGE_TYPES = {
70
75
  /**
71
- * Extract entities from natural language sentence
76
+ * Extract entities from natural language sentence (HARD - more distractors)
72
77
  */
73
78
  nlp_extract: {
74
79
  generate: (nonce) => {
75
80
  const salt = generateSalt(nonce, 0);
76
81
  const category = ['animals', 'fruits', 'colors'][parseInt(nonce[0], 16) % 3];
77
82
  const pool = WORD_POOLS[category];
78
- const targets = seededSelect(pool, nonce, 2, 0);
83
+ const targets = seededSelect(pool, nonce, 3, 0); // 3 targets now
84
+ const distractorPool = category === 'animals' ? 'fruits' : category === 'fruits' ? 'colors' : 'animals';
85
+ const distractors = seededSelect(WORD_POOLS[distractorPool], nonce, 2, 8);
79
86
  const verb = seededSelect(WORD_POOLS.verbs, nonce, 1, 4)[0];
87
+ const adj = seededSelect(WORD_POOLS.adjectives, nonce, 1, 6)[0];
80
88
 
81
- const sentence = `The ${targets[0]} and ${targets[1]} ${verb} in the park.`;
89
+ // Complex sentence with distractors mixed in
90
+ const templates = [
91
+ `The ${adj} ${targets[0]}, a ${distractors[0]}, the ${targets[1]}, and ${targets[2]} all ${verb} near the ${distractors[1]}.`,
92
+ `I saw ${targets[0]} and ${distractors[0]} yesterday, but today only ${targets[1]}, ${targets[2]}, and a ${distractors[1]} appeared.`,
93
+ `Between the ${distractors[0]} and ${distractors[1]}, there were ${targets[0]}, ${targets[1]}, and ${targets[2]} ${verb}ing.`
94
+ ];
95
+ const sentence = templates[parseInt(nonce[12], 16) % templates.length];
82
96
 
83
97
  return {
84
- challenge_string: `[REQ-${salt}] Extract only the ${category} from the following sentence.
98
+ challenge_string: `[REQ-${salt}] Extract ONLY the ${category} from this sentence (ignore other categories).
85
99
  Sentence: "${sentence}"
86
- Response format: {"salt": "${salt}", "items": ["item1", "item2"]}`,
87
- expected: { salt, items: targets.sort() },
100
+ Response format: {"salt": "${salt}", "items": ["item1", "item2", "item3"]}`,
101
+ expected: { salt, items: targets.map(s => s.toLowerCase()).sort() },
88
102
  validate: (solution) => {
89
103
  try {
90
104
  const match = solution.match(/\{[\s\S]*\}/);
@@ -95,7 +109,8 @@ Response format: {"salt": "${salt}", "items": ["item1", "item2"]}`,
95
109
  if (obj.salt !== salt) return false;
96
110
 
97
111
  const items = (obj.items || obj.animals || obj.fruits || obj.colors || []).map(s => s.toLowerCase()).sort();
98
- return JSON.stringify(items) === JSON.stringify(targets.map(s => s.toLowerCase()).sort());
112
+ const expected = targets.map(s => s.toLowerCase()).sort();
113
+ return JSON.stringify(items) === JSON.stringify(expected);
99
114
  } catch { return false; }
100
115
  }
101
116
  };
@@ -103,31 +118,45 @@ Response format: {"salt": "${salt}", "items": ["item1", "item2"]}`,
103
118
  },
104
119
 
105
120
  /**
106
- * Math problem expressed in natural language
121
+ * Math problem expressed in natural language (EXTREME)
107
122
  */
108
123
  nlp_math: {
109
124
  generate: (nonce) => {
110
125
  const salt = generateSalt(nonce, 2);
111
- const a = seededNumber(nonce, 0, 10, 50);
112
- const b = seededNumber(nonce, 2, 5, 20);
113
- const c = seededNumber(nonce, 4, 2, 5);
126
+ const a = seededNumber(nonce, 0, 50, 200);
127
+ const b = seededNumber(nonce, 2, 10, 50);
128
+ const c = seededNumber(nonce, 4, 2, 9);
129
+ const d = seededNumber(nonce, 6, 5, 25);
130
+ const e = seededNumber(nonce, 8, 2, 6);
114
131
 
115
132
  const templates = [
116
133
  {
117
- text: `Subtract ${b} from ${a}, then multiply the result by ${c}.`,
118
- answer: (a - b) * c
134
+ text: `Start with ${a}. Subtract ${b}. Multiply that by ${c}. Divide the result by ${e}. Finally, add ${d}. What's the final value?`,
135
+ answer: (((a - b) * c) / e) + d
136
+ },
137
+ {
138
+ text: `Compute: ((${a} + ${b}) × ${c} - ${d}) ÷ ${e}. Give the result rounded to two decimal places.`,
139
+ answer: ((a + b) * c - d) / e
140
+ },
141
+ {
142
+ text: `Take the sum of ${a} and ${b}. Square root of that sum, rounded down. Then multiply by ${c} and subtract ${d}.`,
143
+ answer: Math.floor(Math.sqrt(a + b)) * c - d
119
144
  },
120
145
  {
121
- text: `Add ${a} and ${b} together, then divide by ${c}.`,
122
- answer: (a + b) / c
146
+ text: `${a} divided by ${c}, plus ${b} divided by ${e}, minus ${d}. Round to nearest integer.`,
147
+ answer: Math.round(a / c + b / e - d)
123
148
  },
124
149
  {
125
- text: `Divide ${a} by ${c}, then add ${b} to the result.`,
126
- answer: a / c + b
150
+ text: `Triple ${a}, halve that result, add ${b}, then take away ${d}. Multiply everything by ${e}. Final answer?`,
151
+ answer: ((a * 3 / 2) + b - d) * e
152
+ },
153
+ {
154
+ text: `What is ${a} mod ${c} (remainder), plus ${b} mod ${e}, times ${d}?`,
155
+ answer: ((a % c) + (b % e)) * d
127
156
  }
128
157
  ];
129
158
 
130
- const template = templates[parseInt(nonce[6], 16) % templates.length];
159
+ const template = templates[parseInt(nonce[10], 16) % templates.length];
131
160
  const expected = Math.round(template.answer * 100) / 100;
132
161
 
133
162
  return {
@@ -151,32 +180,53 @@ Response format: {"salt": "${salt}", "result": number}`,
151
180
  },
152
181
 
153
182
  /**
154
- * String transformation described in natural language
183
+ * String transformation described in natural language (EXTREME - multi-step transforms)
155
184
  */
156
185
  nlp_transform: {
157
186
  generate: (nonce) => {
158
187
  const salt = generateSalt(nonce, 4);
159
- const input = nonce.slice(8, 14);
160
- const transformType = parseInt(nonce[6], 16) % 4;
188
+ const input = nonce.slice(6, 16); // Longer input: 10 chars
189
+ const transformType = parseInt(nonce[16], 16) % 6;
161
190
 
162
191
  let instruction, expected;
163
192
 
164
193
  switch (transformType) {
165
194
  case 0:
166
- instruction = `Reverse the string "${input}" and convert it to uppercase.`;
167
- expected = input.split('').reverse().join('').toUpperCase();
195
+ // Reverse uppercase take first 5
196
+ expected = input.split('').reverse().join('').toUpperCase().slice(0, 5);
197
+ instruction = `Take "${input}": reverse it, convert to uppercase, then return only the first 5 characters.`;
168
198
  break;
169
199
  case 1:
170
- instruction = `Extract only the digits from "${input}" and calculate their sum.`;
171
- expected = input.split('').filter(c => /\d/.test(c)).reduce((a, b) => a + parseInt(b), 0);
200
+ // Extract digits sum return as string with prefix
201
+ const digitSum = input.split('').filter(c => /\d/.test(c)).reduce((a, b) => a + parseInt(b), 0);
202
+ expected = `SUM:${digitSum}`;
203
+ instruction = `From "${input}": extract all digits, sum them, and format as "SUM:X" where X is the total.`;
172
204
  break;
173
205
  case 2:
174
- instruction = `Extract only the letters from "${input}" and sort them alphabetically.`;
175
- expected = input.split('').filter(c => /[a-zA-Z]/.test(c)).sort().join('');
206
+ // Letters only sort reverse join with dots
207
+ expected = input.split('').filter(c => /[a-zA-Z]/.test(c)).sort().reverse().join('.');
208
+ instruction = `From "${input}": extract letters only, sort alphabetically, reverse that order, join with dots.`;
176
209
  break;
177
210
  case 3:
178
- instruction = `Insert a hyphen "-" between each character of "${input}".`;
179
- expected = input.split('').join('-');
211
+ // Alternate case: odd positions uppercase, even lowercase
212
+ expected = input.split('').map((c, i) => i % 2 === 0 ? c.toLowerCase() : c.toUpperCase()).join('');
213
+ instruction = `Transform "${input}": characters at even positions (0,2,4...) lowercase, odd positions (1,3,5...) uppercase.`;
214
+ break;
215
+ case 4:
216
+ // Count each char type
217
+ const letters = input.split('').filter(c => /[a-zA-Z]/.test(c)).length;
218
+ const digits = input.split('').filter(c => /\d/.test(c)).length;
219
+ expected = `L${letters}D${digits}`;
220
+ instruction = `Analyze "${input}": count letters and digits. Format answer as "LxDy" where x=letter count, y=digit count.`;
221
+ break;
222
+ case 5:
223
+ // Replace vowels with *, consonants with #, keep digits
224
+ expected = input.split('').map(c => {
225
+ if (/[aeiouAEIOU]/.test(c)) return '*';
226
+ if (/[a-zA-Z]/.test(c)) return '#';
227
+ return c;
228
+ }).join('');
229
+ instruction = `Transform "${input}": replace vowels with "*", consonants with "#", keep digits unchanged.`;
180
230
  break;
181
231
  }
182
232
 
@@ -201,31 +251,71 @@ Response format: {"salt": "${salt}", "output": "result"}`,
201
251
  },
202
252
 
203
253
  /**
204
- * Conditional logic
254
+ * Conditional logic (EXTREME - multi-layer nested conditions)
205
255
  */
206
256
  nlp_logic: {
207
257
  generate: (nonce) => {
208
258
  const salt = generateSalt(nonce, 6);
209
- const a = seededNumber(nonce, 0, 10, 100);
210
- const b = seededNumber(nonce, 2, 10, 100);
211
- const threshold = seededNumber(nonce, 4, 20, 80);
259
+ const a = seededNumber(nonce, 0, 20, 150);
260
+ const b = seededNumber(nonce, 2, 20, 150);
261
+ const c = seededNumber(nonce, 4, 20, 100);
262
+ const d = seededNumber(nonce, 6, 10, 50);
263
+ const threshold = seededNumber(nonce, 8, 40, 100);
212
264
 
213
265
  const templates = [
214
266
  {
215
- text: `If the larger number between ${a} and ${b} is greater than ${threshold}, answer "YES". Otherwise, answer "NO".`,
216
- answer: Math.max(a, b) > threshold ? "YES" : "NO"
267
+ text: `Let X=${a}, Y=${b}, Z=${c}, W=${d}. If (X > Y AND Z > W) OR (X < Y AND Z < W), answer "CONSISTENT". If (X > Y AND Z < W) OR (X < Y AND Z > W), answer "CROSSED". Otherwise, answer "EQUAL".`,
268
+ answer: ((a > b && c > d) || (a < b && c < d)) ? "CONSISTENT" : ((a > b && c < d) || (a < b && c > d)) ? "CROSSED" : "EQUAL"
269
+ },
270
+ {
271
+ text: `Given four numbers [${a}, ${b}, ${c}, ${d}]: Count how many are divisible by 3. If count is 0, say "NONE". If 1-2, say "FEW". If 3-4, say "MANY".`,
272
+ answer: (() => {
273
+ const count = [a, b, c, d].filter(n => n % 3 === 0).length;
274
+ return count === 0 ? "NONE" : count <= 2 ? "FEW" : "MANY";
275
+ })()
276
+ },
277
+ {
278
+ text: `Evaluate: Is (${a} + ${b}) greater than (${c} + ${d})? AND is (${a} × ${d}) less than (${b} × ${c})? If BOTH true: "ALPHA". If NEITHER true: "GAMMA". Otherwise: "BETA".`,
279
+ answer: (() => {
280
+ const cond1 = (a + b) > (c + d);
281
+ const cond2 = (a * d) < (b * c);
282
+ return (cond1 && cond2) ? "ALPHA" : (!cond1 && !cond2) ? "GAMMA" : "BETA";
283
+ })()
284
+ },
285
+ {
286
+ text: `Numbers: ${a}, ${b}, ${c}, ${d}. First, find the median (average of middle two when sorted). If median > ${threshold}, output "HIGH". If median < ${threshold / 2}, output "LOW". Otherwise, output "MID".`,
287
+ answer: (() => {
288
+ const sorted = [a, b, c, d].sort((x, y) => x - y);
289
+ const median = (sorted[1] + sorted[2]) / 2;
290
+ return median > threshold ? "HIGH" : median < (threshold / 2) ? "LOW" : "MID";
291
+ })()
217
292
  },
218
293
  {
219
- text: `If the sum of ${a} and ${b} is less than ${threshold * 2}, answer "SMALL". Otherwise, answer "LARGE".`,
220
- answer: (a + b) < (threshold * 2) ? "SMALL" : "LARGE"
294
+ text: `Check these conditions for [${a}, ${b}, ${c}, ${d}]: (1) Sum > ${threshold * 3}? (2) Product of smallest two < ${threshold * 10}? (3) Largest is even? Count TRUE conditions. Answer with the count (0-3).`,
295
+ answer: (() => {
296
+ const sorted = [a, b, c, d].sort((x, y) => x - y);
297
+ let count = 0;
298
+ if (a + b + c + d > threshold * 3) count++;
299
+ if (sorted[0] * sorted[1] < threshold * 10) count++;
300
+ if (sorted[3] % 2 === 0) count++;
301
+ return String(count);
302
+ })()
221
303
  },
222
304
  {
223
- text: `If ${a} is even and ${b} is odd, answer "MIXED". Otherwise, answer "SAME".`,
224
- answer: (a % 2 === 0 && b % 2 === 1) ? "MIXED" : "SAME"
305
+ text: `If ${a} AND ${b} are both prime, answer "TWIN". If exactly one is prime, answer "SOLO". If neither is prime, answer "NONE". (Hint: primes are only divisible by 1 and themselves)`,
306
+ answer: (() => {
307
+ const isPrime = n => {
308
+ if (n < 2) return false;
309
+ for (let i = 2; i <= Math.sqrt(n); i++) if (n % i === 0) return false;
310
+ return true;
311
+ };
312
+ const ap = isPrime(a), bp = isPrime(b);
313
+ return (ap && bp) ? "TWIN" : (ap || bp) ? "SOLO" : "NONE";
314
+ })()
225
315
  }
226
316
  ];
227
317
 
228
- const template = templates[parseInt(nonce[6], 16) % templates.length];
318
+ const template = templates[parseInt(nonce[10], 16) % templates.length];
229
319
 
230
320
  return {
231
321
  challenge_string: `[REQ-${salt}] ${template.text}
@@ -247,30 +337,47 @@ Response format: {"salt": "${salt}", "answer": "your answer"}`,
247
337
  },
248
338
 
249
339
  /**
250
- * Counting task
340
+ * Counting task (EXTREME - multiple categories, complex sentences)
251
341
  */
252
342
  nlp_count: {
253
343
  generate: (nonce) => {
254
344
  const salt = generateSalt(nonce, 8);
255
- const category = ['animals', 'fruits', 'colors'][parseInt(nonce[0], 16) % 3];
256
- const pool = WORD_POOLS[category];
257
- const count1 = seededNumber(nonce, 0, 2, 4);
258
- const count2 = seededNumber(nonce, 2, 1, 3);
345
+ const targetCategory = ['animals', 'fruits', 'colors'][parseInt(nonce[0], 16) % 3];
346
+ const pool = WORD_POOLS[targetCategory];
347
+ const targetCount = seededNumber(nonce, 0, 3, 6);
348
+
349
+ // Add distractors from OTHER categories
350
+ const distractor1Cat = targetCategory === 'animals' ? 'fruits' : 'animals';
351
+ const distractor2Cat = targetCategory === 'colors' ? 'fruits' : 'colors';
352
+ const distractorCount1 = seededNumber(nonce, 2, 2, 4);
353
+ const distractorCount2 = seededNumber(nonce, 4, 2, 3);
354
+
355
+ const targets = seededSelect(pool, nonce, targetCount, 0);
356
+ const distractors1 = seededSelect(WORD_POOLS[distractor1Cat], nonce, distractorCount1, 6);
357
+ const distractors2 = seededSelect(WORD_POOLS[distractor2Cat], nonce, distractorCount2, 10);
358
+ const countryDistractors = seededSelect(WORD_POOLS.countries, nonce, 2, 14);
259
359
 
260
- const items1 = seededSelect(pool, nonce, count1, 0);
261
- const items2 = seededSelect(WORD_POOLS.countries, nonce, count2, 8);
360
+ // Mix everything together
361
+ const allItems = [...targets, ...distractors1, ...distractors2, ...countryDistractors];
362
+ // Shuffle using nonce
363
+ for (let i = allItems.length - 1; i > 0; i--) {
364
+ const j = parseInt(nonce.slice(i % 28, (i % 28) + 2), 16) % (i + 1);
365
+ [allItems[i], allItems[j]] = [allItems[j], allItems[i]];
366
+ }
262
367
 
263
- // Create sentence with mixed items
264
- const allItems = [...items1, ...items2].sort(() =>
265
- parseInt(nonce.slice(10, 12), 16) % 2 - 0.5
266
- );
267
- const sentence = `I see ${allItems.join(', ')} in the picture.`;
368
+ const templates = [
369
+ `At the market, I noticed: ${allItems.join(', ')}. Quite a mix!`,
370
+ `The list contains: ${allItems.join(', ')}. Some things don't belong.`,
371
+ `Inventory check: ${allItems.join(', ')}. Sort by category mentally.`,
372
+ `Mixed bag: ${allItems.join(', ')}. Focus on what matters.`
373
+ ];
374
+ const sentence = templates[parseInt(nonce[20], 16) % templates.length];
268
375
 
269
376
  return {
270
- challenge_string: `[REQ-${salt}] Count only the ${category} in the following sentence.
271
- Sentence: "${sentence}"
377
+ challenge_string: `[REQ-${salt}] Count ONLY the ${targetCategory} in this text. Ignore all other categories (other nouns are distractors).
378
+ Text: "${sentence}"
272
379
  Response format: {"salt": "${salt}", "count": number}`,
273
- expected: { salt, count: count1 },
380
+ expected: { salt, count: targetCount },
274
381
  validate: (solution) => {
275
382
  try {
276
383
  const match = solution.match(/\{[\s\S]*\}/);
@@ -279,7 +386,7 @@ Response format: {"salt": "${salt}", "count": number}`,
279
386
 
280
387
  if (obj.salt !== salt) return false;
281
388
 
282
- return parseInt(obj.count) === count1;
389
+ return parseInt(obj.count) === targetCount;
283
390
  } catch { return false; }
284
391
  }
285
392
  };
@@ -287,32 +394,85 @@ Response format: {"salt": "${salt}", "count": number}`,
287
394
  },
288
395
 
289
396
  /**
290
- * Multi-step instruction following
397
+ * Multi-step instruction following (EXTREME - 5-6 steps)
291
398
  */
292
399
  nlp_multistep: {
293
400
  generate: (nonce) => {
294
401
  const salt = generateSalt(nonce, 10);
295
402
  const numbers = [
296
- seededNumber(nonce, 0, 1, 9),
297
- seededNumber(nonce, 2, 1, 9),
298
- seededNumber(nonce, 4, 1, 9),
299
- seededNumber(nonce, 6, 1, 9)
403
+ seededNumber(nonce, 0, 5, 30),
404
+ seededNumber(nonce, 2, 5, 30),
405
+ seededNumber(nonce, 4, 5, 30),
406
+ seededNumber(nonce, 6, 5, 30),
407
+ seededNumber(nonce, 8, 5, 30),
408
+ seededNumber(nonce, 10, 5, 30)
300
409
  ];
301
410
 
302
- // Step 1: Sum all
303
- const sum = numbers.reduce((a, b) => a + b, 0);
304
- // Step 2: Multiply by smallest
305
- const min = Math.min(...numbers);
306
- const step2 = sum * min;
307
- // Step 3: Subtract largest
308
- const max = Math.max(...numbers);
309
- const final = step2 - max;
411
+ const templateType = parseInt(nonce[12], 16) % 4;
412
+ let instructions, final;
413
+
414
+ if (templateType === 0) {
415
+ // Complex: filter transform → aggregate → adjust
416
+ const evens = numbers.filter(n => n % 2 === 0);
417
+ const odds = numbers.filter(n => n % 2 !== 0);
418
+ const evensProduct = evens.length > 0 ? evens.reduce((a, b) => a * b, 1) : 0;
419
+ const oddsSum = odds.reduce((a, b) => a + b, 0);
420
+ const diff = Math.abs(evensProduct - oddsSum);
421
+ final = diff % 100; // Keep manageable
422
+ instructions = `1. From [${numbers.join(', ')}], separate even and odd numbers.
423
+ 2. Calculate the PRODUCT of all even numbers (or 0 if none).
424
+ 3. Calculate the SUM of all odd numbers.
425
+ 4. Find the absolute difference between these two results.
426
+ 5. Take that difference modulo 100 (remainder when divided by 100).`;
427
+ } else if (templateType === 1) {
428
+ // Sort → pair operations → combine
429
+ const sorted = [...numbers].sort((a, b) => a - b);
430
+ const pair1 = sorted[0] * sorted[5]; // smallest × largest
431
+ const pair2 = sorted[1] + sorted[4]; // 2nd smallest + 2nd largest
432
+ const pair3 = sorted[2] - sorted[3]; // middle pair difference (might be negative)
433
+ final = pair1 + pair2 + Math.abs(pair3);
434
+ instructions = `1. Sort [${numbers.join(', ')}] from smallest to largest.
435
+ 2. Multiply the smallest by the largest.
436
+ 3. Add the second-smallest to the second-largest.
437
+ 4. Find absolute difference between the two middle numbers.
438
+ 5. Sum all three results from steps 2, 3, and 4.`;
439
+ } else if (templateType === 2) {
440
+ // Chunked processing
441
+ const chunk1 = numbers.slice(0, 3);
442
+ const chunk2 = numbers.slice(3, 6);
443
+ const avg1 = chunk1.reduce((a, b) => a + b, 0) / 3;
444
+ const avg2 = chunk2.reduce((a, b) => a + b, 0) / 3;
445
+ const max1 = Math.max(...chunk1);
446
+ const max2 = Math.max(...chunk2);
447
+ final = Math.round((avg1 + avg2) * (max1 > max2 ? 2 : 1));
448
+ instructions = `1. Split [${numbers.join(', ')}] into two groups: first 3 and last 3.
449
+ 2. Calculate average of first group: [${chunk1.join(', ')}].
450
+ 3. Calculate average of second group: [${chunk2.join(', ')}].
451
+ 4. Add both averages together.
452
+ 5. If max of first group > max of second group, double the sum. Otherwise keep as is.
453
+ 6. Round to nearest integer.`;
454
+ } else {
455
+ // Recursive-style
456
+ let val = numbers[0];
457
+ for (let i = 1; i < numbers.length; i++) {
458
+ if (i % 2 === 1) val += numbers[i];
459
+ else val -= numbers[i];
460
+ }
461
+ val = Math.abs(val);
462
+ final = val * (numbers.length);
463
+ instructions = `1. Start with the first number from [${numbers.join(', ')}].
464
+ 2. Add the 2nd number.
465
+ 3. Subtract the 3rd number.
466
+ 4. Add the 4th number.
467
+ 5. Subtract the 5th number.
468
+ 6. Add the 6th number.
469
+ 7. Take absolute value of result.
470
+ 8. Multiply by 6 (the count of numbers).`;
471
+ }
310
472
 
311
473
  return {
312
- challenge_string: `[REQ-${salt}] Follow these instructions in order:
313
- 1. Add all the numbers in [${numbers.join(', ')}] together.
314
- 2. Multiply the result by the smallest number.
315
- 3. Subtract the largest number from that result.
474
+ challenge_string: `[REQ-${salt}] Execute these steps IN ORDER:
475
+ ${instructions}
316
476
  Response format: {"salt": "${salt}", "result": final_value}`,
317
477
  expected: { salt, result: final },
318
478
  validate: (solution) => {
@@ -434,8 +594,8 @@ Response format: {"salt": "${salt}", "answer": "word"}`,
434
594
  /**
435
595
  * Batch challenge settings (v2.5 - Burst Mode)
436
596
  */
437
- export const BATCH_SIZE = 5; // 5 challenges per batch (was 3)
438
- export const MAX_RESPONSE_TIME_MS = 8000; // 8 seconds total (was 12)
597
+ export const BATCH_SIZE = 7; // 7 challenges per batch (v2.6: was 5)
598
+ export const MAX_RESPONSE_TIME_MS = 6000; // 6 seconds total (v2.6: was 8)
439
599
  export const CHALLENGE_EXPIRY_MS = 60000; // 60 seconds
440
600
 
441
601
  /**
package/index.js CHANGED
@@ -1,20 +1,41 @@
1
1
  /**
2
- * @aap/server
2
+ * @aap/server v2.5.0
3
3
  *
4
4
  * Server-side utilities for Agent Attestation Protocol.
5
- * Provides middleware and challenge generation for verification servers.
5
+ * The Reverse Turing Test - CAPTCHAs block bots, AAP blocks humans.
6
6
  */
7
7
 
8
8
  export * from './middleware.js';
9
9
  export * from './challenges.js';
10
+ export * from './ratelimit.js';
11
+ export * from './whitelist.js';
12
+ export * from './persistence.js';
13
+ export * from './errors.js';
14
+ export * as logger from './logger.js';
10
15
 
11
16
  import { aapMiddleware, createRouter } from './middleware.js';
12
17
  import challenges from './challenges.js';
18
+ import { createRateLimiter, createFailureLimiter } from './ratelimit.js';
19
+ import { createWhitelist, createKeyRotation } from './whitelist.js';
20
+ import { createStore, createMemoryStore, createFileStore, createRedisStore } from './persistence.js';
13
21
 
14
22
  export { challenges };
15
23
 
16
24
  export default {
25
+ // Core
17
26
  aapMiddleware,
18
27
  createRouter,
19
- challenges
28
+ challenges,
29
+
30
+ // Security
31
+ createRateLimiter,
32
+ createFailureLimiter,
33
+ createWhitelist,
34
+ createKeyRotation,
35
+
36
+ // Persistence
37
+ createStore,
38
+ createMemoryStore,
39
+ createFileStore,
40
+ createRedisStore
20
41
  };
package/middleware.js CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  MAX_RESPONSE_TIME_MS,
16
16
  CHALLENGE_EXPIRY_MS
17
17
  } from './challenges.js';
18
+ import * as logger from './logger.js';
19
+ import { ErrorCodes, sendError } from './errors.js';
18
20
 
19
21
  /**
20
22
  * Create AAP verification middleware/router
@@ -38,7 +40,8 @@ export function aapMiddleware(options = {}) {
38
40
  onFailed
39
41
  } = options;
40
42
 
41
- // In-memory challenge store
43
+ // In-memory challenge store with size limit (DoS protection)
44
+ const MAX_CHALLENGES = 10000;
42
45
  const challenges = new Map();
43
46
 
44
47
  // Cleanup expired challenges periodically
@@ -49,6 +52,15 @@ export function aapMiddleware(options = {}) {
49
52
  challenges.delete(nonce);
50
53
  }
51
54
  }
55
+
56
+ // Emergency cleanup if still too many (keep newest)
57
+ if (challenges.size > MAX_CHALLENGES) {
58
+ const entries = [...challenges.entries()]
59
+ .sort((a, b) => b[1].timestamp - a[1].timestamp)
60
+ .slice(0, MAX_CHALLENGES / 2);
61
+ challenges.clear();
62
+ entries.forEach(([k, v]) => challenges.set(k, v));
63
+ }
52
64
  };
53
65
 
54
66
  // Return a function that creates routes
@@ -121,6 +133,7 @@ export function aapMiddleware(options = {}) {
121
133
  } = req.body;
122
134
 
123
135
  const checks = {
136
+ inputValid: false,
124
137
  challengeExists: false,
125
138
  notExpired: false,
126
139
  solutionsExist: false,
@@ -130,7 +143,28 @@ export function aapMiddleware(options = {}) {
130
143
  };
131
144
 
132
145
  try {
133
- // Check 1: Challenge exists
146
+ // Check 0: Input validation (security)
147
+ if (!nonce || typeof nonce !== 'string' || nonce.length !== 32) {
148
+ return res.status(400).json({ verified: false, error: 'Invalid nonce format', checks });
149
+ }
150
+ if (!publicId || typeof publicId !== 'string' || publicId.length !== 20) {
151
+ return res.status(400).json({ verified: false, error: 'Invalid publicId format', checks });
152
+ }
153
+ if (!signature || typeof signature !== 'string' || signature.length < 50) {
154
+ return res.status(400).json({ verified: false, error: 'Invalid signature format', checks });
155
+ }
156
+ if (!publicKey || typeof publicKey !== 'string' || !publicKey.includes('BEGIN PUBLIC KEY')) {
157
+ return res.status(400).json({ verified: false, error: 'Invalid publicKey format', checks });
158
+ }
159
+ if (!timestamp || typeof timestamp !== 'number') {
160
+ return res.status(400).json({ verified: false, error: 'Invalid timestamp', checks });
161
+ }
162
+ if (!responseTimeMs || typeof responseTimeMs !== 'number' || responseTimeMs < 0) {
163
+ return res.status(400).json({ verified: false, error: 'Invalid responseTimeMs', checks });
164
+ }
165
+ checks.inputValid = true;
166
+
167
+ // Check 1: Challenge exists (check BEFORE delete for race condition fix)
134
168
  const challenge = challenges.get(nonce);
135
169
  if (!challenge) {
136
170
  if (onFailed) onFailed({ error: 'Challenge not found', checks }, req);
@@ -142,12 +176,9 @@ export function aapMiddleware(options = {}) {
142
176
  }
143
177
  checks.challengeExists = true;
144
178
 
145
- // Remove challenge (one-time use)
146
- const { validators, batchSize: size } = challenge;
147
- challenges.delete(nonce);
148
-
149
- // Check 2: Not expired
179
+ // Check 2: Not expired (check BEFORE delete - race condition fix)
150
180
  if (Date.now() > challenge.expiresAt) {
181
+ challenges.delete(nonce); // Clean up expired
151
182
  if (onFailed) onFailed({ error: 'Challenge expired', checks }, req);
152
183
  return res.status(400).json({
153
184
  verified: false,
@@ -157,6 +188,10 @@ export function aapMiddleware(options = {}) {
157
188
  }
158
189
  checks.notExpired = true;
159
190
 
191
+ // Remove challenge (one-time use) - only after expiry check
192
+ const { validators, batchSize: size } = challenge;
193
+ challenges.delete(nonce);
194
+
160
195
  // Check 3: Solutions exist
161
196
  if (!solutions || !Array.isArray(solutions) || solutions.length !== size) {
162
197
  if (onFailed) onFailed({ error: 'Invalid solutions array', checks }, req);
@@ -183,13 +218,17 @@ export function aapMiddleware(options = {}) {
183
218
  }
184
219
  checks.solutionsValid = true;
185
220
 
186
- // Check 5: Response time (Proof of Liveness)
187
- if (responseTimeMs > maxResponseTimeMs) {
221
+ // Check 5: Response time (Proof of Liveness) - SERVER-SIDE validation
222
+ const serverResponseTime = Date.now() - challenge.timestamp;
223
+ const effectiveResponseTime = Math.max(responseTimeMs, serverResponseTime);
224
+
225
+ if (effectiveResponseTime > maxResponseTimeMs) {
188
226
  if (onFailed) onFailed({ error: 'Response too slow', checks }, req);
189
227
  return res.status(400).json({
190
228
  verified: false,
191
- error: `Response too slow: ${responseTimeMs}ms > ${maxResponseTimeMs}ms (Proof of Liveness failed)`,
192
- checks
229
+ error: `Response too slow: ${effectiveResponseTime}ms > ${maxResponseTimeMs}ms (Proof of Liveness failed)`,
230
+ checks,
231
+ timing: { client: responseTimeMs, server: serverResponseTime }
193
232
  });
194
233
  }
195
234
  checks.responseTimeValid = true;
@@ -361,8 +400,14 @@ export function aapMiddleware(options = {}) {
361
400
  * const app = express();
362
401
  * app.use('/aap/v1', createRouter());
363
402
  */
364
- export function createRouter(options = {}) {
365
- const express = require('express');
403
+ export async function createRouter(options = {}) {
404
+ // Dynamic import for optional express dependency
405
+ let express;
406
+ try {
407
+ express = (await import('express')).default;
408
+ } catch {
409
+ throw new Error('express is required for createRouter. Install with: npm install express');
410
+ }
366
411
  const router = express.Router();
367
412
  router.use(express.json());
368
413
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aap-agent-server",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Server middleware for Agent Attestation Protocol - verify AI agents",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "homepage": "https://github.com/ira-hash/agent-attestation-protocol#readme",
23
23
  "dependencies": {
24
- "aap-agent-core": "^2.5.0"
24
+ "aap-agent-core": "^2.6.0"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "express": "^4.18.0 || ^5.0.0"
package/persistence.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * AAP Challenge Persistence
3
+ *
4
+ * Optional: Persist challenges to survive server restarts
5
+ * Supports: Memory (default), File, Redis
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+
11
+ /**
12
+ * Create in-memory store (default, no persistence)
13
+ */
14
+ export function createMemoryStore() {
15
+ const challenges = new Map();
16
+
17
+ return {
18
+ type: 'memory',
19
+
20
+ async get(nonce) {
21
+ return challenges.get(nonce) || null;
22
+ },
23
+
24
+ async set(nonce, data) {
25
+ challenges.set(nonce, data);
26
+ },
27
+
28
+ async delete(nonce) {
29
+ challenges.delete(nonce);
30
+ },
31
+
32
+ async has(nonce) {
33
+ return challenges.has(nonce);
34
+ },
35
+
36
+ async size() {
37
+ return challenges.size;
38
+ },
39
+
40
+ async keys() {
41
+ return [...challenges.keys()];
42
+ },
43
+
44
+ async clear() {
45
+ challenges.clear();
46
+ },
47
+
48
+ async cleanup(now = Date.now()) {
49
+ for (const [nonce, data] of challenges.entries()) {
50
+ if (now > data.expiresAt) {
51
+ challenges.delete(nonce);
52
+ }
53
+ }
54
+ }
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Create file-based store (survives restarts)
60
+ * @param {string} filePath - Path to store file
61
+ */
62
+ export function createFileStore(filePath = '.aap/challenges.json') {
63
+ const fullPath = join(process.cwd(), filePath);
64
+ let challenges = new Map();
65
+
66
+ // Load existing data
67
+ try {
68
+ if (existsSync(fullPath)) {
69
+ const data = JSON.parse(readFileSync(fullPath, 'utf8'));
70
+ challenges = new Map(Object.entries(data));
71
+ console.log(`[AAP] Loaded ${challenges.size} challenges from ${fullPath}`);
72
+ }
73
+ } catch (error) {
74
+ console.warn('[AAP] Could not load challenges:', error.message);
75
+ }
76
+
77
+ // Save to file
78
+ const save = () => {
79
+ try {
80
+ const dir = dirname(fullPath);
81
+ if (!existsSync(dir)) {
82
+ mkdirSync(dir, { recursive: true });
83
+ }
84
+
85
+ const data = Object.fromEntries(challenges);
86
+ // Remove validator functions (not serializable)
87
+ for (const key of Object.keys(data)) {
88
+ delete data[key].validators;
89
+ }
90
+
91
+ writeFileSync(fullPath, JSON.stringify(data, null, 2));
92
+ } catch (error) {
93
+ console.error('[AAP] Could not save challenges:', error.message);
94
+ }
95
+ };
96
+
97
+ // Auto-save periodically
98
+ const saveInterval = setInterval(save, 30000);
99
+
100
+ return {
101
+ type: 'file',
102
+
103
+ async get(nonce) {
104
+ return challenges.get(nonce) || null;
105
+ },
106
+
107
+ async set(nonce, data) {
108
+ challenges.set(nonce, data);
109
+ save();
110
+ },
111
+
112
+ async delete(nonce) {
113
+ challenges.delete(nonce);
114
+ save();
115
+ },
116
+
117
+ async has(nonce) {
118
+ return challenges.has(nonce);
119
+ },
120
+
121
+ async size() {
122
+ return challenges.size;
123
+ },
124
+
125
+ async keys() {
126
+ return [...challenges.keys()];
127
+ },
128
+
129
+ async clear() {
130
+ challenges.clear();
131
+ save();
132
+ },
133
+
134
+ async cleanup(now = Date.now()) {
135
+ let cleaned = 0;
136
+ for (const [nonce, data] of challenges.entries()) {
137
+ if (now > data.expiresAt) {
138
+ challenges.delete(nonce);
139
+ cleaned++;
140
+ }
141
+ }
142
+ if (cleaned > 0) save();
143
+ return cleaned;
144
+ },
145
+
146
+ close() {
147
+ clearInterval(saveInterval);
148
+ save();
149
+ }
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Create Redis-based store (for distributed deployments)
155
+ * @param {Object} redisClient - Redis client instance (ioredis or redis)
156
+ * @param {string} [prefix='aap:challenge:'] - Key prefix
157
+ */
158
+ export function createRedisStore(redisClient, prefix = 'aap:challenge:') {
159
+ return {
160
+ type: 'redis',
161
+
162
+ async get(nonce) {
163
+ const data = await redisClient.get(prefix + nonce);
164
+ return data ? JSON.parse(data) : null;
165
+ },
166
+
167
+ async set(nonce, data, ttlMs = 60000) {
168
+ // Store without validators (not serializable)
169
+ const { validators, ...storable } = data;
170
+ await redisClient.set(
171
+ prefix + nonce,
172
+ JSON.stringify(storable),
173
+ 'PX',
174
+ ttlMs
175
+ );
176
+ },
177
+
178
+ async delete(nonce) {
179
+ await redisClient.del(prefix + nonce);
180
+ },
181
+
182
+ async has(nonce) {
183
+ return (await redisClient.exists(prefix + nonce)) === 1;
184
+ },
185
+
186
+ async size() {
187
+ const keys = await redisClient.keys(prefix + '*');
188
+ return keys.length;
189
+ },
190
+
191
+ async keys() {
192
+ const keys = await redisClient.keys(prefix + '*');
193
+ return keys.map(k => k.slice(prefix.length));
194
+ },
195
+
196
+ async clear() {
197
+ const keys = await redisClient.keys(prefix + '*');
198
+ if (keys.length > 0) {
199
+ await redisClient.del(...keys);
200
+ }
201
+ },
202
+
203
+ async cleanup() {
204
+ // Redis handles TTL automatically
205
+ return 0;
206
+ }
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Auto-detect and create appropriate store
212
+ * @param {Object} options
213
+ * @param {'memory'|'file'|'redis'} [options.type='memory']
214
+ * @param {string} [options.filePath]
215
+ * @param {Object} [options.redisClient]
216
+ */
217
+ export function createStore(options = {}) {
218
+ const { type = 'memory', filePath, redisClient } = options;
219
+
220
+ switch (type) {
221
+ case 'file':
222
+ return createFileStore(filePath);
223
+ case 'redis':
224
+ if (!redisClient) {
225
+ throw new Error('Redis client required for redis store');
226
+ }
227
+ return createRedisStore(redisClient);
228
+ default:
229
+ return createMemoryStore();
230
+ }
231
+ }
232
+
233
+ export default {
234
+ createMemoryStore,
235
+ createFileStore,
236
+ createRedisStore,
237
+ createStore
238
+ };
package/ratelimit.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * AAP Rate Limiter
3
+ *
4
+ * Simple in-memory rate limiting (no external dependencies)
5
+ */
6
+
7
+ /**
8
+ * Create a rate limiter
9
+ * @param {Object} options
10
+ * @param {number} [options.windowMs=60000] - Time window in ms
11
+ * @param {number} [options.max=10] - Max requests per window
12
+ * @param {string} [options.message] - Error message
13
+ * @returns {Function} Express middleware
14
+ */
15
+ export function createRateLimiter(options = {}) {
16
+ const {
17
+ windowMs = 60000,
18
+ max = 10,
19
+ message = 'Too many requests, please try again later'
20
+ } = options;
21
+
22
+ const requests = new Map();
23
+
24
+ // Cleanup old entries periodically
25
+ setInterval(() => {
26
+ const now = Date.now();
27
+ for (const [key, data] of requests.entries()) {
28
+ if (now - data.firstRequest > windowMs) {
29
+ requests.delete(key);
30
+ }
31
+ }
32
+ }, windowMs);
33
+
34
+ return (req, res, next) => {
35
+ const key = req.ip || req.connection.remoteAddress || 'unknown';
36
+ const now = Date.now();
37
+
38
+ let data = requests.get(key);
39
+
40
+ if (!data || now - data.firstRequest > windowMs) {
41
+ // New window
42
+ data = { count: 1, firstRequest: now };
43
+ requests.set(key, data);
44
+ } else {
45
+ data.count++;
46
+ }
47
+
48
+ // Set headers
49
+ const remaining = Math.max(0, max - data.count);
50
+ const resetTime = Math.ceil((data.firstRequest + windowMs) / 1000);
51
+
52
+ res.setHeader('X-RateLimit-Limit', max);
53
+ res.setHeader('X-RateLimit-Remaining', remaining);
54
+ res.setHeader('X-RateLimit-Reset', resetTime);
55
+
56
+ if (data.count > max) {
57
+ res.setHeader('Retry-After', Math.ceil((data.firstRequest + windowMs - now) / 1000));
58
+ return res.status(429).json({
59
+ error: message,
60
+ retryAfter: Math.ceil((data.firstRequest + windowMs - now) / 1000)
61
+ });
62
+ }
63
+
64
+ next();
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Create rate limiter for failed attempts
70
+ * Stricter limits after failures
71
+ */
72
+ export function createFailureLimiter(options = {}) {
73
+ const {
74
+ windowMs = 60000,
75
+ maxFailures = 5,
76
+ message = 'Too many failed attempts'
77
+ } = options;
78
+
79
+ const failures = new Map();
80
+
81
+ return {
82
+ middleware: (req, res, next) => {
83
+ const key = req.ip || req.connection.remoteAddress || 'unknown';
84
+ const data = failures.get(key);
85
+
86
+ if (data && data.count >= maxFailures && Date.now() - data.firstFailure < windowMs) {
87
+ return res.status(429).json({
88
+ error: message,
89
+ retryAfter: Math.ceil((data.firstFailure + windowMs - Date.now()) / 1000)
90
+ });
91
+ }
92
+
93
+ next();
94
+ },
95
+
96
+ recordFailure: (req) => {
97
+ const key = req.ip || req.connection.remoteAddress || 'unknown';
98
+ const now = Date.now();
99
+ let data = failures.get(key);
100
+
101
+ if (!data || now - data.firstFailure > windowMs) {
102
+ data = { count: 1, firstFailure: now };
103
+ } else {
104
+ data.count++;
105
+ }
106
+
107
+ failures.set(key, data);
108
+ },
109
+
110
+ clearFailures: (req) => {
111
+ const key = req.ip || req.connection.remoteAddress || 'unknown';
112
+ failures.delete(key);
113
+ }
114
+ };
115
+ }
116
+
117
+ export default { createRateLimiter, createFailureLimiter };
package/whitelist.js ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * AAP Whitelist & Key Management
3
+ *
4
+ * Optional: Maintain list of trusted agent public IDs
5
+ */
6
+
7
+ /**
8
+ * Create a whitelist manager
9
+ * @param {Object} options
10
+ * @param {boolean} [options.enabled=false] - Enable whitelist enforcement
11
+ * @param {string[]} [options.allowedIds=[]] - Pre-approved public IDs
12
+ * @param {Function} [options.onNewAgent] - Callback when new agent attempts verification
13
+ */
14
+ export function createWhitelist(options = {}) {
15
+ const {
16
+ enabled = false,
17
+ allowedIds = [],
18
+ onNewAgent = null
19
+ } = options;
20
+
21
+ const whitelist = new Set(allowedIds);
22
+ const pendingApproval = new Map(); // publicId -> { firstSeen, attempts }
23
+
24
+ return {
25
+ /**
26
+ * Check if agent is allowed
27
+ * @param {string} publicId
28
+ * @returns {boolean}
29
+ */
30
+ isAllowed(publicId) {
31
+ if (!enabled) return true;
32
+ return whitelist.has(publicId);
33
+ },
34
+
35
+ /**
36
+ * Add agent to whitelist
37
+ * @param {string} publicId
38
+ */
39
+ add(publicId) {
40
+ whitelist.add(publicId);
41
+ pendingApproval.delete(publicId);
42
+ },
43
+
44
+ /**
45
+ * Remove agent from whitelist
46
+ * @param {string} publicId
47
+ */
48
+ remove(publicId) {
49
+ whitelist.delete(publicId);
50
+ },
51
+
52
+ /**
53
+ * Get all whitelisted IDs
54
+ * @returns {string[]}
55
+ */
56
+ list() {
57
+ return [...whitelist];
58
+ },
59
+
60
+ /**
61
+ * Record attempt from unknown agent
62
+ * @param {string} publicId
63
+ * @param {Object} details
64
+ */
65
+ recordAttempt(publicId, details = {}) {
66
+ if (!enabled) return;
67
+
68
+ let record = pendingApproval.get(publicId);
69
+ if (!record) {
70
+ record = { firstSeen: Date.now(), attempts: 0, lastDetails: null };
71
+ pendingApproval.set(publicId, record);
72
+
73
+ if (onNewAgent) {
74
+ onNewAgent(publicId, details);
75
+ }
76
+ }
77
+
78
+ record.attempts++;
79
+ record.lastDetails = details;
80
+ },
81
+
82
+ /**
83
+ * Get pending approvals
84
+ * @returns {Object[]}
85
+ */
86
+ getPending() {
87
+ return [...pendingApproval.entries()].map(([id, data]) => ({
88
+ publicId: id,
89
+ ...data
90
+ }));
91
+ },
92
+
93
+ /**
94
+ * Middleware to enforce whitelist
95
+ */
96
+ middleware() {
97
+ return (req, res, next) => {
98
+ if (!enabled) return next();
99
+
100
+ const publicId = req.body?.publicId;
101
+ if (!publicId) return next(); // Will fail later anyway
102
+
103
+ if (!whitelist.has(publicId)) {
104
+ this.recordAttempt(publicId, {
105
+ ip: req.ip,
106
+ timestamp: Date.now()
107
+ });
108
+
109
+ return res.status(403).json({
110
+ verified: false,
111
+ error: 'Agent not in whitelist',
112
+ publicId
113
+ });
114
+ }
115
+
116
+ next();
117
+ };
118
+ },
119
+
120
+ /**
121
+ * Check if whitelist is enabled
122
+ */
123
+ isEnabled() {
124
+ return enabled;
125
+ },
126
+
127
+ /**
128
+ * Get stats
129
+ */
130
+ stats() {
131
+ return {
132
+ enabled,
133
+ whitelistedCount: whitelist.size,
134
+ pendingCount: pendingApproval.size
135
+ };
136
+ }
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Key rotation helper for agents
142
+ *
143
+ * Tracks key history and provides rotation utilities
144
+ */
145
+ export function createKeyRotation(options = {}) {
146
+ const {
147
+ maxKeyAge = 30 * 24 * 60 * 60 * 1000, // 30 days default
148
+ onRotationNeeded = null
149
+ } = options;
150
+
151
+ const keyHistory = new Map(); // publicId -> { keys: [], currentIndex }
152
+
153
+ return {
154
+ /**
155
+ * Register a key
156
+ * @param {string} publicId
157
+ * @param {string} publicKey
158
+ * @param {number} [createdAt]
159
+ */
160
+ registerKey(publicId, publicKey, createdAt = Date.now()) {
161
+ let history = keyHistory.get(publicId);
162
+ if (!history) {
163
+ history = { keys: [], currentIndex: 0 };
164
+ keyHistory.set(publicId, history);
165
+ }
166
+
167
+ history.keys.push({
168
+ publicKey,
169
+ createdAt,
170
+ revokedAt: null
171
+ });
172
+ history.currentIndex = history.keys.length - 1;
173
+ },
174
+
175
+ /**
176
+ * Get current key for agent
177
+ * @param {string} publicId
178
+ * @returns {string|null}
179
+ */
180
+ getCurrentKey(publicId) {
181
+ const history = keyHistory.get(publicId);
182
+ if (!history) return null;
183
+ return history.keys[history.currentIndex]?.publicKey || null;
184
+ },
185
+
186
+ /**
187
+ * Check if key needs rotation
188
+ * @param {string} publicId
189
+ * @returns {boolean}
190
+ */
191
+ needsRotation(publicId) {
192
+ const history = keyHistory.get(publicId);
193
+ if (!history || !history.keys.length) return false;
194
+
195
+ const currentKey = history.keys[history.currentIndex];
196
+ const age = Date.now() - currentKey.createdAt;
197
+
198
+ if (age > maxKeyAge) {
199
+ if (onRotationNeeded) {
200
+ onRotationNeeded(publicId, age);
201
+ }
202
+ return true;
203
+ }
204
+
205
+ return false;
206
+ },
207
+
208
+ /**
209
+ * Revoke old key
210
+ * @param {string} publicId
211
+ * @param {number} keyIndex
212
+ */
213
+ revokeKey(publicId, keyIndex) {
214
+ const history = keyHistory.get(publicId);
215
+ if (!history || !history.keys[keyIndex]) return;
216
+
217
+ history.keys[keyIndex].revokedAt = Date.now();
218
+ },
219
+
220
+ /**
221
+ * Get key history for agent
222
+ * @param {string} publicId
223
+ * @returns {Object[]}
224
+ */
225
+ getHistory(publicId) {
226
+ return keyHistory.get(publicId)?.keys || [];
227
+ }
228
+ };
229
+ }
230
+
231
+ export default { createWhitelist, createKeyRotation };