@ecomconsult/consentkit 0.5.7 → 0.5.9

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
@@ -46,7 +46,7 @@
46
46
  // Bare entries are used ONLY for domains dedicated entirely to tracking;
47
47
  // where a parent domain also serves ordinary site assets (CDNs, fonts,
48
48
  // images) the specific tracking subdomain is listed instead.
49
- // Snapshot: 2026-08. Not exhaustive — extended as new trackers appear.
49
+ // Snapshot: 2026-09. Not exhaustive — extended as new trackers appear.
50
50
  var HOST_DB = {
51
51
  // --- analytics -----------------------------------------------------
52
52
  'google-analytics.com': 'analytics',
@@ -77,6 +77,7 @@
77
77
  'nr-data.net': 'analytics', // New Relic beacon
78
78
  'datadoghq.com': 'analytics', // RUM
79
79
  'datadoghq-browser-agent.com': 'analytics',
80
+ 'vercel-insights.com': 'analytics', // Vercel Web Analytics
80
81
  // Cloudflare Web Analytics. Subdomain only, and deliberately NOT in
81
82
  // INFRA_DB: the rest of Cloudflare's edge is infrastructure, but this one
82
83
  // beacon is a measurement product (§8 names it by hand for that reason).
@@ -116,6 +117,15 @@
116
117
  'redditstatic.com': 'marketing',
117
118
  'q.quora.com': 'marketing', // subdomain: quora.com is a normal site
118
119
  'amazon-adsystem.com': 'marketing',
120
+ // Embedded video and social plugins. The player/plugin sets the vendor's own
121
+ // advertising cookies on play or render, which is an ad-profile decision the
122
+ // visitor has to make — not a feature the site owner merely switched on.
123
+ 'youtube.com': 'marketing', // the player sets Google ad cookies on play
124
+ 'youtube-nocookie.com': 'marketing', // "privacy-enhanced" still sets them once played
125
+ 'facebook.com': 'marketing', // like/page/comments plugins and iframes
126
+ 'instagram.com': 'marketing', // post and profile embeds
127
+ 'cdninstagram.com': 'marketing', // the embed's own asset host
128
+ 'sendpulse.com': 'marketing', // email/push marketing automation
119
129
 
120
130
  // --- functional ----------------------------------------------------
121
131
  'intercom.io': 'functional',
@@ -161,12 +171,72 @@
161
171
  'places.googleapis.com': 'functional',
162
172
  'maps.gstatic.com': 'functional',
163
173
 
174
+ // Vimeo — an embedded player. Unlike YouTube it does not feed an ad
175
+ // profile by default, so a visitor who declines functional loses the video
176
+ // and nothing else. player.vimeo.com is redundant under the bare entry
177
+ // (suffix matching covers it) and is named anyway, the way
178
+ // static.tildacdn.one and fonts.gstatic.com are: the embed fixtures and the
179
+ // docs both refer to it by its full host.
180
+ 'vimeo.com': 'functional',
181
+ 'player.vimeo.com': 'functional',
182
+ 'vimeocdn.com': 'functional', // the player's own asset host
183
+ // Freshworks — support chat and helpdesk widgets. The widget IS the support
184
+ // channel, so declining functional costs the visitor the feature, not a
185
+ // measurement they were unaware of.
186
+ 'freshworks.com': 'functional',
187
+ 'freshchat.com': 'functional',
188
+ 'freshdesk.com': 'functional',
189
+ // Messenger buttons and chat widgets a shop puts on its own pages. The
190
+ // Moldovan/Romanian market runs on these the way the western one runs on
191
+ // Intercom, and each is a feature the owner chose.
192
+ 'viber.com': 'functional',
193
+ 'telegram.org': 'functional',
194
+ 't.me': 'functional',
195
+ // CRM widgets: callback forms, chat and lead capture embedded on the site.
196
+ // Every regional TLD is named in full — hostMatches is plain suffix
197
+ // matching with no pattern form, so bitrix24.ru does not cover
198
+ // bitrix24.com.
199
+ 'bitrix24.ru': 'functional',
200
+ 'bitrix24.com': 'functional',
201
+ 'bitrix24.eu': 'functional',
202
+ 'amocrm.ru': 'functional',
203
+ 'amocrm.com': 'functional',
204
+ // Booking and form embeds — the visitor came to the page to use them.
205
+ 'calendly.com': 'functional',
206
+ 'typeform.com': 'functional',
207
+ // 999.md — the Moldovan classifieds platform. Shops embed its listing
208
+ // widgets; simpalsmedia.com is the group's asset host that serves them.
209
+ '999.md': 'functional',
210
+ 'simpalsmedia.com': 'functional',
211
+
164
212
  // --- necessary -----------------------------------------------------
165
213
  // recaptcha.net is Google's alternate reCAPTCHA domain, served for regions
166
214
  // where google.com is unreachable. Unlike the two path-scoped Google hosts
167
215
  // in PATH_DB, this domain hosts NOTHING but the captcha, so the whole host
168
216
  // is the right scope.
169
- 'recaptcha.net': 'necessary'
217
+ 'recaptcha.net': 'necessary',
218
+ // Payment, error reporting and the page builder's own runtime. A
219
+ // `necessary` category is never held — allowed('necessary') is always true,
220
+ // so shouldBlock() lets these through before strict mode is ever consulted.
221
+ // What the entry buys is a NAME: the audit stops filing a checkout form or
222
+ // a crash reporter under «сторонние подключения без категории». This is why
223
+ // js.stripe.com can stay in BASE_ALLOW and gain a category here without
224
+ // contradiction — the allowlist decides strict mode, the category decides
225
+ // the report, and both answers are «let it through».
226
+ 'sentry.io': 'necessary', // crash reporting: no visitor profile
227
+ 'ingest.sentry.io': 'necessary', // redundant under the line above, named
228
+ // because the DSN host is what an owner
229
+ // actually sees in a report
230
+ 'sentry-cdn.com': 'necessary',
231
+ 'paypal.com': 'necessary',
232
+ 'paypalobjects.com': 'necessary', // PayPal button assets
233
+ 'paynet.md': 'necessary', // Moldovan payment gateway
234
+ 'maib.md': 'necessary', // MAIB card processing
235
+ 'maibank.md': 'necessary',
236
+ 'stripe.com': 'necessary',
237
+ 'js.stripe.com': 'necessary', // also in BASE_ALLOW; see the note above
238
+ 'stripe.network': 'necessary',
239
+ 'elementor.com': 'necessary' // the WordPress builder's own runtime
170
240
  };
171
241
 
172
242
  // Runtime overrides fed in by ConsentKit._extendHostDb(map) — the SaaS
@@ -324,7 +394,16 @@
324
394
  // 'strict' — additionally hold back EVERY third-party script/iframe that is
325
395
  // not same-site, not in `allow` and not in BASE_ALLOW.
326
396
  blocking: { mode: 'known', allow: [] },
327
- cookieTable: []
397
+ cookieTable: [],
398
+ // SPEC V1.12 §2/§3 — the services the site declares. Each row names one
399
+ // third party (Google Analytics, Hotjar, Tilda Forms), the hosts and paths
400
+ // its resources come from and the cookies it sets, so the panel can list it
401
+ // under its category with its own switch and the engine can hold back that
402
+ // ONE service while the rest of the category runs.
403
+ //
404
+ // Empty by default: a config that predates 0.5.8 has no `services` key at
405
+ // all and must render and block exactly as it did before.
406
+ services: []
328
407
  };
329
408
 
330
409
  // Infrastructure (§8) — NOT a consent category, a CLASS of host.
@@ -407,6 +486,20 @@
407
486
  // Google-hosted halves are path-scoped in BASE_ALLOW_PATH below, since
408
487
  // www.google.com and www.gstatic.com cannot be waved through wholesale.
409
488
  'hcaptcha.com',
489
+ // --- hosting platforms (continued) -----------------------------------
490
+ // Vercel and Netlify serve the site's own build output. Vercel's MEASUREMENT
491
+ // product is vercel-insights.com, a separate registrable domain filed above
492
+ // as analytics — so neither entry here shadows it (the §8 invariant that an
493
+ // infra host must carry no category still holds).
494
+ 'vercel.app',
495
+ 'vercel.com',
496
+ 'netlify.app',
497
+ 'netlify.com',
498
+ // YouTube thumbnails and player static assets. The PLAYER is marketing and
499
+ // sits in HOST_DB on youtube.com; this host serves only the poster image
500
+ // and sprites, sets nothing, and a held placeholder still wants its
501
+ // thumbnail.
502
+ 'ytimg.com',
410
503
  // --- our own service -------------------------------------------------
411
504
  // The consent tool must not report itself as an unnamed third party.
412
505
  'consent.ecomconsult.net'
@@ -690,10 +783,30 @@
690
783
  ts: rec.ts,
691
784
  policyVersion: String(rec.policyVersion),
692
785
  categories: cats,
786
+ // SPEC V1.12 §3 — the denial map. Only `false` entries are stored and only
787
+ // `false` entries are read back: a record written by 0.5.7 has no
788
+ // `services` key at all and yields «ничего не отклонено», which is what a
789
+ // visitor who was never shown a service list actually agreed to.
790
+ services: readServices(rec.services),
693
791
  method: rec.method || 'custom'
694
792
  };
695
793
  }
696
794
 
795
+ // { id: false } only. Anything else in the stored map — a `true`, a number, a
796
+ // key that is not a service id — is dropped rather than trusted: this record
797
+ // is attacker-writable (it lives in a cookie and in localStorage), and a
798
+ // malformed entry must not be able to widen or narrow what gets blocked.
799
+ function readServices(raw) {
800
+ var out = {};
801
+ if (!isPlainObject(raw)) { return out; }
802
+ var keys = Object.keys(raw);
803
+ for (var i = 0; i < keys.length && i < SERVICE_MAX; i++) {
804
+ var k = keys[i];
805
+ if (raw[k] === false && SERVICE_ID_RE.test(k)) { out[k] = false; }
806
+ }
807
+ return out;
808
+ }
809
+
697
810
  // ---------------------------------------------------------------------------
698
811
  // State
699
812
  // ---------------------------------------------------------------------------
@@ -705,6 +818,9 @@
705
818
  ts: null,
706
819
  policyVersion: String(config.policyVersion),
707
820
  categories: emptyCategories(),
821
+ // SPEC V1.12 §3 — denials only: { '<serviceId>': false }. An id that is not
822
+ // here is allowed (subject to its category).
823
+ services: {},
708
824
  method: null
709
825
  };
710
826
 
@@ -720,10 +836,31 @@
720
836
  analytics: !!state.categories.analytics,
721
837
  marketing: !!state.categories.marketing
722
838
  },
839
+ // A COPY: publicState() is handed to page code and to ck-saas.js, and a
840
+ // live reference would let either of them rewrite what the engine blocks.
841
+ services: cloneDenials(state.services),
723
842
  method: state.method
724
843
  };
725
844
  }
726
845
 
846
+ function hasDenials(map) {
847
+ try {
848
+ var keys = Object.keys(map || {});
849
+ for (var i = 0; i < keys.length; i++) { if (map[keys[i]] === false) { return true; } }
850
+ } catch (e) { /* noop */ }
851
+ return false;
852
+ }
853
+
854
+ function cloneDenials(map) {
855
+ var out = {};
856
+ try {
857
+ Object.keys(map || {}).forEach(function (k) {
858
+ if (map[k] === false) { out[k] = false; }
859
+ });
860
+ } catch (e) { /* noop */ }
861
+ return out;
862
+ }
863
+
727
864
  function dispatch(name, detail) {
728
865
  try {
729
866
  if (!doc || typeof doc.dispatchEvent !== 'function') { return; }
@@ -906,6 +1043,217 @@
906
1043
  return added;
907
1044
  }
908
1045
 
1046
+ // ---------------------------------------------------------------------------
1047
+ // Services (SPEC V1.12 §2/§3)
1048
+ // ---------------------------------------------------------------------------
1049
+ // The normalised view of config.services, rebuilt by init() once the server's
1050
+ // config has been merged. Kept as a separate array rather than read out of
1051
+ // `config` on every call: _serviceForUrl runs inside the blocking hot path
1052
+ // (every script and iframe the page inserts), and re-validating 50 raw rows
1053
+ // per resource would be paid on every insertion.
1054
+ var SERVICES = [];
1055
+ var SERVICE_BY_ID = {};
1056
+
1057
+ var SERVICE_ID_RE = /^[a-z0-9-]{1,64}$/;
1058
+ var SERVICE_MAX = 50;
1059
+
1060
+ // §2's row shape, validated defensively: this arrives over the network in the
1061
+ // SaaS path exactly like `hostdb` does, so nothing here is trusted. A row that
1062
+ // fails any check is DROPPED rather than repaired — a half-understood service
1063
+ // would block resources under a category nobody agreed to.
1064
+ //
1065
+ // `enabled: false` means «не показывать и не блокировать отдельно»: the row is
1066
+ // dropped here, so the panel never lists it and _serviceForUrl never names it.
1067
+ function normalizeService(raw) {
1068
+ if (!isPlainObject(raw)) { return null; }
1069
+ if (raw.enabled === false) { return null; }
1070
+
1071
+ var id = typeof raw.id === 'string' ? raw.id.trim().toLowerCase() : '';
1072
+ if (!SERVICE_ID_RE.test(id)) { return null; }
1073
+
1074
+ var cat = typeof raw.category === 'string' ? raw.category : '';
1075
+ if (CATEGORIES.indexOf(cat) === -1) { return null; }
1076
+
1077
+ var hosts = [];
1078
+ if (raw.hosts && typeof raw.hosts.length === 'number') {
1079
+ for (var i = 0; i < raw.hosts.length && hosts.length < 20; i++) {
1080
+ var h = raw.hosts[i];
1081
+ if (typeof h !== 'string') { continue; }
1082
+ h = h.trim().toLowerCase().replace(/:\d+$/, '').replace(/^\.+|\.+$/g, '');
1083
+ if (!h || h.length > 253 || h.indexOf('.') === -1 || /[^a-z0-9.\-]/.test(h)) { continue; }
1084
+ if (hosts.indexOf(h) === -1) { hosts.push(h); }
1085
+ }
1086
+ }
1087
+
1088
+ // Path fragments, matched exactly the way PATH_DB entries are: a
1089
+ // case-insensitive substring of the resolved URL.
1090
+ var paths = [];
1091
+ if (raw.paths && typeof raw.paths.length === 'number') {
1092
+ for (var j = 0; j < raw.paths.length && paths.length < 20; j++) {
1093
+ var p = raw.paths[j];
1094
+ if (typeof p !== 'string') { continue; }
1095
+ p = p.trim().toLowerCase();
1096
+ if (p && p.length <= 253 && paths.indexOf(p) === -1) { paths.push(p); }
1097
+ }
1098
+ }
1099
+
1100
+ var cookies = [];
1101
+ if (raw.cookies && typeof raw.cookies.length === 'number') {
1102
+ for (var k = 0; k < raw.cookies.length; k++) {
1103
+ var c = raw.cookies[k];
1104
+ if (typeof c !== 'string') { continue; }
1105
+ c = c.trim();
1106
+ if (c && cookies.indexOf(c) === -1) { cookies.push(c); }
1107
+ }
1108
+ }
1109
+
1110
+ var purpose = {};
1111
+ if (isPlainObject(raw.purpose)) {
1112
+ ['ru', 'ro', 'en'].forEach(function (lang) {
1113
+ var v = raw.purpose[lang];
1114
+ if (typeof v === 'string' && v.trim()) { purpose[lang] = v.trim().slice(0, 400); }
1115
+ });
1116
+ }
1117
+
1118
+ // http(s) only, for the same reason resolveDetails() in ck-ui.js insists on
1119
+ // it: this becomes a link the visitor is invited to click, and a
1120
+ // javascript: URL there is an XSS vector.
1121
+ var privacyUrl = null;
1122
+ if (typeof raw.privacyUrl === 'string' && /^https?:\/\//i.test(raw.privacyUrl.trim())) {
1123
+ privacyUrl = raw.privacyUrl.trim();
1124
+ }
1125
+
1126
+ return {
1127
+ id: id,
1128
+ name: (typeof raw.name === 'string' && raw.name.trim()) ? raw.name.trim() : id,
1129
+ vendor: (typeof raw.vendor === 'string' && raw.vendor.trim()) ? raw.vendor.trim() : '',
1130
+ category: cat,
1131
+ hosts: hosts,
1132
+ paths: paths,
1133
+ cookies: cookies,
1134
+ purpose: purpose,
1135
+ privacyUrl: privacyUrl
1136
+ };
1137
+ }
1138
+
1139
+ // Rebuilds SERVICES/SERVICE_BY_ID from the merged config and extends the
1140
+ // block map with every service host, so a host HOST_DB has never heard of is
1141
+ // still held back under its service's category (§2: «hosts не обязаны быть в
1142
+ // HOST_DB»). Returns the normalised list.
1143
+ function buildServices(cfg) {
1144
+ SERVICES = [];
1145
+ SERVICE_BY_ID = {};
1146
+ var extra = {};
1147
+ try {
1148
+ var list = cfg && cfg.services;
1149
+ if (!list || typeof list.length !== 'number') { return SERVICES; }
1150
+ for (var i = 0; i < list.length && SERVICES.length < SERVICE_MAX; i++) {
1151
+ var s = normalizeService(list[i]);
1152
+ if (!s) { continue; }
1153
+ if (SERVICE_BY_ID[s.id]) { continue; } // first row of an id wins
1154
+ SERVICE_BY_ID[s.id] = s;
1155
+ SERVICES.push(s);
1156
+ for (var j = 0; j < s.hosts.length; j++) { extra[s.hosts[j]] = s.category; }
1157
+ }
1158
+ // Reuses the existing override map, so a service host is classified by the
1159
+ // one lookup categoryForUrl already does — no second code path, and an
1160
+ // explicit `hostdb` override from the server still wins because
1161
+ // extendHostDb skips a host already sitting at the same category and
1162
+ // init() applies hostdb FIRST.
1163
+ extendHostDb(extra);
1164
+ } catch (e) { /* noop */ }
1165
+ return SERVICES;
1166
+ }
1167
+
1168
+ // Which service does this URL belong to? Host suffixes are matched like
1169
+ // HOST_DB, path fragments like PATH_DB. Returns the normalised row or null.
1170
+ //
1171
+ // Hosts before paths, and in declaration order: a config that lists the same
1172
+ // host under two services is the owner's mistake, and answering with the
1173
+ // first row is at least stable.
1174
+ function serviceForUrl(src) {
1175
+ if (!SERVICES.length) { return null; }
1176
+ if (!src || typeof src !== 'string') { return null; }
1177
+ var parts = urlParts(src);
1178
+ var host = parts.host;
1179
+ var i, j, s;
1180
+ if (host) {
1181
+ for (i = 0; i < SERVICES.length; i++) {
1182
+ s = SERVICES[i];
1183
+ for (j = 0; j < s.hosts.length; j++) {
1184
+ if (hostMatches(host, s.hosts[j])) { return s; }
1185
+ }
1186
+ }
1187
+ }
1188
+ var low = String(parts.url).toLowerCase();
1189
+ for (i = 0; i < SERVICES.length; i++) {
1190
+ s = SERVICES[i];
1191
+ for (j = 0; j < s.paths.length; j++) {
1192
+ if (low.indexOf(s.paths[j]) > -1) { return s; }
1193
+ }
1194
+ }
1195
+ return null;
1196
+ }
1197
+
1198
+ // §3: «хранение — только отказы». An id absent from the map is allowed, so a
1199
+ // visitor who never opened the settings panel, and every config that gains a
1200
+ // service after the visitor decided, default to «разрешено» rather than to a
1201
+ // silent block of something the visitor was never asked about.
1202
+ function serviceDenied(id) {
1203
+ if (!id || typeof id !== 'string') { return false; }
1204
+ return state.services[id] === false;
1205
+ }
1206
+
1207
+ // The public predicate. A service is allowed when its category is granted AND
1208
+ // the visitor has not denied it individually — the two are deliberately NOT
1209
+ // collapsed into one flag: §3 requires a denial to SURVIVE the group switch
1210
+ // going off and back on («включён → сервисы включены, кроме отключённых
1211
+ // вручную»).
1212
+ function allowedService(id) {
1213
+ var s = SERVICE_BY_ID[id];
1214
+ if (!s) { return true; } // unknown id: nothing to withhold
1215
+ if (!allowed(s.category)) { return false; }
1216
+ return !serviceDenied(id);
1217
+ }
1218
+
1219
+ // Does this URL belong to a service the visitor turned off? The one question
1220
+ // both the blocking patches and applyConsentToDom() ask; kept as its own
1221
+ // function so the two can never drift apart.
1222
+ function deniedForSrc(src) {
1223
+ var s = serviceForUrl(src);
1224
+ return !!(s && serviceDenied(s.id));
1225
+ }
1226
+
1227
+ // Denied ids, in config order — the beacon field and the debug report both
1228
+ // want a stable, deduplicated list rather than object key order.
1229
+ function deniedServiceIds() {
1230
+ var out = [];
1231
+ for (var i = 0; i < SERVICES.length; i++) {
1232
+ if (state.services[SERVICES[i].id] === false) { out.push(SERVICES[i].id); }
1233
+ }
1234
+ return out;
1235
+ }
1236
+
1237
+ // Removes the cookies of the given services, exactly as a category withdrawal
1238
+ // removes a category's (§3). Only names the service declared: the engine has
1239
+ // no business guessing at cookies nobody wrote down.
1240
+ function purgeServiceCookies(ids) {
1241
+ var names = [];
1242
+ for (var i = 0; i < ids.length; i++) {
1243
+ var s = SERVICE_BY_ID[ids[i]];
1244
+ if (!s) { continue; }
1245
+ for (var j = 0; j < s.cookies.length; j++) {
1246
+ if (s.cookies[j] !== STORAGE_KEY && names.indexOf(s.cookies[j]) === -1) {
1247
+ names.push(s.cookies[j]);
1248
+ }
1249
+ }
1250
+ }
1251
+ // Deleted whether or not document.cookie can see them: purgeCookies() does
1252
+ // the same for its masks, because a cookie set with a path or domain this
1253
+ // page cannot read is still worth the (harmless) delete attempt.
1254
+ for (var k = 0; k < names.length; k++) { deleteCookie(names[k]); }
1255
+ }
1256
+
909
1257
  // ---------------------------------------------------------------------------
910
1258
  // Blocking engine — strict mode (§2)
911
1259
  // ---------------------------------------------------------------------------
@@ -1032,24 +1380,37 @@
1032
1380
  }
1033
1381
 
1034
1382
  // True when the URL must be held back: a known tracker whose category is not
1035
- // yet granted, or in strict mode an unknown third party.
1383
+ // yet granted, a resource of a service the visitor turned off, or — in strict
1384
+ // mode — an unknown third party.
1385
+ //
1386
+ // SPEC V1.12 §3: «ресурс сервиса, отклонённого посетителем, задерживается как
1387
+ // при отсутствии согласия на категорию». The service test comes FIRST, so a
1388
+ // denied Hotjar is held back even though analytics as a whole is granted;
1389
+ // without it the categoryForUrl branch below would return early and let it in.
1036
1390
  function shouldBlock(src) {
1037
1391
  if (bypass) { return false; }
1392
+ var svc = serviceForUrl(src);
1393
+ if (svc && serviceDenied(svc.id)) { return true; }
1038
1394
  var cat = categoryForUrl(src);
1039
1395
  if (cat) { return !allowed(cat); }
1040
1396
  return strictBlocks(src);
1041
1397
  }
1042
1398
 
1043
- // The category an interception is filed under. Known hosts keep their own;
1044
- // a strict interception is marketing.
1399
+ // The category an interception is filed under. Known hosts keep their own; a
1400
+ // service's own category covers a host (or path) the tracker database has
1401
+ // never heard of; a strict interception is marketing.
1045
1402
  function blockCategory(src) {
1046
- return categoryForUrl(src) || STRICT_CATEGORY;
1403
+ var cat = categoryForUrl(src);
1404
+ if (cat) { return cat; }
1405
+ var svc = serviceForUrl(src);
1406
+ return (svc && svc.category) || STRICT_CATEGORY;
1047
1407
  }
1048
1408
 
1049
1409
  // Was this particular interception a strict-mode one (i.e. the URL is not in
1050
1410
  // the tracker database at all)? Drives the «strict» label in the debug panel.
1411
+ // A service match is not a strict hit: the config named that resource.
1051
1412
  function isStrictHit(src) {
1052
- return !categoryForUrl(src) && strictMode();
1413
+ return !categoryForUrl(src) && !serviceForUrl(src) && strictMode();
1053
1414
  }
1054
1415
 
1055
1416
  // Registry of everything the engine intercepted, for the debug panel (§8.1
@@ -1403,9 +1764,14 @@
1403
1764
  scripts.forEach(function (el) {
1404
1765
  try {
1405
1766
  if (el.getAttribute('data-ck-restored')) { return; }
1767
+ var src = el.getAttribute('data-src') || el.getAttribute('data-ck-src') || '';
1406
1768
  var cat = el.getAttribute('data-ck');
1407
- if (!cat) { cat = categoryForUrl(el.getAttribute('data-src') || el.getAttribute('data-ck-src') || ''); }
1769
+ if (!cat) { cat = categoryForUrl(src); }
1408
1770
  if (!allowed(cat)) { return; }
1771
+ // SPEC V1.12 §3 — a denied service stays held even once its category is
1772
+ // granted. Without this the category grant would revive the very
1773
+ // resource the visitor singled out to refuse.
1774
+ if (deniedForSrc(src)) { return; }
1409
1775
  reviveScript(el);
1410
1776
  } catch (e) { /* noop */ }
1411
1777
  });
@@ -1418,6 +1784,7 @@
1418
1784
  if (el.getAttribute('src')) { return; }
1419
1785
  var src = el.getAttribute('data-src');
1420
1786
  if (!src) { return; }
1787
+ if (deniedForSrc(src)) { return; }
1421
1788
  var prev = bypass;
1422
1789
  bypass = true;
1423
1790
  try { nativeSetAttribute.call(el, 'src', src); } finally { bypass = prev; }
@@ -1435,8 +1802,14 @@
1435
1802
  // ---------------------------------------------------------------------------
1436
1803
  // Decisions
1437
1804
  // ---------------------------------------------------------------------------
1438
- function commit(categories, method) {
1805
+ // `services` is the FULL denial map for the new decision, or undefined to
1806
+ // keep the one already in state. Undefined is what accept('all'),
1807
+ // rejectAll() and every pre-0.5.8 caller pass, and §3 wants denials to
1808
+ // survive a category being switched off and back on — so «not mentioned»
1809
+ // must mean «unchanged», never «cleared».
1810
+ function commit(categories, method, services) {
1439
1811
  var wasDecided = state.decided;
1812
+ if (services !== undefined) { state.services = readServices(services); }
1440
1813
  state.categories = {
1441
1814
  necessary: true,
1442
1815
  functional: categories.functional === true,
@@ -1459,6 +1832,10 @@
1459
1832
  analytics: state.categories.analytics,
1460
1833
  marketing: state.categories.marketing
1461
1834
  },
1835
+ // Written only when something is actually denied, so a site with no
1836
+ // services (and a visitor who denied none) stores the exact same record
1837
+ // 0.5.7 stored — the stored shape does not change until it has to.
1838
+ services: hasDenials(state.services) ? cloneDenials(state.services) : undefined,
1462
1839
  method: state.method
1463
1840
  });
1464
1841
 
@@ -1470,6 +1847,14 @@
1470
1847
  var denied = OPT_IN.filter(function (c) { return !state.categories[c]; });
1471
1848
  if (denied.length) { purgeCookies(denied, denied.length === OPT_IN.length); }
1472
1849
 
1850
+ // SPEC V1.12 §3: «cookie отклонённого сервиса удаляются как при отзыве
1851
+ // категории». Every service that is denied NOW is swept, not only the ones
1852
+ // denied by this particular click: a visitor who denies Hotjar and then
1853
+ // grants analytics has just handed the category the chance to write the
1854
+ // cookies of a service they said no to, and the sweep is what closes it.
1855
+ var deniedSvc = deniedServiceIds();
1856
+ if (deniedSvc.length) { purgeServiceCookies(deniedSvc); }
1857
+
1473
1858
  applyConsentToDom();
1474
1859
 
1475
1860
  if (!wasDecided) { dispatch('ck:consent', { state: publicState() }); }
@@ -1489,25 +1874,38 @@
1489
1874
 
1490
1875
  function accept(arg) {
1491
1876
  try {
1492
- var cats, method;
1877
+ var cats, method, svcs;
1493
1878
  if (arg === 'all' || arg === undefined || arg === null) {
1494
1879
  cats = { functional: true, analytics: true, marketing: true };
1495
1880
  method = 'accept_all';
1881
+ // «Принять всё» means all of it: an accept_all that silently kept an
1882
+ // earlier per-service refusal would be a decision the visitor did not
1883
+ // make. The panel's own Save goes through the object branch below and
1884
+ // carries its switches, so this clears nothing a visitor just chose.
1885
+ svcs = {};
1496
1886
  } else if (isPlainObject(arg)) {
1497
1887
  cats = { functional: arg.functional === true, analytics: arg.analytics === true, marketing: arg.marketing === true };
1498
1888
  method = 'custom';
1889
+ // SPEC V1.12 §3 — accept({ ..., services: { hotjar: false } }). Absent
1890
+ // means «leave the denials as they are», which is what every 0.5.7
1891
+ // caller (and the placeholder's grantCategory) relies on.
1892
+ svcs = isPlainObject(arg.services) ? arg.services : undefined;
1499
1893
  } else {
1500
1894
  cats = { functional: true, analytics: true, marketing: true };
1501
1895
  method = 'accept_all';
1896
+ svcs = {};
1502
1897
  }
1503
- commit(filterByConfig(cats), method);
1898
+ commit(filterByConfig(cats), method, svcs);
1504
1899
  } catch (e) { /* noop */ }
1505
1900
  return publicState();
1506
1901
  }
1507
1902
 
1508
1903
  function rejectAll() {
1509
1904
  try {
1510
- commit({ functional: false, analytics: false, marketing: false }, 'reject_all');
1905
+ // Denials are cleared, not accumulated: every opt-in category is off, so
1906
+ // every service is blocked by its category anyway, and keeping the map
1907
+ // would leave a refusal standing that outlives the next «Принять всё».
1908
+ commit({ functional: false, analytics: false, marketing: false }, 'reject_all', {});
1511
1909
  } catch (e) { /* noop */ }
1512
1910
  return publicState();
1513
1911
  }
@@ -1519,10 +1917,16 @@
1519
1917
  state.ts = null;
1520
1918
  state.method = null;
1521
1919
  state.categories = emptyCategories();
1920
+ // Back to «ничего не решено»: a withdrawal erases the record, so the
1921
+ // per-service refusals it carried go with it rather than surviving as
1922
+ // invisible state the visitor can no longer see or change.
1923
+ var lastDenied = deniedServiceIds();
1924
+ state.services = {};
1522
1925
  state.policyVersion = String(config.policyVersion);
1523
1926
 
1524
1927
  clearRecord();
1525
1928
  purgeKnownCookies();
1929
+ if (lastDenied.length) { purgeServiceCookies(lastDenied); }
1526
1930
  // 'update' with everything denied, not a second 'default': Consent Mode
1527
1931
  // accepts only one default, set before tags load. State was reset above,
1528
1932
  // so gcmUpdate() emits all-denied and honours integrations.gcm.
@@ -1537,7 +1941,7 @@
1537
1941
  // Public API
1538
1942
  // ---------------------------------------------------------------------------
1539
1943
  var ConsentKit = {
1540
- version: '0.5.7',
1944
+ version: '0.5.9',
1541
1945
  config: config,
1542
1946
 
1543
1947
  init: function (userConfig) {
@@ -1551,6 +1955,14 @@
1551
1955
  // idempotent, so doing it twice costs nothing.
1552
1956
  if (userConfig && isPlainObject(userConfig.hostdb)) { extendHostDb(userConfig.hostdb); }
1553
1957
 
1958
+ // SPEC V1.12 §2/§3 — normalise the service rows and fold their hosts
1959
+ // into the block map. AFTER hostdb, so an explicit server override of a
1960
+ // host wins over the category the service row would file it under; and
1961
+ // before initialScan() below, so the scripts already in the markup are
1962
+ // classified against the extended map. Re-run on an idempotent init()
1963
+ // too, because that call is how a SaaS config arrives late.
1964
+ buildServices(config);
1965
+
1554
1966
  if (initialized) {
1555
1967
  // Idempotent: merge config, no re-restore, no duplicate ck:init.
1556
1968
  return publicState();
@@ -1564,6 +1976,7 @@
1564
1976
  state.ts = rec.ts;
1565
1977
  state.policyVersion = rec.policyVersion;
1566
1978
  state.categories = rec.categories;
1979
+ state.services = rec.services || {};
1567
1980
  state.method = rec.method;
1568
1981
  gcmUpdate();
1569
1982
  // Return visit: GTM triggers must fire for the restored categories.
@@ -1584,6 +1997,21 @@
1584
1997
  try { return allowed(cat); } catch (e) { return false; }
1585
1998
  },
1586
1999
 
2000
+ /* SPEC V1.12 §3 — may this ONE service run?
2001
+
2002
+ True when its category is granted and the visitor has not switched it off
2003
+ individually. An id the config does not declare answers `true`: the
2004
+ engine withholds nothing it was never told about, and a site that asks
2005
+ about a service it removed from the config should not have its own code
2006
+ silently disabled by the leftover question.
2007
+
2008
+ Deliberately not derived from `allowed(category)` alone by the caller:
2009
+ a denial outlives the category being switched off and back on, which is
2010
+ exactly the state a caller cannot reconstruct from getState().categories. */
2011
+ allowedService: function (id) {
2012
+ try { return allowedService(id); } catch (e) { return true; }
2013
+ },
2014
+
1587
2015
  getState: function () {
1588
2016
  try { return publicState(); } catch (e) {
1589
2017
  return { decided: false, id: null, ts: null, policyVersion: '1', categories: emptyCategories(), method: null };
@@ -1623,6 +2051,34 @@
1623
2051
  _categoryForUrl: categoryForUrl,
1624
2052
  _categories: CATEGORIES.slice(),
1625
2053
 
2054
+ /* SPEC V1.12 §3 — which declared service does this URL belong to?
2055
+ Hosts are suffix-matched like HOST_DB, paths substring-matched like
2056
+ PATH_DB. Returns a COPY of the normalised row (id, name, vendor,
2057
+ category, hosts, paths, cookies, purpose, privacyUrl) or null.
2058
+
2059
+ A copy, for the reason `_baseAllow` is a getter: the row this returns is
2060
+ the one the blocking hot path reads, and handing out a live reference
2061
+ would let page code rewrite what gets held back. */
2062
+ _serviceForUrl: function (url) {
2063
+ try {
2064
+ var s = serviceForUrl(url);
2065
+ return s ? clone(s) : null;
2066
+ } catch (e) { return null; }
2067
+ },
2068
+
2069
+ // The normalised service list the panel renders and the engine blocks by —
2070
+ // rows the config declared with `enabled: false`, a bad id or an unknown
2071
+ // category are already gone. Fresh copies, like every other list here.
2072
+ _services: function () {
2073
+ try { return SERVICES.map(function (s) { return clone(s); }); } catch (e) { return []; }
2074
+ },
2075
+
2076
+ // Ids the visitor switched off, in config order. Read by ck-saas.js for the
2077
+ // beacon's optional `services` field and by the debug panel.
2078
+ _deniedServices: function () {
2079
+ try { return deniedServiceIds(); } catch (e) { return []; }
2080
+ },
2081
+
1626
2082
  // Merges { host: category } into the runtime tracker database (§1.3).
1627
2083
  // Works before AND after init(): after init nothing already inserted is
1628
2084
  // re-evaluated — a script that has loaded cannot be unloaded — but every