@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/README.md +43 -1
- package/npm/core.cjs +3 -0
- package/npm/core.mjs +4 -0
- package/npm/index.cjs +3 -0
- package/npm/index.d.ts +67 -1
- package/npm/index.mjs +4 -0
- package/npm/internal-stub.mjs +11 -1
- package/npm/react.mjs +10 -1
- package/package.json +3 -2
- package/src/ck-core.js +470 -14
- package/src/ck-debug.js +273 -3
- package/src/ck-locales.js +6 -0
- package/src/ck-saas.js +33 -0
- package/src/ck-ui.js +550 -43
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-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|