@anonympins/fingerprint 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -140,10 +140,10 @@ const securityConfig = {
140
140
  // Useful for internal tools, trusted partners, or monitoring services.
141
141
  // This check is performed first for maximum efficiency.
142
142
  { type: 'allowlist', entries: [
143
- '192.168.1.100', // A specific internal IP
144
- '203.0.113.0/24', // A partner's network range
145
- '2001:db8::/32' // An IPv6 range
146
- ]},
143
+ '192.168.1.100', // A specific internal IP
144
+ '203.0.113.0/24', // A partner's network range
145
+ '2001:db8::/32' // An IPv6 range
146
+ ]},
147
147
  // Option 2: DNS-verified bots (e.g., search engine crawlers).
148
148
  // This uses a secure DNS lookup (reverse then forward) to verify the bot's identity.
149
149
  // The result is cached per IP to avoid repeated DNS lookups.
@@ -198,17 +198,41 @@ The main Express middleware. It orchestrates identification, suspicion calculati
198
198
 
199
199
  #### `configureStore(store)`
200
200
  Allows replacing the in-memory store with an external datastore (like Redis) for persistence and scaling.
201
+ The library provides ready-to-use adapters for popular datastores like Redis and MongoDB, which automatically handle the Time-To-Live (TTL) required for temporary data like challenge secrets.
201
202
 
202
- See the Datastore Integration Guide for a complete example of creating a Redis store.
203
+ **Redis Example:**
203
204
 
204
205
  ```javascript
205
206
  import { configureStore } from './fingerprint.js';
206
- import { createRedisStore } from './redis-store.js'; // Assuming a redis store implementation exists
207
+ import { createRedisStore } from './redis-store.js';
208
+ import Redis from 'ioredis';
207
209
 
208
- const redisStore = createRedisStore(process.env.REDIS_URL);
210
+ const redisClient = new Redis(process.env.REDIS_URL);
211
+ const redisStore = createRedisStore(redisClient);
209
212
  configureStore(redisStore);
210
213
  ```
211
214
 
215
+ **MongoDB Example:**
216
+
217
+ ```javascript
218
+ import { configureStore } from './fingerprint.js';
219
+ import { createMongoDbStore } from './mongodb-store.js';
220
+ import { MongoClient } from 'mongodb';
221
+
222
+ const mongoClient = new MongoClient(process.env.MONGODB_URL);
223
+
224
+ // It's recommended to connect before your application starts listening.
225
+ await mongoClient.connect();
226
+
227
+ const mongoStore = createMongoDbStore(mongoClient.db('your-db-name'), 'sessions'); // 'sessions' is the collection name
228
+ configureStore(mongoStore);
229
+
230
+ // IMPORTANT: For automatic expiration of challenges and other temporary data to work,
231
+ // you must create a TTL index on the `expiresAt` field in your MongoDB collection.
232
+ // Run this command in the mongo shell:
233
+ // db.sessions.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 })
234
+ ```
235
+
212
236
  #### `identifyRequest(req, res)`
213
237
  An asynchronous function that returns an identification string for a given request, based on its suspicion level (`device:<id>`, `suspicious_medium:<ip>`, etc.). Useful for integration with a custom rate-limiter.
214
238
 
@@ -300,34 +324,31 @@ To simplify setup, all client-side features can be enabled and configured throug
300
324
 
301
325
  ```javascript
302
326
  import { initializeClient } from './path/to/fingerprint.client.js';
303
-
304
- // --- EXAMPLES ---
305
-
306
- // Example 1: Enable all default protections and protect same-origin fetch requests.
327
+
328
+ /**
329
+ * Initializes all client-side protections.
330
+ * This is the recommended way to set up the client-side library.
331
+ */
307
332
  initializeClient({
308
- honeypots: ['email_confirm', 'user_nickname'], // Your honeypot field names
309
- fetch: {} // An empty object enables fetch protection for same-origin
310
- });
311
-
312
- // Example 2: Enable everything and protect a specific API domain.
313
- initializeClient({
314
- honeypots: ['email_confirm'],
333
+ // (Optional, default: true) Enable mouse movement tracking to detect non-human patterns.
334
+ // Set to `false` to disable.
335
+ mouse: true,
336
+
337
+ // (Optional, default: true) Enable keystroke dynamics tracking (timing between key presses).
338
+ // Set to `false` to disable.
339
+ keystrokes: true,
340
+
341
+ // (Optional) An array of `name` attributes for hidden form fields that act as bot traps.
342
+ honeypots: ['email_confirm', 'user_nickname', 'website_url'],
343
+
344
+ // (Optional) Enables automatic protection for `fetch` requests.
345
+ // If the `fetch` object is present, the protection is active.
315
346
  fetch: {
316
- targetDomains: ['api.yourdomain.com']
347
+ // (Optional) An array of domains to protect. If empty or not provided,
348
+ // it protects same-origin requests by default.
349
+ targetDomains: ['api.yourdomain.com', 'auth.yourdomain.com']
317
350
  }
318
351
  });
319
-
320
- // Example 3: Enable only mouse and keystroke tracking, without patching fetch.
321
- initializeClient({
322
- mouse: true,
323
- keystrokes: true
324
- });
325
-
326
- // Example 4: Disable keystroke tracking but keep other defaults.
327
- initializeClient({
328
- keystrokes: false,
329
- fetch: {}
330
- });
331
352
  ```
332
353
 
333
354
  ### Client-Side Behavioral Analysis
@@ -352,15 +373,15 @@ This provides a proactive, first-line defense against simple bots. By setting up
352
373
  If you prefer not to modify global functions or need fine-grained control over which requests are protected, you can use the `protectedFetch` wrapper. You must use this function instead of the standard `fetch` for your API calls.
353
374
 
354
375
  - **`protectedFetch(resource, options)`**: A wrapper around the native `fetch` API that automatically enriches requests with security headers. It adds:
355
- - `X-Device-Fingerprint`: The client's device fingerprint.
356
- - `X-Behavior-Metrics`: A JSON string containing the metrics collected by the behavioral trackers (e.g., mouse entropy, honeypot interaction).
376
+ - `X-Device-Fingerprint`: The client's device fingerprint.
377
+ - `X-Behavior-Metrics`: A JSON string containing the metrics collected by the behavioral trackers (e.g., mouse entropy, honeypot interaction).
357
378
 
358
379
  **Example:**
359
380
 
360
381
  ```javascript
361
- import {
362
- initializeClient,
363
- protectedFetch
382
+ import {
383
+ initializeClient,
384
+ protectedFetch
364
385
  } from './path/to/fingerprint.client.js';
365
386
 
366
387
  // Start tracking user behavior as soon as the app loads.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Algorithme de hachage cyrb53 (rapide et faible taux de collision).
3
+ */
4
+ export const cyrb53 = (str, seed = 0) => {
5
+ let h1 = 0xdeadbeef ^ seed,
6
+ h2 = 0x41c6ce57 ^ seed;
7
+ for (let i = 0, ch; i < str.length; i++) {
8
+ ch = str.charCodeAt(i);
9
+ h1 = Math.imul(h1 ^ ch, 2654435761);
10
+ h2 = Math.imul(h2 ^ ch, 1597334677);
11
+ }
12
+ h1 =
13
+ Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
14
+ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
15
+ h2 =
16
+ Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
17
+ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
18
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
19
+ };
20
+
21
+ /**
22
+ * Classe pour construire une empreinte composite (Multi-Hash).
23
+ * Format de sortie : "grp1:hash1|grp2:hash2|grp3:hash3"
24
+ */
25
+ export class FingerprintBuilder {
26
+ constructor() {
27
+ this.components = new Map();
28
+ }
29
+
30
+ /**
31
+ * Ajoute un composant au hash global.
32
+ * @param {string} group - Le nom du groupe (ex: 'hw', 'screen', 'geo')
33
+ * @param {string|number|boolean} value - La valeur brute à hasher
34
+ */
35
+ add(group, value) {
36
+ if (value === undefined || value === null) return this;
37
+ // On hash la valeur individuellement pour l'anonymiser et réduire sa taille
38
+ this.components.set(group, cyrb53(String(value)));
39
+ return this;
40
+ }
41
+
42
+ /**
43
+ * Adds a raw component without hashing it.
44
+ * Useful for metrics that need to be read on the server.
45
+ * @param {string} group - The name of the group.
46
+ * @param {string|number} value - The raw value.
47
+ */
48
+ addRaw(group, value) {
49
+ if (value === undefined || value === null) return this;
50
+ this.components.set(group, value);
51
+ return this;
52
+ }
53
+ /**
54
+ * Génère la chaîne de signature finale.
55
+ * Trie les clés pour garantir un ordre déterministe.
56
+ */
57
+ toString() {
58
+ return Array.from(this.components.entries())
59
+ .sort((a, b) => a[0].localeCompare(b[0])) // Tri alphabétique des clés
60
+ .map(([key, hash]) => `${key}:${hash}`)
61
+ .join("|");
62
+ }
63
+
64
+ /**
65
+ * Compares two fingerprints and returns a similarity score (0 to 1).
66
+ * Uses weights to give more importance to strong invariants (Canvas, GPU).
67
+ * @param {string} fpString1 - Fingerprint A
68
+ * @param {string} fpString2 - Fingerprint B
69
+ */
70
+ static compare(fpString1, fpString2) {
71
+ if (!fpString1 || !fpString2) return 0;
72
+
73
+ const parse = (str) => new Map(str.split("|").map(part => part.split(":")).filter(([k,v]) => k && v));
74
+
75
+ const map1 = parse(fpString1);
76
+ const map2 = parse(fpString2);
77
+
78
+ // Poids de "véracité" (Entropie/Stabilité)
79
+ const weights = {
80
+ cvs: 4.0, // Canvas: Très haute entropie (Rendu unique)
81
+ gpu: 3.0, // GPU: Haute entropie (Matériel spécifique)
82
+ hw: 1.5, // Hardware: Moyenne entropie
83
+ scr: 1.0, // Screen: Moyenne
84
+ geo: 0.5, // Geo: Faible (VPN/Voyage)
85
+ os: 0.5, // OS: Faible (Générique)
86
+ bot: 0.0, // Bot: Informatif
87
+ };
88
+
89
+ let weightedMatches = 0;
90
+ let totalWeight = 0;
91
+
92
+ const allKeys = new Set([...map1.keys(), ...map2.keys()]);
93
+
94
+ allKeys.forEach((key) => {
95
+ const weight = weights[key] || 1.0;
96
+ totalWeight += weight;
97
+ if (map1.get(key) === map2.get(key)) {
98
+ weightedMatches += weight;
99
+ }
100
+ });
101
+
102
+ return totalWeight === 0 ? 0 : weightedMatches / totalWeight;
103
+ }
104
+ }
package/fingerprint.js CHANGED
@@ -1,9 +1,11 @@
1
1
  // C:/Dev/games.primals.net/src/utils/fingerprint.js
2
2
  import crypto from "node:crypto";
3
- import { parse } from "node:net";
3
+ import { BlockList } from "node:net";
4
4
  import dns from "node:dns/promises";
5
5
  import { Optimization } from "./library.js";
6
6
  import { cyrb53, FingerprintBuilder } from "./fingerprint.builder.js";
7
+ export { createRedisStore } from "./redis-store.js";
8
+ export { createMongoDbStore } from "./mongodb-store.js";
7
9
 
8
10
  /**
9
11
  * Retrieves the POW_SECRET from environment variables with appropriate checks.
@@ -702,7 +704,7 @@ function verifyTrapUrl(path, signature, nonce) {
702
704
  /**
703
705
  * @typedef {object} IStore
704
706
  * @property {(key: string) => Promise<any>} get
705
- * @property {(key: string, value: any) => Promise<void>} set
707
+ * @property {(key: string, value: any, ttl?: number) => Promise<void>} set
706
708
  * @property {(key: string) => Promise<boolean>} has
707
709
  * @property {(key: string) => Promise<void>} delete
708
710
  */
@@ -713,8 +715,21 @@ function verifyTrapUrl(path, signature, nonce) {
713
715
  */
714
716
  const inMemoryStore = {
715
717
  _map: new Map(),
718
+ _timeouts: new Map(),
716
719
  async get(key) { return this._map.get(key); },
717
- async set(key, value) { this._map.set(key, value); },
720
+ async set(key, value, ttl) {
721
+ this._map.set(key, value);
722
+ // If a timeout already exists for this key, clear it.
723
+ if (this._timeouts.has(key)) {
724
+ clearTimeout(this._timeouts.get(key));
725
+ this._timeouts.delete(key);
726
+ }
727
+ // If a TTL is provided, set a timeout to delete the key.
728
+ if (ttl && ttl > 0) {
729
+ const timeoutId = setTimeout(() => this._map.delete(key), ttl * 1000);
730
+ this._timeouts.set(key, timeoutId);
731
+ }
732
+ },
718
733
  async has(key) { return this._map.has(key); },
719
734
  async delete(key) { this._map.delete(key); },
720
735
  };
@@ -861,7 +876,7 @@ export const getSuspicionVector = async (context, securityConfig) => {
861
876
  context._newCookies = context._newCookies || [];
862
877
  context._newCookies.push(newCookie);
863
878
  }
864
- await store.set(`ip-device:${clientIp}`, deviceId); // Link the IP to the device
879
+ await store.set(`ip-device:${clientIp}`, deviceId, 600); // Link the IP to the device for 10 minutes
865
880
 
866
881
  // Periodically clean up device data
867
882
  if (Date.now() - deviceData.lastUpdate > 10 * 60 * 1000) { // 10 minutes
@@ -1084,14 +1099,16 @@ function generateCombinedPoWChallengePage(cpuChallengeDetails, memoryDifficulty,
1084
1099
  * Verifies a PoW solution based on a target and generates a ticket.
1085
1100
  */
1086
1101
  export function verifyCpuTargetPoWAndGenerateTicket(
1087
- clientIp,
1102
+ clientIp, // This parameter is crucial and must be the actual client IP
1088
1103
  nonce,
1089
1104
  solution,
1090
1105
  suspicionFactor,
1091
1106
  clientSecret, // Le secret est maintenant requis
1092
1107
  ) {
1093
1108
  const target = calculateTarget(suspicionFactor);
1094
- const message = clientSecret ? `${clientIp}:${nonce}:${solution}:${clientSecret}` : `${clientIp}:${nonce}:${solution}`;
1109
+ const message = clientSecret
1110
+ ? `${clientIp}:${nonce}:${solution}:${clientSecret}`
1111
+ : `${clientIp}:${nonce}:${solution}`;
1095
1112
  const hash = crypto
1096
1113
  .createHash("sha256")
1097
1114
  .update(message)
@@ -1124,6 +1141,7 @@ export class FingerprintEngine {
1124
1141
  const isProduction = process.env.NODE_ENV === 'production';
1125
1142
  this.securityConfig = securityConfig;
1126
1143
  this.isProduction = isProduction;
1144
+ this._allowlist = this._buildAllowlist();
1127
1145
  }
1128
1146
 
1129
1147
  /**
@@ -1133,50 +1151,31 @@ export class FingerprintEngine {
1133
1151
  * @param {string} clientIp - The IP address of the client.
1134
1152
  * @returns {boolean} True if the IP is in the allowlist.
1135
1153
  */
1136
- _isIpInAllowlist(clientIp) {
1154
+ _buildAllowlist() {
1155
+ const blockList = new BlockList();
1137
1156
  const { whitelist = [] } = this.securityConfig;
1138
1157
  const allowlistRule = whitelist.find(rule => rule.type === 'allowlist');
1139
1158
 
1140
1159
  if (!allowlistRule || !allowlistRule.entries || allowlistRule.entries.length === 0) {
1141
- return false;
1160
+ return blockList; // Retourne une liste vide
1142
1161
  }
1143
1162
 
1144
- const ip = parse(clientIp);
1145
- const ipVersion = ip.family;
1146
-
1147
1163
  for (const entry of allowlistRule.entries) {
1148
1164
  if (entry.includes('/')) { // CIDR range
1149
1165
  try {
1150
- const [range, prefixStr] = entry.split('/');
1151
- const prefix = parseInt(prefixStr, 10);
1152
- const rangeIp = parse(range);
1153
-
1154
- if (rangeIp.family !== ipVersion) continue;
1155
-
1156
- const ipBytes = ip.toBuffer();
1157
- const rangeBytes = rangeIp.toBuffer();
1158
- const mask = Buffer.alloc(ipBytes.length, 0xff);
1159
-
1160
- for (let i = 0; i < Math.floor(prefix / 8); i++) {
1161
- if (ipBytes[i] !== rangeBytes[i]) {
1162
- break; // Mismatch in full byte, move to next entry
1163
- }
1164
- }
1165
- const remainingBits = prefix % 8;
1166
- if (remainingBits > 0) {
1167
- const byteIndex = Math.floor(prefix / 8);
1168
- const bitmask = (0xff << (8 - remainingBits)) & 0xff;
1169
- if ((ipBytes[byteIndex] & bitmask) !== (rangeBytes[byteIndex] & bitmask)) {
1170
- continue; // Mismatch in partial byte
1171
- }
1172
- }
1173
- return true; // IP is in CIDR range
1174
- } catch (e) { continue; /* Ignore invalid CIDR entries */ }
1175
- } else if (entry === clientIp) { // Direct IP match
1176
- return true;
1166
+ const [address, prefix] = entry.split('/');
1167
+ blockList.addSubnet(address, parseInt(prefix, 10));
1168
+ } catch (e) {
1169
+ // Ignore les entrées CIDR invalides
1170
+ }
1171
+ } else { // Direct IP match
1172
+ blockList.addAddress(entry);
1177
1173
  }
1178
1174
  }
1179
- return false;
1175
+ return blockList;
1176
+ }
1177
+ _isIpInAllowlist(clientIp) {
1178
+ return this._allowlist.check(clientIp);
1180
1179
  }
1181
1180
  /**
1182
1181
  * Verifies if a request comes from a legitimate, whitelisted bot (e.g., Googlebot)
@@ -1224,26 +1223,26 @@ export class FingerprintEngine {
1224
1223
  const validHostname = hostnames.find(h => h.endsWith(matchedRule.hostnameSuffix));
1225
1224
 
1226
1225
  if (!validHostname) {
1227
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
1226
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1228
1227
  return false;
1229
1228
  }
1230
1229
 
1231
1230
  // 2. Forward DNS lookup
1232
1231
  const addresses = await dns.resolve(validHostname);
1233
1232
  if (addresses.includes(clientIp)) {
1234
- await store.set(cacheKey, 'verified', 86400); // Cache success for 24h
1233
+ await store.set(cacheKey, 'verified', 86400); // Cache success for 24h (TTL in seconds)
1235
1234
  return true;
1236
1235
  }
1237
1236
  } catch (error) {
1238
1237
  // DNS errors are common (e.g., for IPs with no rDNS record), treat as failure.
1239
1238
  }
1240
1239
 
1241
- await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h
1240
+ await store.set(cacheKey, 'failed', 86400); // Cache failure for 24h (TTL in seconds)
1242
1241
  return false;
1243
1242
  }
1244
1243
 
1245
1244
  async processRequest(requestContext) {
1246
- const { clientIp, path, cookies, query, isStatic } = requestContext;
1245
+ const { clientIp = "unknown", path, cookies, query, isStatic } = requestContext;
1247
1246
  const { weights, thresholds, logger, onDeviceCompromised } = this.securityConfig;
1248
1247
  if (isStatic) {
1249
1248
  return { action: 'next', score: 0, vector: {} };
@@ -1350,7 +1349,7 @@ export class FingerprintEngine {
1350
1349
  if (logger) {
1351
1350
  logger({ type: 'trap_triggered', deviceId: cookies?.device_id, score: 100, path: path, timestamp: Date.now() });
1352
1351
  }
1353
- await store.set(`device:${cookies.device_id}`, deviceData);
1352
+ await store.set(`device:${cookies.device_id}`, deviceData); // No TTL for condemned status
1354
1353
  return { action: 'block', status: 403, body: 'Forbidden', score: 100, vector: { honeypotScore: 100 } };
1355
1354
  }
1356
1355
 
@@ -1436,7 +1435,7 @@ export class FingerprintEngine {
1436
1435
 
1437
1436
  // Associate the current challenge nonce with the device for trap URL verification later.
1438
1437
  if (deviceData) {
1439
- deviceData.lastChallengeNonce = nonce;
1438
+ deviceData.lastChallengeNonce = nonce; // No TTL, part of the main device object
1440
1439
  await store.set(`device:${cookies.device_id}`, deviceData);
1441
1440
  }
1442
1441
 
@@ -1512,7 +1511,7 @@ export class FingerprintEngine {
1512
1511
  if (ipProfile.statelessCount > statelessLimit) {
1513
1512
  return `suspicious_high:${clientIp}`;
1514
1513
  }
1515
- await store.set(`ip:${clientIp}`, ipProfile);
1514
+ await store.set(`ip:${clientIp}`, ipProfile, 600); // Keep IP profile for 10 minutes
1516
1515
 
1517
1516
  const vector = await __internal.getSuspicionVector(requestContext, this.securityConfig); // Pass the config
1518
1517
  const { honeypotScore } = getHoneypotScore(requestContext, this.securityConfig.honeypot);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @file Creates a store adapter for MongoDB.
3
+ * This adapter uses a collection as a key-value store and leverages MongoDB's TTL indexes
4
+ * for automatic expiration of documents.
5
+ */
6
+
7
+ /**
8
+ * Creates a store adapter for a MongoDB collection.
9
+ * It's recommended to pass the `db` object and let the adapter handle the collection.
10
+ *
11
+ * **Note:** For TTL to work, you must create a TTL index on the `expiresAt` field in your collection.
12
+ * In the mongo shell, run:
13
+ * `db.yourCollectionName.createIndex({ "expiresAt": 1 }, { expireAfterSeconds: 0 })`
14
+ *
15
+ * @param {import('mongodb').Db} db - An instance of a MongoDB Db object.
16
+ * @param {string} [collectionName='fingerprint_store'] - The name of the collection to use.
17
+ * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
18
+ */
19
+ export function createMongoDbStore(db, collectionName = 'fingerprint_store') {
20
+ const collection = db.collection(collectionName);
21
+
22
+ return {
23
+ async get(key) {
24
+ const doc = await collection.findOne({ _id: key });
25
+ // The TTL index automatically removes expired documents, so no need to check `expiresAt` here.
26
+ return doc ? doc.value : null;
27
+ },
28
+ async set(key, value, ttl) {
29
+ const doc = {
30
+ _id: key,
31
+ value: value,
32
+ };
33
+
34
+ if (ttl && ttl > 0) {
35
+ // Set the expiration date for the TTL index.
36
+ doc.expiresAt = new Date(Date.now() + ttl * 1000);
37
+ }
38
+
39
+ await collection.updateOne(
40
+ { _id: key },
41
+ { $set: doc },
42
+ { upsert: true }
43
+ );
44
+ },
45
+ async has(key) {
46
+ const count = await collection.countDocuments({ _id: key });
47
+ return count > 0;
48
+ },
49
+ async delete(key) {
50
+ await collection.deleteOne({ _id: key });
51
+ },
52
+ };
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anonympins/fingerprint",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "description": "An HTTP(S) client mitigation and anti-bot protection library for Node.js/Express, based on digital fingerprinting and dynamic Proof-of-Work (PoW) challenges.",
5
5
  "main": "fingerprint.js",
6
6
  "type": "module",
@@ -13,7 +13,11 @@
13
13
  "files": [
14
14
  "fingerprint.js",
15
15
  "fingerprint.client.js",
16
+ "fingerprint.builder.js",
16
17
  "library.js",
18
+ "redis-store.js",
19
+ "mongodb-store.js",
20
+ "sql-store.js",
17
21
  "README.md",
18
22
  "LICENSE"
19
23
  ],
@@ -45,17 +49,27 @@
45
49
  "body-parser": "^1.20.2",
46
50
  "cookie-parser": "^1.4.6",
47
51
  "express": "^4.18.2",
48
- "vitest": "^4.1.11",
49
- "ioredis": "^5.3.2",
50
- "mongodb": "^6.3.0",
51
- "prom-client": "^15.1.2"
52
+ "prom-client": "^15.1.2",
53
+ "vitest": "^4.1.11"
52
54
  },
53
55
  "peerDependencies": {
54
56
  "ioredis": "^5.3.2",
55
- "mongodb": "^6.3.0"
57
+ "knex": ">=3.0.0",
58
+ "mongodb": "^6.3.0",
59
+ "sqlite3": "^5.1.7"
56
60
  },
57
61
  "peerDependenciesMeta": {
58
- "ioredis": { "optional": true },
59
- "mongodb": { "optional": true }
62
+ "ioredis": {
63
+ "optional": true
64
+ },
65
+ "mongodb": {
66
+ "optional": true
67
+ },
68
+ "knex": {
69
+ "optional": true
70
+ },
71
+ "sqlite3": {
72
+ "optional": true
73
+ }
60
74
  }
61
75
  }
package/redis-store.js ADDED
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @file Creates a store adapter for ioredis.
3
+ * This adapter handles the serialization and deserialization of complex objects,
4
+ * including the conversion of Set objects to arrays for storage in Redis.
5
+ */
6
+
7
+ /**
8
+ * Creates a store adapter for an ioredis client.
9
+ * @param {import('ioredis').Redis} redisClient - An instance of the ioredis client.
10
+ * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
11
+ */
12
+ export function createRedisStore(redisClient) {
13
+ return {
14
+ async get(key) {
15
+ const value = await redisClient.get(key);
16
+ if (!value) return null;
17
+ // Use a reviver to convert arrays back to Sets for specific keys like 'ips'.
18
+ return JSON.parse(value, (k, v) => {
19
+ if (k === 'ips' && Array.isArray(v)) {
20
+ return new Set(v);
21
+ }
22
+ return v;
23
+ });
24
+ },
25
+ async set(key, value, ttl) {
26
+ // Use a replacer to convert Set objects into arrays before serialization.
27
+ const stringValue = JSON.stringify(value, (k, v) => {
28
+ if (v instanceof Set) {
29
+ return Array.from(v);
30
+ }
31
+ return v;
32
+ });
33
+
34
+ if (ttl && ttl > 0) {
35
+ await redisClient.set(key, stringValue, 'EX', ttl);
36
+ } else {
37
+ await redisClient.set(key, stringValue);
38
+ }
39
+ },
40
+ async has(key) { return (await redisClient.exists(key)) === 1; },
41
+ async delete(key) { await redisClient.del(key); },
42
+ };
43
+ }
package/sql-store.js ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @file Creates a store adapter for Knex.js.
3
+ * This adapter is compatible with various SQL databases like PostgreSQL, MySQL, and SQLite.
4
+ * It handles serialization of complex objects and TTL for automatic data expiration.
5
+ */
6
+
7
+ /**
8
+ * Creates a store adapter for a Knex.js client.
9
+ *
10
+ * **Note:** You must create the table yourself before using the store.
11
+ * The table should have at least the following columns:
12
+ * - `key` (string, primary key)
13
+ * - `value` (text or json/jsonb)
14
+ * - `expiresAt` (datetime or timestamp with time zone)
15
+ *
16
+ * Example schema for PostgreSQL:
17
+ * ```sql
18
+ * CREATE TABLE your_table_name (
19
+ * "key" VARCHAR(255) PRIMARY KEY,
20
+ * "value" TEXT NOT NULL,
21
+ * "expiresAt" TIMESTAMPTZ
22
+ * );
23
+ * ```
24
+ *
25
+ * @param {import('knex').Knex} knex - An instance of the Knex client.
26
+ * @param {string} [tableName='fingerprint_store'] - The name of the table to use.
27
+ * @returns {import('./fingerprint.js').IStore} An object that complies with the IStore interface.
28
+ */
29
+ export function createSqlStore(knex, tableName = 'fingerprint_store') {
30
+ // Custom replacer/reviver to handle Set serialization, similar to the Redis store.
31
+ const replacer = (k, v) => (v instanceof Set ? Array.from(v) : v);
32
+ const reviver = (k, v) => (k === 'ips' && Array.isArray(v) ? new Set(v) : v);
33
+
34
+ return {
35
+ async get(key) {
36
+ const row = await knex(tableName).where('key', key).first();
37
+ if (!row) return null;
38
+
39
+ // Manually check for expiration, as not all SQL databases have automatic TTL cleanup.
40
+ if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
41
+ await this.delete(key); // Clean up expired key.
42
+ return null;
43
+ }
44
+
45
+ try {
46
+ return JSON.parse(row.value, reviver);
47
+ } catch (e) {
48
+ // In case of malformed JSON, treat it as a miss.
49
+ return null;
50
+ }
51
+ },
52
+
53
+ async set(key, value, ttl) {
54
+ const stringValue = JSON.stringify(value, replacer);
55
+ const expiresAt = ttl ? new Date(Date.now() + ttl * 1000) : null;
56
+
57
+ // Use native upsert capabilities of Knex for different SQL dialects.
58
+ await knex(tableName)
59
+ .insert({ key, value: stringValue, expiresAt })
60
+ .onConflict('key')
61
+ .merge();
62
+ },
63
+
64
+ async has(key) {
65
+ const row = await knex(tableName).where('key', key).first('key');
66
+ if (!row) return false;
67
+ // Also check for expiration here.
68
+ if (row.expiresAt && new Date(row.expiresAt) < new Date()) {
69
+ return false;
70
+ }
71
+ return true;
72
+ },
73
+
74
+ async delete(key) {
75
+ await knex(tableName).where('key', key).del();
76
+ },
77
+ };
78
+ }