@ecomconsult/consentkit 0.3.5 → 0.4.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/src/ck-core.js CHANGED
@@ -22,6 +22,13 @@
22
22
  nativeScriptSrcDesc = Object.getOwnPropertyDescriptor(global.HTMLScriptElement.prototype, 'src');
23
23
  }
24
24
  } catch (e) { /* noop */ }
25
+ var nativeIframeSrcDesc = null;
26
+ try {
27
+ if (global.HTMLIFrameElement && global.HTMLIFrameElement.prototype) {
28
+ nativeIframeSrcDesc = Object.getOwnPropertyDescriptor(global.HTMLIFrameElement.prototype, 'src');
29
+ }
30
+ } catch (e) { /* noop */ }
31
+ var nativeRemoveAttribute = (global.Element && global.Element.prototype && global.Element.prototype.removeAttribute) || null;
25
32
 
26
33
  // Internal flag: while true, patches let everything through (used when we
27
34
  // re-create previously blocked scripts after consent).
@@ -115,6 +122,15 @@
115
122
  'tidiochat.com': 'functional'
116
123
  };
117
124
 
125
+ // Runtime overrides fed in by ConsentKit._extendHostDb(map) — the SaaS
126
+ // config's `hostdb`, or a hand call on a standalone page. Kept SEPARATE from
127
+ // HOST_DB on purpose: tools/export-hostdb.mjs extracts the HOST_DB literal
128
+ // out of this file with node:vm, and tools/sync-hostdb.mjs writes into that
129
+ // same literal. A runtime map merged into HOST_DB would be invisible to both
130
+ // (it never exists on disk) yet would blur what "the shipped database" means.
131
+ // Consulted BEFORE HOST_DB, so an override wins over a built-in entry.
132
+ var EXTRA_DB = {};
133
+
118
134
  // Path-fragment database: URL substring -> category. Matched against the full
119
135
  // resolved URL, so it can distinguish two very different scripts served from
120
136
  // the SAME host (googletagmanager.com).
@@ -207,9 +223,61 @@
207
223
  },
208
224
  consentTtlDays: 365,
209
225
  integrations: { gcm: true, gtmDataLayer: true },
226
+ // 'known' — block what the tracker database recognises (the default, and
227
+ // everything ConsentKit did before 0.4.0).
228
+ // 'strict' — additionally hold back EVERY third-party script/iframe that is
229
+ // not same-site, not in `allow` and not in BASE_ALLOW.
230
+ blocking: { mode: 'known', allow: [] },
210
231
  cookieTable: []
211
232
  };
212
233
 
234
+ // Hosts strict mode never intercepts, even though they are third-party and
235
+ // unknown to HOST_DB. Two kinds only: asset CDNs that serve the site's own
236
+ // code, and things a page is broken or unusable without (payment, captcha).
237
+ // Deliberately short — everything else is the site owner's `blocking.allow`.
238
+ // Matched with the same suffix semantics as HOST_DB; the recaptcha entries
239
+ // are hosts because www.google.com/recaptcha and www.gstatic.com/recaptcha
240
+ // cannot be expressed host-wise without also allowing the whole of google.com,
241
+ // so they are handled by BASE_ALLOW_PATH below instead.
242
+ var BASE_ALLOW = [
243
+ 'cdn.jsdelivr.net',
244
+ 'unpkg.com',
245
+ 'cdnjs.cloudflare.com',
246
+ 'code.jquery.com',
247
+ 'fonts.googleapis.com',
248
+ 'fonts.gstatic.com',
249
+ 'hcaptcha.com',
250
+ 'js.stripe.com',
251
+ 'pay.google.com',
252
+ 'checkout.creem.io'
253
+ ];
254
+
255
+ // Path-scoped members of the base allowlist: allowed only on this exact path
256
+ // prefix, because the host at large is not something to wave through.
257
+ var BASE_ALLOW_PATH = [
258
+ { host: 'www.google.com', path: '/recaptcha' },
259
+ { host: 'www.gstatic.com', path: '/recaptcha' }
260
+ ];
261
+
262
+ // Multi-label public suffixes: without these, `bbc.co.uk` and `itv.co.uk`
263
+ // would share the registrable domain `co.uk` and count as same-site. The list
264
+ // only ever WIDENS the registrable domain (two labels -> three), so a missing
265
+ // entry can only make strict mode treat a sibling as first-party and let it
266
+ // through — never make it block a genuine first-party asset. A full public
267
+ // suffix list is ~10k entries and has no place in a zero-dependency client.
268
+ var PSL_TWO_LABEL = {
269
+ 'co.uk': 1, 'org.uk': 1, 'me.uk': 1, 'ac.uk': 1, 'gov.uk': 1, 'net.uk': 1, 'sch.uk': 1,
270
+ 'com.au': 1, 'net.au': 1, 'org.au': 1, 'edu.au': 1, 'gov.au': 1,
271
+ 'co.nz': 1, 'net.nz': 1, 'org.nz': 1,
272
+ 'com.br': 1, 'net.br': 1, 'org.br': 1,
273
+ 'co.jp': 1, 'ne.jp': 1, 'or.jp': 1, 'ac.jp': 1,
274
+ 'co.za': 1, 'org.za': 1,
275
+ 'com.cn': 1, 'net.cn': 1, 'org.cn': 1,
276
+ 'co.in': 1, 'net.in': 1, 'org.in': 1,
277
+ 'com.tr': 1, 'com.mx': 1, 'com.ar': 1, 'com.sg': 1, 'com.hk': 1,
278
+ 'com.ua': 1, 'com.pl': 1, 'com.ru': 1, 'co.il': 1, 'co.kr': 1
279
+ };
280
+
213
281
  // ---------------------------------------------------------------------------
214
282
  // Small utilities (all defensive — core must never throw)
215
283
  // ---------------------------------------------------------------------------
@@ -585,9 +653,25 @@
585
653
  // ---------------------------------------------------------------------------
586
654
  // Blocking engine — URL classification
587
655
  // ---------------------------------------------------------------------------
588
- function categoryForUrl(src) {
589
- if (!src || typeof src !== 'string') { return null; }
590
- var s = src;
656
+ // host === key, or host is a subdomain of key. The one matching rule in the
657
+ // engine: HOST_DB, EXTRA_DB and BASE_ALLOW all use it.
658
+ function hostMatches(host, key) {
659
+ if (!host || !key) { return false; }
660
+ return host === key || (host.length > key.length && host.slice(-(key.length + 1)) === '.' + key);
661
+ }
662
+
663
+ function lookupHostMap(map, host) {
664
+ var keys = Object.keys(map);
665
+ for (var i = 0; i < keys.length; i++) {
666
+ if (hostMatches(host, keys[i])) { return map[keys[i]]; }
667
+ }
668
+ return null;
669
+ }
670
+
671
+ // Splits a URL into { host, url } with the page as the resolution base, so a
672
+ // relative src resolves to the first-party host rather than to nothing.
673
+ function urlParts(src) {
674
+ var s = String(src);
591
675
  var host = '';
592
676
  try {
593
677
  var base = (global.location && global.location.href) || 'http://localhost/';
@@ -595,19 +679,25 @@
595
679
  host = (u.hostname || '').toLowerCase();
596
680
  s = u.href;
597
681
  } catch (e) {
598
- var m = /^(?:[a-z]+:)?\/\/([^/?#]+)/i.exec(src);
682
+ var m = /^(?:[a-z]+:)?\/\/([^/?#]+)/i.exec(String(src));
599
683
  host = m ? m[1].toLowerCase().replace(/:\d+$/, '') : '';
600
684
  }
685
+ return { host: host, url: s };
686
+ }
687
+
688
+ function categoryForUrl(src) {
689
+ if (!src || typeof src !== 'string') { return null; }
690
+ var parts = urlParts(src);
691
+ var host = parts.host;
601
692
  if (host) {
602
- var keys = Object.keys(HOST_DB);
603
- for (var i = 0; i < keys.length; i++) {
604
- var e2 = keys[i];
605
- if (host === e2 || host.length > e2.length && host.slice(-(e2.length + 1)) === '.' + e2) {
606
- return HOST_DB[e2];
607
- }
608
- }
693
+ // Service overrides first: an override exists precisely to correct or
694
+ // extend what the shipped table says about a host.
695
+ var over = lookupHostMap(EXTRA_DB, host);
696
+ if (over) { return over; }
697
+ var built = lookupHostMap(HOST_DB, host);
698
+ if (built) { return built; }
609
699
  }
610
- var low = String(s).toLowerCase();
700
+ var low = String(parts.url).toLowerCase();
611
701
  var pkeys = Object.keys(PATH_DB);
612
702
  for (var j = 0; j < pkeys.length; j++) {
613
703
  if (low.indexOf(pkeys[j]) > -1) { return PATH_DB[pkeys[j]]; }
@@ -615,18 +705,139 @@
615
705
  return null;
616
706
  }
617
707
 
708
+ // Merges { host: category } into the runtime database. Safe before and after
709
+ // init(): after init nothing already inserted is re-evaluated (a script that
710
+ // has loaded cannot be unloaded), but every later insertion sees the new map.
711
+ function extendHostDb(map) {
712
+ var added = 0;
713
+ try {
714
+ if (!isPlainObject(map)) { return 0; }
715
+ Object.keys(map).forEach(function (rawHost) {
716
+ // The map arrives over the network in the SaaS path: validate both
717
+ // halves rather than trusting the server's shape.
718
+ if (typeof rawHost !== 'string') { return; }
719
+ var host = rawHost.trim().toLowerCase().replace(/:\d+$/, '').replace(/^\.+|\.+$/g, '');
720
+ if (!host || host.indexOf('.') === -1 || /[^a-z0-9.\-]/.test(host)) { return; }
721
+ var cat = map[rawHost];
722
+ if (typeof cat !== 'string' || CATEGORIES.indexOf(cat) === -1) { return; }
723
+ if (EXTRA_DB[host] === cat) { return; }
724
+ EXTRA_DB[host] = cat;
725
+ added++;
726
+ });
727
+ } catch (e) { /* noop */ }
728
+ return added;
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // Blocking engine — strict mode (§2)
733
+ // ---------------------------------------------------------------------------
734
+ // Everything strict intercepts is filed under the strictest category, so it
735
+ // is released only by a consent that covers marketing.
736
+ var STRICT_CATEGORY = 'marketing';
737
+
738
+ // Registrable domain, best effort: last two labels, or last three when the
739
+ // last two form a known multi-label public suffix.
740
+ function registrable(host) {
741
+ if (!host) { return ''; }
742
+ var labels = String(host).split('.');
743
+ if (labels.length <= 2) { return host; }
744
+ var lastTwo = labels.slice(-2).join('.');
745
+ if (PSL_TWO_LABEL[lastTwo] && labels.length >= 3) { return labels.slice(-3).join('.'); }
746
+ return lastTwo;
747
+ }
748
+
749
+ // Conservative on purpose: whenever the answer is not clearly "third party",
750
+ // this says same-site. A wrong "third party" verdict breaks a live site in
751
+ // strict mode; a wrong "same-site" verdict merely lets one unknown script
752
+ // through, which is exactly what every version before 0.4.0 did.
753
+ function isSameSite(host) {
754
+ if (!host) { return true; } // unparseable -> do not intercept
755
+ var page = hostname().toLowerCase();
756
+ if (!page) { return true; } // no location (SSR/about:blank) -> strict is inert
757
+ if (host === page) { return true; }
758
+ var r = registrable(host);
759
+ var pr = registrable(page);
760
+ return !!r && r === pr;
761
+ }
762
+
763
+ function baseAllowed(host, url) {
764
+ for (var i = 0; i < BASE_ALLOW.length; i++) {
765
+ if (hostMatches(host, BASE_ALLOW[i])) { return true; }
766
+ }
767
+ for (var j = 0; j < BASE_ALLOW_PATH.length; j++) {
768
+ var e = BASE_ALLOW_PATH[j];
769
+ if (host !== e.host) { continue; }
770
+ var path = '';
771
+ try { path = new URL(String(url), 'http://localhost/').pathname || ''; } catch (e2) { path = ''; }
772
+ if (path.indexOf(e.path) === 0) { return true; }
773
+ }
774
+ return false;
775
+ }
776
+
777
+ function siteAllowed(host) {
778
+ try {
779
+ var list = config.blocking && config.blocking.allow;
780
+ if (!list || typeof list.length !== 'number') { return false; }
781
+ for (var i = 0; i < list.length; i++) {
782
+ var entry = list[i];
783
+ if (typeof entry !== 'string') { continue; }
784
+ var key = entry.trim().toLowerCase().replace(/^\.+/, '');
785
+ if (key && hostMatches(host, key)) { return true; }
786
+ }
787
+ } catch (e) { /* noop */ }
788
+ return false;
789
+ }
790
+
791
+ function strictMode() {
792
+ try { return !!(config.blocking && config.blocking.mode === 'strict'); } catch (e) { return false; }
793
+ }
794
+
795
+ // True when strict mode should hold this URL back. Reached only for URLs the
796
+ // tracker database does NOT recognise: a known host has a real category and
797
+ // is decided by allowed() long before this runs, which is why a HOST_DB
798
+ // `necessary` or `functional` host passes strict without a special case.
799
+ function strictBlocks(src) {
800
+ if (!strictMode()) { return false; }
801
+ var s = String(src || '');
802
+ // Non-network schemes carry no third party. A bare "//host/x" has no scheme
803
+ // and is protocol-relative, so it is deliberately not caught here.
804
+ if (/^\s*(?:data|blob|javascript|about|mailto|tel):/i.test(s)) { return false; }
805
+ var parts = urlParts(s);
806
+ var host = parts.host;
807
+ if (!host) { return false; }
808
+ if (isSameSite(host)) { return false; }
809
+ if (baseAllowed(host, parts.url)) { return false; }
810
+ if (siteAllowed(host)) { return false; }
811
+ // Intercepted unknowns are treated as marketing — the strictest category —
812
+ // so they come back only when the visitor accepts marketing.
813
+ return !allowed(STRICT_CATEGORY);
814
+ }
815
+
618
816
  function allowed(cat) {
619
817
  if (cat === 'necessary' || !cat) { return true; }
620
818
  if (CATEGORIES.indexOf(cat) === -1) { return true; }
621
819
  return state.categories[cat] === true;
622
820
  }
623
821
 
624
- // True when the URL is a known tracker whose category is not (yet) granted.
822
+ // True when the URL must be held back: a known tracker whose category is not
823
+ // yet granted, or — in strict mode — an unknown third party.
625
824
  function shouldBlock(src) {
626
825
  if (bypass) { return false; }
627
826
  var cat = categoryForUrl(src);
628
- if (!cat) { return false; }
629
- return !allowed(cat);
827
+ if (cat) { return !allowed(cat); }
828
+ return strictBlocks(src);
829
+ }
830
+
831
+ // The category an interception is filed under. Known hosts keep their own;
832
+ // a strict interception is marketing.
833
+ function blockCategory(src) {
834
+ return categoryForUrl(src) || STRICT_CATEGORY;
835
+ }
836
+
837
+ // Was this particular interception a strict-mode one (i.e. the URL is not in
838
+ // the tracker database at all)? Drives the «strict» label in the debug panel.
839
+ function isStrictHit(src) {
840
+ return !categoryForUrl(src) && strictMode();
630
841
  }
631
842
 
632
843
  // Registry of everything the engine intercepted, for the debug panel (§8.1
@@ -660,7 +871,7 @@
660
871
  return { host: host, path: path };
661
872
  }
662
873
 
663
- function noteBlocked(el, src, cat, origin) {
874
+ function noteBlocked(el, src, cat, origin, strict) {
664
875
  try {
665
876
  if (blockedLog.length >= BLOCKED_MAX) { return; }
666
877
  var kind = 'script';
@@ -678,13 +889,18 @@
678
889
  path: parts.path,
679
890
  kind: kind,
680
891
  category: cat || null,
681
- origin: origin || 'engine'
892
+ origin: origin || 'engine',
893
+ strict: strict === true,
894
+ // Flipped by noteRevived() when applyConsentToDom() actually brings the
895
+ // element back. An entry that never flips is one the visitor consented
896
+ // to and that still did not load — worth showing in the debug panel.
897
+ revived: false
682
898
  });
683
899
  } catch (e) { /* noop */ }
684
900
  }
685
901
 
686
902
  function markBlocked(el, src, cat) {
687
- noteBlocked(el, src, cat, 'engine');
903
+ noteBlocked(el, src, cat, 'engine', isStrictHit(src));
688
904
  try {
689
905
  if (nativeSetAttribute) {
690
906
  nativeSetAttribute.call(el, 'data-ck-blocked', '1');
@@ -702,6 +918,36 @@
702
918
  } catch (e) { /* noop */ }
703
919
  }
704
920
 
921
+ // An intercepted iframe is left in EXACTLY the shape applyConsentToDom()
922
+ // already revives — data-ck + data-src and NO src attribute — so revival is
923
+ // the one code path for hand-marked, plugin-rewritten and engine-blocked
924
+ // iframes alike. `type="text/plain"` is script-only and must not be set here.
925
+ // data-ck-blocked also keeps _blocked()'s markup sweep from listing this
926
+ // element a second time: it matches iframe[data-ck][data-src] too.
927
+ // Flags the registry entry for this URL+kind as revived, so _blocked() can
928
+ // tell "came back after consent" from "was intercepted and never returned".
929
+ function noteRevived(src, kind) {
930
+ try {
931
+ var parts = safeUrlParts(src);
932
+ for (var i = 0; i < blockedLog.length; i++) {
933
+ var b = blockedLog[i];
934
+ if (b.host === parts.host && b.path === parts.path && b.kind === kind) { b.revived = true; }
935
+ }
936
+ } catch (e) { /* noop */ }
937
+ }
938
+
939
+ function markBlockedIframe(el, src, cat) {
940
+ noteBlocked(el, src, cat, 'engine', isStrictHit(src));
941
+ try {
942
+ if (!nativeSetAttribute) { return; }
943
+ nativeSetAttribute.call(el, 'data-ck-blocked', '1');
944
+ nativeSetAttribute.call(el, 'data-src', src);
945
+ nativeSetAttribute.call(el, 'data-ck', cat || STRICT_CATEGORY);
946
+ // Any src already on the element must go, or revival skips it.
947
+ try { if (nativeRemoveAttribute) { nativeRemoveAttribute.call(el, 'src'); } } catch (e2) { /* noop */ }
948
+ } catch (e) { /* noop */ }
949
+ }
950
+
705
951
  // ---------------------------------------------------------------------------
706
952
  // Blocking engine — patches (installed at parse time)
707
953
  // ---------------------------------------------------------------------------
@@ -726,16 +972,44 @@
726
972
  }
727
973
  } catch (e) { /* noop */ }
728
974
 
729
- // 2. Element.prototype.setAttribute covers setAttribute('src', ...).
975
+ // 1b. HTMLIFrameElement.prototype.src setter. Strict mode holds back
976
+ // third-party frames too (§2), and a known tracker embedded as an iframe
977
+ // was never caught before either.
978
+ try {
979
+ if (nativeIframeSrcDesc && nativeIframeSrcDesc.set && nativeIframeSrcDesc.configurable !== false) {
980
+ Object.defineProperty(global.HTMLIFrameElement.prototype, 'src', {
981
+ configurable: true,
982
+ enumerable: nativeIframeSrcDesc.enumerable,
983
+ get: function () {
984
+ try { return nativeIframeSrcDesc.get.call(this); } catch (e) { return ''; }
985
+ },
986
+ set: function (v) {
987
+ if (shouldBlock(v)) {
988
+ markBlockedIframe(this, String(v), blockCategory(v));
989
+ return;
990
+ }
991
+ try { nativeIframeSrcDesc.set.call(this, v); } catch (e) { /* noop */ }
992
+ }
993
+ });
994
+ }
995
+ } catch (e) { /* noop */ }
996
+
997
+ // 2. Element.prototype.setAttribute — covers setAttribute('src', ...) on
998
+ // both scripts and iframes.
730
999
  try {
731
1000
  if (nativeSetAttribute && global.Element && global.Element.prototype) {
732
1001
  global.Element.prototype.setAttribute = function (name, value) {
733
1002
  try {
734
- if (!bypass && typeof name === 'string' && name.toLowerCase() === 'src' &&
735
- this && this.tagName && String(this.tagName).toUpperCase() === 'SCRIPT' &&
736
- shouldBlock(value)) {
737
- markBlocked(this, String(value), categoryForUrl(value));
738
- return undefined;
1003
+ if (!bypass && typeof name === 'string' && name.toLowerCase() === 'src' && this && this.tagName) {
1004
+ var t = String(this.tagName).toUpperCase();
1005
+ if ((t === 'SCRIPT' || t === 'IFRAME') && shouldBlock(value)) {
1006
+ if (t === 'IFRAME') {
1007
+ markBlockedIframe(this, String(value), blockCategory(value));
1008
+ } else {
1009
+ markBlocked(this, String(value), blockCategory(value));
1010
+ }
1011
+ return undefined;
1012
+ }
739
1013
  }
740
1014
  } catch (e) { /* fall through to native */ }
741
1015
  return nativeSetAttribute.apply(this, arguments);
@@ -749,10 +1023,10 @@
749
1023
  doc.createElement = function (tag) {
750
1024
  var el = nativeCreateElement.apply(null, arguments);
751
1025
  try {
752
- if (!bypass && typeof tag === 'string' && tag.toLowerCase() === 'script' &&
753
- nativeScriptSrcDesc && nativeScriptSrcDesc.set) {
754
- // Own-property guard so the element is covered even if the
755
- // prototype patch was reverted by another library.
1026
+ var name = (!bypass && typeof tag === 'string') ? tag.toLowerCase() : '';
1027
+ // Own-property guard so the element is covered even if the
1028
+ // prototype patch was reverted by another library.
1029
+ if (name === 'script' && nativeScriptSrcDesc && nativeScriptSrcDesc.set) {
756
1030
  Object.defineProperty(el, 'src', {
757
1031
  configurable: true,
758
1032
  enumerable: false,
@@ -761,12 +1035,27 @@
761
1035
  },
762
1036
  set: function (v) {
763
1037
  if (shouldBlock(v)) {
764
- markBlocked(this, String(v), categoryForUrl(v));
1038
+ markBlocked(this, String(v), blockCategory(v));
765
1039
  return;
766
1040
  }
767
1041
  try { nativeScriptSrcDesc.set.call(this, v); } catch (e) { /* noop */ }
768
1042
  }
769
1043
  });
1044
+ } else if (name === 'iframe' && nativeIframeSrcDesc && nativeIframeSrcDesc.set) {
1045
+ Object.defineProperty(el, 'src', {
1046
+ configurable: true,
1047
+ enumerable: false,
1048
+ get: function () {
1049
+ try { return nativeIframeSrcDesc.get.call(this); } catch (e) { return ''; }
1050
+ },
1051
+ set: function (v) {
1052
+ if (shouldBlock(v)) {
1053
+ markBlockedIframe(this, String(v), blockCategory(v));
1054
+ return;
1055
+ }
1056
+ try { nativeIframeSrcDesc.set.call(this, v); } catch (e) { /* noop */ }
1057
+ }
1058
+ });
770
1059
  }
771
1060
  } catch (e) { /* noop */ }
772
1061
  return el;
@@ -798,13 +1087,30 @@
798
1087
  if (!node || node.nodeType !== 1) { return; }
799
1088
  var tag = node.tagName ? String(node.tagName).toUpperCase() : '';
800
1089
  if (tag === 'SCRIPT') { inspectScript(node); }
1090
+ if (tag === 'IFRAME') { inspectIframe(node); }
801
1091
  if (typeof node.querySelectorAll === 'function') {
802
1092
  var kids = node.querySelectorAll('script');
803
1093
  for (var i = 0; i < kids.length; i++) { inspectScript(kids[i]); }
1094
+ var frames = node.querySelectorAll('iframe');
1095
+ for (var j = 0; j < frames.length; j++) { inspectIframe(frames[j]); }
804
1096
  }
805
1097
  } catch (e) { /* noop */ }
806
1098
  }
807
1099
 
1100
+ // Late net for iframes that arrived as markup. Same honesty as inspectScript:
1101
+ // once the element is connected the request may already be in flight — the
1102
+ // src/createElement patches are the reliable path.
1103
+ function inspectIframe(el) {
1104
+ try {
1105
+ if (!el || el.getAttribute === undefined) { return; }
1106
+ if (el.getAttribute('data-ck-blocked')) { return; }
1107
+ if (el.getAttribute('data-ck') && el.getAttribute('data-src')) { return; } // manual markup
1108
+ var src = el.getAttribute('src');
1109
+ if (!src) { return; }
1110
+ if (shouldBlock(src)) { markBlockedIframe(el, src, blockCategory(src)); }
1111
+ } catch (e) { /* noop */ }
1112
+ }
1113
+
808
1114
  function inspectScript(el) {
809
1115
  try {
810
1116
  if (!el || el.getAttribute === undefined) { return; }
@@ -814,7 +1120,7 @@
814
1120
  var src = el.getAttribute('src');
815
1121
  if (!src) { return; }
816
1122
  if (shouldBlock(src)) {
817
- var cat = categoryForUrl(src);
1123
+ var cat = blockCategory(src);
818
1124
  markBlocked(el, src, cat);
819
1125
  // Clear the attribute. Note: if the element was already connected the
820
1126
  // request may already be in flight — the createElement/src patches are
@@ -862,6 +1168,7 @@
862
1168
  try { nativeSetAttribute.call(fresh, 'data-ck-restored', '1'); } catch (e) { /* noop */ }
863
1169
  old.parentNode.insertBefore(fresh, old);
864
1170
  try { old.parentNode.removeChild(old); } catch (e) { /* noop */ }
1171
+ if (src) { noteRevived(src, 'script'); }
865
1172
  } catch (e) {
866
1173
  /* noop */
867
1174
  } finally {
@@ -902,6 +1209,7 @@
902
1209
  var prev = bypass;
903
1210
  bypass = true;
904
1211
  try { nativeSetAttribute.call(el, 'src', src); } finally { bypass = prev; }
1212
+ noteRevived(src, 'iframe');
905
1213
  } catch (e) { /* noop */ }
906
1214
  });
907
1215
  }
@@ -909,6 +1217,7 @@
909
1217
  // Initial sweep for scripts already parsed before the observer attached.
910
1218
  function initialScan() {
911
1219
  qsa('script[src]').forEach(inspectScript);
1220
+ qsa('iframe[src]').forEach(inspectIframe);
912
1221
  }
913
1222
 
914
1223
  // ---------------------------------------------------------------------------
@@ -1016,7 +1325,7 @@
1016
1325
  // Public API
1017
1326
  // ---------------------------------------------------------------------------
1018
1327
  var ConsentKit = {
1019
- version: '0.3.5',
1328
+ version: '0.4.0',
1020
1329
  config: config,
1021
1330
 
1022
1331
  init: function (userConfig) {
@@ -1024,6 +1333,12 @@
1024
1333
  config = mergeConfig(config, userConfig);
1025
1334
  ConsentKit.config = config;
1026
1335
 
1336
+ // Service / author overrides, merged BEFORE initialScan() below so the
1337
+ // scripts already in the markup are classified against them. In SaaS
1338
+ // mode ck-saas.js has usually applied these already; extendHostDb is
1339
+ // idempotent, so doing it twice costs nothing.
1340
+ if (userConfig && isPlainObject(userConfig.hostdb)) { extendHostDb(userConfig.hostdb); }
1341
+
1027
1342
  if (initialized) {
1028
1343
  // Idempotent: merge config, no re-restore, no duplicate ck:init.
1029
1344
  return publicState();
@@ -1074,6 +1389,28 @@
1074
1389
  _categoryForUrl: categoryForUrl,
1075
1390
  _categories: CATEGORIES.slice(),
1076
1391
 
1392
+ // Merges { host: category } into the runtime tracker database (§1.3).
1393
+ // Works before AND after init(): after init nothing already inserted is
1394
+ // re-evaluated — a script that has loaded cannot be unloaded — but every
1395
+ // later insertion is classified against the extended map. Returns the
1396
+ // number of entries actually added; unknown categories and malformed hosts
1397
+ // are dropped, because this map arrives over the network in the SaaS path.
1398
+ _extendHostDb: function (map) {
1399
+ try { return extendHostDb(map); } catch (e) { return 0; }
1400
+ },
1401
+
1402
+ // The built-in strict-mode allowlist, exported for docs and tests so the
1403
+ // list a site owner reads is the list the engine actually uses.
1404
+ //
1405
+ // A GETTER, not a plain array: a plain property is evaluated once, and the
1406
+ // single array it produced would be handed to every caller — one
1407
+ // `ConsentKit._baseAllow.push(...)` from page code would then silently
1408
+ // widen what strict mode lets through for the rest of the page load.
1409
+ // Each read returns a fresh copy, so the list is readable and inert.
1410
+ get _baseAllow() {
1411
+ return BASE_ALLOW.slice().concat(BASE_ALLOW_PATH.map(function (e) { return e.host + e.path; }));
1412
+ },
1413
+
1077
1414
  // What is being held back until consent: everything the engine intercepted
1078
1415
  // (origin 'engine') plus what the site author marked up by hand (origin
1079
1416
  // 'markup'), which never goes through markBlocked. Read-only, host+path
@@ -1090,11 +1427,24 @@
1090
1427
  try {
1091
1428
  for (var i = 0; i < blockedLog.length; i++) {
1092
1429
  var b = blockedLog[i];
1093
- // Interceptions are kept for the life of the page, but once the
1094
- // category is granted the element has been revived and is no longer
1095
- // held back reporting it as still blocked would be false.
1096
- if (allowed(b.category)) { continue; }
1097
- add({ host: b.host, path: b.path, kind: b.kind, category: b.category, origin: b.origin });
1430
+ // Interceptions are kept for the life of the page. Once the category
1431
+ // is granted the element has USUALLY been revived and is no longer
1432
+ // held back, so reporting it as blocked would be false — but not
1433
+ // always: applyConsentToDom() can only revive an element that is in
1434
+ // the document, and a script created and given a src without ever
1435
+ // being appended stays dead for the life of the page.
1436
+ //
1437
+ // That case is exactly the strict-mode support ticket ("my widget did
1438
+ // not come back after I accepted"), and both README and INSTALL.ru
1439
+ // send the site owner to this panel to diagnose it. So an entry is
1440
+ // dropped only when something on the page actually came back for it;
1441
+ // otherwise it stays, marked `revived: false`.
1442
+ if (allowed(b.category) && b.revived !== false) { continue; }
1443
+ add({
1444
+ host: b.host, path: b.path, kind: b.kind,
1445
+ category: b.category, origin: b.origin, strict: b.strict === true,
1446
+ revived: b.revived !== false
1447
+ });
1098
1448
  }
1099
1449
  } catch (e) { /* noop */ }
1100
1450
  // Hand-marked tags still waiting for their category.
@@ -1117,7 +1467,10 @@
1117
1467
  add({
1118
1468
  host: parts.host, path: parts.path,
1119
1469
  kind: tag === 'iframe' ? 'iframe' : 'script',
1120
- category: cat || null, origin: 'markup'
1470
+ // revived: true means "not applicable" here, not "came back":
1471
+ // this sweep only ever lists hand-marked tags still waiting for
1472
+ // their category, so none of them can be a failed revival.
1473
+ category: cat || null, origin: 'markup', strict: false, revived: true
1121
1474
  });
1122
1475
  } catch (e2) { /* noop */ }
1123
1476
  });
package/src/ck-debug.js CHANGED
@@ -137,7 +137,16 @@
137
137
  ttlDays: d.ttlDays == null ? null : Number(d.ttlDays)
138
138
  },
139
139
  blocked: (d.blocked || []).map(function (b) {
140
- return { host: b.host, path: b.path, kind: b.kind, category: b.category, origin: b.origin };
140
+ // `strict` marks an entry the engine held back only because strict mode
141
+ // is on and the host is an unknown third party — not because the
142
+ // tracker database recognised it.
143
+ return {
144
+ host: b.host, path: b.path, kind: b.kind,
145
+ category: b.category, origin: b.origin, strict: b.strict === true,
146
+ // false when the visitor granted the category but the element never
147
+ // loaded — usually a script that was created and never appended.
148
+ revived: b.revived !== false
149
+ };
141
150
  }),
142
151
  requests: buildRequests(d.entries, d.consentAtMs, d.classify),
143
152
  consentMode: (d.consentMode || []).slice(),
@@ -180,6 +189,8 @@
180
189
  secBlocked: 'Заблокировано до согласия',
181
190
  noBlocked: 'ничего не перехвачено',
182
191
  markup: ' (разметка)',
192
+ strict: 'strict',
193
+ notRevived: ' — не ожил после согласия',
183
194
  secRequests: 'Запросы к трекерам',
184
195
  noRequests: 'запросов к известным трекерам не было',
185
196
  after: 'после согласия',
@@ -215,6 +226,8 @@
215
226
  secBlocked: 'Blocked until consent',
216
227
  noBlocked: 'nothing intercepted',
217
228
  markup: ' (markup)',
229
+ strict: 'strict',
230
+ notRevived: ' — did not come back after consent',
218
231
  secRequests: 'Tracker requests',
219
232
  noRequests: 'no requests to known trackers',
220
233
  after: 'after consent',
@@ -594,8 +607,10 @@
594
607
  var li = doc.createElement('li');
595
608
  li.appendChild(tag(b.kind));
596
609
  li.appendChild(tag(b.category || '?'));
610
+ if (b.strict) { li.appendChild(tag(T.strict, 'off')); }
597
611
  li.appendChild(doc.createTextNode(b.host + b.path +
598
- (b.origin === 'markup' ? T.markup : '')));
612
+ (b.origin === 'markup' ? T.markup : '') +
613
+ (b.revived === false ? T.notRevived : '')));
599
614
  u3.appendChild(li);
600
615
  });
601
616
  s3.appendChild(u3);