Initial reliable release of consent engine

This commit is contained in:
Deyson Thomé
2026-09-02 14:19:05 -03:00
commit 9e1082d47b
22 changed files with 3377 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
/**
* Cookie Consent Core
* categories.js
*
* Defines the consent categories and normalizes category state.
* This module has no DOM, storage, network, or GTM dependencies.
*/
export const CATEGORY_NAMES = Object.freeze([
"necessary",
"preferences",
"analytics",
"marketing"
]);
/**
* Legacy/default denied state kept as a public export for compatibility.
* New/undecided visitors should use createUnknownCategories().
*/
export const DEFAULT_CATEGORIES = Object.freeze({
necessary: "granted",
preferences: "denied",
analytics: "denied",
marketing: "denied"
});
const VALID_STATES = new Set(["unknown", "granted", "denied"]);
export function createDefaultCategories() {
return { ...DEFAULT_CATEGORIES };
}
export function createUnknownCategories() {
return {
necessary: "granted",
preferences: "unknown",
analytics: "unknown",
marketing: "unknown"
};
}
export function isValidCategory(category) {
return CATEGORY_NAMES.includes(category);
}
export function isValidState(state) {
return VALID_STATES.has(state);
}
export function normalizeCategories(input = {}, fallback = DEFAULT_CATEGORIES) {
const source = input && typeof input === "object" ? input : {};
const fallbackState =
fallback && typeof fallback === "object"
? fallback
: DEFAULT_CATEGORIES;
const normalized = {
necessary: "granted",
preferences: "denied",
analytics: "denied",
marketing: "denied"
};
for (const category of CATEGORY_NAMES) {
if (category === "necessary") {
normalized.necessary = "granted";
continue;
}
const candidate = source[category];
if (isValidState(candidate)) {
normalized[category] = candidate;
continue;
}
const fallbackValue = fallbackState[category];
normalized[category] = isValidState(fallbackValue)
? fallbackValue
: "denied";
}
return normalized;
}
/**
* Strict validation used for persisted or externally supplied category
* objects. Unlike normalizeCategories(), this function never repairs data.
*/
export function isValidCategoriesObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
for (const category of CATEGORY_NAMES) {
if (!Object.prototype.hasOwnProperty.call(value, category)) {
return false;
}
}
if (value.necessary !== "granted") {
return false;
}
return CATEGORY_NAMES
.filter((category) => category !== "necessary")
.every((category) => isValidState(value[category]));
}
export function mergeCategories(current, changes) {
const base = normalizeCategories(current, DEFAULT_CATEGORIES);
const incoming = changes && typeof changes === "object" ? changes : {};
const merged = { ...base };
for (const category of CATEGORY_NAMES) {
if (category === "necessary") {
if (Object.prototype.hasOwnProperty.call(incoming, category) &&
incoming[category] !== "granted") {
throw new TypeError(
`Invalid consent state for "necessary". ` +
`The necessary category must always be "granted".`
);
}
merged.necessary = "granted";
continue;
}
if (Object.prototype.hasOwnProperty.call(incoming, category)) {
const value = incoming[category];
if (!isValidState(value)) {
throw new TypeError(
`Invalid consent state for "${category}". ` +
`Expected "unknown", "granted", or "denied".`
);
}
merged[category] = value;
}
}
return merged;
}
export function createAcceptedCategories() {
return {
necessary: "granted",
preferences: "granted",
analytics: "granted",
marketing: "granted"
};
}
export function createRejectedCategories() {
return {
necessary: "granted",
preferences: "denied",
analytics: "denied",
marketing: "denied"
};
}
export function hasAcceptedAll(categories) {
const state = normalizeCategories(categories, DEFAULT_CATEGORIES);
return CATEGORY_NAMES
.filter((category) => category !== "necessary")
.every((category) => state[category] === "granted");
}
export function hasRejectedAll(categories) {
const state = normalizeCategories(categories, DEFAULT_CATEGORIES);
return CATEGORY_NAMES
.filter((category) => category !== "necessary")
.every((category) => state[category] === "denied");
}
export function isDecisionComplete(categories) {
const state = normalizeCategories(categories, createUnknownCategories());
return CATEGORY_NAMES
.filter((category) => category !== "necessary")
.every((category) => state[category] !== "unknown");
}
export function toPermissionMap(categories) {
const state = normalizeCategories(categories, DEFAULT_CATEGORIES);
return Object.fromEntries(
CATEGORY_NAMES.map((category) => [
category,
state[category] === "granted"
])
);
}
+551
View File
@@ -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
});
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Cookie Consent Core
* events.js
*
* Tiny dependency-free event bus.
*/
export function createEventBus() {
const listeners = new Map();
function on(eventName, callback) {
if (typeof eventName !== "string" || !eventName.trim()) {
throw new TypeError("eventName must be a non-empty string.");
}
if (typeof callback !== "function") {
throw new TypeError("callback must be a function.");
}
if (!listeners.has(eventName)) {
listeners.set(eventName, new Set());
}
listeners.get(eventName).add(callback);
return () => off(eventName, callback);
}
function once(eventName, callback) {
const unsubscribe = on(eventName, (payload) => {
unsubscribe();
callback(payload);
});
return unsubscribe;
}
function off(eventName, callback) {
const callbacks = listeners.get(eventName);
if (!callbacks) {
return false;
}
const removed = callbacks.delete(callback);
if (callbacks.size === 0) {
listeners.delete(eventName);
}
return removed;
}
function emit(eventName, payload) {
const callbacks = listeners.get(eventName);
if (!callbacks) {
return;
}
[...callbacks].forEach((callback) => {
try {
callback(payload);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
});
}
function clear(eventName) {
if (typeof eventName === "string") {
listeners.delete(eventName);
return;
}
listeners.clear();
}
function listenerCount(eventName) {
return listeners.get(eventName)?.size || 0;
}
return Object.freeze({
on,
once,
off,
emit,
clear,
listenerCount
});
}
export const EVENT_NAMES = Object.freeze([
"initialized",
"consent:loaded",
"consent:accepted",
"consent:rejected",
"consent:changed",
"consent:revoked",
"consent:expired",
"consent:reset",
"storage:error"
]);
+216
View File
@@ -0,0 +1,216 @@
/**
* Cookie Consent Core
* storage.js
*
* Cookie-based persistence for consent state.
* This module deliberately contains no consent/business operations.
*/
const DEFAULT_COOKIE_NAME = "cc_consent";
const DEFAULT_DURATION_DAYS = 180;
const DEFAULT_PATH = "/";
const DEFAULT_SAME_SITE = "Lax";
function isBrowser() {
return typeof document !== "undefined";
}
function encode(value) {
return encodeURIComponent(value);
}
function decode(value) {
try {
return decodeURIComponent(value);
} catch {
return null;
}
}
function buildAttributes(options = {}) {
const {
duration,
path = DEFAULT_PATH,
domain,
sameSite = DEFAULT_SAME_SITE,
secure,
partitioned = false
} = options;
const attributes = [];
if (typeof duration === "number" && Number.isFinite(duration)) {
if (duration <= 0) {
attributes.push("Max-Age=0");
} else {
const maxAge = Math.floor(duration * 24 * 60 * 60);
attributes.push(`Max-Age=${maxAge}`);
}
}
if (path) {
attributes.push(`Path=${path}`);
}
if (domain) {
attributes.push(`Domain=${domain}`);
}
if (sameSite) {
attributes.push(`SameSite=${sameSite}`);
}
const shouldSecure =
secure === true ||
(secure === undefined &&
typeof location !== "undefined" &&
location.protocol === "https:");
if (shouldSecure) {
attributes.push("Secure");
}
if (partitioned) {
attributes.push("Partitioned");
}
return attributes.join("; ");
}
export function createCookieStorage(options = {}) {
const config = {
name: options.name || DEFAULT_COOKIE_NAME,
duration:
typeof options.duration === "number"
? options.duration
: DEFAULT_DURATION_DAYS,
path: options.path || DEFAULT_PATH,
domain: options.domain,
sameSite: options.sameSite || DEFAULT_SAME_SITE,
secure: options.secure,
partitioned: options.partitioned === true
};
return Object.freeze({
name: config.name,
getRaw() {
if (!isBrowser()) {
return null;
}
const prefix = `${encode(config.name)}=`;
const cookies = document.cookie ? document.cookie.split("; ") : [];
for (const item of cookies) {
if (item.startsWith(prefix)) {
return decode(item.slice(prefix.length));
}
}
return null;
},
get() {
const raw = this.getRaw();
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return null;
}
},
set(payload) {
if (!isBrowser()) {
return false;
}
let serialized;
try {
serialized = JSON.stringify(payload);
} catch {
return false;
}
const attributes = buildAttributes(config);
document.cookie =
`${encode(config.name)}=${encode(serialized)}` +
(attributes ? `; ${attributes}` : "");
return this.getRaw() !== null;
},
remove() {
if (!isBrowser()) {
return false;
}
const attributes = buildAttributes({
...config,
duration: 0
});
document.cookie =
`${encode(config.name)}=` +
(attributes ? `; ${attributes}` : "");
return this.getRaw() === null;
},
getConfig() {
return { ...config };
}
});
}
/**
* Validates the persisted envelope before the consent engine attempts to
* interpret its contents. Category semantics are validated separately.
*/
export function isStoragePayload(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
if (typeof value.engineVersion !== "string" || !value.engineVersion) {
return false;
}
if (typeof value.policyVersion !== "string" || !value.policyVersion) {
return false;
}
if (!Number.isFinite(value.timestamp)) {
return false;
}
if (!value.categories ||
typeof value.categories !== "object" ||
Array.isArray(value.categories)) {
return false;
}
if (typeof value.jurisdiction !== "string" || !value.jurisdiction) {
return false;
}
if (typeof value.gpc !== "boolean") {
return false;
}
return true;
}
export const STORAGE_DEFAULTS = Object.freeze({
name: DEFAULT_COOKIE_NAME,
duration: DEFAULT_DURATION_DAYS,
path: DEFAULT_PATH,
sameSite: DEFAULT_SAME_SITE
});