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