@ecomconsult/consentkit 0.5.13 → 0.5.14

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 CHANGED
@@ -29,7 +29,7 @@ Vanilla ES2020, zero dependencies, no build step.
29
29
  - **Equal-weight buttons, no pre-ticked boxes** — the consent invariants are
30
30
  fixed by design, see [CONTRIBUTING.md](https://github.com/vermoh/ConsentKit/blob/main/CONTRIBUTING.md)
31
31
 
32
- > **Status: prototype (v0.5.13).** The core, the UI and the demo are verified in
32
+ > **Status: prototype (v0.5.14).** The core, the UI and the demo are verified in
33
33
  > a browser and covered by an automated suite (`npm test`); several distribution
34
34
  > paths are not yet tested against live systems. See
35
35
  > [Project status](#project-status) before shipping this to production.
@@ -819,19 +819,19 @@ external requests. Rebuild them with `tools/build-inline.mjs` (see
819
819
  [`tools/README.md`](https://github.com/vermoh/ConsentKit/blob/main/tools/README.md)); each block's header records the exact
820
820
  command that produced it.
821
821
 
822
- ConsentKit 0.5.13, rebuilt 2026-09-07, uncompressed — gzip on the server cuts
822
+ ConsentKit 0.5.14, rebuilt 2026-09-07, uncompressed — gzip on the server cuts
823
823
  this roughly threefold. Every block includes the branding extension and the
824
824
  attribution line; `--no-branding` drops both the code and the config and takes
825
825
  **~26 KB** back off:
826
826
 
827
827
  | Block | Languages | Bytes | gzip | `--no-branding` |
828
828
  |---|---|---|---|---|
829
- | `ready/en-bar.txt` | en | 264,816 | 82,798 | 238,438 |
830
- | `ready/ru-bar.txt` | ru, ro, en | 266,806 | 83,673 | 240,242 |
831
- | `ready/ru-box.txt` | ru, ro, en | 266,821 | 83,682 | 240,257 |
832
- | `ready/ru-box-right.txt` | ru, ro, en | 266,830 | 83,687 | 240,266 |
833
- | `ready/ru-modal.txt` | ru, ro, en | 266,814 | 83,679 | 240,250 |
834
- | `ready/eu-bar.txt` | 34 languages | 315,460 | 101,998 | 288,916 |
829
+ | `ready/en-bar.txt` | en | 275,509 | 86,249 | 249,096 |
830
+ | `ready/ru-bar.txt` | ru, ro, en | 277,578 | 87,146 | 250,979 |
831
+ | `ready/ru-box.txt` | ru, ro, en | 277,593 | 87,156 | 250,994 |
832
+ | `ready/ru-box-right.txt` | ru, ro, en | 277,602 | 87,161 | 250,997 |
833
+ | `ready/ru-modal.txt` | ru, ro, en | 277,586 | 87,153 | 250,985 |
834
+ | `ready/eu-bar.txt` | 34 languages | 326,232 | 105,534 | 299,653 |
835
835
 
836
836
  The blocks are dominated by the core and the UI (roughly 97 KB and 129 KB of
837
837
  source respectively, comments included — the builder concatenates the sources
@@ -1023,6 +1023,23 @@ Client versions. The WordPress plugin tracks the same numbers and keeps its own
1023
1023
  notes in
1024
1024
  [`plugins/wordpress/consentkit/readme.txt`](https://github.com/vermoh/ConsentKit/blob/main/plugins/wordpress/consentkit/readme.txt).
1025
1025
 
1026
+ ### 0.5.14
1027
+
1028
+ - **The loader tolerates a second snippet with a dead site id.** A page that
1029
+ carries two `data-ck-id` blocks — a migration that left the old one in place —
1030
+ is driven by the snippet whose config loads; the failing one only warns. The
1031
+ strict fallback is raised solely when *every* snippet fails, never while
1032
+ another is still in flight. The same id twice (Tilda duplicates the head
1033
+ block) initialises once and warns about nothing.
1034
+ - **A second copy of the script on the same page no longer replaces the first.**
1035
+ Each snippet loads the whole bundle, so a two-snippet page ran the client
1036
+ twice: the second core published a fresh, uninitialised engine over
1037
+ `window.ConsentKit` and the second UI layer mounted its own banner, leaving
1038
+ the real config applied to an engine nothing pointed at any more — a banner
1039
+ drawn from the built-in defaults. The second copy now stands down: the first
1040
+ engine keeps its state, its DOM patches and its observer, and the page mounts
1041
+ exactly one banner.
1042
+
1026
1043
  ### 0.5.13
1027
1044
 
1028
1045
  - Host database: `fbcdn.net` → marketing (Facebook SDK chunks and plugin images); `aichat.md`, `bubble.aichat.md` → functional (chat widget); `challenges.cloudflare.com` (Cloudflare challenge / Turnstile) and `i.imgur.com` → infrastructure, never held.
@@ -1143,7 +1160,7 @@ notes in
1143
1160
 
1144
1161
  ## Project status
1145
1162
 
1146
- **This is a prototype (v0.5.13), not a released product.** It is honest about
1163
+ **This is a prototype (v0.5.14), not a released product.** It is honest about
1147
1164
  what has been verified and what has not.
1148
1165
 
1149
1166
  ### Verified
@@ -28,7 +28,7 @@ export function undecidedState() {
28
28
  */
29
29
  export function createStub() {
30
30
  const stub = {
31
- version: '0.5.13',
31
+ version: '0.5.14',
32
32
  config: {},
33
33
  init: function () { return undecidedState(); },
34
34
  allowed: function (cat) { return cat === 'necessary'; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomconsult/consentkit",
3
- "version": "0.5.13",
3
+ "version": "0.5.14",
4
4
  "description": "GDPR cookie consent core with blocking engine, Shadow DOM UI and Google Consent Mode v2. Zero dependencies, no build step.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/ck-core.js CHANGED
@@ -9,6 +9,29 @@
9
9
  'use strict';
10
10
 
11
11
  if (!global) { return; }
12
+
13
+ /* SPEC §1.9 — a second copy of ck.js on the page stands down.
14
+ A page can carry two snippets: an old page-level block with a dead site id
15
+ that nobody ever removed, plus the site-wide one. Each <script> loads the
16
+ WHOLE bundle, so this file runs twice — and without this guard the second
17
+ run would publish a fresh, uninitialised engine over `window.ConsentKit`.
18
+ Everything the first engine did stays behind on an object nothing points at
19
+ any more: its consent state, and the loader that is mid-fetch holding a
20
+ `CK` reference to it. The page would then end on the default config with a
21
+ banner drawn from default texts, while the real config was applied to the
22
+ orphan.
23
+ Standing down here is also what the two loaders' arbitration in ck-saas.js
24
+ assumes: `__ckSaas` coordinates them on the premise that both are talking
25
+ to the SAME core, so `shared.done` genuinely means "this page is
26
+ initialised" rather than "some object was initialised".
27
+ The patches, the MutationObserver and the initial scan are already live
28
+ from the first run; re-installing them would double every interception.
29
+ `init` is the test rather than mere presence, because ck-ui-branding.js
30
+ pre-creates a bare `window.ConsentKit = {}` when it happens to load first.
31
+ That object has no `init`, so we fall through and publish over it exactly
32
+ as before. */
33
+ if (global.ConsentKit && typeof global.ConsentKit.init === 'function') { return; }
34
+
12
35
  var doc = global.document;
13
36
 
14
37
  // ---------------------------------------------------------------------------
@@ -1978,7 +2001,7 @@
1978
2001
  // Public API
1979
2002
  // ---------------------------------------------------------------------------
1980
2003
  var ConsentKit = {
1981
- version: '0.5.13',
2004
+ version: '0.5.14',
1982
2005
  config: config,
1983
2006
 
1984
2007
  init: function (userConfig) {
package/src/ck-debug.js CHANGED
@@ -272,6 +272,7 @@
272
272
  source: 'источник',
273
273
  srcSaas: 'SaaS',
274
274
  srcInline: 'инлайн',
275
+ twoSnippets: 'два снипета',
275
276
  secConsent: 'Согласие',
276
277
  decidedAt: 'решение',
277
278
  method: 'способ',
@@ -348,6 +349,7 @@
348
349
  source: 'source',
349
350
  srcSaas: 'SaaS',
350
351
  srcInline: 'inline',
352
+ twoSnippets: 'two snippets',
351
353
  secConsent: 'Consent',
352
354
  decidedAt: 'decided',
353
355
  method: 'method',
@@ -423,6 +425,7 @@
423
425
  source: 'sursă',
424
426
  srcSaas: 'SaaS',
425
427
  srcInline: 'inline',
428
+ twoSnippets: 'două fragmente',
426
429
  secConsent: 'Consimțământ',
427
430
  decidedAt: 'decizie',
428
431
  method: 'mod',
@@ -910,14 +913,26 @@
910
913
  body.textContent = '';
911
914
 
912
915
  // 1. Client
916
+ /* Read straight off _saas rather than through buildReport(): more than one
917
+ snippet on a page is a fact about the page, not about the report, and it
918
+ is worth surfacing because the symptom it explains — a banner configured
919
+ by an id the owner did not expect — is otherwise invisible. */
920
+ var siteIds = null;
921
+ try {
922
+ var ids = CK && CK._saas && CK._saas.siteIds;
923
+ if (ids && ids.length > 1) { siteIds = ids.join(', '); }
924
+ } catch (e) { siteIds = null; }
925
+
913
926
  var s1 = section(T.secClient);
914
- s1.appendChild(defs([
927
+ var rows1 = [
915
928
  [T.version, r.client.version],
916
929
  [T.source, r.client.source === 'saas' ? T.srcSaas : T.srcInline],
917
- ['siteId', r.client.siteId],
918
- ['policyVersion', r.client.policyVersion],
919
- ['ETag', r.client.etag]
920
- ]));
930
+ ['siteId', r.client.siteId]
931
+ ];
932
+ if (siteIds) { rows1.push([T.twoSnippets, siteIds]); }
933
+ rows1.push(['policyVersion', r.client.policyVersion]);
934
+ rows1.push(['ETag', r.client.etag]);
935
+ s1.appendChild(defs(rows1));
921
936
  body.appendChild(s1);
922
937
 
923
938
  // 2. Consent
package/src/ck-saas.js CHANGED
@@ -64,11 +64,35 @@
64
64
  // document.currentScript is correct while this file is executing.
65
65
  var cur = doc.currentScript;
66
66
  if (cur && cur.getAttribute && cur.getAttribute('data-ck-id')) { return cur; }
67
+ // Fallback for the exotic case where currentScript is unavailable (an
68
+ // async injection, an old browser). With two snippets on the page both
69
+ // loaders would pick the same last tag — which the §1.2 stand-down below
70
+ // turns into "the second one goes quiet" rather than a double init. Not
71
+ // ideal, but it degrades in the safe direction, and every real snippet is
72
+ // a plain synchronous <script> where currentScript is set.
67
73
  var all = doc.querySelectorAll('script[data-ck-id]');
68
74
  return all && all.length ? all[all.length - 1] : null;
69
75
  } catch (e) { return null; }
70
76
  }
71
77
 
78
+ // Every distinct data-ck-id on the page, in document order. Read from the DOM
79
+ // rather than accumulated across loaders, because on a real page the second
80
+ // snippet's tag may not be parsed yet when the first loader runs — the last
81
+ // loader to execute sees them all, and it is the one that matters.
82
+ function tagIds() {
83
+ var out = [];
84
+ try {
85
+ var all = doc.querySelectorAll('script[data-ck-id]');
86
+ if (!all) { return out; }
87
+ for (var i = 0; i < all.length; i++) {
88
+ var id = '';
89
+ try { id = String(all[i].getAttribute('data-ck-id') || '').trim(); } catch (e2) { id = ''; }
90
+ if (id && out.indexOf(id) === -1) { out.push(id); }
91
+ }
92
+ } catch (e) { /* noop */ }
93
+ return out;
94
+ }
95
+
72
96
  var tag = findOwnTag();
73
97
  if (!tag) { return; } // no data-ck-id -> standalone page, stay inert
74
98
 
@@ -87,6 +111,54 @@
87
111
  return;
88
112
  }
89
113
 
114
+ // ---------------------------------------------------------------------------
115
+ // Shared state across every loader on the page (SPEC §1.1)
116
+ // ---------------------------------------------------------------------------
117
+ /* A page can carry more than one snippet: a site migrated between two ids and
118
+ the old block was never removed, an agency pasted its own on top of the
119
+ client's. Each copy of this file is a separate IIFE with its own closure, so
120
+ the only thing they can agree on is a global.
121
+
122
+ `pending` here counts CONFIG FETCHES still in flight — deliberately not the
123
+ same thing as the beacon queue further down, which is also called `pending`
124
+ but lives in this file's closure and is what _saas.pending() reports.
125
+
126
+ `warnedDup` and `activeId` are not in the spec's field list, but the once-
127
+ only duplicate warning and the "driven by snippet X" message have nowhere
128
+ else to live: whichever loader speaks last would otherwise repeat the
129
+ warning, and the loser needs the winner's id. */
130
+ var shared = global.__ckSaas;
131
+ if (!shared || typeof shared !== 'object') {
132
+ shared = global.__ckSaas = { pending: 0, done: false, ids: [], failures: [] };
133
+ }
134
+ if (!shared.ids || typeof shared.ids.length !== 'number') { shared.ids = []; }
135
+ if (!shared.failures || typeof shared.failures.length !== 'number') { shared.failures = []; }
136
+
137
+ /* SPEC §1.2 — the same id twice is Tilda duplicating the head block, not two
138
+ snippets. It is not a misconfiguration, so it must not warn; but it must
139
+ also not fetch or initialise a second time, or the page would pay for two
140
+ config requests and CK.init() would run twice. Stand down silently, and
141
+ BEFORE pending++ so the vanished loader cannot hold the strict fallback
142
+ hostage in §1.4. */
143
+ if (shared.ids.indexOf(siteId) !== -1) { return; }
144
+ shared.ids.push(siteId);
145
+ shared.pending++;
146
+
147
+ var allIds = tagIds();
148
+ // A tag whose loader has not executed yet is still a snippet on this page.
149
+ for (var ai = 0; ai < shared.ids.length; ai++) {
150
+ if (allIds.indexOf(shared.ids[ai]) === -1) { allIds.push(shared.ids[ai]); }
151
+ }
152
+ if (allIds.length > 1 && !shared.warnedDup) {
153
+ shared.warnedDup = true;
154
+ var others = [];
155
+ for (var oi = 0; oi < allIds.length; oi++) {
156
+ if (allIds[oi] !== siteId) { others.push(allIds[oi]); }
157
+ }
158
+ warn('another ConsentKit snippet on this page uses site id ' + others.join(', ') +
159
+ '; keep one snippet per site');
160
+ }
161
+
90
162
  var cacheKey = CACHE_PREFIX + siteId;
91
163
  var activeConfig = null; // config currently driving this page load
92
164
 
@@ -142,13 +214,64 @@
142
214
  } catch (e) { return 0; }
143
215
  }
144
216
 
217
+ /* This loader's own config attempt resolves exactly once. Both the cold path
218
+ and the cached path run through here, and the cached path is the reason the
219
+ guard exists at all: it settles SUCCESSFULLY the moment the cache is read,
220
+ and then starts a background revalidation whose failure must not decrement
221
+ `pending` a second time or push a failure the page never suffered. */
222
+ var settled = false;
223
+ function settleOk() {
224
+ if (settled) { return; }
225
+ settled = true;
226
+ shared.done = true;
227
+ shared.activeId = shared.activeId || siteId;
228
+ shared.pending--;
229
+ }
230
+
145
231
  function initWith(config, why) {
232
+ settleOk();
146
233
  activeConfig = config;
147
234
  applyHostDb(config);
148
235
  try { CK.init(config); } catch (e) { error('init() failed: ' + (e && e.message)); }
149
236
  if (why) { /* reserved for diagnostics */ }
150
237
  }
151
238
 
239
+ /* SPEC §1.4 — one loader's config failed. What follows depends entirely on
240
+ what the OTHER loaders are doing, which is why none of this can be decided
241
+ locally.
242
+
243
+ The case that motivates the whole section: a site has a live snippet and a
244
+ dead one (an id that was deleted from the account). Before 0.5.14 the dead
245
+ one's 404 raised the strict fallback and re-initialised the core with
246
+ everything denied — a working banner replaced by a broken one because of a
247
+ block nobody had noticed in years. Now the dead snippet only fails; the
248
+ live snippet still drives the page. */
249
+ function settleFail(reason) {
250
+ if (settled) { return; }
251
+ settled = true;
252
+ shared.pending--;
253
+ shared.failures.push(siteId + ': ' + reason);
254
+
255
+ if (shared.done) {
256
+ // Someone else already initialised the page. Touching CK.init() now would
257
+ // swap ConsentKit.config identity out from under a banner the visitor may
258
+ // already be looking at.
259
+ warn('config for ' + siteId + ' unavailable (' + reason + '); the page is driven by snippet ' +
260
+ (shared.activeId || 'unknown'));
261
+ return;
262
+ }
263
+ if (shared.pending > 0) {
264
+ // SPEC §1.5: the strict fallback is a last resort, and it is not the last
265
+ // resort while another snippet may still succeed. CFG_TIMEOUT_MS bounds
266
+ // that wait — every in-flight fetch aborts by then, so this cannot hang.
267
+ warn('config for ' + siteId + ' unavailable (' + reason + '); waiting for the other snippet');
268
+ return;
269
+ }
270
+ // Every snippet on the page failed. This is the 0.5.13 behaviour, and for a
271
+ // single tag the joined list is exactly one entry.
272
+ initStrict(shared.failures.join('; '));
273
+ }
274
+
152
275
  // Strict FALLBACK: banner shows, every opt-in category stays off, no journal.
153
276
  // policyVersion 'strict-fallback' deliberately mismatches any stored consent,
154
277
  // so a previous decision is not silently reused when the server is unreachable.
@@ -161,13 +284,28 @@
161
284
  // Known trackers are still blocked, as always.
162
285
  function initStrict(reason) {
163
286
  warn('config unavailable (' + reason + ') — strict fallback: banner shown, all opt-in categories denied, journal disabled.');
287
+ shared.strictFallback = true;
288
+ // A _saas already published by another loader must learn this too: the
289
+ // object below is a plain assignment and the last loader to run wins, so a
290
+ // late publisher would otherwise report strictFallback: false.
291
+ try { if (CK._saas) { CK._saas.strictFallback = true; } } catch (e) { /* noop */ }
164
292
  initWith({ policyVersion: 'strict-fallback' });
165
293
  }
166
294
 
167
295
  // cacheMode: 'default' lets the HTTP cache answer (cold load); 'no-cache'
168
296
  // forces a conditional request to the origin (background revalidation).
169
297
  function fetchConfig(etag, cacheMode, onOk, onFail) {
170
- if (typeof global.fetch !== 'function') { onFail('fetch unsupported'); return; }
298
+ if (typeof global.fetch !== 'function') {
299
+ /* Deferred to a microtask rather than called inline. Every other failure
300
+ path here is already asynchronous, so a synchronous one would be the
301
+ single case where this loader could reach the strict fallback before a
302
+ later snippet on the page had a chance to register itself in
303
+ __ckSaas.pending. A microtask, not a timer: it still resolves before
304
+ any network could. */
305
+ try { global.Promise.resolve().then(function () { onFail('fetch unsupported'); }); }
306
+ catch (e) { onFail('fetch unsupported'); }
307
+ return;
308
+ }
171
309
  var ctrl = null, timer = null;
172
310
  try { ctrl = new global.AbortController(); } catch (e) { ctrl = null; }
173
311
  var opts = { method: 'GET', credentials: 'omit', mode: 'cors' };
@@ -236,12 +374,13 @@
236
374
  // Cold path: no cached config exists, so the HTTP cache cannot serve a
237
375
  // stale one. Default caching is the right economy here.
238
376
  fetchConfig(null, null, function (r) {
239
- if (r.notModified || !r.config) { initStrict('empty response without cache'); return; }
377
+ // An empty 304 without a cache to satisfy it is a failure like any other,
378
+ // and now goes through the same arbitration: another snippet may still be
379
+ // holding a usable config.
380
+ if (r.notModified || !r.config) { settleFail('empty response without cache'); return; }
240
381
  writeCache(r.etag, r.config);
241
382
  initWith(r.config, 'network');
242
- }, function (reason) {
243
- initStrict(reason);
244
- });
383
+ }, settleFail);
245
384
  }
246
385
 
247
386
  // ---------------------------------------------------------------------------
@@ -421,10 +560,19 @@
421
560
  global.addEventListener && global.addEventListener('pagehide', flush, false);
422
561
  } catch (e) { /* noop */ }
423
562
 
424
- // Minimal surface for the demo status panel; not a public API.
563
+ /* Minimal surface for the demo status panel; not a public API.
564
+
565
+ With two loaders this is assigned twice and the last one wins, so nothing
566
+ that must survive may be read out of this closure: `siteIds` comes from the
567
+ DOM and `strictFallback` from the shared object, both of which every loader
568
+ agrees on. `pending()` still reports the BEACON queue — a different counter
569
+ from __ckSaas.pending, and the one the demo page and the panel already
570
+ read. */
425
571
  CK._saas = {
426
572
  siteId: siteId,
427
573
  api: apiBase,
574
+ siteIds: allIds.slice(),
575
+ strictFallback: shared.strictFallback === true,
428
576
  pending: function () { return pending.length; },
429
577
  config: function () { return activeConfig; }
430
578
  };
package/src/ck-ui.js CHANGED
@@ -5,6 +5,18 @@
5
5
  (function () {
6
6
  'use strict';
7
7
 
8
+ /* SPEC §1.9 — the second copy of ck.js on the page draws nothing.
9
+ Two snippets each load the whole bundle, so this file runs twice. Every
10
+ copy is its own IIFE with its own `mounted` flag, so a second copy would
11
+ register a second set of ck:* listeners and mount a second banner into the
12
+ same #ck-root — the first copy's, which it cannot see. ck-core.js stands
13
+ down for the same reason; this is the UI half of it.
14
+ SSR-safe: with no window we behave exactly as before and fall through. The
15
+ flag is claimed further down, after the `typeof document` guard, so a copy
16
+ that only ever published the pure `_contrast` helpers into a DOM-less
17
+ context does not lock out a later copy that could actually render. */
18
+ if (typeof window !== 'undefined' && window.__ckUiLoaded) { return; }
19
+
8
20
  var OPT_IN = ['functional', 'analytics', 'marketing'];
9
21
  var ALL_CATS = ['necessary'].concat(OPT_IN);
10
22
 
@@ -2971,6 +2983,11 @@
2971
2983
  // this file in Node is a no-op rather than a throw (mirrors the core).
2972
2984
  if (typeof document === 'undefined') return;
2973
2985
 
2986
+ // Claim the page for this copy (SPEC §1.9, guard at the top of the file).
2987
+ // Here rather than at the top: only now does this copy take ownership of the
2988
+ // listeners and the mount, and only that ownership is worth locking.
2989
+ try { if (typeof window !== 'undefined') { window.__ckUiLoaded = true; } } catch (e) { /* noop */ }
2990
+
2974
2991
  document.addEventListener('ck:init', function (e) {
2975
2992
  var d = (e && e.detail) || {};
2976
2993
  var cfg = d.config || safeConfig();