/** * Cookie Consent Core * consent.js * * Main consent engine. * * Responsibilities: * - initialize consent state * - load/save persisted consent * - manage categories * - manage policy version * - expose consent operations * - emit lifecycle events * * Deliberately excluded: * - DOM/UI * - GTM * - geolocation * - network requests * - legal/jurisdiction rules */ import { createDefaultCategories, createUnknownCategories, createAcceptedCategories, createRejectedCategories, normalizeCategories, mergeCategories, hasAcceptedAll, hasRejectedAll, isDecisionComplete, isValidCategoriesObject, toPermissionMap } from "./categories.js"; import { createCookieStorage, isStoragePayload, STORAGE_DEFAULTS } from "./storage.js"; import { createEventBus } from "./events.js"; export const ENGINE_VERSION = "1.0.1"; export const DEFAULT_POLICY_VERSION = "2026-01"; export const DEFAULT_JURISDICTION = "global"; const DEFAULT_CONFIG = Object.freeze({ policyVersion: DEFAULT_POLICY_VERSION, jurisdiction: DEFAULT_JURISDICTION, requireDecision: true, storage: { name: STORAGE_DEFAULTS.name, duration: STORAGE_DEFAULTS.duration, path: STORAGE_DEFAULTS.path, sameSite: STORAGE_DEFAULTS.sameSite } }); function isPlainObject(value) { return Boolean( value && typeof value === "object" && !Array.isArray(value) ); } function clone(value) { return JSON.parse(JSON.stringify(value)); } function mergeConfig(base, overrides) { const source = isPlainObject(overrides) ? overrides : {}; return { ...base, ...source, storage: { ...base.storage, ...(isPlainObject(source.storage) ? source.storage : {}) } }; } function buildStoredPayload({ categories, policyVersion, jurisdiction, gpc, timestamp }) { return { engineVersion: ENGINE_VERSION, policyVersion, timestamp, categories: clone(categories), jurisdiction, gpc: Boolean(gpc) }; } function isExpired(payload, durationDays, currentTime = Date.now()) { if (!payload || !Number.isFinite(payload.timestamp)) { return true; } if (!Number.isFinite(durationDays) || durationDays <= 0) { return false; } const durationMs = durationDays * 24 * 60 * 60 * 1000; const age = currentTime - payload.timestamp; // A materially future timestamp is not accepted as valid persistence. if (age < 0) { return true; } return age >= durationMs; } function getInitialState(config) { return { initialized: false, hasDecision: false, categories: createUnknownCategories(), policyVersion: config.policyVersion, jurisdiction: config.jurisdiction, gpc: false, timestamp: null, source: "default" }; } function categoriesEqual(a, b) { return ( a.necessary === b.necessary && a.preferences === b.preferences && a.analytics === b.analytics && a.marketing === b.marketing ); } export function createConsentEngine(options = {}) { const config = mergeConfig(DEFAULT_CONFIG, options); const events = createEventBus(); const storage = createCookieStorage(config.storage); let state = getInitialState(config); let initialized = false; function getState() { return clone(state); } function emit(name, extra = {}) { events.emit(name, { ...getState(), ...extra }); } function persist(categories, metadata = {}) { const timestamp = Date.now(); const payload = buildStoredPayload({ categories, policyVersion: config.policyVersion, jurisdiction: metadata.jurisdiction || config.jurisdiction, gpc: typeof metadata.gpc === "boolean" ? metadata.gpc : state.gpc, timestamp }); try { const saved = storage.set(payload); if (!saved) { events.emit("storage:error", { operation: "set", payload: getState() }); return false; } state = { ...state, categories: normalizeCategories( payload.categories, createDefaultCategories() ), hasDecision: isDecisionComplete(payload.categories), policyVersion: payload.policyVersion, jurisdiction: payload.jurisdiction, gpc: Boolean(payload.gpc), timestamp: payload.timestamp, source: metadata.source || "user" }; return true; } catch (error) { events.emit("storage:error", { operation: "set", error }); return false; } } function removePersistedCookie(operation) { try { const removed = storage.remove(); if (!removed) { events.emit("storage:error", { operation }); return false; } return true; } catch (error) { events.emit("storage:error", { operation, error }); return false; } } function loadPersistedState() { let payload; try { payload = storage.get(); } catch (error) { events.emit("storage:error", { operation: "get", error }); return false; } if (!isStoragePayload(payload)) { return false; } if (!isValidCategoriesObject(payload.categories)) { // Invalid persistence is not allowed to influence the live state. // Attempt to remove it so it cannot repeatedly poison initialization. removePersistedCookie("remove-invalid"); return false; } if (payload.policyVersion !== config.policyVersion) { removePersistedCookie("remove-policy-mismatch"); return false; } if (isExpired(payload, config.storage.duration)) { const removed = removePersistedCookie("remove-expired"); if (!removed) { // Do not pretend the stale cookie has been removed. // The in-memory state remains undecided for this page load. state = { ...state, categories: createUnknownCategories(), hasDecision: false, timestamp: null, source: "expired" }; emit("consent:expired"); return false; } state = { ...state, categories: createUnknownCategories(), hasDecision: false, timestamp: null, source: "expired" }; emit("consent:expired"); return false; } state = { ...state, categories: normalizeCategories( payload.categories, createUnknownCategories() ), hasDecision: isDecisionComplete(payload.categories), policyVersion: config.policyVersion, jurisdiction: payload.jurisdiction, gpc: payload.gpc, timestamp: payload.timestamp, source: "storage" }; emit("consent:loaded"); return true; } function initialize() { if (initialized) { return getState(); } initialized = true; loadPersistedState(); state = { ...state, initialized: true }; emit("initialized"); return getState(); } function ensureInitialized() { if (!initialized) { initialize(); } } function setConsent(changes, metadata = {}) { ensureInitialized(); if (!isPlainObject(changes)) { throw new TypeError("Consent changes must be an object."); } const previous = state.categories; const next = mergeCategories(previous, changes); const changed = !categoriesEqual(previous, next); if (!changed) { return getState(); } const saved = persist(next, metadata); if (!saved) { return getState(); } emit("consent:changed", { previousCategories: previous, categories: next }); if (hasAcceptedAll(next)) { emit("consent:accepted"); } else if (hasRejectedAll(next)) { emit("consent:rejected"); } return getState(); } function acceptAll() { ensureInitialized(); const previous = state.categories; const next = createAcceptedCategories(); if (categoriesEqual(previous, next)) { return getState(); } const saved = persist(next); if (!saved) { return getState(); } emit("consent:changed", { previousCategories: previous, categories: next }); emit("consent:accepted"); return getState(); } function rejectAll() { ensureInitialized(); const previous = state.categories; const next = createRejectedCategories(); if (categoriesEqual(previous, next)) { return getState(); } const saved = persist(next); if (!saved) { return getState(); } emit("consent:changed", { previousCategories: previous, categories: next }); emit("consent:rejected"); return getState(); } function revoke() { ensureInitialized(); const previous = state.categories; const next = createRejectedCategories(); if (categoriesEqual(previous, next)) { return getState(); } const saved = persist(next, { jurisdiction: state.jurisdiction, gpc: state.gpc, source: "user" }); if (!saved) { return getState(); } emit("consent:revoked", { previousCategories: previous, categories: next }); emit("consent:changed", { previousCategories: previous, categories: next }); return getState(); } function reset() { ensureInitialized(); const removed = removePersistedCookie("remove-reset"); if (!removed) { // Do not create a false local reset when persisted consent may // still exist. This guarantees reload consistency. return getState(); } state = getInitialState(config); state.initialized = true; emit("consent:reset"); return getState(); } function setGpc(value) { ensureInitialized(); state = { ...state, gpc: Boolean(value) }; return getState(); } function setJurisdiction(value) { ensureInitialized(); if (typeof value !== "string" || !value.trim()) { throw new TypeError( "jurisdiction must be a non-empty string." ); } state = { ...state, jurisdiction: value.trim() }; return getState(); } function hasConsent(category) { ensureInitialized(); if ( typeof category !== "string" || !Object.prototype.hasOwnProperty.call( state.categories, category ) ) { return false; } return state.categories[category] === "granted"; } function isComplete() { ensureInitialized(); return state.hasDecision; } function isInitialized() { return initialized; } return Object.freeze({ initialize, getState, getConsent: getState, setConsent, acceptAll, rejectAll, revoke, reset, setGpc, setJurisdiction, hasConsent, isComplete, isInitialized, toPermissionMap() { ensureInitialized(); return toPermissionMap(state.categories); }, getConfig() { return clone(config); }, on: events.on, once: events.once, off: events.off }); }