@ecomconsult/consentkit 0.5.7 → 0.5.8

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
@@ -324,7 +324,16 @@
324
324
  // 'strict' — additionally hold back EVERY third-party script/iframe that is
325
325
  // not same-site, not in `allow` and not in BASE_ALLOW.
326
326
  blocking: { mode: 'known', allow: [] },
327
- cookieTable: []
327
+ cookieTable: [],
328
+ // SPEC V1.12 §2/§3 — the services the site declares. Each row names one
329
+ // third party (Google Analytics, Hotjar, Tilda Forms), the hosts and paths
330
+ // its resources come from and the cookies it sets, so the panel can list it
331
+ // under its category with its own switch and the engine can hold back that
332
+ // ONE service while the rest of the category runs.
333
+ //
334
+ // Empty by default: a config that predates 0.5.8 has no `services` key at
335
+ // all and must render and block exactly as it did before.
336
+ services: []
328
337
  };
329
338
 
330
339
  // Infrastructure (§8) — NOT a consent category, a CLASS of host.
@@ -690,10 +699,30 @@
690
699
  ts: rec.ts,
691
700
  policyVersion: String(rec.policyVersion),
692
701
  categories: cats,
702
+ // SPEC V1.12 §3 — the denial map. Only `false` entries are stored and only
703
+ // `false` entries are read back: a record written by 0.5.7 has no
704
+ // `services` key at all and yields «ничего не отклонено», which is what a
705
+ // visitor who was never shown a service list actually agreed to.
706
+ services: readServices(rec.services),
693
707
  method: rec.method || 'custom'
694
708
  };
695
709
  }
696
710
 
711
+ // { id: false } only. Anything else in the stored map — a `true`, a number, a
712
+ // key that is not a service id — is dropped rather than trusted: this record
713
+ // is attacker-writable (it lives in a cookie and in localStorage), and a
714
+ // malformed entry must not be able to widen or narrow what gets blocked.
715
+ function readServices(raw) {
716
+ var out = {};
717
+ if (!isPlainObject(raw)) { return out; }
718
+ var keys = Object.keys(raw);
719
+ for (var i = 0; i < keys.length && i < SERVICE_MAX; i++) {
720
+ var k = keys[i];
721
+ if (raw[k] === false && SERVICE_ID_RE.test(k)) { out[k] = false; }
722
+ }
723
+ return out;
724
+ }
725
+
697
726
  // ---------------------------------------------------------------------------
698
727
  // State
699
728
  // ---------------------------------------------------------------------------
@@ -705,6 +734,9 @@
705
734
  ts: null,
706
735
  policyVersion: String(config.policyVersion),
707
736
  categories: emptyCategories(),
737
+ // SPEC V1.12 §3 — denials only: { '<serviceId>': false }. An id that is not
738
+ // here is allowed (subject to its category).
739
+ services: {},
708
740
  method: null
709
741
  };
710
742
 
@@ -720,10 +752,31 @@
720
752
  analytics: !!state.categories.analytics,
721
753
  marketing: !!state.categories.marketing
722
754
  },
755
+ // A COPY: publicState() is handed to page code and to ck-saas.js, and a
756
+ // live reference would let either of them rewrite what the engine blocks.
757
+ services: cloneDenials(state.services),
723
758
  method: state.method
724
759
  };
725
760
  }
726
761
 
762
+ function hasDenials(map) {
763
+ try {
764
+ var keys = Object.keys(map || {});
765
+ for (var i = 0; i < keys.length; i++) { if (map[keys[i]] === false) { return true; } }
766
+ } catch (e) { /* noop */ }
767
+ return false;
768
+ }
769
+
770
+ function cloneDenials(map) {
771
+ var out = {};
772
+ try {
773
+ Object.keys(map || {}).forEach(function (k) {
774
+ if (map[k] === false) { out[k] = false; }
775
+ });
776
+ } catch (e) { /* noop */ }
777
+ return out;
778
+ }
779
+
727
780
  function dispatch(name, detail) {
728
781
  try {
729
782
  if (!doc || typeof doc.dispatchEvent !== 'function') { return; }
@@ -906,6 +959,217 @@
906
959
  return added;
907
960
  }
908
961
 
962
+ // ---------------------------------------------------------------------------
963
+ // Services (SPEC V1.12 §2/§3)
964
+ // ---------------------------------------------------------------------------
965
+ // The normalised view of config.services, rebuilt by init() once the server's
966
+ // config has been merged. Kept as a separate array rather than read out of
967
+ // `config` on every call: _serviceForUrl runs inside the blocking hot path
968
+ // (every script and iframe the page inserts), and re-validating 50 raw rows
969
+ // per resource would be paid on every insertion.
970
+ var SERVICES = [];
971
+ var SERVICE_BY_ID = {};
972
+
973
+ var SERVICE_ID_RE = /^[a-z0-9-]{1,64}$/;
974
+ var SERVICE_MAX = 50;
975
+
976
+ // §2's row shape, validated defensively: this arrives over the network in the
977
+ // SaaS path exactly like `hostdb` does, so nothing here is trusted. A row that
978
+ // fails any check is DROPPED rather than repaired — a half-understood service
979
+ // would block resources under a category nobody agreed to.
980
+ //
981
+ // `enabled: false` means «не показывать и не блокировать отдельно»: the row is
982
+ // dropped here, so the panel never lists it and _serviceForUrl never names it.
983
+ function normalizeService(raw) {
984
+ if (!isPlainObject(raw)) { return null; }
985
+ if (raw.enabled === false) { return null; }
986
+
987
+ var id = typeof raw.id === 'string' ? raw.id.trim().toLowerCase() : '';
988
+ if (!SERVICE_ID_RE.test(id)) { return null; }
989
+
990
+ var cat = typeof raw.category === 'string' ? raw.category : '';
991
+ if (CATEGORIES.indexOf(cat) === -1) { return null; }
992
+
993
+ var hosts = [];
994
+ if (raw.hosts && typeof raw.hosts.length === 'number') {
995
+ for (var i = 0; i < raw.hosts.length && hosts.length < 20; i++) {
996
+ var h = raw.hosts[i];
997
+ if (typeof h !== 'string') { continue; }
998
+ h = h.trim().toLowerCase().replace(/:\d+$/, '').replace(/^\.+|\.+$/g, '');
999
+ if (!h || h.length > 253 || h.indexOf('.') === -1 || /[^a-z0-9.\-]/.test(h)) { continue; }
1000
+ if (hosts.indexOf(h) === -1) { hosts.push(h); }
1001
+ }
1002
+ }
1003
+
1004
+ // Path fragments, matched exactly the way PATH_DB entries are: a
1005
+ // case-insensitive substring of the resolved URL.
1006
+ var paths = [];
1007
+ if (raw.paths && typeof raw.paths.length === 'number') {
1008
+ for (var j = 0; j < raw.paths.length && paths.length < 20; j++) {
1009
+ var p = raw.paths[j];
1010
+ if (typeof p !== 'string') { continue; }
1011
+ p = p.trim().toLowerCase();
1012
+ if (p && p.length <= 253 && paths.indexOf(p) === -1) { paths.push(p); }
1013
+ }
1014
+ }
1015
+
1016
+ var cookies = [];
1017
+ if (raw.cookies && typeof raw.cookies.length === 'number') {
1018
+ for (var k = 0; k < raw.cookies.length; k++) {
1019
+ var c = raw.cookies[k];
1020
+ if (typeof c !== 'string') { continue; }
1021
+ c = c.trim();
1022
+ if (c && cookies.indexOf(c) === -1) { cookies.push(c); }
1023
+ }
1024
+ }
1025
+
1026
+ var purpose = {};
1027
+ if (isPlainObject(raw.purpose)) {
1028
+ ['ru', 'ro', 'en'].forEach(function (lang) {
1029
+ var v = raw.purpose[lang];
1030
+ if (typeof v === 'string' && v.trim()) { purpose[lang] = v.trim().slice(0, 400); }
1031
+ });
1032
+ }
1033
+
1034
+ // http(s) only, for the same reason resolveDetails() in ck-ui.js insists on
1035
+ // it: this becomes a link the visitor is invited to click, and a
1036
+ // javascript: URL there is an XSS vector.
1037
+ var privacyUrl = null;
1038
+ if (typeof raw.privacyUrl === 'string' && /^https?:\/\//i.test(raw.privacyUrl.trim())) {
1039
+ privacyUrl = raw.privacyUrl.trim();
1040
+ }
1041
+
1042
+ return {
1043
+ id: id,
1044
+ name: (typeof raw.name === 'string' && raw.name.trim()) ? raw.name.trim() : id,
1045
+ vendor: (typeof raw.vendor === 'string' && raw.vendor.trim()) ? raw.vendor.trim() : '',
1046
+ category: cat,
1047
+ hosts: hosts,
1048
+ paths: paths,
1049
+ cookies: cookies,
1050
+ purpose: purpose,
1051
+ privacyUrl: privacyUrl
1052
+ };
1053
+ }
1054
+
1055
+ // Rebuilds SERVICES/SERVICE_BY_ID from the merged config and extends the
1056
+ // block map with every service host, so a host HOST_DB has never heard of is
1057
+ // still held back under its service's category (§2: «hosts не обязаны быть в
1058
+ // HOST_DB»). Returns the normalised list.
1059
+ function buildServices(cfg) {
1060
+ SERVICES = [];
1061
+ SERVICE_BY_ID = {};
1062
+ var extra = {};
1063
+ try {
1064
+ var list = cfg && cfg.services;
1065
+ if (!list || typeof list.length !== 'number') { return SERVICES; }
1066
+ for (var i = 0; i < list.length && SERVICES.length < SERVICE_MAX; i++) {
1067
+ var s = normalizeService(list[i]);
1068
+ if (!s) { continue; }
1069
+ if (SERVICE_BY_ID[s.id]) { continue; } // first row of an id wins
1070
+ SERVICE_BY_ID[s.id] = s;
1071
+ SERVICES.push(s);
1072
+ for (var j = 0; j < s.hosts.length; j++) { extra[s.hosts[j]] = s.category; }
1073
+ }
1074
+ // Reuses the existing override map, so a service host is classified by the
1075
+ // one lookup categoryForUrl already does — no second code path, and an
1076
+ // explicit `hostdb` override from the server still wins because
1077
+ // extendHostDb skips a host already sitting at the same category and
1078
+ // init() applies hostdb FIRST.
1079
+ extendHostDb(extra);
1080
+ } catch (e) { /* noop */ }
1081
+ return SERVICES;
1082
+ }
1083
+
1084
+ // Which service does this URL belong to? Host suffixes are matched like
1085
+ // HOST_DB, path fragments like PATH_DB. Returns the normalised row or null.
1086
+ //
1087
+ // Hosts before paths, and in declaration order: a config that lists the same
1088
+ // host under two services is the owner's mistake, and answering with the
1089
+ // first row is at least stable.
1090
+ function serviceForUrl(src) {
1091
+ if (!SERVICES.length) { return null; }
1092
+ if (!src || typeof src !== 'string') { return null; }
1093
+ var parts = urlParts(src);
1094
+ var host = parts.host;
1095
+ var i, j, s;
1096
+ if (host) {
1097
+ for (i = 0; i < SERVICES.length; i++) {
1098
+ s = SERVICES[i];
1099
+ for (j = 0; j < s.hosts.length; j++) {
1100
+ if (hostMatches(host, s.hosts[j])) { return s; }
1101
+ }
1102
+ }
1103
+ }
1104
+ var low = String(parts.url).toLowerCase();
1105
+ for (i = 0; i < SERVICES.length; i++) {
1106
+ s = SERVICES[i];
1107
+ for (j = 0; j < s.paths.length; j++) {
1108
+ if (low.indexOf(s.paths[j]) > -1) { return s; }
1109
+ }
1110
+ }
1111
+ return null;
1112
+ }
1113
+
1114
+ // §3: «хранение — только отказы». An id absent from the map is allowed, so a
1115
+ // visitor who never opened the settings panel, and every config that gains a
1116
+ // service after the visitor decided, default to «разрешено» rather than to a
1117
+ // silent block of something the visitor was never asked about.
1118
+ function serviceDenied(id) {
1119
+ if (!id || typeof id !== 'string') { return false; }
1120
+ return state.services[id] === false;
1121
+ }
1122
+
1123
+ // The public predicate. A service is allowed when its category is granted AND
1124
+ // the visitor has not denied it individually — the two are deliberately NOT
1125
+ // collapsed into one flag: §3 requires a denial to SURVIVE the group switch
1126
+ // going off and back on («включён → сервисы включены, кроме отключённых
1127
+ // вручную»).
1128
+ function allowedService(id) {
1129
+ var s = SERVICE_BY_ID[id];
1130
+ if (!s) { return true; } // unknown id: nothing to withhold
1131
+ if (!allowed(s.category)) { return false; }
1132
+ return !serviceDenied(id);
1133
+ }
1134
+
1135
+ // Does this URL belong to a service the visitor turned off? The one question
1136
+ // both the blocking patches and applyConsentToDom() ask; kept as its own
1137
+ // function so the two can never drift apart.
1138
+ function deniedForSrc(src) {
1139
+ var s = serviceForUrl(src);
1140
+ return !!(s && serviceDenied(s.id));
1141
+ }
1142
+
1143
+ // Denied ids, in config order — the beacon field and the debug report both
1144
+ // want a stable, deduplicated list rather than object key order.
1145
+ function deniedServiceIds() {
1146
+ var out = [];
1147
+ for (var i = 0; i < SERVICES.length; i++) {
1148
+ if (state.services[SERVICES[i].id] === false) { out.push(SERVICES[i].id); }
1149
+ }
1150
+ return out;
1151
+ }
1152
+
1153
+ // Removes the cookies of the given services, exactly as a category withdrawal
1154
+ // removes a category's (§3). Only names the service declared: the engine has
1155
+ // no business guessing at cookies nobody wrote down.
1156
+ function purgeServiceCookies(ids) {
1157
+ var names = [];
1158
+ for (var i = 0; i < ids.length; i++) {
1159
+ var s = SERVICE_BY_ID[ids[i]];
1160
+ if (!s) { continue; }
1161
+ for (var j = 0; j < s.cookies.length; j++) {
1162
+ if (s.cookies[j] !== STORAGE_KEY && names.indexOf(s.cookies[j]) === -1) {
1163
+ names.push(s.cookies[j]);
1164
+ }
1165
+ }
1166
+ }
1167
+ // Deleted whether or not document.cookie can see them: purgeCookies() does
1168
+ // the same for its masks, because a cookie set with a path or domain this
1169
+ // page cannot read is still worth the (harmless) delete attempt.
1170
+ for (var k = 0; k < names.length; k++) { deleteCookie(names[k]); }
1171
+ }
1172
+
909
1173
  // ---------------------------------------------------------------------------
910
1174
  // Blocking engine — strict mode (§2)
911
1175
  // ---------------------------------------------------------------------------
@@ -1032,24 +1296,37 @@
1032
1296
  }
1033
1297
 
1034
1298
  // 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.
1299
+ // yet granted, a resource of a service the visitor turned off, or — in strict
1300
+ // mode — an unknown third party.
1301
+ //
1302
+ // SPEC V1.12 §3: «ресурс сервиса, отклонённого посетителем, задерживается как
1303
+ // при отсутствии согласия на категорию». The service test comes FIRST, so a
1304
+ // denied Hotjar is held back even though analytics as a whole is granted;
1305
+ // without it the categoryForUrl branch below would return early and let it in.
1036
1306
  function shouldBlock(src) {
1037
1307
  if (bypass) { return false; }
1308
+ var svc = serviceForUrl(src);
1309
+ if (svc && serviceDenied(svc.id)) { return true; }
1038
1310
  var cat = categoryForUrl(src);
1039
1311
  if (cat) { return !allowed(cat); }
1040
1312
  return strictBlocks(src);
1041
1313
  }
1042
1314
 
1043
- // The category an interception is filed under. Known hosts keep their own;
1044
- // a strict interception is marketing.
1315
+ // The category an interception is filed under. Known hosts keep their own; a
1316
+ // service's own category covers a host (or path) the tracker database has
1317
+ // never heard of; a strict interception is marketing.
1045
1318
  function blockCategory(src) {
1046
- return categoryForUrl(src) || STRICT_CATEGORY;
1319
+ var cat = categoryForUrl(src);
1320
+ if (cat) { return cat; }
1321
+ var svc = serviceForUrl(src);
1322
+ return (svc && svc.category) || STRICT_CATEGORY;
1047
1323
  }
1048
1324
 
1049
1325
  // Was this particular interception a strict-mode one (i.e. the URL is not in
1050
1326
  // the tracker database at all)? Drives the «strict» label in the debug panel.
1327
+ // A service match is not a strict hit: the config named that resource.
1051
1328
  function isStrictHit(src) {
1052
- return !categoryForUrl(src) && strictMode();
1329
+ return !categoryForUrl(src) && !serviceForUrl(src) && strictMode();
1053
1330
  }
1054
1331
 
1055
1332
  // Registry of everything the engine intercepted, for the debug panel (§8.1
@@ -1403,9 +1680,14 @@
1403
1680
  scripts.forEach(function (el) {
1404
1681
  try {
1405
1682
  if (el.getAttribute('data-ck-restored')) { return; }
1683
+ var src = el.getAttribute('data-src') || el.getAttribute('data-ck-src') || '';
1406
1684
  var cat = el.getAttribute('data-ck');
1407
- if (!cat) { cat = categoryForUrl(el.getAttribute('data-src') || el.getAttribute('data-ck-src') || ''); }
1685
+ if (!cat) { cat = categoryForUrl(src); }
1408
1686
  if (!allowed(cat)) { return; }
1687
+ // SPEC V1.12 §3 — a denied service stays held even once its category is
1688
+ // granted. Without this the category grant would revive the very
1689
+ // resource the visitor singled out to refuse.
1690
+ if (deniedForSrc(src)) { return; }
1409
1691
  reviveScript(el);
1410
1692
  } catch (e) { /* noop */ }
1411
1693
  });
@@ -1418,6 +1700,7 @@
1418
1700
  if (el.getAttribute('src')) { return; }
1419
1701
  var src = el.getAttribute('data-src');
1420
1702
  if (!src) { return; }
1703
+ if (deniedForSrc(src)) { return; }
1421
1704
  var prev = bypass;
1422
1705
  bypass = true;
1423
1706
  try { nativeSetAttribute.call(el, 'src', src); } finally { bypass = prev; }
@@ -1435,8 +1718,14 @@
1435
1718
  // ---------------------------------------------------------------------------
1436
1719
  // Decisions
1437
1720
  // ---------------------------------------------------------------------------
1438
- function commit(categories, method) {
1721
+ // `services` is the FULL denial map for the new decision, or undefined to
1722
+ // keep the one already in state. Undefined is what accept('all'),
1723
+ // rejectAll() and every pre-0.5.8 caller pass, and §3 wants denials to
1724
+ // survive a category being switched off and back on — so «not mentioned»
1725
+ // must mean «unchanged», never «cleared».
1726
+ function commit(categories, method, services) {
1439
1727
  var wasDecided = state.decided;
1728
+ if (services !== undefined) { state.services = readServices(services); }
1440
1729
  state.categories = {
1441
1730
  necessary: true,
1442
1731
  functional: categories.functional === true,
@@ -1459,6 +1748,10 @@
1459
1748
  analytics: state.categories.analytics,
1460
1749
  marketing: state.categories.marketing
1461
1750
  },
1751
+ // Written only when something is actually denied, so a site with no
1752
+ // services (and a visitor who denied none) stores the exact same record
1753
+ // 0.5.7 stored — the stored shape does not change until it has to.
1754
+ services: hasDenials(state.services) ? cloneDenials(state.services) : undefined,
1462
1755
  method: state.method
1463
1756
  });
1464
1757
 
@@ -1470,6 +1763,14 @@
1470
1763
  var denied = OPT_IN.filter(function (c) { return !state.categories[c]; });
1471
1764
  if (denied.length) { purgeCookies(denied, denied.length === OPT_IN.length); }
1472
1765
 
1766
+ // SPEC V1.12 §3: «cookie отклонённого сервиса удаляются как при отзыве
1767
+ // категории». Every service that is denied NOW is swept, not only the ones
1768
+ // denied by this particular click: a visitor who denies Hotjar and then
1769
+ // grants analytics has just handed the category the chance to write the
1770
+ // cookies of a service they said no to, and the sweep is what closes it.
1771
+ var deniedSvc = deniedServiceIds();
1772
+ if (deniedSvc.length) { purgeServiceCookies(deniedSvc); }
1773
+
1473
1774
  applyConsentToDom();
1474
1775
 
1475
1776
  if (!wasDecided) { dispatch('ck:consent', { state: publicState() }); }
@@ -1489,25 +1790,38 @@
1489
1790
 
1490
1791
  function accept(arg) {
1491
1792
  try {
1492
- var cats, method;
1793
+ var cats, method, svcs;
1493
1794
  if (arg === 'all' || arg === undefined || arg === null) {
1494
1795
  cats = { functional: true, analytics: true, marketing: true };
1495
1796
  method = 'accept_all';
1797
+ // «Принять всё» means all of it: an accept_all that silently kept an
1798
+ // earlier per-service refusal would be a decision the visitor did not
1799
+ // make. The panel's own Save goes through the object branch below and
1800
+ // carries its switches, so this clears nothing a visitor just chose.
1801
+ svcs = {};
1496
1802
  } else if (isPlainObject(arg)) {
1497
1803
  cats = { functional: arg.functional === true, analytics: arg.analytics === true, marketing: arg.marketing === true };
1498
1804
  method = 'custom';
1805
+ // SPEC V1.12 §3 — accept({ ..., services: { hotjar: false } }). Absent
1806
+ // means «leave the denials as they are», which is what every 0.5.7
1807
+ // caller (and the placeholder's grantCategory) relies on.
1808
+ svcs = isPlainObject(arg.services) ? arg.services : undefined;
1499
1809
  } else {
1500
1810
  cats = { functional: true, analytics: true, marketing: true };
1501
1811
  method = 'accept_all';
1812
+ svcs = {};
1502
1813
  }
1503
- commit(filterByConfig(cats), method);
1814
+ commit(filterByConfig(cats), method, svcs);
1504
1815
  } catch (e) { /* noop */ }
1505
1816
  return publicState();
1506
1817
  }
1507
1818
 
1508
1819
  function rejectAll() {
1509
1820
  try {
1510
- commit({ functional: false, analytics: false, marketing: false }, 'reject_all');
1821
+ // Denials are cleared, not accumulated: every opt-in category is off, so
1822
+ // every service is blocked by its category anyway, and keeping the map
1823
+ // would leave a refusal standing that outlives the next «Принять всё».
1824
+ commit({ functional: false, analytics: false, marketing: false }, 'reject_all', {});
1511
1825
  } catch (e) { /* noop */ }
1512
1826
  return publicState();
1513
1827
  }
@@ -1519,10 +1833,16 @@
1519
1833
  state.ts = null;
1520
1834
  state.method = null;
1521
1835
  state.categories = emptyCategories();
1836
+ // Back to «ничего не решено»: a withdrawal erases the record, so the
1837
+ // per-service refusals it carried go with it rather than surviving as
1838
+ // invisible state the visitor can no longer see or change.
1839
+ var lastDenied = deniedServiceIds();
1840
+ state.services = {};
1522
1841
  state.policyVersion = String(config.policyVersion);
1523
1842
 
1524
1843
  clearRecord();
1525
1844
  purgeKnownCookies();
1845
+ if (lastDenied.length) { purgeServiceCookies(lastDenied); }
1526
1846
  // 'update' with everything denied, not a second 'default': Consent Mode
1527
1847
  // accepts only one default, set before tags load. State was reset above,
1528
1848
  // so gcmUpdate() emits all-denied and honours integrations.gcm.
@@ -1537,7 +1857,7 @@
1537
1857
  // Public API
1538
1858
  // ---------------------------------------------------------------------------
1539
1859
  var ConsentKit = {
1540
- version: '0.5.7',
1860
+ version: '0.5.8',
1541
1861
  config: config,
1542
1862
 
1543
1863
  init: function (userConfig) {
@@ -1551,6 +1871,14 @@
1551
1871
  // idempotent, so doing it twice costs nothing.
1552
1872
  if (userConfig && isPlainObject(userConfig.hostdb)) { extendHostDb(userConfig.hostdb); }
1553
1873
 
1874
+ // SPEC V1.12 §2/§3 — normalise the service rows and fold their hosts
1875
+ // into the block map. AFTER hostdb, so an explicit server override of a
1876
+ // host wins over the category the service row would file it under; and
1877
+ // before initialScan() below, so the scripts already in the markup are
1878
+ // classified against the extended map. Re-run on an idempotent init()
1879
+ // too, because that call is how a SaaS config arrives late.
1880
+ buildServices(config);
1881
+
1554
1882
  if (initialized) {
1555
1883
  // Idempotent: merge config, no re-restore, no duplicate ck:init.
1556
1884
  return publicState();
@@ -1564,6 +1892,7 @@
1564
1892
  state.ts = rec.ts;
1565
1893
  state.policyVersion = rec.policyVersion;
1566
1894
  state.categories = rec.categories;
1895
+ state.services = rec.services || {};
1567
1896
  state.method = rec.method;
1568
1897
  gcmUpdate();
1569
1898
  // Return visit: GTM triggers must fire for the restored categories.
@@ -1584,6 +1913,21 @@
1584
1913
  try { return allowed(cat); } catch (e) { return false; }
1585
1914
  },
1586
1915
 
1916
+ /* SPEC V1.12 §3 — may this ONE service run?
1917
+
1918
+ True when its category is granted and the visitor has not switched it off
1919
+ individually. An id the config does not declare answers `true`: the
1920
+ engine withholds nothing it was never told about, and a site that asks
1921
+ about a service it removed from the config should not have its own code
1922
+ silently disabled by the leftover question.
1923
+
1924
+ Deliberately not derived from `allowed(category)` alone by the caller:
1925
+ a denial outlives the category being switched off and back on, which is
1926
+ exactly the state a caller cannot reconstruct from getState().categories. */
1927
+ allowedService: function (id) {
1928
+ try { return allowedService(id); } catch (e) { return true; }
1929
+ },
1930
+
1587
1931
  getState: function () {
1588
1932
  try { return publicState(); } catch (e) {
1589
1933
  return { decided: false, id: null, ts: null, policyVersion: '1', categories: emptyCategories(), method: null };
@@ -1623,6 +1967,34 @@
1623
1967
  _categoryForUrl: categoryForUrl,
1624
1968
  _categories: CATEGORIES.slice(),
1625
1969
 
1970
+ /* SPEC V1.12 §3 — which declared service does this URL belong to?
1971
+ Hosts are suffix-matched like HOST_DB, paths substring-matched like
1972
+ PATH_DB. Returns a COPY of the normalised row (id, name, vendor,
1973
+ category, hosts, paths, cookies, purpose, privacyUrl) or null.
1974
+
1975
+ A copy, for the reason `_baseAllow` is a getter: the row this returns is
1976
+ the one the blocking hot path reads, and handing out a live reference
1977
+ would let page code rewrite what gets held back. */
1978
+ _serviceForUrl: function (url) {
1979
+ try {
1980
+ var s = serviceForUrl(url);
1981
+ return s ? clone(s) : null;
1982
+ } catch (e) { return null; }
1983
+ },
1984
+
1985
+ // The normalised service list the panel renders and the engine blocks by —
1986
+ // rows the config declared with `enabled: false`, a bad id or an unknown
1987
+ // category are already gone. Fresh copies, like every other list here.
1988
+ _services: function () {
1989
+ try { return SERVICES.map(function (s) { return clone(s); }); } catch (e) { return []; }
1990
+ },
1991
+
1992
+ // Ids the visitor switched off, in config order. Read by ck-saas.js for the
1993
+ // beacon's optional `services` field and by the debug panel.
1994
+ _deniedServices: function () {
1995
+ try { return deniedServiceIds(); } catch (e) { return []; }
1996
+ },
1997
+
1626
1998
  // Merges { host: category } into the runtime tracker database (§1.3).
1627
1999
  // Works before AND after init(): after init nothing already inserted is
1628
2000
  // re-evaluated — a script that has loaded cannot be unloaded — but every