@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/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
- let c = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
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': return canLocal() ? window.localStorage.getItem(cookieName) : null;
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
- if (canLocal())
98
- window.localStorage.setItem(cookieName, value);
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 = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}${cookieCfg.domain ? `; Domain=${cookieCfg.domain}` : ''}${cookieCfg.secure ? '; Secure' : ''}`;
140
+ document.cookie = clearCookieHeader();
107
141
  break;
108
142
  case 'localStorage':
109
- if (canLocal())
110
- window.localStorage.removeItem(cookieName);
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 => 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 prev = client.get();
226
- const base = prev.decision === 'decided' ? prev.snapshot.choices : normalize();
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 {};