@webdecoy/fcaptcha 1.0.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.
Files changed (4) hide show
  1. package/detection.js +442 -0
  2. package/index.js +799 -0
  3. package/package.json +51 -0
  4. package/server.js +842 -0
package/index.js ADDED
@@ -0,0 +1,799 @@
1
+ /**
2
+ * @webdecoy/fcaptcha - Open source CAPTCHA with PoW, bot detection, and Vision AI protection
3
+ *
4
+ * Main entry point for library usage.
5
+ *
6
+ * Usage:
7
+ * const fcaptcha = require('@webdecoy/fcaptcha');
8
+ * const engine = fcaptcha.createScoringEngine({ secret: 'your-secret' });
9
+ * const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
10
+ */
11
+
12
+ const crypto = require('crypto');
13
+ const detection = require('./detection');
14
+
15
+ // =============================================================================
16
+ // PoW Challenge Store (can be extended with Redis)
17
+ // =============================================================================
18
+
19
+ class PoWChallengeStore {
20
+ constructor(options = {}) {
21
+ this.secret = options.secret || 'dev-secret-change-in-production';
22
+ this.challenges = new Map();
23
+ this.usedSolutions = new Set();
24
+ this.expirationMs = options.expirationMs || 5 * 60 * 1000; // 5 minutes
25
+ }
26
+
27
+ generate(siteKey, ip, difficulty = 4) {
28
+ const challengeId = crypto.randomBytes(16).toString('hex');
29
+ const timestamp = Date.now();
30
+ const expiresAt = timestamp + this.expirationMs;
31
+
32
+ const challengeData = {
33
+ id: challengeId,
34
+ siteKey,
35
+ timestamp,
36
+ expiresAt,
37
+ difficulty,
38
+ prefix: `${challengeId}:${timestamp}:${difficulty}`
39
+ };
40
+
41
+ // Sign the challenge
42
+ const sig = crypto.createHmac('sha256', this.secret)
43
+ .update(JSON.stringify(challengeData))
44
+ .digest('hex')
45
+ .slice(0, 16);
46
+
47
+ challengeData.sig = sig;
48
+
49
+ // Store challenge
50
+ this.challenges.set(challengeId, {
51
+ ...challengeData,
52
+ ip,
53
+ createdAt: timestamp
54
+ });
55
+
56
+ // Periodic cleanup
57
+ if (Math.random() < 0.1) this._cleanup();
58
+
59
+ return challengeData;
60
+ }
61
+
62
+ verify(challengeId, nonce, hash, siteKey) {
63
+ const challenge = this.challenges.get(challengeId);
64
+
65
+ if (!challenge) {
66
+ return { valid: false, reason: 'challenge_not_found' };
67
+ }
68
+
69
+ if (Date.now() > challenge.expiresAt) {
70
+ this.challenges.delete(challengeId);
71
+ return { valid: false, reason: 'challenge_expired' };
72
+ }
73
+
74
+ if (challenge.siteKey !== siteKey) {
75
+ return { valid: false, reason: 'site_key_mismatch' };
76
+ }
77
+
78
+ // Check for replay
79
+ const solutionKey = `${challengeId}:${nonce}`;
80
+ if (this.usedSolutions.has(solutionKey)) {
81
+ return { valid: false, reason: 'solution_already_used' };
82
+ }
83
+
84
+ // Verify the hash
85
+ const input = `${challenge.prefix}:${nonce}`;
86
+ const expectedHash = crypto.createHash('sha256').update(input).digest('hex');
87
+
88
+ if (hash !== expectedHash) {
89
+ return { valid: false, reason: 'invalid_hash' };
90
+ }
91
+
92
+ // Check difficulty
93
+ const target = '0'.repeat(challenge.difficulty);
94
+ if (!hash.startsWith(target)) {
95
+ return { valid: false, reason: 'insufficient_difficulty' };
96
+ }
97
+
98
+ // Mark as used
99
+ this.usedSolutions.add(solutionKey);
100
+ this.challenges.delete(challengeId);
101
+
102
+ return { valid: true, difficulty: challenge.difficulty };
103
+ }
104
+
105
+ _cleanup() {
106
+ const now = Date.now();
107
+ for (const [id, challenge] of this.challenges) {
108
+ if (now > challenge.expiresAt) {
109
+ this.challenges.delete(id);
110
+ }
111
+ }
112
+ if (this.usedSolutions.size > 10000) {
113
+ this.usedSolutions.clear();
114
+ }
115
+ }
116
+ }
117
+
118
+ // =============================================================================
119
+ // Rate Limiter
120
+ // =============================================================================
121
+
122
+ class RateLimiter {
123
+ constructor() {
124
+ this.requests = new Map();
125
+ }
126
+
127
+ check(key, windowSeconds = 60, maxRequests = 10) {
128
+ const now = Date.now();
129
+ const cutoff = now - (windowSeconds * 1000);
130
+
131
+ let timestamps = this.requests.get(key) || [];
132
+ timestamps = timestamps.filter(t => t > cutoff);
133
+
134
+ const count = timestamps.length;
135
+ if (count >= maxRequests) {
136
+ return [true, count];
137
+ }
138
+
139
+ timestamps.push(now);
140
+ this.requests.set(key, timestamps);
141
+ return [false, count + 1];
142
+ }
143
+ }
144
+
145
+ // =============================================================================
146
+ // Fingerprint Store
147
+ // =============================================================================
148
+
149
+ class FingerprintStore {
150
+ constructor() {
151
+ this.fingerprints = new Map();
152
+ this.ipFingerprints = new Map();
153
+ }
154
+
155
+ record(fp, ip, siteKey) {
156
+ const key = `${siteKey}:${fp}`;
157
+
158
+ if (!this.fingerprints.has(key)) {
159
+ this.fingerprints.set(key, { count: 0, ips: new Set() });
160
+ }
161
+ const data = this.fingerprints.get(key);
162
+ data.count++;
163
+ data.ips.add(ip);
164
+
165
+ if (!this.ipFingerprints.has(ip)) {
166
+ this.ipFingerprints.set(ip, new Set());
167
+ }
168
+ this.ipFingerprints.get(ip).add(fp);
169
+ }
170
+
171
+ getIpFpCount(ip) {
172
+ return this.ipFingerprints.get(ip)?.size || 0;
173
+ }
174
+
175
+ getFpIpCount(fp, siteKey) {
176
+ const key = `${siteKey}:${fp}`;
177
+ return this.fingerprints.get(key)?.ips.size || 0;
178
+ }
179
+ }
180
+
181
+ // =============================================================================
182
+ // Scoring Engine
183
+ // =============================================================================
184
+
185
+ const WEIGHTS = {
186
+ vision_ai: 0.15,
187
+ headless: 0.15,
188
+ automation: 0.10,
189
+ behavioral: 0.20,
190
+ fingerprint: 0.10,
191
+ rate_limit: 0.05,
192
+ datacenter: 0.10,
193
+ tor_vpn: 0.05,
194
+ bot: 0.10
195
+ };
196
+
197
+ const AUTOMATION_UA_PATTERNS = [
198
+ /headless/i, /phantomjs/i, /selenium/i, /webdriver/i,
199
+ /puppeteer/i, /playwright/i, /cypress/i, /nightwatch/i,
200
+ /zombie/i, /electron/i, /chromium.*headless/i
201
+ ];
202
+
203
+ class ScoringEngine {
204
+ constructor(options = {}) {
205
+ this.secret = options.secret || 'dev-secret-change-in-production';
206
+ this.powStore = options.powStore || new PoWChallengeStore({ secret: this.secret });
207
+ this.rateLimiter = options.rateLimiter || new RateLimiter();
208
+ this.fingerprintStore = options.fingerprintStore || new FingerprintStore();
209
+ this.weights = options.weights || WEIGHTS;
210
+ }
211
+
212
+ // Generate a PoW challenge
213
+ generateChallenge(siteKey, ip, options = {}) {
214
+ let difficulty = options.difficulty || 4;
215
+
216
+ if (options.scaleByReputation !== false) {
217
+ if (detection.isDatacenterIP(ip)) {
218
+ difficulty = Math.max(difficulty, 5);
219
+ }
220
+
221
+ const rateKey = `pow:${siteKey}:${ip}`;
222
+ const [exceeded, count] = this.rateLimiter.check(rateKey, 60, 20);
223
+ if (count > 10) {
224
+ difficulty = Math.min(6, difficulty + 1);
225
+ }
226
+ if (exceeded) {
227
+ difficulty = 6;
228
+ }
229
+ }
230
+
231
+ return this.powStore.generate(siteKey, ip, difficulty);
232
+ }
233
+
234
+ // Verify signals and return score
235
+ verify(signals, ip, siteKey, userAgent, headers = {}, powSolution = null) {
236
+ const detections = [];
237
+
238
+ // Run all detection modules
239
+ detections.push(...this._detectVisionAI(signals));
240
+ detections.push(...this._detectHeadless(signals, userAgent));
241
+ detections.push(...this._detectAutomation(signals));
242
+ detections.push(...this._detectBehavioral(signals));
243
+ detections.push(...this._detectFingerprint(signals, ip, siteKey));
244
+ detections.push(...this._detectRateAbuse(ip, siteKey));
245
+
246
+ // Verify PoW
247
+ if (powSolution && powSolution.challengeId) {
248
+ const powResult = this.powStore.verify(
249
+ powSolution.challengeId,
250
+ powSolution.nonce,
251
+ powSolution.hash,
252
+ siteKey
253
+ );
254
+
255
+ if (!powResult.valid) {
256
+ detections.push({
257
+ category: 'bot',
258
+ score: 0.7,
259
+ confidence: 0.8,
260
+ reason: `PoW verification failed: ${powResult.reason}`
261
+ });
262
+ }
263
+ } else {
264
+ detections.push({
265
+ category: 'bot',
266
+ score: 0.5,
267
+ confidence: 0.6,
268
+ reason: 'No PoW solution provided'
269
+ });
270
+ }
271
+
272
+ // IP reputation
273
+ if (detection.isDatacenterIP(ip)) {
274
+ detections.push({
275
+ category: 'datacenter',
276
+ score: 0.6,
277
+ confidence: 0.8,
278
+ reason: 'Request from known datacenter IP range'
279
+ });
280
+ }
281
+
282
+ // Header analysis
283
+ detections.push(...detection.analyzeHeaders(headers));
284
+
285
+ // Browser consistency
286
+ detections.push(...detection.checkBrowserConsistency(userAgent, signals));
287
+
288
+ // JA3 fingerprint
289
+ if (headers['x-ja3-hash']) {
290
+ detections.push(...detection.checkJA3Fingerprint(headers['x-ja3-hash']));
291
+ }
292
+
293
+ // Form interaction
294
+ if (signals.formAnalysis) {
295
+ detections.push(...detection.analyzeFormInteraction(signals.formAnalysis));
296
+ }
297
+
298
+ // Calculate scores
299
+ const categoryScores = this._calculateCategoryScores(detections);
300
+ const finalScore = this._calculateFinalScore(categoryScores);
301
+
302
+ let recommendation;
303
+ if (finalScore < 0.3) recommendation = 'allow';
304
+ else if (finalScore < 0.6) recommendation = 'challenge';
305
+ else recommendation = 'block';
306
+
307
+ const success = finalScore < 0.5;
308
+ const token = success ? this._generateToken(ip, siteKey, finalScore) : null;
309
+
310
+ return {
311
+ success,
312
+ score: finalScore,
313
+ token,
314
+ timestamp: Math.floor(Date.now() / 1000),
315
+ recommendation,
316
+ categoryScores,
317
+ detections
318
+ };
319
+ }
320
+
321
+ // Verify a previously issued token
322
+ verifyToken(token) {
323
+ try {
324
+ const decoded = JSON.parse(Buffer.from(token, 'base64url').toString());
325
+
326
+ if (Date.now() / 1000 - decoded.timestamp > 300) {
327
+ return { valid: false, reason: 'expired' };
328
+ }
329
+
330
+ const sig = decoded.sig;
331
+ delete decoded.sig;
332
+
333
+ const payload = JSON.stringify(decoded, Object.keys(decoded).sort());
334
+ const expectedSig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex').slice(0, 16);
335
+
336
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) {
337
+ return { valid: false, reason: 'invalid_signature' };
338
+ }
339
+
340
+ return {
341
+ valid: true,
342
+ site_key: decoded.site_key,
343
+ timestamp: decoded.timestamp,
344
+ score: decoded.score
345
+ };
346
+ } catch (e) {
347
+ return { valid: false, reason: e.message };
348
+ }
349
+ }
350
+
351
+ // Internal detection methods
352
+ _getNestedValue(obj, ...keys) {
353
+ return keys.reduce((o, k) => (o && o[k] !== undefined) ? o[k] : null, obj);
354
+ }
355
+
356
+ _detectVisionAI(signals) {
357
+ const detections = [];
358
+ const b = signals.behavioral || {};
359
+ const t = signals.temporal || {};
360
+
361
+ const pow = t.pow || {};
362
+ if (pow.duration && pow.iterations) {
363
+ const expectedMin = (pow.iterations / 500000) * 1000;
364
+ const expectedMax = (pow.iterations / 50000) * 1000;
365
+
366
+ if (pow.duration < expectedMin * 0.5) {
367
+ detections.push({
368
+ category: 'vision_ai', score: 0.8, confidence: 0.7,
369
+ reason: 'PoW completed impossibly fast'
370
+ });
371
+ } else if (pow.duration > expectedMax * 3) {
372
+ detections.push({
373
+ category: 'vision_ai', score: 0.6, confidence: 0.5,
374
+ reason: 'PoW timing suggests external processing'
375
+ });
376
+ }
377
+ }
378
+
379
+ const microTremor = b.microTremorScore ?? 0.5;
380
+ if (microTremor < 0.15) {
381
+ detections.push({
382
+ category: 'vision_ai', score: 0.7, confidence: 0.6,
383
+ reason: 'Mouse movement lacks natural micro-tremor'
384
+ });
385
+ }
386
+
387
+ if ((b.approachDirectness ?? 0) > 0.95) {
388
+ detections.push({
389
+ category: 'vision_ai', score: 0.5, confidence: 0.5,
390
+ reason: 'Mouse path to target is unnaturally direct'
391
+ });
392
+ }
393
+
394
+ const precision = b.clickPrecision ?? 10;
395
+ if (precision > 0 && precision < 2) {
396
+ detections.push({
397
+ category: 'vision_ai', score: 0.4, confidence: 0.5,
398
+ reason: 'Click precision is unnaturally accurate'
399
+ });
400
+ }
401
+
402
+ const exploration = b.explorationRatio ?? 0.3;
403
+ const trajectory = b.trajectoryLength ?? 0;
404
+ if (exploration < 0.05 && trajectory > 50) {
405
+ detections.push({
406
+ category: 'vision_ai', score: 0.4, confidence: 0.4,
407
+ reason: 'No exploratory mouse movement before click'
408
+ });
409
+ }
410
+
411
+ return detections;
412
+ }
413
+
414
+ _detectHeadless(signals, userAgent) {
415
+ const detections = [];
416
+ const env = signals.environmental || {};
417
+ const headless = env.headlessIndicators || {};
418
+ const automation = env.automationFlags || {};
419
+
420
+ if (env.webdriver) {
421
+ detections.push({
422
+ category: 'headless', score: 0.95, confidence: 0.95,
423
+ reason: 'WebDriver detected'
424
+ });
425
+ }
426
+
427
+ if (automation.plugins === 0) {
428
+ detections.push({
429
+ category: 'headless', score: 0.6, confidence: 0.6,
430
+ reason: 'No browser plugins detected'
431
+ });
432
+ }
433
+
434
+ if (automation.languages === false) {
435
+ detections.push({
436
+ category: 'headless', score: 0.5, confidence: 0.5,
437
+ reason: 'No navigator.languages'
438
+ });
439
+ }
440
+
441
+ if (headless.hasOuterDimensions === false) {
442
+ detections.push({
443
+ category: 'headless', score: 0.7, confidence: 0.7,
444
+ reason: 'Window lacks outer dimensions'
445
+ });
446
+ }
447
+
448
+ if (headless.innerEqualsOuter === true) {
449
+ detections.push({
450
+ category: 'headless', score: 0.4, confidence: 0.5,
451
+ reason: 'Viewport equals window size'
452
+ });
453
+ }
454
+
455
+ if (headless.notificationPermission === 'denied') {
456
+ detections.push({
457
+ category: 'headless', score: 0.3, confidence: 0.4,
458
+ reason: 'Notifications pre-denied'
459
+ });
460
+ }
461
+
462
+ for (const pattern of AUTOMATION_UA_PATTERNS) {
463
+ if (pattern.test(userAgent)) {
464
+ detections.push({
465
+ category: 'headless', score: 0.9, confidence: 0.9,
466
+ reason: 'Automation pattern in User-Agent'
467
+ });
468
+ break;
469
+ }
470
+ }
471
+
472
+ const renderer = (this._getNestedValue(env, 'webglInfo', 'renderer') || '').toLowerCase();
473
+ if (renderer.includes('swiftshader') || renderer.includes('llvmpipe')) {
474
+ detections.push({
475
+ category: 'headless', score: 0.8, confidence: 0.8,
476
+ reason: 'Software WebGL renderer detected'
477
+ });
478
+ }
479
+
480
+ return detections;
481
+ }
482
+
483
+ _detectAutomation(signals) {
484
+ const detections = [];
485
+ const env = signals.environmental || {};
486
+ const b = signals.behavioral || {};
487
+
488
+ const jsTime = this._getNestedValue(env, 'jsExecutionTime', 'mathOps') || 0;
489
+ if (jsTime > 0) {
490
+ if (jsTime < 0.1) {
491
+ detections.push({
492
+ category: 'automation', score: 0.4, confidence: 0.3,
493
+ reason: 'JS execution unusually fast'
494
+ });
495
+ } else if (jsTime > 50) {
496
+ detections.push({
497
+ category: 'automation', score: 0.3, confidence: 0.3,
498
+ reason: 'JS execution unusually slow'
499
+ });
500
+ }
501
+ }
502
+
503
+ const raf = env.rafConsistency || {};
504
+ if (raf.frameTimeVariance !== undefined && raf.frameTimeVariance < 0.1) {
505
+ detections.push({
506
+ category: 'automation', score: 0.5, confidence: 0.4,
507
+ reason: 'RequestAnimationFrame timing too consistent'
508
+ });
509
+ }
510
+
511
+ const eventVar = b.eventDeltaVariance ?? 10;
512
+ const totalPoints = b.totalPoints ?? 0;
513
+ if (eventVar < 2 && totalPoints > 10) {
514
+ detections.push({
515
+ category: 'automation', score: 0.6, confidence: 0.6,
516
+ reason: 'Mouse event timing unnaturally consistent'
517
+ });
518
+ }
519
+
520
+ return detections;
521
+ }
522
+
523
+ _detectBehavioral(signals) {
524
+ const detections = [];
525
+ const b = signals.behavioral || {};
526
+ const t = signals.temporal || {};
527
+
528
+ const velVar = b.velocityVariance ?? 1;
529
+ const trajectory = b.trajectoryLength ?? 0;
530
+ if (velVar < 0.02 && trajectory > 50) {
531
+ detections.push({
532
+ category: 'behavioral', score: 0.6, confidence: 0.6,
533
+ reason: 'Mouse velocity too consistent'
534
+ });
535
+ }
536
+
537
+ const overshoots = b.overshootCorrections ?? 0;
538
+ if (overshoots === 0 && trajectory > 200) {
539
+ detections.push({
540
+ category: 'behavioral', score: 0.4, confidence: 0.4,
541
+ reason: 'No overshoot corrections on long trajectory'
542
+ });
543
+ }
544
+
545
+ const interactionTime = b.interactionDuration ?? 1000;
546
+ if (interactionTime > 0 && interactionTime < 200) {
547
+ detections.push({
548
+ category: 'behavioral', score: 0.7, confidence: 0.7,
549
+ reason: 'Interaction completed too quickly'
550
+ });
551
+ } else if (interactionTime > 60000) {
552
+ detections.push({
553
+ category: 'captcha_farm', score: 0.3, confidence: 0.3,
554
+ reason: 'Unusually long interaction time'
555
+ });
556
+ }
557
+
558
+ const firstInt = t.pageLoadToFirstInteraction;
559
+ if (firstInt !== null && firstInt > 0 && firstInt < 100) {
560
+ detections.push({
561
+ category: 'behavioral', score: 0.5, confidence: 0.5,
562
+ reason: 'First interaction too soon after page load'
563
+ });
564
+ }
565
+
566
+ const eventRate = b.mouseEventRate ?? 60;
567
+ if (eventRate > 200) {
568
+ detections.push({
569
+ category: 'behavioral', score: 0.6, confidence: 0.5,
570
+ reason: 'Mouse event rate abnormally high'
571
+ });
572
+ } else if (eventRate > 0 && eventRate < 10) {
573
+ detections.push({
574
+ category: 'behavioral', score: 0.4, confidence: 0.4,
575
+ reason: 'Mouse event rate abnormally low'
576
+ });
577
+ }
578
+
579
+ const straight = b.straightLineRatio ?? 0;
580
+ if (straight > 0.8 && trajectory > 100) {
581
+ detections.push({
582
+ category: 'behavioral', score: 0.5, confidence: 0.5,
583
+ reason: 'Mouse movements too straight'
584
+ });
585
+ }
586
+
587
+ const dirChanges = b.directionChanges ?? 10;
588
+ const totalPoints = b.totalPoints ?? 0;
589
+ if (totalPoints > 50 && dirChanges < 3) {
590
+ detections.push({
591
+ category: 'behavioral', score: 0.4, confidence: 0.4,
592
+ reason: 'Too few direction changes'
593
+ });
594
+ }
595
+
596
+ return detections;
597
+ }
598
+
599
+ _detectFingerprint(signals, ip, siteKey) {
600
+ const detections = [];
601
+ const env = signals.environmental || {};
602
+ const automation = env.automationFlags || {};
603
+
604
+ const components = [
605
+ String(this._getNestedValue(env, 'canvasHash', 'hash') || ''),
606
+ String(this._getNestedValue(env, 'webglInfo', 'renderer') || ''),
607
+ String(automation.platform || ''),
608
+ String(automation.hardwareConcurrency || '')
609
+ ];
610
+ const fp = crypto.createHash('sha256').update(components.join('|')).digest('hex').slice(0, 16);
611
+
612
+ this.fingerprintStore.record(fp, ip, siteKey);
613
+
614
+ const ipFpCount = this.fingerprintStore.getIpFpCount(ip);
615
+ if (ipFpCount > 5) {
616
+ detections.push({
617
+ category: 'fingerprint', score: 0.6, confidence: 0.6,
618
+ reason: 'IP has used many different fingerprints'
619
+ });
620
+ }
621
+
622
+ const fpIpCount = this.fingerprintStore.getFpIpCount(fp, siteKey);
623
+ if (fpIpCount > 10) {
624
+ detections.push({
625
+ category: 'fingerprint', score: 0.5, confidence: 0.5,
626
+ reason: 'Fingerprint seen from many IPs'
627
+ });
628
+ }
629
+
630
+ const canvas = env.canvasHash || {};
631
+ if (canvas.error || canvas.supported === false) {
632
+ detections.push({
633
+ category: 'fingerprint', score: 0.4, confidence: 0.4,
634
+ reason: 'Canvas fingerprinting blocked or failed'
635
+ });
636
+ }
637
+
638
+ return detections;
639
+ }
640
+
641
+ _detectRateAbuse(ip, siteKey) {
642
+ const detections = [];
643
+ const key = `${siteKey}:${ip}`;
644
+
645
+ const [exceeded, count] = this.rateLimiter.check(key, 60, 10);
646
+ if (exceeded) {
647
+ detections.push({
648
+ category: 'rate_limit', score: 0.8, confidence: 0.9,
649
+ reason: 'Rate limit exceeded'
650
+ });
651
+ } else if (count > 5) {
652
+ detections.push({
653
+ category: 'rate_limit', score: 0.3, confidence: 0.5,
654
+ reason: 'High request rate'
655
+ });
656
+ }
657
+
658
+ return detections;
659
+ }
660
+
661
+ _calculateCategoryScores(detections) {
662
+ const categoryData = {};
663
+
664
+ for (const d of detections) {
665
+ if (!categoryData[d.category]) {
666
+ categoryData[d.category] = [];
667
+ }
668
+ categoryData[d.category].push([d.score, d.confidence]);
669
+ }
670
+
671
+ const result = {};
672
+ for (const [cat, scores] of Object.entries(categoryData)) {
673
+ if (scores.length > 0) {
674
+ const totalWeight = scores.reduce((sum, [, conf]) => sum + conf, 0);
675
+ if (totalWeight > 0) {
676
+ const weightedSum = scores.reduce((sum, [score, conf]) => sum + score * conf, 0);
677
+ result[cat] = Math.min(1.0, weightedSum / totalWeight);
678
+ }
679
+ }
680
+ }
681
+
682
+ for (const cat of Object.keys(this.weights)) {
683
+ if (!(cat in result)) {
684
+ result[cat] = 0.0;
685
+ }
686
+ }
687
+
688
+ return result;
689
+ }
690
+
691
+ _calculateFinalScore(categoryScores) {
692
+ let total = 0;
693
+ for (const [cat, weight] of Object.entries(this.weights)) {
694
+ total += (categoryScores[cat] || 0) * weight;
695
+ }
696
+ return Math.min(1.0, total);
697
+ }
698
+
699
+ _generateToken(ip, siteKey, score) {
700
+ const ipHash = crypto.createHash('sha256').update(ip).digest('hex').slice(0, 8);
701
+ const data = {
702
+ site_key: siteKey,
703
+ timestamp: Math.floor(Date.now() / 1000),
704
+ score: Math.round(score * 1000) / 1000,
705
+ ip_hash: ipHash
706
+ };
707
+
708
+ const payload = JSON.stringify(data, Object.keys(data).sort());
709
+ const sig = crypto.createHmac('sha256', this.secret).update(payload).digest('hex').slice(0, 16);
710
+ data.sig = sig;
711
+
712
+ return Buffer.from(JSON.stringify(data)).toString('base64url');
713
+ }
714
+ }
715
+
716
+ // =============================================================================
717
+ // Express Middleware Factory
718
+ // =============================================================================
719
+
720
+ function createMiddleware(options = {}) {
721
+ const engine = new ScoringEngine(options);
722
+
723
+ return {
724
+ engine,
725
+
726
+ // Middleware to extract IP from request
727
+ getIP: (req) => {
728
+ let ip = req.headers['x-real-ip'] || '';
729
+ if (!ip) {
730
+ const forwarded = req.headers['x-forwarded-for'];
731
+ if (forwarded) {
732
+ ip = forwarded.split(',')[0].trim();
733
+ } else {
734
+ ip = req.socket?.remoteAddress || '127.0.0.1';
735
+ }
736
+ }
737
+ return ip;
738
+ },
739
+
740
+ // Challenge route handler
741
+ challengeHandler: (req, res) => {
742
+ const siteKey = req.query.siteKey || 'default';
743
+ const ip = options.getIP ? options.getIP(req) : module.exports.createMiddleware({}).getIP(req);
744
+ const challenge = engine.generateChallenge(siteKey, ip);
745
+
746
+ res.json({
747
+ challengeId: challenge.id,
748
+ prefix: challenge.prefix,
749
+ difficulty: challenge.difficulty,
750
+ expiresAt: challenge.expiresAt,
751
+ sig: challenge.sig
752
+ });
753
+ },
754
+
755
+ // Verify route handler
756
+ verifyHandler: (req, res) => {
757
+ const { siteKey, signals, powSolution } = req.body;
758
+ const ip = options.getIP ? options.getIP(req) : module.exports.createMiddleware({}).getIP(req);
759
+ const userAgent = req.headers['user-agent'] || '';
760
+
761
+ const headers = {};
762
+ for (const [key, value] of Object.entries(req.headers)) {
763
+ headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value;
764
+ }
765
+
766
+ const result = engine.verify(signals, ip, siteKey, userAgent, headers, powSolution);
767
+ res.json(result);
768
+ },
769
+
770
+ // Token verify route handler
771
+ tokenVerifyHandler: (req, res) => {
772
+ const { token } = req.body;
773
+ res.json(engine.verifyToken(token));
774
+ }
775
+ };
776
+ }
777
+
778
+ // =============================================================================
779
+ // Exports
780
+ // =============================================================================
781
+
782
+ module.exports = {
783
+ // Core classes
784
+ ScoringEngine,
785
+ PoWChallengeStore,
786
+ RateLimiter,
787
+ FingerprintStore,
788
+
789
+ // Detection module (re-export)
790
+ detection,
791
+
792
+ // Factory functions
793
+ createScoringEngine: (options) => new ScoringEngine(options),
794
+ createMiddleware,
795
+
796
+ // Constants
797
+ WEIGHTS,
798
+ AUTOMATION_UA_PATTERNS
799
+ };