@elhamdev/tracejs
Version:
A modern, privacy-conscious alternative to browser fingerprinting for unique user identification.
331 lines (330 loc) • 11.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ConsentManager = void 0;
/**
* Service for managing user consent for various fingerprinting methods
* in compliance with privacy regulations like GDPR, CCPA, etc.
*/
class ConsentManager {
constructor(options = {}) {
this.initialized = false;
this.STORAGE_KEY = "tracejs_consent";
this.options = {
requireExplicitConsent: {
gdpr: true,
lgpd: true,
pipl: true,
pdpa: true,
cdpa: true,
ctdpa: true,
ccpa: false,
global: false,
},
requiredCategories: ["essential"],
categoryMapping: {
battery: "functionality",
screen: "functionality",
canvas: "analytics",
audio: "analytics",
behavior: "personalization",
geolocation: "personalization",
},
autoDetectRegion: true,
defaultRegion: "global",
persistConsent: true,
consentExpiresDays: 365,
...options,
};
// Initialize default state
this.consentState = {
essential: true,
functionality: false,
analytics: false,
advertising: false,
personalization: false,
lastUpdated: Date.now(),
region: this.options.defaultRegion || "global",
};
}
/**
* Initialize the consent manager
*/
async initialize() {
if (this.initialized)
return;
// 1. Try to load saved consent
if (this.options.persistConsent) {
this.loadSavedConsent();
}
// 2. Detect user's region if enabled
if (this.options.autoDetectRegion) {
this.consentState.region = await this.detectRegion();
}
// 3. Apply default consent based on region
this.applyRegionalDefaults();
this.initialized = true;
}
/**
* Check if a specific fingerprinting method has consent
* @param method The fingerprinting method to check
* @returns Whether the method has consent
*/
hasConsent(method) {
if (!this.initialized) {
// Auto-initialize with defaults
this.initialized = true;
this.applyRegionalDefaults();
}
// Required categories always have consent
const category = this.options.categoryMapping?.[method];
if (!category)
return true; // No category mapping means no consent required
if (this.options.requiredCategories?.includes(category)) {
return true;
}
return this.consentState[category] === true;
}
/**
* Update consent for a specific category
* @param category The category to update
* @param granted Whether consent is granted
*/
updateConsent(category, granted) {
if (this.options.requiredCategories?.includes(category) && !granted) {
// Cannot revoke consent for required categories
return;
}
this.consentState[category] = granted;
this.consentState.lastUpdated = Date.now();
// Save the updated state
if (this.options.persistConsent) {
this.saveConsent();
}
// Trigger callback if defined
if (this.options.onConsentChange) {
const categories = {
essential: this.consentState.essential,
functionality: this.consentState.functionality,
analytics: this.consentState.analytics,
advertising: this.consentState.advertising,
personalization: this.consentState.personalization,
};
this.options.onConsentChange(categories);
}
}
/**
* Update consent for multiple categories at once
* @param categories Map of categories to consent values
*/
updateMultipleConsent(categories) {
let changed = false;
for (const [category, granted] of Object.entries(categories)) {
if (this.options.requiredCategories?.includes(category) && !granted) {
// Skip required categories
continue;
}
if (this.consentState[category] !== granted) {
this.consentState[category] = granted;
changed = true;
}
}
if (changed) {
this.consentState.lastUpdated = Date.now();
// Save the updated state
if (this.options.persistConsent) {
this.saveConsent();
}
// Trigger callback if defined
if (this.options.onConsentChange) {
const allCategories = {
essential: this.consentState.essential,
functionality: this.consentState.functionality,
analytics: this.consentState.analytics,
advertising: this.consentState.advertising,
personalization: this.consentState.personalization,
};
this.options.onConsentChange(allCategories);
}
}
}
/**
* Get the current consent state
* @returns The current consent state
*/
getConsentState() {
return { ...this.consentState };
}
/**
* Check if consent is expired and needs renewal
* @returns Whether consent needs renewal
*/
needsConsentRenewal() {
if (!this.options.consentExpiresDays)
return false;
const expiryMs = this.options.consentExpiresDays * 24 * 60 * 60 * 1000;
const expiryDate = this.consentState.lastUpdated + expiryMs;
return Date.now() > expiryDate;
}
/**
* Reset consent to default values
*/
resetConsent() {
this.consentState = {
essential: true,
functionality: false,
analytics: false,
advertising: false,
personalization: false,
lastUpdated: Date.now(),
region: this.consentState.region,
};
this.applyRegionalDefaults();
if (this.options.persistConsent) {
this.saveConsent();
}
// Trigger callback if defined
if (this.options.onConsentChange) {
const categories = {
essential: this.consentState.essential,
functionality: this.consentState.functionality,
analytics: this.consentState.analytics,
advertising: this.consentState.advertising,
personalization: this.consentState.personalization,
};
this.options.onConsentChange(categories);
}
}
/**
* Load saved consent from localStorage
*/
loadSavedConsent() {
try {
const saved = localStorage.getItem(this.STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
// Only update if it's a valid consent state
if (typeof parsed === "object" &&
typeof parsed.lastUpdated === "number" &&
typeof parsed.region === "string") {
this.consentState = {
essential: parsed.essential === false ? false : true, // Essential defaults to true
functionality: !!parsed.functionality,
analytics: !!parsed.analytics,
advertising: !!parsed.advertising,
personalization: !!parsed.personalization,
lastUpdated: parsed.lastUpdated,
region: parsed.region,
};
}
}
}
catch (e) {
console.error("Error loading saved consent:", e);
}
}
/**
* Save consent to localStorage
*/
saveConsent() {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.consentState));
}
catch (e) {
console.error("Error saving consent:", e);
}
}
/**
* Apply default consent values based on the region
*/
applyRegionalDefaults() {
// Apply required categories
this.options.requiredCategories?.forEach((category) => {
this.consentState[category] = true;
});
// For regions with explicit consent required, all non-required categories default to false
let requiresExplicitConsent;
if (typeof this.options.requireExplicitConsent === "boolean") {
requiresExplicitConsent = this.options.requireExplicitConsent;
}
else {
requiresExplicitConsent =
this.options.requireExplicitConsent?.[this.consentState.region] ??
false;
}
if (requiresExplicitConsent) {
// Make sure non-required categories default to false
const allCategories = [
"essential",
"functionality",
"analytics",
"advertising",
"personalization",
];
allCategories.forEach((category) => {
if (!this.options.requiredCategories?.includes(category)) {
this.consentState[category] = false;
}
});
}
}
/**
* Detect the user's region based on browser locale or IP geolocation
* @returns The detected region
*/
async detectRegion() {
// Try to detect by browser locale first
const locale = navigator.language || navigator.userLanguage || "";
// Map common EU country codes to GDPR
const euCountries = [
"at",
"be",
"bg",
"hr",
"cy",
"cz",
"dk",
"ee",
"fi",
"fr",
"de",
"gr",
"hu",
"ie",
"it",
"lv",
"lt",
"lu",
"mt",
"nl",
"pl",
"pt",
"ro",
"sk",
"si",
"es",
"se",
];
// Extract country code from locale (e.g., "en-US" -> "us")
const country = locale.split("-")[1]?.toLowerCase() || "";
if (euCountries.includes(country)) {
return "gdpr";
}
if (country === "br") {
return "lgpd";
}
if (country === "cn") {
return "pipl";
}
if (country === "th") {
return "pdpa";
}
if (country === "us") {
// Check for US state-specific laws (simplified)
// In a real implementation you'd want more accurate geolocation
return "ccpa"; // Default US to CCPA
}
// Fallback to default region
return this.options.defaultRegion || "global";
}
}
exports.ConsentManager = ConsentManager;