552 lines
13 KiB
JavaScript
552 lines
13 KiB
JavaScript
/**
|
|
* 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
|
|
});
|
|
}
|