@consentify/core 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +210 -118
- package/dist/index.d.ts +43 -34
- package/dist/index.js +176 -46
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +788 -0
- package/package.json +7 -2
package/dist/index.js
CHANGED
|
@@ -15,11 +15,8 @@ function fnv1a(str) {
|
|
|
15
15
|
return ('00000000' + h.toString(16)).slice(-8);
|
|
16
16
|
}
|
|
17
17
|
function hashPolicy(categories, identifier) {
|
|
18
|
-
// Deterministic identity for the policy. If you provide `identifier`, it is folded into the hash,
|
|
19
|
-
// but consider using `identifier` itself as the canonical version key for clarity.
|
|
20
18
|
return fnv1a(stableStringify({ categories: [...categories].sort(), identifier: identifier ?? null }));
|
|
21
19
|
}
|
|
22
|
-
// --- Internals ---
|
|
23
20
|
const DEFAULT_COOKIE = 'consentify';
|
|
24
21
|
const enc = (o) => encodeURIComponent(JSON.stringify(o));
|
|
25
22
|
const dec = (s) => { try {
|
|
@@ -29,6 +26,28 @@ catch {
|
|
|
29
26
|
return null;
|
|
30
27
|
} };
|
|
31
28
|
const toISO = () => new Date().toISOString();
|
|
29
|
+
function isValidSnapshot(s) {
|
|
30
|
+
if (typeof s !== 'object' || s === null ||
|
|
31
|
+
typeof s.policy !== 'string' || s.policy === '' ||
|
|
32
|
+
typeof s.givenAt !== 'string' ||
|
|
33
|
+
typeof s.choices !== 'object' || s.choices === null)
|
|
34
|
+
return false;
|
|
35
|
+
if (isNaN(new Date(s.givenAt).getTime()))
|
|
36
|
+
return false;
|
|
37
|
+
for (const v of Object.values(s.choices)) {
|
|
38
|
+
if (typeof v !== 'boolean')
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function buildSetCookieHeader(name, value, opt) {
|
|
44
|
+
let h = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
|
|
45
|
+
if (opt.domain)
|
|
46
|
+
h += `; Domain=${opt.domain}`;
|
|
47
|
+
if (opt.secure)
|
|
48
|
+
h += `; Secure`;
|
|
49
|
+
return h;
|
|
50
|
+
}
|
|
32
51
|
function readCookie(name, cookieStr) {
|
|
33
52
|
const src = cookieStr ?? (typeof document !== 'undefined' ? document.cookie : '');
|
|
34
53
|
if (!src)
|
|
@@ -39,14 +58,8 @@ function readCookie(name, cookieStr) {
|
|
|
39
58
|
function writeCookie(name, value, opt) {
|
|
40
59
|
if (typeof document === 'undefined')
|
|
41
60
|
return;
|
|
42
|
-
|
|
43
|
-
if (opt.domain)
|
|
44
|
-
c += `; Domain=${opt.domain}`;
|
|
45
|
-
if (opt.secure)
|
|
46
|
-
c += `; Secure`;
|
|
47
|
-
document.cookie = c;
|
|
61
|
+
document.cookie = buildSetCookieHeader(name, value, opt);
|
|
48
62
|
}
|
|
49
|
-
// --- Unified Factory (single entry point) ---
|
|
50
63
|
export function createConsentify(init) {
|
|
51
64
|
const policyHash = init.policy.identifier ?? hashPolicy(init.policy.categories);
|
|
52
65
|
const cookieName = init.cookie?.name ?? DEFAULT_COOKIE;
|
|
@@ -59,6 +72,16 @@ export function createConsentify(init) {
|
|
|
59
72
|
domain: init.cookie?.domain,
|
|
60
73
|
};
|
|
61
74
|
const storageOrder = (init.storage && init.storage.length > 0) ? init.storage : ['cookie'];
|
|
75
|
+
const consentMaxAgeDays = init.consentMaxAgeDays;
|
|
76
|
+
const isExpired = (givenAt) => {
|
|
77
|
+
if (!consentMaxAgeDays)
|
|
78
|
+
return false;
|
|
79
|
+
const givenTime = new Date(givenAt).getTime();
|
|
80
|
+
if (isNaN(givenTime))
|
|
81
|
+
return true;
|
|
82
|
+
const maxAgeMs = consentMaxAgeDays * 24 * 60 * 60 * 1000;
|
|
83
|
+
return Date.now() - givenTime > maxAgeMs;
|
|
84
|
+
};
|
|
62
85
|
const allowed = new Set(['necessary', ...init.policy.categories]);
|
|
63
86
|
const normalize = (choices) => {
|
|
64
87
|
const base = { necessary: true };
|
|
@@ -73,7 +96,6 @@ export function createConsentify(init) {
|
|
|
73
96
|
base.necessary = true;
|
|
74
97
|
return base;
|
|
75
98
|
};
|
|
76
|
-
// --- client-side storage helpers ---
|
|
77
99
|
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
78
100
|
const canLocal = () => { try {
|
|
79
101
|
return isBrowser() && !!window.localStorage;
|
|
@@ -84,7 +106,13 @@ export function createConsentify(init) {
|
|
|
84
106
|
const readFromStore = (kind) => {
|
|
85
107
|
switch (kind) {
|
|
86
108
|
case 'cookie': return readCookie(cookieName);
|
|
87
|
-
case 'localStorage':
|
|
109
|
+
case 'localStorage': try {
|
|
110
|
+
return canLocal() ? window.localStorage.getItem(cookieName) : null;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.warn('[consentify] localStorage read failed:', err);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
88
116
|
default: return null;
|
|
89
117
|
}
|
|
90
118
|
};
|
|
@@ -94,20 +122,31 @@ export function createConsentify(init) {
|
|
|
94
122
|
writeCookie(cookieName, value, cookieCfg);
|
|
95
123
|
break;
|
|
96
124
|
case 'localStorage':
|
|
97
|
-
|
|
98
|
-
|
|
125
|
+
try {
|
|
126
|
+
if (canLocal())
|
|
127
|
+
window.localStorage.setItem(cookieName, value);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.warn('[consentify] localStorage write failed:', err);
|
|
131
|
+
}
|
|
99
132
|
break;
|
|
100
133
|
}
|
|
101
134
|
};
|
|
135
|
+
const clearCookieHeader = () => buildSetCookieHeader(cookieName, '', { ...cookieCfg, maxAgeSec: 0 });
|
|
102
136
|
const clearStore = (kind) => {
|
|
103
137
|
switch (kind) {
|
|
104
138
|
case 'cookie':
|
|
105
139
|
if (isBrowser())
|
|
106
|
-
document.cookie =
|
|
140
|
+
document.cookie = clearCookieHeader();
|
|
107
141
|
break;
|
|
108
142
|
case 'localStorage':
|
|
109
|
-
|
|
110
|
-
|
|
143
|
+
try {
|
|
144
|
+
if (canLocal())
|
|
145
|
+
window.localStorage.removeItem(cookieName);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.warn('[consentify] localStorage clear failed:', err);
|
|
149
|
+
}
|
|
111
150
|
break;
|
|
112
151
|
}
|
|
113
152
|
};
|
|
@@ -134,14 +173,15 @@ export function createConsentify(init) {
|
|
|
134
173
|
if (primary !== 'cookie' && storageOrder.includes('cookie'))
|
|
135
174
|
writeToStore('cookie', value);
|
|
136
175
|
};
|
|
137
|
-
// --- read helpers ---
|
|
138
176
|
const readClient = () => {
|
|
139
177
|
const raw = readClientRaw();
|
|
140
178
|
const s = raw ? dec(raw) : null;
|
|
141
|
-
if (!s)
|
|
179
|
+
if (!s || !isValidSnapshot(s))
|
|
142
180
|
return null;
|
|
143
181
|
if (s.policy !== policyHash)
|
|
144
182
|
return null;
|
|
183
|
+
if (isExpired(s.givenAt))
|
|
184
|
+
return null;
|
|
145
185
|
return s;
|
|
146
186
|
};
|
|
147
187
|
const writeClientIfChanged = (next) => {
|
|
@@ -151,23 +191,16 @@ export function createConsentify(init) {
|
|
|
151
191
|
writeClientRaw(enc(next));
|
|
152
192
|
return !same;
|
|
153
193
|
};
|
|
154
|
-
function buildSetCookieHeader(name, value, opt) {
|
|
155
|
-
let header = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
|
|
156
|
-
if (opt.domain)
|
|
157
|
-
header += `; Domain=${opt.domain}`;
|
|
158
|
-
if (opt.secure)
|
|
159
|
-
header += `; Secure`;
|
|
160
|
-
return header;
|
|
161
|
-
}
|
|
162
|
-
// ---- server API
|
|
163
194
|
const server = {
|
|
164
195
|
get: (cookieHeader) => {
|
|
165
196
|
const raw = cookieHeader ? readCookie(cookieName, cookieHeader) : null;
|
|
166
197
|
const s = raw ? dec(raw) : null;
|
|
167
|
-
if (!s)
|
|
198
|
+
if (!s || !isValidSnapshot(s))
|
|
168
199
|
return { decision: 'unset' };
|
|
169
200
|
if (s.policy !== policyHash)
|
|
170
201
|
return { decision: 'unset' };
|
|
202
|
+
if (isExpired(s.givenAt))
|
|
203
|
+
return { decision: 'unset' };
|
|
171
204
|
return { decision: 'decided', snapshot: s };
|
|
172
205
|
},
|
|
173
206
|
set: (choices, currentCookieHeader) => {
|
|
@@ -180,16 +213,8 @@ export function createConsentify(init) {
|
|
|
180
213
|
};
|
|
181
214
|
return buildSetCookieHeader(cookieName, enc(snapshot), cookieCfg);
|
|
182
215
|
},
|
|
183
|
-
clear: () =>
|
|
184
|
-
let h = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}`;
|
|
185
|
-
if (cookieCfg.domain)
|
|
186
|
-
h += `; Domain=${cookieCfg.domain}`;
|
|
187
|
-
if (cookieCfg.secure)
|
|
188
|
-
h += `; Secure`;
|
|
189
|
-
return h;
|
|
190
|
-
}
|
|
216
|
+
clear: () => clearCookieHeader()
|
|
191
217
|
};
|
|
192
|
-
// ========== NEW: Subscribe pattern for React ==========
|
|
193
218
|
const listeners = new Set();
|
|
194
219
|
const unsetState = { decision: 'unset' };
|
|
195
220
|
let cachedState = unsetState;
|
|
@@ -203,14 +228,19 @@ export function createConsentify(init) {
|
|
|
203
228
|
}
|
|
204
229
|
};
|
|
205
230
|
const notifyListeners = () => {
|
|
206
|
-
listeners.forEach(cb =>
|
|
231
|
+
listeners.forEach(cb => {
|
|
232
|
+
try {
|
|
233
|
+
cb();
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
console.error('[consentify] Listener callback threw:', err);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
207
239
|
};
|
|
208
|
-
// Init cache on browser
|
|
209
240
|
if (isBrowser()) {
|
|
210
241
|
syncState();
|
|
211
242
|
}
|
|
212
243
|
function clientGet(category) {
|
|
213
|
-
// Return cached state for React compatibility
|
|
214
244
|
if (typeof category === 'undefined')
|
|
215
245
|
return cachedState;
|
|
216
246
|
if (category === 'necessary')
|
|
@@ -222,8 +252,8 @@ export function createConsentify(init) {
|
|
|
222
252
|
const client = {
|
|
223
253
|
get: clientGet,
|
|
224
254
|
set: (choices) => {
|
|
225
|
-
const
|
|
226
|
-
const base =
|
|
255
|
+
const fresh = readClient();
|
|
256
|
+
const base = fresh ? fresh.choices : normalize();
|
|
227
257
|
const next = {
|
|
228
258
|
policy: policyHash,
|
|
229
259
|
givenAt: toISO(),
|
|
@@ -241,14 +271,47 @@ export function createConsentify(init) {
|
|
|
241
271
|
syncState();
|
|
242
272
|
notifyListeners();
|
|
243
273
|
},
|
|
244
|
-
// NEW: Subscribe for React useSyncExternalStore
|
|
245
274
|
subscribe: (callback) => {
|
|
246
275
|
listeners.add(callback);
|
|
247
276
|
return () => listeners.delete(callback);
|
|
248
277
|
},
|
|
249
|
-
// NEW: Server snapshot for SSR (always unset)
|
|
250
278
|
getServerSnapshot: () => unsetState,
|
|
279
|
+
guard: (category, onGrant, onRevoke) => {
|
|
280
|
+
let phase = 'waiting';
|
|
281
|
+
const check = () => clientGet(category) === true;
|
|
282
|
+
const tick = () => {
|
|
283
|
+
if (phase === 'waiting' && check()) {
|
|
284
|
+
onGrant();
|
|
285
|
+
phase = onRevoke ? 'granted' : 'done';
|
|
286
|
+
if (phase === 'done')
|
|
287
|
+
unsub();
|
|
288
|
+
}
|
|
289
|
+
else if (phase === 'granted' && !check()) {
|
|
290
|
+
onRevoke();
|
|
291
|
+
phase = 'done';
|
|
292
|
+
unsub();
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const unsub = client.subscribe(tick);
|
|
296
|
+
tick();
|
|
297
|
+
return () => { phase = 'done'; unsub(); };
|
|
298
|
+
},
|
|
251
299
|
};
|
|
300
|
+
function flatGet(cookieHeader) {
|
|
301
|
+
return typeof cookieHeader === 'string'
|
|
302
|
+
? server.get(cookieHeader)
|
|
303
|
+
: client.get();
|
|
304
|
+
}
|
|
305
|
+
function flatSet(choices, cookieHeader) {
|
|
306
|
+
if (typeof cookieHeader === 'string')
|
|
307
|
+
return server.set(choices, cookieHeader);
|
|
308
|
+
client.set(choices);
|
|
309
|
+
}
|
|
310
|
+
function flatClear(serverMode) {
|
|
311
|
+
if (typeof serverMode === 'string')
|
|
312
|
+
return server.clear();
|
|
313
|
+
client.clear();
|
|
314
|
+
}
|
|
252
315
|
return {
|
|
253
316
|
policy: {
|
|
254
317
|
categories: init.policy.categories,
|
|
@@ -256,7 +319,74 @@ export function createConsentify(init) {
|
|
|
256
319
|
},
|
|
257
320
|
server,
|
|
258
321
|
client,
|
|
322
|
+
get: flatGet,
|
|
323
|
+
isGranted: (category) => {
|
|
324
|
+
return clientGet(category);
|
|
325
|
+
},
|
|
326
|
+
set: flatSet,
|
|
327
|
+
clear: flatClear,
|
|
328
|
+
subscribe: client.subscribe,
|
|
329
|
+
getServerSnapshot: client.getServerSnapshot,
|
|
330
|
+
guard: client.guard,
|
|
259
331
|
};
|
|
260
332
|
}
|
|
261
|
-
// Common predefined category names you can reuse in your policy.
|
|
262
333
|
export const defaultCategories = ['preferences', 'analytics', 'marketing', 'functional', 'unclassified'];
|
|
334
|
+
export const defaultConsentModeMapping = {
|
|
335
|
+
necessary: ['security_storage'],
|
|
336
|
+
analytics: ['analytics_storage'],
|
|
337
|
+
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
|
|
338
|
+
preferences: ['functionality_storage', 'personalization_storage'],
|
|
339
|
+
};
|
|
340
|
+
function safeGtag(...args) {
|
|
341
|
+
try {
|
|
342
|
+
window.gtag(...args);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
console.error('[consentify] gtag call failed:', err);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
export function enableConsentMode(instance, options) {
|
|
349
|
+
if (typeof window === 'undefined')
|
|
350
|
+
return () => { };
|
|
351
|
+
window.dataLayer = window.dataLayer || [];
|
|
352
|
+
if (typeof window.gtag !== 'function') {
|
|
353
|
+
window.gtag = function gtag() {
|
|
354
|
+
window.dataLayer.push(arguments);
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const resolve = () => {
|
|
358
|
+
const state = instance.get();
|
|
359
|
+
const result = {};
|
|
360
|
+
for (const [category, gTypes] of Object.entries(options.mapping)) {
|
|
361
|
+
if (!gTypes)
|
|
362
|
+
continue;
|
|
363
|
+
let granted = false;
|
|
364
|
+
if (category === 'necessary') {
|
|
365
|
+
granted = true;
|
|
366
|
+
}
|
|
367
|
+
else if (state.decision === 'decided') {
|
|
368
|
+
granted = !!state.snapshot.choices[category];
|
|
369
|
+
}
|
|
370
|
+
for (const gType of gTypes) {
|
|
371
|
+
result[gType] = granted ? 'granted' : 'denied';
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
};
|
|
376
|
+
const defaultPayload = { ...resolve() };
|
|
377
|
+
if (options.waitForUpdate != null) {
|
|
378
|
+
defaultPayload.wait_for_update = options.waitForUpdate;
|
|
379
|
+
}
|
|
380
|
+
safeGtag('consent', 'default', defaultPayload);
|
|
381
|
+
const state = instance.get();
|
|
382
|
+
if (state.decision === 'decided') {
|
|
383
|
+
safeGtag('consent', 'update', resolve());
|
|
384
|
+
}
|
|
385
|
+
const unsubscribe = instance.subscribe(() => {
|
|
386
|
+
const current = instance.get();
|
|
387
|
+
if (current.decision === 'decided') {
|
|
388
|
+
safeGtag('consent', 'update', resolve());
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
return unsubscribe;
|
|
392
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|