Initial reliable release of consent engine
This commit is contained in:
+551
@@ -0,0 +1,551 @@
|
||||
/**
|
||||
* 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
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user