From ce358923d997bf1e058a78987ad15f3d8310f537 Mon Sep 17 00:00:00 2001 From: SaiVignan276 Date: Sat, 18 Apr 2026 14:02:59 +0530 Subject: [PATCH] Add files via upload --- models/AuthenticationEvent.js | 43 ++ models/BiometricProfile.js | 38 + models/DeviceProfile.js | 32 + models/EncryptedKeystrokeData.js | 27 + models/PasteDetectionLog.js | 55 ++ models/Report.js | 45 ++ models/SessionTrustScore.js | 32 + models/Text.js | 71 ++ models/User.js | 9 + models/WritingVersion.js | 32 + package-lock.json | 1174 ++++++++++++++++++++++++++++++ package.json | 16 + public/index.html | 1052 ++++++++++++++++++++++++++ public/login.html | 147 ++++ public/register.html | 147 ++++ public/report.html | 91 +++ routes/biometrics.js | 281 +++++++ routes/deviceTracking.js | 124 ++++ routes/pasteDetection.js | 283 +++++++ routes/privacy.js | 144 ++++ routes/reportSharing.js | 159 ++++ routes/trustScore.js | 117 +++ routes/versionControl.js | 106 +++ server-integration.js | 10 + server.js | 367 ++++++++++ utils/auth.js | 63 ++ utils/encryption.js | 85 +++ utils/loadEnv.js | 35 + utils/sessionPipeline.js | 1053 +++++++++++++++++++++++++++ 29 files changed, 5838 insertions(+) create mode 100644 models/AuthenticationEvent.js create mode 100644 models/BiometricProfile.js create mode 100644 models/DeviceProfile.js create mode 100644 models/EncryptedKeystrokeData.js create mode 100644 models/PasteDetectionLog.js create mode 100644 models/Report.js create mode 100644 models/SessionTrustScore.js create mode 100644 models/Text.js create mode 100644 models/User.js create mode 100644 models/WritingVersion.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/index.html create mode 100644 public/login.html create mode 100644 public/register.html create mode 100644 public/report.html create mode 100644 routes/biometrics.js create mode 100644 routes/deviceTracking.js create mode 100644 routes/pasteDetection.js create mode 100644 routes/privacy.js create mode 100644 routes/reportSharing.js create mode 100644 routes/trustScore.js create mode 100644 routes/versionControl.js create mode 100644 server-integration.js create mode 100644 server.js create mode 100644 utils/auth.js create mode 100644 utils/encryption.js create mode 100644 utils/loadEnv.js create mode 100644 utils/sessionPipeline.js diff --git a/models/AuthenticationEvent.js b/models/AuthenticationEvent.js new file mode 100644 index 000000000..d81f15ece --- /dev/null +++ b/models/AuthenticationEvent.js @@ -0,0 +1,43 @@ +const mongoose = require('mongoose'); + +// Log authentication verification events +const authEventSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + sessionId: String, + keystrokeMetrics: { + avgKeystrokeTime: Number, + stdDevKeystrokeTime: Number, + avgPauseTime: Number, + typingSpeed: Number, + sampleSize: Number + }, + deviceInfo: { + deviceId: String, + osType: String, + timezone: String + }, + verificationResult: { + status: { + type: String, + enum: ['success', 'failed', 'challenge'], + default: 'challenge' + }, + confidenceScore: Number, + modelPrediction: Number, + matchPercentage: Number, + flags: [String] + }, + fallbackMethod: { + type: String, + enum: ['none', 'otp', 'email_link', 'security_question'], + default: 'none' + }, + fallbackStatus: String, + timestamp: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('AuthenticationEvent', authEventSchema); diff --git a/models/BiometricProfile.js b/models/BiometricProfile.js new file mode 100644 index 000000000..0f980447e --- /dev/null +++ b/models/BiometricProfile.js @@ -0,0 +1,38 @@ +const mongoose = require('mongoose'); + +// Track keystroke profiles for behavioral authentication +const biometricProfileSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User', + required: true + }, + baselineMetrics: { + avgKeystrokeTime: Number, + stdDevKeystrokeTime: Number, + avgPauseTime: Number, + typingSpeed: Number, + deletionRate: Number, + rhythmPattern: [Number] + }, + deviceSignature: { + keyboardType: String, + osType: String, + browserAgent: String, + screenResolution: String, + timezone: String + }, + status: { + type: String, + enum: ['collecting_baseline', 'active', 'suspended'], + default: 'collecting_baseline' + }, + baselineSessionsCount: { type: Number, default: 0 }, + baselineRequired: { type: Number, default: 5 }, + failureCount: { type: Number, default: 0 }, + lastVerificationFailed: Date, + createdAt: { type: Date, default: Date.now }, + lastUpdated: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('BiometricProfile', biometricProfileSchema); diff --git a/models/DeviceProfile.js b/models/DeviceProfile.js new file mode 100644 index 000000000..f369fe19e --- /dev/null +++ b/models/DeviceProfile.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +// Track device profiles and typing behavior per device +const deviceProfileSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + deviceId: String, + + deviceInfo: { + osType: String, + browserAgent: String, + screenResolution: String, + timezone: String, + inputMethod: String + }, + + behaviorProfile: { + avgKeystrokeTime: Number, + typingSpeed: Number, + errorRate: Number, + pasteFrequency: Number + }, + + trustScore: { type: Number, default: 100 }, + isKnownDevice: { type: Boolean, default: false }, + + firstSeen: Date, + lastSeen: Date, + + createdAt: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('DeviceProfile', deviceProfileSchema); diff --git a/models/EncryptedKeystrokeData.js b/models/EncryptedKeystrokeData.js new file mode 100644 index 000000000..0ab1fcbc2 --- /dev/null +++ b/models/EncryptedKeystrokeData.js @@ -0,0 +1,27 @@ +const mongoose = require('mongoose'); + +// Store encrypted keystroke data for privacy +const encryptedKeystrokeSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + sessionId: { type: mongoose.Schema.Types.ObjectId, ref: 'Text' }, + + encryptedData: { + iv: String, + encryptedData: String, + authTag: String + }, + + anonymizedMetrics: { + avgKeystrokeTime: Number, + stdDevKeystrokeTime: Number, + pauseFrequency: Number + }, + + encryptionTimestamp: Date, + dataEncryptionEnabled: { type: Boolean, default: true }, + userConsent: { type: Boolean, default: true }, + + createdAt: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('EncryptedKeystrokeData', encryptedKeystrokeSchema); diff --git a/models/PasteDetectionLog.js b/models/PasteDetectionLog.js new file mode 100644 index 000000000..5b21f58ab --- /dev/null +++ b/models/PasteDetectionLog.js @@ -0,0 +1,55 @@ +const mongoose = require('mongoose'); + +// Track paste and AI-generated content detection +const pasteDetectionLogSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + textSessionId: { type: mongoose.Schema.Types.ObjectId, ref: 'Text' }, + + detectionResults: [{ + segmentId: String, + startPosition: Number, + endPosition: Number, + segmentText: String, + keystrokeAnalysis: { + hasKeystrokeIntervals: Boolean, + isInstantAppearance: Boolean, + uniformityScore: Number, + variationCv: Number, + expectedIntervals: Number, + actualIntervals: Number + }, + linguisticMarkers: { + vocabularyShift: Number, + sentenceComplexity: Number, + punctuationConsistency: Number, + grammarAnomalies: [String] + }, + aiConfidenceScore: Number, + pasteConfidenceScore: Number, + verdict: { + type: String, + enum: ['human_typed', 'pasted', 'ai_generated', 'uncertain'], + default: 'uncertain' + }, + flags: [String] + }], + + summaryAnalysis: { + totalSegments: Number, + pastedSegments: Number, + significantPastedSegments: Number, + trivialPastedSegments: Number, + ignoredPastedSegments: Number, + aiSegments: Number, + mixedContentDetected: Boolean, + suspicionLevel: { + type: String, + enum: ['low', 'medium', 'high'], + default: 'low' + } + }, + + timestamp: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('PasteDetectionLog', pasteDetectionLogSchema); diff --git a/models/Report.js b/models/Report.js new file mode 100644 index 000000000..7a5d8d517 --- /dev/null +++ b/models/Report.js @@ -0,0 +1,45 @@ +const mongoose = require('mongoose'); +const crypto = require('crypto'); + +// Generate and manage shareable verification reports +const reportSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + textSessionId: { type: mongoose.Schema.Types.ObjectId, ref: 'Text' }, + + reportContent: { + title: String, + summary: String, + trustScore: Number, + analysisDate: Date, + findings: [{ + category: String, + result: String, + confidence: Number + }] + }, + + sharingToken: { type: String, unique: true }, + isPublic: { type: Boolean, default: false }, + expiresAt: Date, + accessControl: { + allowVerification: { type: Boolean, default: true }, + restrictedViewers: [String] + }, + + viewCount: { type: Number, default: 0 }, + viewLog: [{ + timestamp: Date, + viewerIP: String + }], + + createdAt: { type: Date, default: Date.now } +}); + +// Auto-generate secure token before saving +reportSchema.pre('save', function() { + if (!this.sharingToken) { + this.sharingToken = crypto.randomBytes(32).toString('hex'); + } +}); + +module.exports = mongoose.model('Report', reportSchema); diff --git a/models/SessionTrustScore.js b/models/SessionTrustScore.js new file mode 100644 index 000000000..5d834ebb3 --- /dev/null +++ b/models/SessionTrustScore.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +// Real-time session trust score tracking +const sessionTrustScoreSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + textSessionId: { type: mongoose.Schema.Types.ObjectId, ref: 'Text' }, + + scoreHistory: [{ + timestamp: Date, + score: Number, + factors: { + keystrokeBehavior: Number, + pasteDetection: Number, + aiLikelihood: Number, + anomalyScore: Number, + devicesConsistency: Number + } + }], + + currentScore: Number, + riskLevel: { type: String, enum: ['low', 'medium', 'high'] }, + + warnings: [{ + timestamp: Date, + message: String, + severity: String + }], + + createdAt: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('SessionTrustScore', sessionTrustScoreSchema); diff --git a/models/Text.js b/models/Text.js new file mode 100644 index 000000000..b8dd18fef --- /dev/null +++ b/models/Text.js @@ -0,0 +1,71 @@ +const mongoose = require('mongoose'); + +const textSchema = new mongoose.Schema({ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + content: String, + startTime: Date, + endTime: Date, + duration: Number, + pasteCount: Number, + pastedTextLength: Number, + totalKeystrokes: Number, + sessionMetrics: { + avgKeystrokeTime: Number, + stdDevKeystrokeTime: Number, + avgPauseTime: Number, + longestPause: Number, + pauseCount: Number, + deletionCount: Number, + focusLossCount: Number, + revisionCount: Number, + typingSpeed: Number, + errorRate: Number, + pasteRatio: Number, + sampleSize: Number, + rhythmPattern: [Number] + }, + linguisticMetrics: { + wordCount: Number, + sentenceCount: Number, + paragraphCount: Number, + vocabularyDiversity: Number, + averageSentenceLength: Number + }, + deviceSnapshot: { + deviceId: String, + osType: String, + browserAgent: String, + screenResolution: String, + timezone: String, + inputMethod: String + }, + analysisSummary: { + pasteVerdict: String, + aiVerdict: String, + trustScore: Number, + riskLevel: String, + biometricStatus: String, + deviceTrustScore: Number, + reportToken: String + }, + artifactRefs: { + authenticationEventId: String, + deviceProfileId: String, + biometricProfileId: String, + pasteLogId: String, + writingVersionId: String, + encryptedTelemetryId: String, + trustScoreId: String, + reportId: String + }, + createdAt: { + type: Date, + default: Date.now + } + +}); + +module.exports = mongoose.model('Text', textSchema); diff --git a/models/User.js b/models/User.js new file mode 100644 index 000000000..a53b7a64b --- /dev/null +++ b/models/User.js @@ -0,0 +1,9 @@ +const mongoose = require('mongoose'); + +const userSchema = new mongoose.Schema({ + username: String, + email: String, + password: String +}); + +module.exports = mongoose.model('User', userSchema); \ No newline at end of file diff --git a/models/WritingVersion.js b/models/WritingVersion.js new file mode 100644 index 000000000..981cb601b --- /dev/null +++ b/models/WritingVersion.js @@ -0,0 +1,32 @@ +const mongoose = require('mongoose'); + +// Track writing versions and keystroke replay data +const writingVersionSchema = new mongoose.Schema({ + userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, + textSessionId: { type: mongoose.Schema.Types.ObjectId, ref: 'Text' }, + + versions: [{ + versionNumber: Number, + timestamp: Date, + content: String, + characterCount: Number, + changesSinceLast: { + inserted: Number, + deleted: Number, + modified: Number + } + }], + + keystrokeReplay: [{ + timestamp: Number, + type: { type: String, enum: ['keystroke', 'delete', 'paste'] }, + character: String, + text: String, + position: Number, + interval: Number + }], + + createdAt: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('WritingVersion', writingVersionSchema); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..f1314f544 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1174 @@ +{ + "name": "vi-notes", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vi-notes", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^3.0.3", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "mongoose": "^9.3.1" + } + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", + "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/kareem": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", + "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/mongoose": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.3.1.tgz", + "integrity": "sha512-58DuQti+LlRS74/UfWN4F3wZsC0Yr1dgTWZ2Wd3/TuSvm6rIdyAjDWbx2xGyuBooqJYdAWotVv4mQgVdivh+3Q==", + "license": "MIT", + "dependencies": { + "kareem": "3.2.0", + "mongodb": "~7.1", + "mpath": "0.9.0", + "mquery": "6.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz", + "integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..1ba5e9dda --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "vi-notes", + "version": "1.0.0", + "private": true, + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "bcryptjs": "^3.0.3", + "express": "^5.2.1", + "jsonwebtoken": "^9.0.3", + "mongoose": "^9.3.1" + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 000000000..25645ea91 --- /dev/null +++ b/public/index.html @@ -0,0 +1,1052 @@ + + + + Vi-Notes Editor + + + +
+
+

Vi-Notes Writing Editor

+ +
+ + + +
+
Keystrokes0
+
Pastes0
+
Deletes0
+
Focus Interruptions0
+
+ +
+ + +
+ +
+ +
+

Latest Analysis

+
Save a session to see trust score, paste detection, device trust, and biometric status.
+
+ +
+

Saved Sessions

+
Click View Sessions to load saved sessions.
+
+ +
+

Replay Viewer

+
+
Session: No session loaded
+
0 / 0 events
+
+
+ + + + +
+
+
Pick a saved session and click Replay.
+
The replay will show typed characters and paste bursts.
+
+
+ + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 000000000..d0ff5d6e5 --- /dev/null +++ b/public/login.html @@ -0,0 +1,147 @@ + + + + Login + + + + +
+

Login

+ + + + + + + Create account +
+
+ + + + + diff --git a/public/register.html b/public/register.html new file mode 100644 index 000000000..066ad43c6 --- /dev/null +++ b/public/register.html @@ -0,0 +1,147 @@ + + + + Register + + + + +
+

Register

+ + + + + + + + Already have an account? Login +
+
+ + + + + diff --git a/public/report.html b/public/report.html new file mode 100644 index 000000000..81c3dae6b --- /dev/null +++ b/public/report.html @@ -0,0 +1,91 @@ + + + + Verification Report + + + +
+
Loading report...
+
+ + + + diff --git a/routes/biometrics.js b/routes/biometrics.js new file mode 100644 index 000000000..649ea6e31 --- /dev/null +++ b/routes/biometrics.js @@ -0,0 +1,281 @@ +const express = require('express'); +const router = express.Router(); +const BiometricProfile = require('../models/BiometricProfile'); +const AuthenticationEvent = require('../models/AuthenticationEvent'); +const { requireAuth } = require('../utils/auth'); + +// 1. Initialize biometric profile +router.post('/biometrics/init', async (req, res) => { + try { + const { userId } = req.body; + + let profile = await BiometricProfile.findOne({ userId }); + if (profile) { + return res.status(400).json({ message: 'Profile already exists' }); + } + + profile = new BiometricProfile({ + userId, + status: 'collecting_baseline' + }); + + await profile.save(); + + res.json({ + message: 'Biometric profile initialized', + profileId: profile._id, + mode: 'baseline_collection' + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 2. Record baseline keystroke sample +router.post('/biometrics/baseline-sample', async (req, res) => { + try { + const { userId, keystrokeMetrics, deviceInfo } = req.body; + + const profile = await BiometricProfile.findOne({ userId }); + if (!profile) { + return res.status(404).json({ message: 'Profile not found' }); + } + + if (profile.status !== 'collecting_baseline') { + return res.status(400).json({ message: 'Not in baseline collection mode' }); + } + + const metrics = { + avgKeystrokeTime: keystrokeMetrics.avgKeystrokeTime, + stdDevKeystrokeTime: keystrokeMetrics.stdDevKeystrokeTime, + avgPauseTime: keystrokeMetrics.avgPauseTime, + typingSpeed: keystrokeMetrics.typingSpeed, + deletionRate: keystrokeMetrics.deletionRate + }; + + const rhythmPattern = calculateRhythmPattern(keystrokeMetrics.intervalArray || []); + + // Update baseline with exponential moving average + if (profile.baselineSessionsCount === 0) { + profile.baselineMetrics = { ...metrics, rhythmPattern }; + } else { + const alpha = 0.3; // EMA smoothing factor + profile.baselineMetrics.avgKeystrokeTime = + (1 - alpha) * profile.baselineMetrics.avgKeystrokeTime + + alpha * metrics.avgKeystrokeTime; + + profile.baselineMetrics.stdDevKeystrokeTime = + (1 - alpha) * profile.baselineMetrics.stdDevKeystrokeTime + + alpha * metrics.stdDevKeystrokeTime; + + profile.baselineMetrics.typingSpeed = + (1 - alpha) * profile.baselineMetrics.typingSpeed + + alpha * metrics.typingSpeed; + } + + profile.baselineSessionsCount += 1; + + if (profile.baselineSessionsCount === 1) { + profile.deviceSignature = deviceInfo; + } + + // Activate after sufficient baseline + if (profile.baselineSessionsCount >= profile.baselineRequired) { + profile.status = 'active'; + } + + profile.lastUpdated = new Date(); + await profile.save(); + + res.json({ + message: 'Baseline sample recorded', + sessionsCollected: profile.baselineSessionsCount, + sessionsRequired: profile.baselineRequired, + profileStatus: profile.status + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 3. Verify biometric on login +router.post('/biometrics/verify', async (req, res) => { + try { + const { userId, keystrokeMetrics, deviceInfo, sessionId } = req.body; + + const profile = await BiometricProfile.findOne({ userId }); + + if (!profile || profile.status !== 'active') { + return res.json({ + status: 'not_ready', + message: 'Biometric verification not yet active' + }); + } + + const isNewDevice = deviceInfo.deviceId !== profile.deviceSignature?.deviceId; + const verificationScore = calculateBiometricMatch( + keystrokeMetrics, + profile.baselineMetrics, + isNewDevice + ); + + const threshold = 75; + let verdict = 'success'; + let flags = []; + + if (verificationScore < threshold) { + verdict = isNewDevice ? 'challenge' : 'failed'; + if (isNewDevice) flags.push('new_device'); + if (Math.abs(keystrokeMetrics.typingSpeed - profile.baselineMetrics.typingSpeed) > + profile.baselineMetrics.stdDevKeystrokeTime * 2) { + flags.push('keystroke_anomaly'); + } + } + + const authEvent = new AuthenticationEvent({ + userId, + sessionId, + keystrokeMetrics: { + avgKeystrokeTime: keystrokeMetrics.avgKeystrokeTime, + stdDevKeystrokeTime: keystrokeMetrics.stdDevKeystrokeTime, + avgPauseTime: keystrokeMetrics.avgPauseTime, + typingSpeed: keystrokeMetrics.typingSpeed, + sampleSize: keystrokeMetrics.sampleSize + }, + deviceInfo, + verificationResult: { + status: verdict, + confidenceScore: verificationScore, + matchPercentage: verificationScore, + flags + } + }); + + if (verdict === 'success') { + profile.failureCount = 0; + } else { + profile.failureCount += 1; + profile.lastVerificationFailed = new Date(); + + if (profile.failureCount >= 3) { + profile.status = 'suspended'; + } + } + + profile.lastUpdated = new Date(); + await profile.save(); + await authEvent.save(); + + res.json({ + status: verdict, + confidenceScore: verificationScore, + flags, + requiresFallback: verdict !== 'success', + fallbackMethods: verdict !== 'success' ? ['otp', 'email_link'] : [] + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// 4. Get biometric profile status +router.get('/biometrics/status', requireAuth, async (req, res) => { + try { + const profile = await BiometricProfile.findOne({ userId: req.userId }); + + if (!profile) { + return res.json({ + message: 'No biometric profile', + initialized: false + }); + } + + res.json({ + initialized: true, + status: profile.status, + baselineSessionsCollected: profile.baselineSessionsCount, + baselineSessionsRequired: profile.baselineRequired, + failureCount: profile.failureCount, + lastVerificationFailed: profile.lastVerificationFailed + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Helper: Calculate biometric match score +function calculateBiometricMatch(current, baseline, isNewDevice) { + const weights = { + keystrokeTime: 0.35, + typingSpeed: 0.25, + pauseTime: 0.20, + variance: 0.15, + deviceBonus: 0.05 + }; + + let score = 0; + + // Keystroke timing comparison + const keystrokeScore = calculateSimilarityScore( + current.avgKeystrokeTime, + baseline.avgKeystrokeTime + ); + score += keystrokeScore * weights.keystrokeTime; + + // Typing speed comparison + const speedScore = calculateSimilarityScore( + current.typingSpeed, + baseline.typingSpeed + ); + score += speedScore * weights.typingSpeed; + + // Pause time comparison + const pauseScore = calculateSimilarityScore( + current.avgPauseTime, + baseline.avgPauseTime + ); + score += pauseScore * weights.pauseTime; + + // Variance comparison + const varScore = calculateSimilarityScore( + current.stdDevKeystrokeTime, + baseline.stdDevKeystrokeTime + ); + score += varScore * weights.variance; + + // Device consistency bonus + if (!isNewDevice) { + score += weights.deviceBonus * 100; + } + + return Math.round(Math.min(100, Math.max(0, score))); +} + +// Helper: Calculate rhythm pattern +function calculateSimilarityScore(currentValue, baselineValue) { + if (!Number.isFinite(currentValue) || !Number.isFinite(baselineValue) || baselineValue <= 0) { + return 0; + } + + const diff = Math.abs(currentValue - baselineValue); + return Math.max(0, 100 - (diff / baselineValue) * 100); +} + +function calculateRhythmPattern(intervals) { + if (!intervals || intervals.length === 0) return [0, 0, 0, 0, 0]; + + const max = Math.max(...intervals); + if (max === 0) return [0, 0, 0, 0, 0]; + + const normalized = intervals.map(i => i / max); + const buckets = [0, 0, 0, 0, 0]; + + normalized.forEach(val => { + const bucketIdx = Math.min(4, Math.floor(val * 5)); + buckets[bucketIdx]++; + }); + + return buckets; +} + +module.exports = router; diff --git a/routes/deviceTracking.js b/routes/deviceTracking.js new file mode 100644 index 000000000..4d86b03c3 --- /dev/null +++ b/routes/deviceTracking.js @@ -0,0 +1,124 @@ +const express = require('express'); +const router = express.Router(); +const crypto = require('crypto'); +const DeviceProfile = require('../models/DeviceProfile'); + +// Register device +router.post('/device/register', async (req, res) => { + try { + const { userId, deviceInfo } = req.body; + const deviceId = generateDeviceFingerprint(deviceInfo); + + let profile = await DeviceProfile.findOne({ userId, deviceId }); + + if (!profile) { + profile = new DeviceProfile({ + userId, + deviceId, + deviceInfo, + firstSeen: new Date(), + isKnownDevice: false + }); + } + + profile.lastSeen = new Date(); + await profile.save(); + + res.json({ + deviceId, + isNewDevice: !profile.isKnownDevice, + trustScore: profile.trustScore + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Verify device consistency +router.post('/device/verify-consistency', async (req, res) => { + try { + const { userId, currentDeviceInfo, behaviorMetrics } = req.body; + const userDevices = await DeviceProfile.find({ userId }); + + if (userDevices.length === 0) { + return res.json({ consistent: true, consistency: 100 }); + } + + const avgBehavior = calculateAverageBehavior(userDevices); + const consistency = calculateConsistency(behaviorMetrics, avgBehavior); + + res.json({ + consistency, + isConsistent: consistency > 70, + deviceCount: userDevices.length + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get all devices for user +router.get('/device/list/:userId', async (req, res) => { + try { + const devices = await DeviceProfile.find({ userId: req.params.userId }) + .select('deviceId deviceInfo trustScore firstSeen lastSeen') + .lean(); + + res.json({ devices }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Mark device as trusted +router.post('/device/:deviceId/trust', async (req, res) => { + try { + const device = await DeviceProfile.findOneAndUpdate( + { deviceId: req.params.deviceId }, + { isKnownDevice: true, trustScore: 100 }, + { new: true } + ); + + res.json({ message: 'Device marked as trusted', device }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Helper: Generate device fingerprint +function generateDeviceFingerprint(deviceInfo) { + const data = JSON.stringify({ + osType: deviceInfo.osType, + browserAgent: deviceInfo.browserAgent, + screenResolution: deviceInfo.screenResolution, + timezone: deviceInfo.timezone + }); + + return crypto.createHash('sha256').update(data).digest('hex').slice(0, 16); +} + +// Helper: Calculate average behavior across devices +function calculateAverageBehavior(devices) { + const avgKeystrokeTime = devices.reduce((sum, d) => + sum + (d.behaviorProfile?.avgKeystrokeTime || 100), 0 + ) / devices.length; + + const avgTypingSpeed = devices.reduce((sum, d) => + sum + (d.behaviorProfile?.typingSpeed || 5), 0 + ) / devices.length; + + return { avgKeystrokeTime, avgTypingSpeed }; +} + +// Helper: Calculate consistency score +function calculateConsistency(current, avg) { + const keystrokeDiff = Math.abs(current.avgKeystrokeTime - avg.avgKeystrokeTime); + const keystrokeScore = Math.max(0, 100 - (keystrokeDiff / avg.avgKeystrokeTime) * 100); + + const speedDiff = Math.abs(current.typingSpeed - avg.avgTypingSpeed); + const speedScore = Math.max(0, 100 - (speedDiff / avg.avgTypingSpeed) * 100); + + return Math.round((keystrokeScore + speedScore) / 2); +} + +module.exports = router; diff --git a/routes/pasteDetection.js b/routes/pasteDetection.js new file mode 100644 index 000000000..f4d086728 --- /dev/null +++ b/routes/pasteDetection.js @@ -0,0 +1,283 @@ +const express = require('express'); +const router = express.Router(); +const PasteDetectionLog = require('../models/PasteDetectionLog'); + +// Analyze text session for paste/AI injection +router.post('/detect-paste-injection', async (req, res) => { + try { + const { keystrokeData, contentText, sessionId, userId } = req.body; + + const segments = segmentContent(contentText); + const detectionResults = []; + + for (const segment of segments) { + const analysis = analyzeSegment( + segment, + keystrokeData, + contentText + ); + detectionResults.push(analysis); + } + + const summary = calculateSummary(detectionResults); + + const log = new PasteDetectionLog({ + userId, + textSessionId: sessionId, + detectionResults, + summaryAnalysis: summary + }); + + await log.save(); + + res.json({ + sessionId, + analysis: detectionResults, + summary, + overallVerdict: summary.suspicionLevel, + recommendations: generateRecommendations(summary) + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Real-time paste detection +router.post('/detect-live-paste', async (req, res) => { + try { + const { recentText, keystrokeIntervals, previousContent } = req.body; + + const isInstantAppearance = detectInstantAppearance( + recentText, + keystrokeIntervals + ); + + if (isInstantAppearance) { + return res.json({ + pasteDetected: true, + confidence: 95, + message: "Text appears to be pasted", + flag: 'instant_appearance', + action: 'highlight_segment' + }); + } + + const uniformityScore = calculateUniformity(keystrokeIntervals); + + if (uniformityScore > 85) { + return res.json({ + aiLikelyDetected: true, + uniformityScore, + confidence: 70, + message: "Text shows highly uniform typing pattern (possible AI)", + flag: 'uniform_keystroke', + action: 'flag_for_review' + }); + } + + res.json({ + pasteDetected: false, + aiLikelyDetected: false, + confidence: 95, + message: "Text appears human-typed" + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Helper: Detect instant appearance +function detectInstantAppearance(newText, keystrokeIntervals) { + const textLength = newText.length; + const expectedIntervals = textLength - 1; + const missingIntervals = expectedIntervals - keystrokeIntervals.length; + + return missingIntervals > expectedIntervals * 0.5; +} + +// Helper: Calculate keystroke uniformity +function calculateUniformity(intervals) { + if (intervals.length < 3) return 0; + + const mean = intervals.reduce((a, b) => a + b, 0) / intervals.length; + const variance = intervals.reduce((sum, val) => + sum + Math.pow(val - mean, 2), 0) / intervals.length; + const stdDev = Math.sqrt(variance); + + const cv = (stdDev / mean) * 100; + return Math.max(0, 100 - cv * 2); +} + +// Helper: Segment content +function segmentContent(text) { + return text.split(/[.!?]\s+/).map((seg, idx, arr) => { + if (idx < arr.length - 1) { + return seg + '.'; + } + return seg; + }).filter(seg => seg.trim().length > 0); +} + +// Helper: Analyze single segment +function analyzeSegment(segment, keystrokeData, fullText) { + const startPos = fullText.indexOf(segment); + const endPos = startPos + segment.length; + + const segmentKeystrokes = keystrokeData.intervals.filter(k => + k.position >= startPos && k.position <= endPos + ); + + const isInstant = detectInstantAppearance(segment, segmentKeystrokes); + const uniformity = calculateUniformity( + segmentKeystrokes.map(k => k.interval) + ); + + const linguisticMarkers = analyzeLinguistics(segment); + const aiConfidence = calculateAIConfidence( + uniformity, + linguisticMarkers, + segmentKeystrokes.length + ); + + const pasteConfidence = isInstant ? 90 : 10; + + let verdict = 'human_typed'; + let flags = []; + + if (isInstant) { + verdict = 'pasted'; + flags.push('instant_appearance'); + } else if (aiConfidence > 80) { + verdict = 'ai_generated'; + flags.push('uniform_typing', 'linguistic_markers'); + } else if (uniformity > 85) { + verdict = 'uncertain'; + flags.push('high_uniformity'); + } + + if (linguisticMarkers.grammarAnomalies.length > 0) { + flags.push('grammar_anomaly'); + } + + return { + segmentId: `seg_${startPos}_${endPos}`, + startPosition: startPos, + endPosition: endPos, + segmentText: segment, + keystrokeAnalysis: { + hasKeystrokeIntervals: segmentKeystrokes.length > 0, + isInstantAppearance: isInstant, + uniformityScore: uniformity, + variationCv: segmentKeystrokes.length > 0 ? + (Math.sqrt( + segmentKeystrokes.reduce((sum, k) => sum + Math.pow(k.interval, 2), 0) / + segmentKeystrokes.length + ) / (segmentKeystrokes.reduce((a, b) => a + b.interval, 0) / segmentKeystrokes.length)) * 100 + : 0, + expectedIntervals: segment.length - 1, + actualIntervals: segmentKeystrokes.length + }, + linguisticMarkers, + aiConfidenceScore: aiConfidence, + pasteConfidenceScore: pasteConfidence, + verdict, + flags + }; +} + +// Helper: Analyze linguistic features +function analyzeLinguistics(text) { + const sentences = text.split(/[.!?]/).filter(s => s.trim()); + const words = text.toLowerCase().split(/\s+/); + const uniqueWords = new Set(words).size; + const vocabularyDiversity = (uniqueWords / words.length) * 100; + + const avgCharsPerSentence = text.length / (sentences.length || 1); + const punctuationCount = (text.match(/[.!?,;:-]/g) || []).length; + const punctuationConsistency = (punctuationCount / words.length) * 100; + + const aiMarkers = [ + 'furthermore', 'moreover', 'in addition', 'consequently', + 'undoubtedly', 'certainly', 'undeniably', 'appears to be' + ]; + + const grammarAnomalies = []; + aiMarkers.forEach(marker => { + if (text.toLowerCase().includes(marker)) { + grammarAnomalies.push(`AI_marker: ${marker}`); + } + }); + + return { + vocabularyShift: 100 - vocabularyDiversity, + sentenceComplexity: Math.round(avgCharsPerSentence), + punctuationConsistency: Math.round(punctuationConsistency), + grammarAnomalies + }; +} + +// Helper: Calculate AI confidence +function calculateAIConfidence(uniformity, linguistic, keystrokeCount) { + const weights = { + uniformity: 0.4, + linguistic: 0.3, + keystrokeCount: 0.3 + }; + + let uniformityScore = uniformity; + const linguisticAnomalies = linguistic.grammarAnomalies.length; + const linguisticScore = (linguisticAnomalies / 5) * 100; + const keystrokeScore = keystrokeCount < 20 ? 50 : Math.max(0, 100 - keystrokeCount / 2); + + const aiConfidence = + uniformityScore * weights.uniformity + + linguisticScore * weights.linguistic + + keystrokeScore * weights.keystrokeCount; + + return Math.round(Math.min(100, aiConfidence)); +} + +// Helper: Calculate summary +function calculateSummary(results) { + const pastedCount = results.filter(r => r.verdict === 'pasted').length; + const aiCount = results.filter(r => r.verdict === 'ai_generated').length; + + const totalSegments = results.length; + const mixedContent = (pastedCount + aiCount) > 0 && totalSegments > (pastedCount + aiCount); + + let suspicionLevel = 'low'; + if (pastedCount + aiCount > totalSegments * 0.5) { + suspicionLevel = 'high'; + } else if (pastedCount + aiCount > totalSegments * 0.2) { + suspicionLevel = 'medium'; + } + + return { + totalSegments, + pastedSegments: pastedCount, + aiSegments: aiCount, + mixedContentDetected: mixedContent, + suspicionLevel + }; +} + +// Helper: Generate recommendations +function generateRecommendations(summary) { + const recs = []; + + if (summary.suspicionLevel === 'high') { + recs.push('⚠️ High suspicion of non-human content'); + recs.push('Recommend manual review'); + recs.push('Consider requiring re-submission'); + } else if (summary.suspicionLevel === 'medium') { + recs.push('⚠️ Some segments show questionable patterns'); + recs.push('Review highlighted segments'); + recs.push('May require clarification from author'); + } else { + recs.push('✓ Content appears naturally authored'); + } + + return recs; +} + +module.exports = router; diff --git a/routes/privacy.js b/routes/privacy.js new file mode 100644 index 000000000..f4921f435 --- /dev/null +++ b/routes/privacy.js @@ -0,0 +1,144 @@ +const express = require('express'); +const router = express.Router(); +const EncryptionManager = require('../utils/encryption'); +const EncryptedKeystrokeData = require('../models/EncryptedKeystrokeData'); +const { requireAuth } = require('../utils/auth'); + +function hasValidAdminKey(candidateKey) { + return Boolean(process.env.ADMIN_KEY && candidateKey && candidateKey === process.env.ADMIN_KEY); +} + +// Store encrypted keystroke data +router.post('/privacy/encrypt-keystroke', async (req, res) => { + try { + const { userId, sessionId, keystrokeData } = req.body; + + const encryptionManager = new EncryptionManager(process.env.ENCRYPTION_KEY); + + const encrypted = encryptionManager.encrypt(keystrokeData); + const anonymized = EncryptionManager.anonymize(keystrokeData); + + const encData = new EncryptedKeystrokeData({ + userId, + sessionId, + encryptedData: encrypted, + anonymizedMetrics: { + avgKeystrokeTime: anonymized.avgKeystrokeTime || keystrokeData.avgKeystrokeTime, + stdDevKeystrokeTime: anonymized.stdDevKeystrokeTime || keystrokeData.stdDevKeystrokeTime, + pauseFrequency: (keystrokeData.pauseIntervals?.length || 0) + }, + encryptionTimestamp: new Date() + }); + + await encData.save(); + + res.json({ + message: 'Data encrypted and stored securely', + encryptedId: encData._id + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Retrieve decrypted keystroke data (admin only) +router.get('/privacy/decrypt/:encryptedId', async (req, res) => { + try { + // Check authorization (this should be admin-only) + if (!hasValidAdminKey(req.query.adminKey)) { + return res.status(403).json({ message: 'Unauthorized' }); + } + + const encData = await EncryptedKeystrokeData.findById(req.params.encryptedId); + + if (!encData) { + return res.status(404).json({ message: 'Encrypted data not found' }); + } + + const encryptionManager = new EncryptionManager(process.env.ENCRYPTION_KEY); + + const decrypted = encryptionManager.decrypt(encData.encryptedData); + + res.json({ + decryptedData: decrypted, + decryptedAt: new Date() + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get anonymized metrics (no decryption needed) +router.get('/privacy/anonymized/:sessionId', async (req, res) => { + try { + const encData = await EncryptedKeystrokeData.findOne({ sessionId: req.params.sessionId }); + + if (!encData) { + return res.status(404).json({ message: 'No data found' }); + } + + res.json({ + anonymizedMetrics: encData.anonymizedMetrics, + timestamp: encData.encryptionTimestamp + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// User consent management +router.post('/privacy/consent/:userId', async (req, res) => { + try { + const { consentGiven } = req.body; + + const updated = await EncryptedKeystrokeData.updateMany( + { userId: req.params.userId }, + { userConsent: consentGiven } + ); + + res.json({ + message: `Consent updated: ${consentGiven ? 'given' : 'revoked'}`, + updatedCount: updated.modifiedCount + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Delete user's encrypted data (GDPR right to be forgotten) +router.delete('/privacy/delete-user/:userId', requireAuth, async (req, res) => { + try { + // Verify ownership + if (req.userId !== req.params.userId && !hasValidAdminKey(req.query.adminKey)) { + return res.status(403).json({ message: 'Unauthorized' }); + } + + const deleted = await EncryptedKeystrokeData.deleteMany({ userId: req.params.userId }); + + res.json({ + message: 'All encrypted keystroke data deleted', + deletedCount: deleted.deletedCount + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get privacy status +router.get('/privacy/status/:userId', async (req, res) => { + try { + const data = await EncryptedKeystrokeData.findOne({ userId: req.params.userId }) + .select('dataEncryptionEnabled userConsent encryptionTimestamp') + .lean(); + + res.json({ + encryptionEnabled: data?.dataEncryptionEnabled || false, + userConsent: data?.userConsent || false, + lastEncrypted: data?.encryptionTimestamp + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/routes/reportSharing.js b/routes/reportSharing.js new file mode 100644 index 000000000..0fc6cf36e --- /dev/null +++ b/routes/reportSharing.js @@ -0,0 +1,159 @@ +const express = require('express'); +const router = express.Router(); +const Report = require('../models/Report'); +const Text = require('../models/Text'); + +// Generate shareable report +router.post('/report/generate', async (req, res) => { + try { + const { userId, sessionId, findings } = req.body; + + const session = await Text.findById(sessionId); + + if (!session) { + return res.status(404).json({ message: 'Session not found' }); + } + + const report = new Report({ + userId, + textSessionId: sessionId, + reportContent: { + title: `Authenticity Report - ${new Date().toLocaleDateString()}`, + summary: `Analysis of "${session.content.slice(0, 50)}..."`, + trustScore: findings.trustScore || 0, + analysisDate: new Date(), + findings: findings.findings || [] + }, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days + }); + + await report.save(); + + const frontendUrl = process.env.FRONTEND_URL || `http://localhost:${process.env.PORT || 3000}`; + const shareUrl = `${frontendUrl}/verify/${report.sharingToken}`; + + res.json({ + reportId: report._id, + sharingLink: shareUrl, + token: report.sharingToken, + expiresAt: report.expiresAt + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// View shared report +router.get('/report/verify/:token', async (req, res) => { + try { + const report = await Report.findOne({ sharingToken: req.params.token }); + + if (!report) { + return res.status(404).json({ message: 'Report not found' }); + } + + if (report.expiresAt && report.expiresAt < new Date()) { + return res.status(410).json({ message: 'Report has expired' }); + } + + // Check access restrictions + if (report.accessControl?.restrictedViewers && report.accessControl.restrictedViewers.length > 0) { + const viewerEmail = req.query.email; + if (!report.accessControl.restrictedViewers.includes(viewerEmail)) { + return res.status(403).json({ message: 'Access denied' }); + } + } + + // Log view + report.viewCount = (report.viewCount || 0) + 1; + report.viewLog = report.viewLog || []; + report.viewLog.push({ + timestamp: new Date(), + viewerIP: req.ip + }); + await report.save(); + + // Return report without sensitive data if not authorized + const reportData = { + reportContent: report.reportContent, + createdAt: report.createdAt, + expiresAt: report.expiresAt, + sharingToken: report.sharingToken + }; + + res.json(reportData); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get report details (authenticated) +router.get('/report/:reportId', async (req, res) => { + try { + const report = await Report.findById(req.params.reportId); + + if (!report) { + return res.status(404).json({ message: 'Report not found' }); + } + + res.json(report); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Update sharing settings +router.put('/report/:reportId/sharing', async (req, res) => { + try { + const { isPublic, restrictedViewers, expiresAt } = req.body; + + const report = await Report.findById(req.params.reportId); + if (!report) { + return res.status(404).json({ message: 'Report not found' }); + } + + if (typeof isPublic === 'boolean') { + report.isPublic = isPublic; + } + + if (Array.isArray(restrictedViewers)) { + report.accessControl = report.accessControl || {}; + report.accessControl.restrictedViewers = restrictedViewers; + } + + if (expiresAt !== undefined) { + report.expiresAt = expiresAt ? new Date(expiresAt) : null; + } + + await report.save(); + + res.json({ message: 'Sharing settings updated', report }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Revoke report sharing +router.post('/report/:reportId/revoke', async (req, res) => { + try { + await Report.findByIdAndDelete(req.params.reportId); + res.json({ message: 'Report access revoked' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get report list for user +router.get('/reports/user/:userId', async (req, res) => { + try { + const reports = await Report.find({ userId: req.params.userId }) + .select('reportContent sharingToken expiresAt createdAt viewCount') + .lean(); + + res.json({ reports }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/routes/trustScore.js b/routes/trustScore.js new file mode 100644 index 000000000..4b24fa565 --- /dev/null +++ b/routes/trustScore.js @@ -0,0 +1,117 @@ +const express = require('express'); +const router = express.Router(); +const SessionTrustScore = require('../models/SessionTrustScore'); + +// Calculate real-time trust score +router.post('/trust-score/calculate', async (req, res) => { + try { + const { userId, sessionId, sessionMetrics, recentAnalysis } = req.body; + + const keystrokeBehaviorScore = calculateKeystrokeBehavior(sessionMetrics); + const pasteDetectionScore = recentAnalysis?.pasteConfidence || 0; + const aiLikelihoodScore = recentAnalysis?.aiConfidenceScore || 0; + const anomalyScore = calculateAnomalyScore(sessionMetrics); + const deviceConsistencyScore = calculateDeviceConsistency(sessionMetrics.deviceInfo); + + const weights = { + keystroke: 0.25, + paste: 0.20, + ai: 0.20, + anomaly: 0.20, + device: 0.15 + }; + + const trustScore = + keystrokeBehaviorScore * weights.keystroke + + (100 - pasteDetectionScore) * weights.paste + + (100 - aiLikelihoodScore) * weights.ai + + (100 - anomalyScore) * weights.anomaly + + deviceConsistencyScore * weights.device; + + let riskLevel = 'low'; + if (trustScore < 50) riskLevel = 'high'; + else if (trustScore < 75) riskLevel = 'medium'; + + let scoreDoc = await SessionTrustScore.findOne({ textSessionId: sessionId }); + if (!scoreDoc) { + scoreDoc = new SessionTrustScore({ userId, textSessionId: sessionId }); + } + + scoreDoc.scoreHistory.push({ + timestamp: new Date(), + score: Math.round(trustScore), + factors: { + keystrokeBehavior: keystrokeBehaviorScore, + pasteDetection: 100 - pasteDetectionScore, + aiLikelihood: 100 - aiLikelihoodScore, + anomalyScore: 100 - anomalyScore, + devicesConsistency: deviceConsistencyScore + } + }); + + scoreDoc.currentScore = Math.round(trustScore); + scoreDoc.riskLevel = riskLevel; + + await scoreDoc.save(); + + res.json({ + trustScore: Math.round(trustScore), + riskLevel, + factors: { + keystrokeBehavior: keystrokeBehaviorScore, + pasteDetection: 100 - pasteDetectionScore, + aiLikelihood: 100 - aiLikelihoodScore, + anomalyScore: 100 - anomalyScore, + devicesConsistency: deviceConsistencyScore + } + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get trust score history +router.get('/trust-score/history/:sessionId', async (req, res) => { + try { + const scoreDoc = await SessionTrustScore.findOne({ textSessionId: req.params.sessionId }); + + if (!scoreDoc) { + return res.json({ scoreHistory: [] }); + } + + res.json({ + currentScore: scoreDoc.currentScore, + riskLevel: scoreDoc.riskLevel, + scoreHistory: scoreDoc.scoreHistory + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Helper: Calculate keystroke behavior score +function calculateKeystrokeBehavior(metrics) { + const baselineAvg = metrics.baselineAvgKeystrokeTime || 100; + const baselineStdDev = metrics.baselineStdDev || 30; + + const diff = Math.abs(metrics.avgKeystrokeTime - baselineAvg); + const score = Math.max(0, 100 - (diff / baselineAvg) * 100); + + return Math.round(score); +} + +// Helper: Calculate anomaly score (scaled 0-100) +function calculateAnomalyScore(metrics) { + // Placeholder: Would integrate with ML model + const isAnomaly = metrics.isAnomalous ? 50 : 0; + return Math.min(100, Math.max(0, isAnomaly)); +} + +// Helper: Calculate device consistency +function calculateDeviceConsistency(deviceInfo) { + // Check if device is known/expected + // Placeholder: Would check historical devices + return 100; // Default: assume known device +} + +module.exports = router; diff --git a/routes/versionControl.js b/routes/versionControl.js new file mode 100644 index 000000000..4d6616f42 --- /dev/null +++ b/routes/versionControl.js @@ -0,0 +1,106 @@ +const express = require('express'); +const router = express.Router(); +const WritingVersion = require('../models/WritingVersion'); + +// Record keystroke for replay +router.post('/version/record-keystroke', async (req, res) => { + try { + const { userId, sessionId, keystrokeEvent } = req.body; + + let version = await WritingVersion.findOne({ textSessionId: sessionId }); + if (!version) { + version = new WritingVersion({ + userId, + textSessionId: sessionId, + keystrokeReplay: [] + }); + } + + version.keystrokeReplay.push({ + timestamp: keystrokeEvent.timestamp, + type: keystrokeEvent.type, + character: keystrokeEvent.character, + text: String(keystrokeEvent.text || '').slice(0, 500), + position: keystrokeEvent.position, + interval: keystrokeEvent.interval + }); + + // Save version snapshot every 50 keystrokes + if (version.keystrokeReplay.length % 50 === 0) { + version.versions = version.versions || []; + version.versions.push({ + versionNumber: version.versions.length + 1, + timestamp: new Date(), + content: keystrokeEvent.currentContent || '', + characterCount: keystrokeEvent.currentContent?.length || 0, + changesSinceLast: { + inserted: 50, + deleted: 0, + modified: 0 + } + }); + } + + await version.save(); + res.json({ saved: true, keystrokeCount: version.keystrokeReplay.length }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get keystroke replay data +router.get('/version/replay/:sessionId', async (req, res) => { + try { + const version = await WritingVersion.findOne({ textSessionId: req.params.sessionId }); + + if (!version) { + return res.json({ keystrokeReplay: [] }); + } + + res.json({ + keystrokeReplay: version.keystrokeReplay, + versions: version.versions || [] + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get versions list +router.get('/version/versions/:sessionId', async (req, res) => { + try { + const version = await WritingVersion.findOne({ textSessionId: req.params.sessionId }); + + res.json({ + versions: version?.versions || [] + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Restore to specific version +router.post('/version/restore/:sessionId/:versionNumber', async (req, res) => { + try { + const version = await WritingVersion.findOne({ textSessionId: req.params.sessionId }); + + if (!version) { + return res.status(404).json({ message: 'Version not found' }); + } + + const targetVersion = version.versions.find(v => v.versionNumber === parseInt(req.params.versionNumber)); + + if (!targetVersion) { + return res.status(404).json({ message: 'Version number not found' }); + } + + res.json({ + restoredContent: targetVersion.content, + versionTimestamp: targetVersion.timestamp + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/server-integration.js b/server-integration.js new file mode 100644 index 000000000..e861f7c80 --- /dev/null +++ b/server-integration.js @@ -0,0 +1,10 @@ +const { app, startServer } = require('./server'); + +if (require.main === module) { + startServer(); +} + +module.exports = { + app, + startServer +}; diff --git a/server.js b/server.js new file mode 100644 index 000000000..91505b576 --- /dev/null +++ b/server.js @@ -0,0 +1,367 @@ +const path = require('path'); + +require('./utils/loadEnv'); + +const express = require('express'); +const mongoose = require('mongoose'); +const bcrypt = require('bcryptjs'); + +const User = require('./models/User'); +const Text = require('./models/Text'); +const BiometricProfile = require('./models/BiometricProfile'); + +const { requireAuth, signAuthToken } = require('./utils/auth'); +const { + buildSessionContext, + buildTextSessionPayload, + fetchSessionArtifacts, + runSessionAnalysis +} = require('./utils/sessionPipeline'); + +const biometricsRoutes = require('./routes/biometrics'); +const pasteDetectionRoutes = require('./routes/pasteDetection'); +const trustScoreRoutes = require('./routes/trustScore'); +const versionControlRoutes = require('./routes/versionControl'); +const deviceTrackingRoutes = require('./routes/deviceTracking'); +const reportSharingRoutes = require('./routes/reportSharing'); +const privacyRoutes = require('./routes/privacy'); + +const app = express(); + +const HOST = process.env.HOST || '127.0.0.1'; +const PORT = Number(process.env.PORT) || 3000; +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://127.0.0.1:27017/vi-notes'; + +let mongoConnectPromise = null; +let databaseListenersAttached = false; + +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +function getDatabaseStatus() { + const stateMap = { + 0: 'disconnected', + 1: 'connected', + 2: 'connecting', + 3: 'disconnecting' + }; + + return stateMap[mongoose.connection.readyState] || 'unknown'; +} + +function isDatabaseReady() { + return mongoose.connection.readyState === 1; +} + +function sendDatabaseUnavailable(res) { + return res.status(503).json({ + message: 'Database is not connected. Check MONGODB_URI and MongoDB availability.', + database: getDatabaseStatus() + }); +} + +async function connectToDatabase() { + if (mongoose.connection.readyState === 1) { + return mongoose.connection; + } + + if (mongoConnectPromise) { + return mongoConnectPromise; + } + + if (!databaseListenersAttached) { + databaseListenersAttached = true; + + mongoose.connection.on('connected', () => { + console.log(`MongoDB connected: ${MONGODB_URI}`); + }); + + mongoose.connection.on('disconnected', () => { + console.warn('MongoDB disconnected'); + }); + + mongoose.connection.on('error', (err) => { + console.error(`MongoDB runtime error: ${err.message}`); + }); + } + + mongoConnectPromise = mongoose.connect(MONGODB_URI, { + serverSelectionTimeoutMS: 5000 + }).then(() => mongoose.connection) + .catch((err) => { + console.error(`MongoDB connection error: ${err.message}`); + return null; + }) + .finally(() => { + mongoConnectPromise = null; + }); + + return mongoConnectPromise; +} + +function normalizeEmail(email) { + return String(email || '').trim().toLowerCase(); +} + +function getUsernameFromInput(username, email) { + const trimmedUsername = String(username || '').trim(); + if (trimmedUsername) { + return trimmedUsername; + } + + const normalizedEmail = normalizeEmail(email); + return normalizedEmail.includes('@') ? normalizedEmail.split('@')[0] : normalizedEmail; +} + +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date(), + database: getDatabaseStatus() + }); +}); + +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +app.get('/verify/:token', (req, res) => { + res.sendFile(path.join(__dirname, 'public', 'report.html')); +}); + +app.post('/register', async (req, res) => { + try { + if (!isDatabaseReady()) { + return sendDatabaseUnavailable(res); + } + + const { username, email, password } = req.body; + const normalizedEmail = normalizeEmail(email); + const resolvedUsername = getUsernameFromInput(username, normalizedEmail); + + if (!normalizedEmail || !password) { + return res.status(400).json({ + message: 'Email and password are required' + }); + } + + const existingUser = await User.findOne({ email: normalizedEmail }); + if (existingUser) { + return res.status(409).json({ message: 'User already exists' }); + } + + const hashedPassword = await bcrypt.hash(password, 10); + + const user = new User({ + username: resolvedUsername, + email: normalizedEmail, + password: hashedPassword + }); + + await user.save(); + + res.status(201).json({ + message: 'User registered successfully', + userId: user._id + }); + } catch (err) { + console.error('Register error:', err); + res.status(500).json({ message: 'Error registering user' }); + } +}); + +app.post('/login', async (req, res) => { + try { + if (!isDatabaseReady()) { + return sendDatabaseUnavailable(res); + } + + const { email, password } = req.body; + const normalizedEmail = normalizeEmail(email); + + if (!normalizedEmail || !password) { + return res.status(400).json({ + message: 'Email and password are required' + }); + } + + const user = await User.findOne({ email: normalizedEmail }); + + if (!user) { + return res.status(400).json({ message: 'User not found' }); + } + + const isMatch = await bcrypt.compare(password, user.password); + + if (!isMatch) { + return res.status(400).json({ message: 'Invalid password' }); + } + + const token = signAuthToken({ id: user._id }); + const bioProfile = await BiometricProfile.findOne({ userId: user._id }); + + res.json({ + message: 'Login successful', + token, + userId: user._id, + requiresBiometricVerification: bioProfile?.status === 'active' + }); + } catch (err) { + console.error('Login error:', err); + res.status(500).json({ message: 'Error logging in' }); + } +}); + +app.post('/save', requireAuth, async (req, res) => { + try { + if (!isDatabaseReady()) { + return sendDatabaseUnavailable(res); + } + + const sessionData = buildSessionContext(req.body); + if (!sessionData.content.trim()) { + return res.status(400).json({ + message: 'Content must not be empty' + }); + } + + const textSession = new Text({ + _id: new mongoose.Types.ObjectId(), + ...buildTextSessionPayload(req.userId, sessionData) + }); + + const analysis = await runSessionAnalysis(req.userId, textSession, sessionData); + + textSession.analysisSummary = analysis.textSummary; + textSession.artifactRefs = analysis.artifactRefs; + await textSession.save(); + + res.json({ + message: 'Session saved and advanced analysis generated', + sessionId: textSession._id, + analysis: analysis.textSummary, + records: analysis.records + }); + } catch (err) { + console.error('Save session error:', err.stack || err); + res.status(500).json({ + message: process.env.NODE_ENV === 'development' + ? err.message + : 'Error saving session' + }); + } +}); + +app.get('/my-sessions', requireAuth, async (req, res) => { + try { + if (!isDatabaseReady()) { + return sendDatabaseUnavailable(res); + } + + const sessions = await Text.find({ userId: req.userId }) + .select('content pasteCount sessionMetrics analysisSummary artifactRefs createdAt') + .sort({ createdAt: -1 }) + .lean(); + + res.json(sessions); + } catch (err) { + console.error('Fetch sessions error:', err); + res.status(500).json({ message: 'Error fetching sessions' }); + } +}); + +app.get('/session-details/:sessionId', requireAuth, async (req, res) => { + try { + if (!isDatabaseReady()) { + return sendDatabaseUnavailable(res); + } + + const session = await Text.findOne({ + _id: req.params.sessionId, + userId: req.userId + }).lean(); + + if (!session) { + return res.status(404).json({ message: 'Session not found' }); + } + + const artifacts = await fetchSessionArtifacts(req.userId, session); + + res.json({ + session, + ...artifacts + }); + } catch (err) { + console.error('Session details error:', err); + res.status(500).json({ message: 'Error fetching session details' }); + } +}); + +app.use('/api', (req, res, next) => { + if (!isDatabaseReady()) { + return sendDatabaseUnavailable(res); + } + + next(); +}); + +app.use('/api', biometricsRoutes); +app.use('/api', pasteDetectionRoutes); +app.use('/api', trustScoreRoutes); +app.use('/api', versionControlRoutes); +app.use('/api', deviceTrackingRoutes); +app.use('/api', reportSharingRoutes); +app.use('/api', privacyRoutes); + +app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + res.status(500).json({ + message: 'Internal Server Error', + ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) + }); +}); + +async function startServer() { + const maxAttempts = 10; + + const listenOnPort = (port) => new Promise((resolve) => { + const server = app.listen(port, HOST, () => { + console.log(`Server running at http://${HOST}:${port}`); + console.log(`Advanced API routes available at http://${HOST}:${port}/api/*`); + resolve(server); + }); + + server.on('error', (err) => { + if (err.code === 'EADDRINUSE') { + console.warn(`Port ${port} on ${HOST} is busy, trying the next port.`); + resolve(null); + return; + } + + console.error(`HTTP server error: ${err.message}`); + resolve(null); + }); + }); + + void connectToDatabase(); + + for (let offset = 0; offset < maxAttempts; offset += 1) { + const candidatePort = PORT + offset; + const server = await listenOnPort(candidatePort); + if (server) { + return server; + } + } + + throw new Error(`Unable to start the server on ports ${PORT} through ${PORT + maxAttempts - 1}.`); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + app, + connectToDatabase, + startServer +}; diff --git a/utils/auth.js b/utils/auth.js new file mode 100644 index 000000000..1de2a0ef8 --- /dev/null +++ b/utils/auth.js @@ -0,0 +1,63 @@ +require('./loadEnv'); + +const jwt = require('jsonwebtoken'); + +function getJwtSecret() { + return process.env.JWT_SECRET || 'vi-notes-development-secret'; +} + +function getJwtExpiry() { + return process.env.JWT_EXPIRY || '24h'; +} + +function normalizeAuthToken(headerValue) { + if (!headerValue || typeof headerValue !== 'string') { + return null; + } + + return headerValue.startsWith('Bearer ') + ? headerValue.slice('Bearer '.length).trim() + : headerValue.trim(); +} + +function signAuthToken(payload, options = {}) { + const { expiresIn = getJwtExpiry(), ...restOptions } = options; + + return jwt.sign(payload, getJwtSecret(), { + expiresIn, + ...restOptions + }); +} + +function verifyAuthToken(headerValue) { + const token = normalizeAuthToken(headerValue); + + if (!token) { + throw new Error('Missing authorization token'); + } + + return jwt.verify(token, getJwtSecret()); +} + +function requireAuth(req, res, next) { + try { + const headerValue = req.headers.authorization || req.headers.Authorization; + const decoded = verifyAuthToken(headerValue); + + req.userId = String(decoded.id); + req.user = decoded; + next(); + } catch (err) { + const message = err.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token'; + res.status(401).json({ message }); + } +} + +module.exports = { + getJwtSecret, + getJwtExpiry, + normalizeAuthToken, + signAuthToken, + verifyAuthToken, + requireAuth +}; diff --git a/utils/encryption.js b/utils/encryption.js new file mode 100644 index 000000000..996183199 --- /dev/null +++ b/utils/encryption.js @@ -0,0 +1,85 @@ +const crypto = require('crypto'); + +// Encryption Manager for sensitive keystroke data +class EncryptionManager { + constructor(encryptionKey = null) { + this.ALGORITHM = 'aes-256-gcm'; + this.ENCRYPTION_KEY = EncryptionManager.resolveKey(encryptionKey); + } + + // Encrypt keystroke data + encrypt(data) { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(this.ALGORITHM, this.ENCRYPTION_KEY, iv); + + const payload = data === undefined ? null : data; + + let encrypted = cipher.update(JSON.stringify(payload), 'utf8', 'hex'); + encrypted += cipher.final('hex'); + + const authTag = cipher.getAuthTag(); + + return { + iv: iv.toString('hex'), + encryptedData: encrypted, + authTag: authTag.toString('hex') + }; + } + + // Decrypt keystroke data + decrypt(encryptedObj) { + const decipher = crypto.createDecipheriv( + this.ALGORITHM, + this.ENCRYPTION_KEY, + Buffer.from(encryptedObj.iv, 'hex') + ); + + decipher.setAuthTag(Buffer.from(encryptedObj.authTag, 'hex')); + + let decrypted = decipher.update(encryptedObj.encryptedData, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + + return JSON.parse(decrypted); + } + + // Anonymize keystroke data (remove PII, keep patterns) + static anonymize(keystrokeData = {}) { + return { + intervals: Array.isArray(keystrokeData.intervals) ? keystrokeData.intervals : [], + pausePatterns: Array.isArray(keystrokeData.pausePatterns) ? keystrokeData.pausePatterns : [], + totalDuration: Number(keystrokeData.totalDuration) || 0, + avgKeystrokeTime: Number(keystrokeData.avgKeystrokeTime) || 0, + stdDevKeystrokeTime: Number(keystrokeData.stdDevKeystrokeTime) || 0, + // Removed: character content, exact positions, detailed timing + }; + } + + static resolveKey(encryptionKey) { + if (Buffer.isBuffer(encryptionKey) && encryptionKey.length > 0) { + return encryptionKey.length === 32 + ? encryptionKey + : crypto.createHash('sha256').update(encryptionKey).digest(); + } + + if (typeof encryptionKey === 'string' && encryptionKey.trim()) { + const normalizedKey = encryptionKey.trim(); + + if (/^[0-9a-fA-F]{64}$/.test(normalizedKey)) { + return Buffer.from(normalizedKey, 'hex'); + } + + return crypto.createHash('sha256').update(normalizedKey).digest(); + } + + const fallbackSecret = process.env.JWT_SECRET || 'vi-notes-development-key'; + return crypto.createHash('sha256').update(fallbackSecret).digest(); + } + + // Hash device fingerprint + static hashDeviceFingerprint(deviceInfo) { + const data = JSON.stringify(deviceInfo); + return crypto.createHash('sha256').update(data).digest('hex'); + } +} + +module.exports = EncryptionManager; diff --git a/utils/loadEnv.js b/utils/loadEnv.js new file mode 100644 index 000000000..41dcc182c --- /dev/null +++ b/utils/loadEnv.js @@ -0,0 +1,35 @@ +const fs = require('fs'); +const path = require('path'); + +const envPath = path.join(__dirname, '..', '.env'); + +if (fs.existsSync(envPath)) { + const envFile = fs.readFileSync(envPath, 'utf8'); + + for (const rawLine of envFile.split(/\r?\n/)) { + const line = rawLine.trim(); + + if (!line || line.startsWith('#') || !line.includes('=')) { + continue; + } + + const separatorIndex = line.indexOf('='); + const key = line.slice(0, separatorIndex).trim(); + let value = line.slice(separatorIndex + 1).trim(); + + if (!key || process.env[key] !== undefined) { + continue; + } + + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith('\'') && value.endsWith('\'')) + ) { + value = value.slice(1, -1); + } + + process.env[key] = value; + } +} + +module.exports = process.env; diff --git a/utils/sessionPipeline.js b/utils/sessionPipeline.js new file mode 100644 index 000000000..48dd4879e --- /dev/null +++ b/utils/sessionPipeline.js @@ -0,0 +1,1053 @@ +const AuthenticationEvent = require('../models/AuthenticationEvent'); +const BiometricProfile = require('../models/BiometricProfile'); +const DeviceProfile = require('../models/DeviceProfile'); +const EncryptedKeystrokeData = require('../models/EncryptedKeystrokeData'); +const PasteDetectionLog = require('../models/PasteDetectionLog'); +const Report = require('../models/Report'); +const SessionTrustScore = require('../models/SessionTrustScore'); +const WritingVersion = require('../models/WritingVersion'); + +const EncryptionManager = require('./encryption'); + +function toNumber(value, fallback = 0) { + return Number.isFinite(Number(value)) ? Number(value) : fallback; +} + +function round(value, digits = 2) { + const factor = 10 ** digits; + return Math.round(toNumber(value) * factor) / factor; +} + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, toNumber(value))); +} + +function average(values) { + if (!Array.isArray(values) || values.length === 0) { + return 0; + } + + return values.reduce((sum, value) => sum + toNumber(value), 0) / values.length; +} + +function standardDeviation(values) { + if (!Array.isArray(values) || values.length === 0) { + return 0; + } + + const mean = average(values); + const variance = average(values.map((value) => (toNumber(value) - mean) ** 2)); + return Math.sqrt(variance); +} + +function normalizeText(text) { + return typeof text === 'string' ? text : ''; +} + +function normalizeTelemetry(telemetry = {}) { + const intervals = Array.isArray(telemetry.intervals) + ? telemetry.intervals.map((value) => clamp(value, 0, 60000)).filter((value) => value > 0) + : []; + + const pauseIntervals = Array.isArray(telemetry.pauseIntervals) + ? telemetry.pauseIntervals.map((value) => clamp(value, 0, 60000)).filter((value) => value > 0) + : []; + + const pastedSegments = Array.isArray(telemetry.pastedSegments) + ? telemetry.pastedSegments.map((segment, index) => ({ + startPosition: toNumber(segment.startPosition), + endPosition: toNumber(segment.endPosition), + text: normalizeText(segment.text).slice(0, 180), + length: toNumber(segment.length, normalizeText(segment.text).length), + trimmedLength: toNumber(segment.trimmedLength, normalizeText(segment.text).trim().length), + timestamp: segment.timestamp || new Date().toISOString(), + segmentId: `paste_${index + 1}`, + isFormattingOnly: Boolean(segment.isFormattingOnly), + clipboardMatched: segment.clipboardMatched !== false, + replacedTextLength: toNumber(segment.replacedTextLength), + isDuplicateReplacement: Boolean(segment.isDuplicateReplacement) + })) + : []; + + const keystrokeEvents = Array.isArray(telemetry.keystrokeEvents) + ? telemetry.keystrokeEvents.slice(-400).map((event) => ({ + timestamp: toNumber(event.timestamp, Date.now()), + type: ['keystroke', 'delete', 'paste'].includes(event.type) ? event.type : 'keystroke', + character: normalizeText(event.character).slice(0, 20) || '[Key]', + text: normalizeText(event.text).slice(0, 500), + position: toNumber(event.position), + interval: toNumber(event.interval) + })) + : []; + + const versionHistory = Array.isArray(telemetry.versionHistory) + ? telemetry.versionHistory.slice(-20).map((version, index) => ({ + versionNumber: toNumber(version.versionNumber, index + 1), + timestamp: version.timestamp || new Date().toISOString(), + reason: normalizeText(version.reason) || 'typing', + content: normalizeText(version.content), + characterCount: toNumber(version.characterCount, normalizeText(version.content).length), + changesSinceLast: { + inserted: toNumber(version.changesSinceLast?.inserted), + deleted: toNumber(version.changesSinceLast?.deleted), + modified: toNumber(version.changesSinceLast?.modified) + } + })) + : []; + + const rawDeviceInfo = telemetry.deviceInfo || {}; + + return { + intervals, + pauseIntervals, + deleteCount: toNumber(telemetry.deleteCount), + focusLossCount: toNumber(telemetry.focusLossCount), + pastedSegments, + keystrokeEvents, + versionHistory, + deviceInfo: { + osType: normalizeText(rawDeviceInfo.osType) || 'Unknown', + browserAgent: normalizeText(rawDeviceInfo.browserAgent) || 'Unknown', + screenResolution: normalizeText(rawDeviceInfo.screenResolution) || 'Unknown', + timezone: normalizeText(rawDeviceInfo.timezone) || 'Unknown', + inputMethod: normalizeText(rawDeviceInfo.inputMethod) || 'keyboard', + language: normalizeText(rawDeviceInfo.language) || 'Unknown' + } + }; +} + +function buildDeviceSnapshot(deviceInfo) { + const baseDeviceInfo = { + osType: deviceInfo.osType, + browserAgent: deviceInfo.browserAgent, + screenResolution: deviceInfo.screenResolution, + timezone: deviceInfo.timezone, + inputMethod: deviceInfo.inputMethod + }; + + return { + deviceId: EncryptionManager.hashDeviceFingerprint(baseDeviceInfo).slice(0, 16), + ...baseDeviceInfo + }; +} + +function buildRhythmPattern(intervals) { + if (!intervals.length) { + return [0, 0, 0, 0, 0]; + } + + const maxValue = Math.max(...intervals); + if (!maxValue) { + return [0, 0, 0, 0, 0]; + } + + const buckets = [0, 0, 0, 0, 0]; + + intervals.forEach((interval) => { + const bucketIndex = Math.min(4, Math.floor((interval / maxValue) * 5)); + buckets[bucketIndex] += 1; + }); + + return buckets; +} + +function buildLinguisticMetrics(content) { + const safeContent = normalizeText(content); + const words = safeContent.match(/\b[\w'-]+\b/g) || []; + const sentences = safeContent + .split(/[.!?]+/) + .map((value) => value.trim()) + .filter(Boolean); + const paragraphs = safeContent + .split(/\n+/) + .map((value) => value.trim()) + .filter(Boolean); + const uniqueWords = new Set(words.map((word) => word.toLowerCase())); + + return { + wordCount: words.length, + sentenceCount: sentences.length, + paragraphCount: paragraphs.length || (safeContent ? 1 : 0), + vocabularyDiversity: round(words.length ? (uniqueWords.size / words.length) * 100 : 0), + averageSentenceLength: round(sentences.length ? words.length / sentences.length : 0) + }; +} + +function buildSessionMetrics(content, duration, totalKeystrokes, pasteCount, telemetry, linguisticMetrics) { + const avgKeystrokeTime = round(average(telemetry.intervals)); + const stdDevKeystrokeTime = round(standardDeviation(telemetry.intervals)); + const avgPauseTime = round(average(telemetry.pauseIntervals)); + const longestPause = round(Math.max(0, ...telemetry.pauseIntervals)); + const typingSpeed = round(duration > 0 ? (linguisticMetrics.wordCount / duration) * 60 : 0); + + return { + avgKeystrokeTime, + stdDevKeystrokeTime, + avgPauseTime, + longestPause, + pauseCount: telemetry.pauseIntervals.length, + deletionCount: telemetry.deleteCount, + focusLossCount: telemetry.focusLossCount, + revisionCount: telemetry.versionHistory.length, + typingSpeed, + errorRate: round(totalKeystrokes > 0 ? (telemetry.deleteCount / totalKeystrokes) * 100 : 0), + pasteCount, + pasteRatio: round(content.length ? (pasteCount / Math.max(1, content.length)) * 100 : 0), + rhythmPattern: buildRhythmPattern(telemetry.intervals), + sampleSize: telemetry.intervals.length + }; +} + +function buildLinguisticMarkers(text) { + const safeText = normalizeText(text); + const metrics = buildLinguisticMetrics(safeText); + const punctuationCount = (safeText.match(/[.!?,;:]/g) || []).length; + const aiMarkers = [ + 'furthermore', + 'moreover', + 'in conclusion', + 'in summary', + 'additionally', + 'overall' + ]; + + return { + vocabularyShift: round(100 - metrics.vocabularyDiversity), + sentenceComplexity: round(metrics.averageSentenceLength), + punctuationConsistency: round(metrics.wordCount ? (punctuationCount / metrics.wordCount) * 100 : 0), + grammarAnomalies: aiMarkers + .filter((marker) => safeText.toLowerCase().includes(marker)) + .map((marker) => `marker:${marker}`) + }; +} + +function buildUniformityScore(intervals) { + if (!intervals.length) { + return 0; + } + + const mean = average(intervals); + if (!mean) { + return 0; + } + + const coefficientOfVariation = standardDeviation(intervals) / mean; + return round(clamp(100 - coefficientOfVariation * 100, 0, 100)); +} + +function classifyPasteSegment(segment, segmentText) { + const safeSegmentText = normalizeText(segmentText); + const trimmedLength = toNumber(segment.trimmedLength, safeSegmentText.trim().length); + const isFormattingOnly = Boolean(segment.isFormattingOnly || trimmedLength === 0); + const isDuplicateReplacement = Boolean(segment.isDuplicateReplacement); + const isShortPaste = trimmedLength > 0 && trimmedLength < 20; + const isMeaningfulPaste = !isFormattingOnly && !isDuplicateReplacement && trimmedLength >= 20; + const severity = isFormattingOnly + ? 'ignored' + : isMeaningfulPaste + ? trimmedLength >= 80 ? 'high' : 'medium' + : 'low'; + const flags = []; + + if (isFormattingOnly) { + flags.push('formatting_only_paste'); + } + + if (isDuplicateReplacement) { + flags.push('duplicate_replacement'); + } + + if (isShortPaste) { + flags.push('short_paste'); + } + + if (segment.clipboardMatched === false) { + flags.push('clipboard_mismatch'); + } + + if (trimmedLength >= 80) { + flags.push('long_paste'); + } + + return { + trimmedLength, + isFormattingOnly, + isDuplicateReplacement, + isShortPaste, + isMeaningfulPaste, + severity, + flags + }; +} + +function buildPasteAnalysis(content, telemetry, sessionMetrics, linguisticMetrics) { + const safeContent = normalizeText(content); + const uniformityScore = buildUniformityScore(telemetry.intervals); + const baseAiConfidence = round(clamp( + uniformityScore * 0.45 + + (sessionMetrics.pauseCount === 0 ? 20 : 0) + + (sessionMetrics.deletionCount <= 1 ? 12 : 0) + + (linguisticMetrics.vocabularyDiversity < 45 ? 12 : 0), + 0, + 100 + )); + + const detectionResults = telemetry.pastedSegments.map((segment) => { + const segmentText = segment.text || safeContent.slice(segment.startPosition, segment.endPosition); + const pasteClassification = classifyPasteSegment(segment, segmentText); + const verdict = pasteClassification.isFormattingOnly ? 'human_typed' : 'pasted'; + const pasteConfidenceScore = pasteClassification.isFormattingOnly + ? 0 + : pasteClassification.severity === 'low' + ? 25 + : pasteClassification.severity === 'medium' + ? 70 + : 90; + + return { + segmentId: segment.segmentId, + startPosition: segment.startPosition, + endPosition: segment.endPosition, + segmentText, + keystrokeAnalysis: { + hasKeystrokeIntervals: false, + isInstantAppearance: true, + uniformityScore, + variationCv: round( + sessionMetrics.avgKeystrokeTime + ? sessionMetrics.stdDevKeystrokeTime / sessionMetrics.avgKeystrokeTime + : 0 + ), + expectedIntervals: Math.max(0, segment.length - 1), + actualIntervals: 0 + }, + linguisticMarkers: buildLinguisticMarkers(segmentText), + aiConfidenceScore: round(clamp(baseAiConfidence + (segment.length > 80 ? 10 : 0), 0, 100)), + pasteConfidenceScore, + verdict, + flags: verdict === 'pasted' + ? ['clipboard_insert', ...pasteClassification.flags] + : pasteClassification.flags + }; + }); + + if (!detectionResults.length && safeContent) { + const verdict = baseAiConfidence >= 70 ? 'ai_generated' : baseAiConfidence >= 50 ? 'uncertain' : 'human_typed'; + + detectionResults.push({ + segmentId: 'full_text', + startPosition: 0, + endPosition: safeContent.length, + segmentText: safeContent.slice(0, 240), + keystrokeAnalysis: { + hasKeystrokeIntervals: telemetry.intervals.length > 0, + isInstantAppearance: false, + uniformityScore, + variationCv: round( + sessionMetrics.avgKeystrokeTime + ? sessionMetrics.stdDevKeystrokeTime / sessionMetrics.avgKeystrokeTime + : 0 + ), + expectedIntervals: Math.max(0, safeContent.length - 1), + actualIntervals: telemetry.intervals.length + }, + linguisticMarkers: buildLinguisticMarkers(safeContent), + aiConfidenceScore: baseAiConfidence, + pasteConfidenceScore: 0, + verdict, + flags: verdict === 'ai_generated' ? ['uniform_typing_pattern'] : [] + }); + } + + const pastedSegments = detectionResults.filter((result) => result.verdict === 'pasted').length; + const significantPastedSegments = detectionResults.filter((result) => ( + result.verdict === 'pasted' && + !result.flags.includes('short_paste') && + !result.flags.includes('duplicate_replacement') + )).length; + const trivialPastedSegments = detectionResults.filter((result) => ( + result.verdict === 'pasted' && + (result.flags.includes('short_paste') || result.flags.includes('duplicate_replacement')) + )).length; + const ignoredPastedSegments = detectionResults.filter((result) => result.flags.includes('formatting_only_paste')).length; + const aiSegments = detectionResults.filter((result) => result.verdict === 'ai_generated').length; + const suspiciousSegments = detectionResults.filter((result) => ( + result.verdict === 'ai_generated' || + result.verdict === 'uncertain' || + (result.verdict === 'pasted' && !result.flags.includes('short_paste') && !result.flags.includes('duplicate_replacement')) + )).length; + const suspicionRatio = detectionResults.length ? suspiciousSegments / detectionResults.length : 0; + + return { + detectionResults, + summaryAnalysis: { + totalSegments: detectionResults.length, + pastedSegments, + significantPastedSegments, + trivialPastedSegments, + ignoredPastedSegments, + aiSegments, + mixedContentDetected: significantPastedSegments > 0 && safeContent.length > 0, + suspicionLevel: significantPastedSegments > 0 || suspicionRatio >= 0.6 + ? 'high' + : trivialPastedSegments > 0 || suspicionRatio >= 0.3 + ? 'medium' + : 'low' + }, + overallAiConfidence: baseAiConfidence + }; +} + +function calculateSimilarityScore(currentValue, baselineValue) { + if (!baselineValue) { + return 100; + } + + const difference = Math.abs(toNumber(currentValue) - toNumber(baselineValue)); + return round(clamp(100 - (difference / Math.max(1, baselineValue)) * 100, 0, 100)); +} + +function buildDeviceConsistencyScore(sessionMetrics, profiles) { + if (!profiles.length) { + return 100; + } + + const avgKeystrokeTime = average( + profiles.map((profile) => profile.behaviorProfile?.avgKeystrokeTime || sessionMetrics.avgKeystrokeTime) + ); + const avgTypingSpeed = average( + profiles.map((profile) => profile.behaviorProfile?.typingSpeed || sessionMetrics.typingSpeed) + ); + + return round( + ( + calculateSimilarityScore(sessionMetrics.avgKeystrokeTime, avgKeystrokeTime) + + calculateSimilarityScore(sessionMetrics.typingSpeed, avgTypingSpeed) + ) / 2 + ); +} + +async function upsertDeviceProfile(userId, deviceSnapshot, sessionMetrics, pasteCount) { + const userProfiles = await DeviceProfile.find({ userId }); + const consistencyScore = buildDeviceConsistencyScore(sessionMetrics, userProfiles); + const existingProfile = userProfiles.find((profile) => profile.deviceId === deviceSnapshot.deviceId); + + const deviceInfo = { + osType: deviceSnapshot.osType, + browserAgent: deviceSnapshot.browserAgent, + screenResolution: deviceSnapshot.screenResolution, + timezone: deviceSnapshot.timezone, + inputMethod: deviceSnapshot.inputMethod + }; + + const behaviorProfile = existingProfile + ? { + avgKeystrokeTime: round(average([existingProfile.behaviorProfile?.avgKeystrokeTime, sessionMetrics.avgKeystrokeTime])), + typingSpeed: round(average([existingProfile.behaviorProfile?.typingSpeed, sessionMetrics.typingSpeed])), + errorRate: round(average([existingProfile.behaviorProfile?.errorRate, sessionMetrics.errorRate])), + pasteFrequency: round(average([existingProfile.behaviorProfile?.pasteFrequency, pasteCount])) + } + : { + avgKeystrokeTime: sessionMetrics.avgKeystrokeTime, + typingSpeed: sessionMetrics.typingSpeed, + errorRate: sessionMetrics.errorRate, + pasteFrequency: pasteCount + }; + + const profile = existingProfile || new DeviceProfile({ + userId, + deviceId: deviceSnapshot.deviceId, + firstSeen: new Date() + }); + + profile.deviceInfo = deviceInfo; + profile.behaviorProfile = behaviorProfile; + profile.trustScore = existingProfile ? round(average([profile.trustScore, consistencyScore])) : (userProfiles.length ? consistencyScore : 100); + profile.isKnownDevice = Boolean(existingProfile || userProfiles.length === 0); + profile.lastSeen = new Date(); + + await profile.save(); + + return { + profileId: String(profile._id), + deviceId: profile.deviceId, + trustScore: round(profile.trustScore), + consistencyScore, + isKnownDevice: profile.isKnownDevice + }; +} + +function getBiometricDeviceKey(deviceSnapshot) { + return [ + deviceSnapshot.osType, + deviceSnapshot.browserAgent, + deviceSnapshot.screenResolution, + deviceSnapshot.timezone + ].join('|'); +} + +async function upsertBiometricProfile(userId, deviceSnapshot, sessionMetrics) { + const currentMetrics = { + avgKeystrokeTime: sessionMetrics.avgKeystrokeTime, + stdDevKeystrokeTime: sessionMetrics.stdDevKeystrokeTime, + avgPauseTime: sessionMetrics.avgPauseTime, + typingSpeed: sessionMetrics.typingSpeed, + deletionRate: sessionMetrics.errorRate, + rhythmPattern: sessionMetrics.rhythmPattern + }; + + let profile = await BiometricProfile.findOne({ userId }); + + if (!profile) { + profile = new BiometricProfile({ + userId, + baselineMetrics: currentMetrics, + deviceSignature: { + keyboardType: deviceSnapshot.inputMethod, + osType: deviceSnapshot.osType, + browserAgent: deviceSnapshot.browserAgent, + screenResolution: deviceSnapshot.screenResolution, + timezone: deviceSnapshot.timezone + }, + baselineRequired: 3, + baselineSessionsCount: 1, + status: 'collecting_baseline', + lastUpdated: new Date() + }); + + await profile.save(); + + return { + profileId: String(profile._id), + profileStatus: profile.status, + verificationStatus: 'success', + verificationScore: 100, + flags: ['baseline_started'] + }; + } + + const baseline = profile.baselineMetrics || {}; + const verificationScore = round(average([ + calculateSimilarityScore(sessionMetrics.avgKeystrokeTime, baseline.avgKeystrokeTime), + calculateSimilarityScore(sessionMetrics.stdDevKeystrokeTime, baseline.stdDevKeystrokeTime), + calculateSimilarityScore(sessionMetrics.avgPauseTime, baseline.avgPauseTime), + calculateSimilarityScore(sessionMetrics.typingSpeed, baseline.typingSpeed) + ])); + + const previousDeviceKey = [ + profile.deviceSignature?.osType, + profile.deviceSignature?.browserAgent, + profile.deviceSignature?.screenResolution, + profile.deviceSignature?.timezone + ].join('|'); + const currentDeviceKey = getBiometricDeviceKey(deviceSnapshot); + const flags = []; + + if (previousDeviceKey && previousDeviceKey !== currentDeviceKey) { + flags.push('new_device_signature'); + } + + if (verificationScore < 60) { + flags.push('behavior_shift'); + } + + const verificationStatus = verificationScore < 55 + ? 'failed' + : verificationScore < 75 + ? 'challenge' + : 'success'; + + const alpha = 0.35; + profile.baselineMetrics = { + avgKeystrokeTime: round((1 - alpha) * toNumber(baseline.avgKeystrokeTime, currentMetrics.avgKeystrokeTime) + alpha * currentMetrics.avgKeystrokeTime), + stdDevKeystrokeTime: round((1 - alpha) * toNumber(baseline.stdDevKeystrokeTime, currentMetrics.stdDevKeystrokeTime) + alpha * currentMetrics.stdDevKeystrokeTime), + avgPauseTime: round((1 - alpha) * toNumber(baseline.avgPauseTime, currentMetrics.avgPauseTime) + alpha * currentMetrics.avgPauseTime), + typingSpeed: round((1 - alpha) * toNumber(baseline.typingSpeed, currentMetrics.typingSpeed) + alpha * currentMetrics.typingSpeed), + deletionRate: round((1 - alpha) * toNumber(baseline.deletionRate, currentMetrics.deletionRate) + alpha * currentMetrics.deletionRate), + rhythmPattern: currentMetrics.rhythmPattern + }; + profile.baselineSessionsCount += 1; + profile.status = profile.baselineSessionsCount >= profile.baselineRequired ? 'active' : profile.status; + profile.failureCount = verificationStatus === 'failed' ? profile.failureCount + 1 : 0; + profile.lastVerificationFailed = verificationStatus === 'failed' ? new Date() : profile.lastVerificationFailed; + profile.deviceSignature = { + keyboardType: deviceSnapshot.inputMethod, + osType: deviceSnapshot.osType, + browserAgent: deviceSnapshot.browserAgent, + screenResolution: deviceSnapshot.screenResolution, + timezone: deviceSnapshot.timezone + }; + profile.lastUpdated = new Date(); + + await profile.save(); + + return { + profileId: String(profile._id), + profileStatus: profile.status, + verificationStatus, + verificationScore, + flags + }; +} + +function buildEventMetrics(sessionMetrics) { + return { + avgKeystrokeTime: sessionMetrics.avgKeystrokeTime, + typingSpeed: sessionMetrics.typingSpeed, + sampleSize: sessionMetrics.sampleSize + }; +} + +async function createAuthenticationEvent(userId, sessionId, deviceSnapshot, sessionMetrics, biometricSummary) { + const authEvent = new AuthenticationEvent({ + userId, + sessionId: String(sessionId), + keystrokeMetrics: buildEventMetrics(sessionMetrics), + deviceInfo: { + deviceId: deviceSnapshot.deviceId, + osType: deviceSnapshot.osType, + timezone: deviceSnapshot.timezone + }, + verificationResult: { + status: biometricSummary.verificationStatus, + confidenceScore: biometricSummary.verificationScore, + matchPercentage: biometricSummary.verificationScore, + flags: biometricSummary.flags + }, + fallbackMethod: biometricSummary.verificationStatus === 'challenge' ? 'email_link' : 'none', + fallbackStatus: biometricSummary.verificationStatus === 'challenge' ? 'recommended' : 'not_required' + }); + + await authEvent.save(); + return authEvent; +} + +async function createWritingVersion(userId, sessionId, content, telemetry) { + const versions = telemetry.versionHistory.length + ? telemetry.versionHistory + : [{ + versionNumber: 1, + timestamp: new Date().toISOString(), + reason: 'save', + content, + characterCount: content.length, + changesSinceLast: { + inserted: content.length, + deleted: 0, + modified: 0 + } + }]; + + const versionDoc = new WritingVersion({ + userId, + textSessionId: sessionId, + versions: versions.map((version) => ({ + versionNumber: version.versionNumber, + timestamp: new Date(version.timestamp), + content: version.content, + characterCount: version.characterCount, + changesSinceLast: version.changesSinceLast + })), + keystrokeReplay: telemetry.keystrokeEvents.map((event) => ({ + timestamp: event.timestamp, + type: event.type, + character: event.character, + text: normalizeText(event.text).slice(0, 500), + position: event.position, + interval: event.interval + })) + }); + + await versionDoc.save(); + return versionDoc; +} + +async function createEncryptedTelemetry(userId, sessionId, telemetry, sessionMetrics) { + const encryptionManager = new EncryptionManager(process.env.ENCRYPTION_KEY); + + const encryptedDoc = new EncryptedKeystrokeData({ + userId, + sessionId, + encryptedData: encryptionManager.encrypt({ + intervals: telemetry.intervals, + pauseIntervals: telemetry.pauseIntervals, + pastedSegments: telemetry.pastedSegments, + keystrokeEvents: telemetry.keystrokeEvents + }), + anonymizedMetrics: { + avgKeystrokeTime: sessionMetrics.avgKeystrokeTime, + stdDevKeystrokeTime: sessionMetrics.stdDevKeystrokeTime, + pauseFrequency: telemetry.pauseIntervals.length + }, + encryptionTimestamp: new Date() + }); + + await encryptedDoc.save(); + return encryptedDoc; +} + +async function createPasteDetectionLog(userId, sessionId, pasteAnalysis) { + const log = new PasteDetectionLog({ + userId, + textSessionId: sessionId, + detectionResults: pasteAnalysis.detectionResults, + summaryAnalysis: pasteAnalysis.summaryAnalysis + }); + + await log.save(); + return log; +} + +function buildTrustSummary(sessionMetrics, pasteAnalysis, deviceSummary, biometricSummary) { + const significantPastedSegments = toNumber(pasteAnalysis.summaryAnalysis.significantPastedSegments); + const trivialPastedSegments = toNumber(pasteAnalysis.summaryAnalysis.trivialPastedSegments); + const ignoredPastedSegments = toNumber(pasteAnalysis.summaryAnalysis.ignoredPastedSegments); + const keystrokeBehavior = round(( + clamp(100 - Math.abs(sessionMetrics.avgKeystrokeTime - 220) / 3, 0, 100) + + clamp(sessionMetrics.stdDevKeystrokeTime * 1.4, 0, 100) + + clamp(35 + sessionMetrics.deletionCount * 8 + sessionMetrics.pauseCount * 6, 0, 100) + ) / 3); + const pasteDetection = round(clamp( + 100 - significantPastedSegments * 35 - trivialPastedSegments * 8, + 0, + 100 + )); + const aiLikelihood = round(clamp(100 - pasteAnalysis.overallAiConfidence, 0, 100)); + const anomalyScore = round(clamp( + 100 - (sessionMetrics.focusLossCount * 8 + (sessionMetrics.deletionCount === 0 ? 8 : 0)), + 0, + 100 + )); + const devicesConsistency = round(deviceSummary.consistencyScore); + + const trustScore = round( + keystrokeBehavior * 0.30 + + pasteDetection * 0.25 + + aiLikelihood * 0.20 + + anomalyScore * 0.10 + + devicesConsistency * 0.15 + ); + + const warnings = []; + if (significantPastedSegments > 0) { + warnings.push({ + timestamp: new Date(), + message: 'Meaningful clipboard paste detected during writing session', + severity: 'high' + }); + } else if (trivialPastedSegments > 0) { + warnings.push({ + timestamp: new Date(), + message: 'Minor clipboard paste detected, but it looks low risk', + severity: 'low' + }); + } + + if (pasteAnalysis.overallAiConfidence >= 60) { + warnings.push({ + timestamp: new Date(), + message: 'Typing pattern looks unusually uniform', + severity: 'medium' + }); + } + + if (!deviceSummary.isKnownDevice) { + warnings.push({ + timestamp: new Date(), + message: 'New device profile observed', + severity: 'medium' + }); + } + + return { + trustScore, + riskLevel: trustScore < 55 ? 'high' : trustScore < 75 ? 'medium' : 'low', + factors: { + keystrokeBehavior, + pasteDetection, + aiLikelihood, + anomalyScore, + devicesConsistency + }, + warnings, + pasteVerdict: significantPastedSegments > 0 + ? 'meaningful_paste_detected' + : trivialPastedSegments > 0 + ? 'minor_paste_activity' + : ignoredPastedSegments > 0 + ? 'formatting_paste_ignored' + : 'no_direct_paste_detected', + aiVerdict: pasteAnalysis.overallAiConfidence >= 70 + ? 'high_ai_suspicion' + : pasteAnalysis.overallAiConfidence >= 50 + ? 'medium_ai_suspicion' + : 'human_like_behavior', + biometricStatus: biometricSummary.profileStatus, + deviceTrustScore: deviceSummary.trustScore + }; +} + +async function createTrustScore(userId, sessionId, trustSummary) { + const trustDoc = new SessionTrustScore({ + userId, + textSessionId: sessionId, + scoreHistory: [{ + timestamp: new Date(), + score: trustSummary.trustScore, + factors: trustSummary.factors + }], + currentScore: trustSummary.trustScore, + riskLevel: trustSummary.riskLevel, + warnings: trustSummary.warnings + }); + + await trustDoc.save(); + return trustDoc; +} + +function buildReportFindings(sessionMetrics, linguisticMetrics, trustSummary, deviceSummary, biometricSummary) { + return [ + { + category: 'Behavior', + result: `Average keystroke time ${sessionMetrics.avgKeystrokeTime} ms with ${sessionMetrics.pauseCount} pauses and ${sessionMetrics.deletionCount} deletions.`, + confidence: trustSummary.factors.keystrokeBehavior + }, + { + category: 'Paste Detection', + result: `Verdict: ${trustSummary.pasteVerdict.replaceAll('_', ' ')}.`, + confidence: trustSummary.factors.pasteDetection + }, + { + category: 'AI Suspicion', + result: `Verdict: ${trustSummary.aiVerdict.replaceAll('_', ' ')}.`, + confidence: 100 - trustSummary.factors.aiLikelihood + }, + { + category: 'Device', + result: `Device ${deviceSummary.isKnownDevice ? 'matches' : 'does not match'} existing profile. Trust score ${deviceSummary.trustScore}.`, + confidence: deviceSummary.consistencyScore + }, + { + category: 'Biometric Profile', + result: `Profile status ${biometricSummary.profileStatus} with verification ${biometricSummary.verificationStatus}.`, + confidence: biometricSummary.verificationScore + }, + { + category: 'Linguistics', + result: `Vocabulary diversity ${linguisticMetrics.vocabularyDiversity}% across ${linguisticMetrics.wordCount} words.`, + confidence: clamp(linguisticMetrics.vocabularyDiversity, 0, 100) + } + ]; +} + +async function createReport(userId, sessionId, content, sessionMetrics, linguisticMetrics, trustSummary, deviceSummary, biometricSummary) { + const report = new Report({ + userId, + textSessionId: sessionId, + reportContent: { + title: `Vi-Notes Session Report - ${new Date().toLocaleDateString()}`, + summary: `Trust score ${trustSummary.trustScore}/100 for "${normalizeText(content).slice(0, 60)}"`, + trustScore: trustSummary.trustScore, + analysisDate: new Date(), + findings: buildReportFindings(sessionMetrics, linguisticMetrics, trustSummary, deviceSummary, biometricSummary) + }, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + }); + + await report.save(); + return report; +} + +function buildTextSessionPayload(userId, session) { + return { + userId, + content: session.content, + startTime: session.startTime, + endTime: session.endTime, + duration: session.duration, + pasteCount: session.pasteCount, + pastedTextLength: session.pastedTextLength, + totalKeystrokes: session.totalKeystrokes, + sessionMetrics: session.sessionMetrics, + linguisticMetrics: session.linguisticMetrics, + deviceSnapshot: session.deviceSnapshot + }; +} + +function buildSessionContext(body = {}) { + const content = normalizeText(body.content); + const startTime = body.startTime ? new Date(body.startTime) : new Date(); + const endTime = body.endTime ? new Date(body.endTime) : new Date(); + const duration = toNumber(body.duration, Math.max(1, (endTime - startTime) / 1000)); + const totalKeystrokes = toNumber(body.totalKeystrokes); + const telemetry = normalizeTelemetry(body.telemetry); + const pasteCount = telemetry.pastedSegments.length || toNumber(body.pasteCount); + const pastedTextLength = telemetry.pastedSegments.length + ? telemetry.pastedSegments.reduce((sum, segment) => sum + toNumber(segment.length), 0) + : toNumber(body.pastedTextLength); + const deviceSnapshot = buildDeviceSnapshot(telemetry.deviceInfo); + const linguisticMetrics = buildLinguisticMetrics(content); + const sessionMetrics = buildSessionMetrics( + content, + duration, + totalKeystrokes, + pasteCount, + telemetry, + linguisticMetrics + ); + + return { + content, + startTime, + endTime, + duration, + pasteCount, + pastedTextLength, + totalKeystrokes, + telemetry, + deviceSnapshot, + linguisticMetrics, + sessionMetrics + }; +} + +async function runSessionAnalysis(userId, textSession, sessionData) { + const { content, telemetry, sessionMetrics, linguisticMetrics, deviceSnapshot, pasteCount } = sessionData; + let stage = 'paste-analysis'; + + try { + const pasteAnalysis = buildPasteAnalysis(content, telemetry, sessionMetrics, linguisticMetrics); + + stage = 'device-profile'; + const deviceSummary = await upsertDeviceProfile(userId, deviceSnapshot, sessionMetrics, pasteCount); + + stage = 'biometric-profile'; + const biometricSummary = await upsertBiometricProfile(userId, deviceSnapshot, sessionMetrics); + + stage = 'authentication-event'; + const authEvent = await createAuthenticationEvent(userId, textSession._id, deviceSnapshot, sessionMetrics, biometricSummary); + + stage = 'paste-log'; + const pasteLog = await createPasteDetectionLog(userId, textSession._id, pasteAnalysis); + + stage = 'writing-version'; + const writingVersion = await createWritingVersion(userId, textSession._id, content, telemetry); + + stage = 'encrypted-telemetry'; + const encryptedTelemetry = await createEncryptedTelemetry(userId, textSession._id, telemetry, sessionMetrics); + + stage = 'trust-score'; + const trustSummary = buildTrustSummary(sessionMetrics, pasteAnalysis, deviceSummary, biometricSummary); + const trustScoreDoc = await createTrustScore(userId, textSession._id, trustSummary); + + stage = 'report'; + const report = await createReport( + userId, + textSession._id, + content, + sessionMetrics, + linguisticMetrics, + trustSummary, + deviceSummary, + biometricSummary + ); + + const artifactRefs = { + authenticationEventId: String(authEvent._id), + deviceProfileId: deviceSummary.profileId, + biometricProfileId: biometricSummary.profileId, + pasteLogId: String(pasteLog._id), + writingVersionId: String(writingVersion._id), + encryptedTelemetryId: String(encryptedTelemetry._id), + trustScoreId: String(trustScoreDoc._id), + reportId: String(report._id) + }; + + return { + pasteAnalysis, + deviceSummary, + biometricSummary, + trustSummary, + report, + artifactRefs, + records: { + textId: String(textSession._id), + authEventId: artifactRefs.authenticationEventId, + pasteLogId: artifactRefs.pasteLogId, + writingVersionId: artifactRefs.writingVersionId, + encryptedTelemetryId: artifactRefs.encryptedTelemetryId, + trustScoreId: artifactRefs.trustScoreId, + reportId: artifactRefs.reportId + }, + textSummary: { + pasteVerdict: trustSummary.pasteVerdict, + aiVerdict: trustSummary.aiVerdict, + trustScore: trustSummary.trustScore, + riskLevel: trustSummary.riskLevel, + biometricStatus: biometricSummary.profileStatus, + deviceTrustScore: deviceSummary.trustScore, + reportToken: report.sharingToken + } + }; + } catch (err) { + err.message = `Analysis failed at ${stage}: ${err.message}`; + throw err; + } +} + +async function fetchSessionArtifacts(userId, session) { + const artifactRefs = session?.artifactRefs || {}; + const sessionId = session?._id; + const deviceId = session?.deviceSnapshot?.deviceId; + + const authEventsPromise = artifactRefs.authenticationEventId + ? AuthenticationEvent.find({ _id: artifactRefs.authenticationEventId, userId }).lean() + : AuthenticationEvent.find({ userId, sessionId: String(sessionId) }).lean(); + + const deviceProfilesPromise = artifactRefs.deviceProfileId + ? DeviceProfile.find({ _id: artifactRefs.deviceProfileId, userId }).lean() + : deviceId + ? DeviceProfile.find({ userId, deviceId }).lean() + : DeviceProfile.find({ userId }).lean(); + + const [authEvents, biometricProfile, deviceProfiles, encryptedTelemetry, pasteLog, report, trustScore, writingVersion] = await Promise.all([ + authEventsPromise, + artifactRefs.biometricProfileId + ? BiometricProfile.findOne({ _id: artifactRefs.biometricProfileId, userId }).lean() + : BiometricProfile.findOne({ userId }).lean(), + deviceProfilesPromise, + artifactRefs.encryptedTelemetryId + ? EncryptedKeystrokeData.findOne({ _id: artifactRefs.encryptedTelemetryId, userId }).lean() + : EncryptedKeystrokeData.findOne({ sessionId }).lean(), + artifactRefs.pasteLogId + ? PasteDetectionLog.findOne({ _id: artifactRefs.pasteLogId, userId }).lean() + : PasteDetectionLog.findOne({ textSessionId: sessionId }).lean(), + artifactRefs.reportId + ? Report.findOne({ _id: artifactRefs.reportId, userId }).lean() + : Report.findOne({ textSessionId: sessionId }).lean(), + artifactRefs.trustScoreId + ? SessionTrustScore.findOne({ _id: artifactRefs.trustScoreId, userId }).lean() + : SessionTrustScore.findOne({ textSessionId: sessionId }).lean(), + artifactRefs.writingVersionId + ? WritingVersion.findOne({ _id: artifactRefs.writingVersionId, userId }).lean() + : WritingVersion.findOne({ textSessionId: sessionId }).lean() + ]); + + return { + authEvents, + biometricProfile, + deviceProfiles, + encryptedTelemetry, + pasteLog, + report, + trustScore, + writingVersion + }; +} + +module.exports = { + buildSessionContext, + buildTextSessionPayload, + fetchSessionArtifacts, + runSessionAnalysis +};