/** * blocker.ts — Script interceptor, cookie blocker, and release manager. * * Installs before any third-party scripts run. Intercepts script creation, * proxies document.cookie and Storage writes, and maintains a queue of * blocked resources that are released per-category when consent is granted. */ import type { CategorySlug, InitiatorMapping } from './types'; /** A script element that was blocked, along with its assigned category. */ interface BlockedScript { /** The original script element or a clone of it. */ element: HTMLScriptElement; /** The consent category this script belongs to. */ category: CategorySlug; } /** Pattern-to-category mapping for URL-based script classification. */ interface ScriptPattern { pattern: RegExp; category: CategorySlug; } /** Categories that have been consented to. */ let acceptedCategories: Set = new Set(['necessary']); /** Queue of blocked scripts awaiting consent. */ const blockedScripts: BlockedScript[] = []; /** URL patterns for classifying scripts by category. */ const scriptPatterns: ScriptPattern[] = []; /** Root initiator URL → category mappings for root-level blocking. */ const initiatorMappings: Array<{ pattern: RegExp; category: CategorySlug }> = []; /** Whether the blocker has been installed. */ let installed = false; /** Original document.createElement reference. */ let originalCreateElement: typeof document.createElement; /** Original document.cookie descriptor. */ let originalCookieDescriptor: PropertyDescriptor | undefined; /** Original Storage.prototype.setItem reference. */ let originalLocalStorageSetItem: typeof Storage.prototype.setItem; // ─── Well-known script patterns (built-in defaults) ─── const BUILTIN_PATTERNS: ScriptPattern[] = [ // Analytics { pattern: /google-analytics\.com/i, category: 'analytics' }, { pattern: /googletagmanager\.com/i, category: 'analytics' }, { pattern: /gtag\/js/i, category: 'analytics' }, { pattern: /analytics\./i, category: 'analytics' }, { pattern: /hotjar\.com/i, category: 'analytics' }, { pattern: /clarity\.ms/i, category: 'analytics' }, { pattern: /plausible\.io/i, category: 'analytics' }, { pattern: /matomo\./i, category: 'analytics' }, // Marketing { pattern: /doubleclick\.net/i, category: 'marketing' }, { pattern: /facebook\.net/i, category: 'marketing' }, { pattern: /fbevents\.js/i, category: 'marketing' }, { pattern: /connect\.facebook/i, category: 'marketing' }, { pattern: /ads-twitter\.com/i, category: 'marketing' }, { pattern: /linkedin\.com\/insight/i, category: 'marketing' }, { pattern: /snap\.licdn\.com/i, category: 'marketing' }, { pattern: /tiktok\.com\/i18n/i, category: 'marketing' }, { pattern: /googlesyndication\.com/i, category: 'marketing' }, { pattern: /adservice\.google/i, category: 'marketing' }, // Functional { pattern: /intercom\.com/i, category: 'functional' }, { pattern: /crisp\.chat/i, category: 'functional' }, { pattern: /livechatinc\.com/i, category: 'functional' }, { pattern: /zendesk\.com/i, category: 'functional' }, ]; // ─── Public API ─── /** Install all interception hooks. Call once, as early as possible. */ export function installBlocker(): void { if (installed) return; installed = true; // Merge built-in patterns scriptPatterns.push(...BUILTIN_PATTERNS); // Install hooks installCreateElementOverride(); installMutationObserver(); installCookieProxy(); installStorageProxy(); } /** Add custom URL-to-category patterns (e.g. from site config allow-list). */ export function addScriptPatterns(patterns: Array<{ pattern: string; category: CategorySlug }>): void { for (const p of patterns) { try { scriptPatterns.push({ pattern: new RegExp(p.pattern, 'i'), category: p.category }); } catch { console.warn(`[ConsentOS] Invalid script pattern: ${p.pattern}`); } } } /** * Load initiator mappings from the site config. Each mapping identifies a root * script URL that is known to set cookies in a given category via a chain of * child scripts. Blocking the root prevents the entire chain from executing. */ export function loadInitiatorMappings(mappings: InitiatorMapping[]): void { for (const m of mappings) { try { initiatorMappings.push({ pattern: new RegExp(m.root_script, 'i'), category: m.category }); } catch { console.warn(`[ConsentOS] Invalid initiator pattern: ${m.root_script}`); } } } /** * Update the set of accepted categories, release any blocked scripts * that now have consent, and sweep any existing cookies / storage * items that belong to a category the visitor has **not** consented * to. Consented categories are left untouched — those cookies are * presumed to be in use by the site. */ export function updateAcceptedCategories(categories: CategorySlug[]): void { acceptedCategories = new Set(categories); releaseBlockedScripts(); sweepDisallowedState(); } /** * Delete any existing cookies and storage items whose classified * category isn't currently consented. Runs on install and every * consent-state update so historical trackers from pre-consent, a * previous session, or a narrowed consent decision get removed. * Unknown / unclassified cookies are left alone since we can't * attribute them to a category. */ export function sweepDisallowedState(): void { sweepDisallowedCookies(); sweepDisallowedStorage(); } /** Get the current blocked script count (useful for debugging/reporting). */ export function getBlockedCount(): number { return blockedScripts.length; } /** Check whether a given category is currently accepted. */ export function isCategoryAllowed(category: CategorySlug): boolean { return acceptedCategories.has(category); } // ─── Script interception ─── /** * Override document.createElement to intercept