@ecomconsult/consentkit 0.3.2

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-saas.js ADDED
@@ -0,0 +1,363 @@
1
+ /*!
2
+ * ConsentKit SaaS mode (experimental) — remote config + consent journal.
3
+ * Vanilla ES2020, zero dependencies, no build step.
4
+ *
5
+ * Load order: ck-core.js -> ck-locales.js -> ck-ui.js -> ck-saas.js
6
+ * Activation: <script src="ck-saas.js" data-ck-id="SITE_ID"
7
+ * data-ck-api="https://api.example.com"></script>
8
+ *
9
+ * This file owns init(): ck-core is loaded but NOT initialised by the page.
10
+ * Standalone (non-SaaS) pages simply do not include this file, so the
11
+ * standalone build is unchanged by construction.
12
+ */
13
+ (function (global) {
14
+ 'use strict';
15
+
16
+ // SSR / non-browser guard, same shape as the other files.
17
+ if (!global || typeof global !== 'object') { return; }
18
+ var doc = global.document;
19
+ if (!doc) { return; }
20
+
21
+ var DEFAULT_API = 'https://api.ecomconsult.net';
22
+ var CFG_TIMEOUT_MS = 3000;
23
+ var RETRY_DELAY_MS = 2000;
24
+ var CACHE_PREFIX = 'ck_cfg_';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Utilities (defensive: this layer must never throw into the host page)
28
+ // ---------------------------------------------------------------------------
29
+ function warn(msg, extra) {
30
+ try { (global.console && global.console.warn) && global.console.warn('[ConsentKit SaaS] ' + msg, extra === undefined ? '' : extra); } catch (e) { /* noop */ }
31
+ }
32
+ function error(msg) {
33
+ try { (global.console && global.console.error) && global.console.error('[ConsentKit SaaS] ' + msg); } catch (e) { /* noop */ }
34
+ }
35
+
36
+ function uuid() {
37
+ try {
38
+ if (global.crypto && typeof global.crypto.randomUUID === 'function') { return global.crypto.randomUUID(); }
39
+ if (global.crypto && typeof global.crypto.getRandomValues === 'function') {
40
+ var b = new Uint8Array(16);
41
+ global.crypto.getRandomValues(b);
42
+ b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
43
+ var h = [];
44
+ for (var i = 0; i < 16; i++) { h.push((b[i] + 0x100).toString(16).slice(1)); }
45
+ return h.slice(0,4).join('') + '-' + h.slice(4,6).join('') + '-' + h.slice(6,8).join('') +
46
+ '-' + h.slice(8,10).join('') + '-' + h.slice(10,16).join('');
47
+ }
48
+ } catch (e) { /* fall through */ }
49
+ // Non-secure context fallback.
50
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
51
+ var r = (Math.random() * 16) | 0;
52
+ return (c === 'x' ? r : ((r & 0x3) | 0x8)).toString(16);
53
+ });
54
+ }
55
+
56
+ function lsGet(k) { try { return global.localStorage ? global.localStorage.getItem(k) : null; } catch (e) { return null; } }
57
+ function lsSet(k, v) { try { global.localStorage && global.localStorage.setItem(k, v); } catch (e) { /* quota/private mode */ } }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Own <script> tag -> siteId / api base
61
+ // ---------------------------------------------------------------------------
62
+ function findOwnTag() {
63
+ try {
64
+ // document.currentScript is correct while this file is executing.
65
+ var cur = doc.currentScript;
66
+ if (cur && cur.getAttribute && cur.getAttribute('data-ck-id')) { return cur; }
67
+ var all = doc.querySelectorAll('script[data-ck-id]');
68
+ return all && all.length ? all[all.length - 1] : null;
69
+ } catch (e) { return null; }
70
+ }
71
+
72
+ var tag = findOwnTag();
73
+ if (!tag) { return; } // no data-ck-id -> standalone page, stay inert
74
+
75
+ var siteId = '';
76
+ var apiBase = DEFAULT_API;
77
+ try {
78
+ siteId = String(tag.getAttribute('data-ck-id') || '').trim();
79
+ var a = tag.getAttribute('data-ck-api');
80
+ if (a) { apiBase = String(a).trim().replace(/\/+$/, ''); }
81
+ } catch (e) { /* noop */ }
82
+ if (!siteId) { return; }
83
+
84
+ var CK = global.ConsentKit;
85
+ if (!CK || typeof CK.init !== 'function') {
86
+ error('ConsentKit core not found on the page. Load ck-core.js before ck-saas.js.');
87
+ return;
88
+ }
89
+
90
+ var cacheKey = CACHE_PREFIX + siteId;
91
+ var activeConfig = null; // config currently driving this page load
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Config cache
95
+ // ---------------------------------------------------------------------------
96
+ function readCache() {
97
+ var raw = lsGet(cacheKey);
98
+ if (!raw) { return null; }
99
+ try {
100
+ var o = JSON.parse(raw);
101
+ if (o && typeof o === 'object' && o.config && typeof o.config === 'object') { return o; }
102
+ } catch (e) { /* corrupt entry */ }
103
+ return null;
104
+ }
105
+
106
+ function writeCache(etag, config) {
107
+ try {
108
+ lsSet(cacheKey, JSON.stringify({ etag: etag || null, savedAt: new Date().toISOString(), config: config }));
109
+ } catch (e) { /* noop */ }
110
+ }
111
+
112
+ // x-ck-country -> ConsentKit._geo. Informational in V1.0; nothing reads it.
113
+ function storeGeo(res) {
114
+ try {
115
+ var c = res && res.headers && res.headers.get ? res.headers.get('x-ck-country') : null;
116
+ if (c) { CK._geo = { country: String(c) }; }
117
+ } catch (e) { /* header not exposed by CORS */ }
118
+ }
119
+
120
+ function configUrl() { return apiBase + '/v1/config/' + encodeURIComponent(siteId) + '.json'; }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Boot
124
+ // ---------------------------------------------------------------------------
125
+ function initWith(config, why) {
126
+ activeConfig = config;
127
+ try { CK.init(config); } catch (e) { error('init() failed: ' + (e && e.message)); }
128
+ if (why) { /* reserved for diagnostics */ }
129
+ }
130
+
131
+ // Strict mode: banner shows, every opt-in category stays off, no journal.
132
+ // policyVersion 'strict-fallback' deliberately mismatches any stored consent,
133
+ // so a previous decision is not silently reused when the server is unreachable.
134
+ function initStrict(reason) {
135
+ warn('config unavailable (' + reason + ') — strict fallback: banner shown, all opt-in categories denied, journal disabled.');
136
+ initWith({ policyVersion: 'strict-fallback' });
137
+ }
138
+
139
+ // cacheMode: 'default' lets the HTTP cache answer (cold load); 'no-cache'
140
+ // forces a conditional request to the origin (background revalidation).
141
+ function fetchConfig(etag, cacheMode, onOk, onFail) {
142
+ if (typeof global.fetch !== 'function') { onFail('fetch unsupported'); return; }
143
+ var ctrl = null, timer = null;
144
+ try { ctrl = new global.AbortController(); } catch (e) { ctrl = null; }
145
+ var opts = { method: 'GET', credentials: 'omit', mode: 'cors' };
146
+ if (cacheMode) { opts.cache = cacheMode; }
147
+ if (etag) { opts.headers = { 'If-None-Match': etag }; }
148
+ if (ctrl) { opts.signal = ctrl.signal; }
149
+ try {
150
+ timer = global.setTimeout(function () { try { ctrl && ctrl.abort(); } catch (e) {} }, CFG_TIMEOUT_MS);
151
+ } catch (e) { /* noop */ }
152
+
153
+ var done = false;
154
+ function finish(fn, arg) {
155
+ if (done) { return; }
156
+ done = true;
157
+ try { timer && global.clearTimeout(timer); } catch (e) {}
158
+ fn(arg);
159
+ }
160
+
161
+ global.fetch(configUrl(), opts).then(function (res) {
162
+ storeGeo(res);
163
+ if (res.status === 304) { finish(onOk, { notModified: true }); return; }
164
+ if (res.status === 404) { finish(onFail, 'site not found (404)'); return; }
165
+ if (!res.ok) { finish(onFail, 'HTTP ' + res.status); return; }
166
+ var newEtag = null;
167
+ try { newEtag = res.headers && res.headers.get ? res.headers.get('etag') : null; } catch (e) { /* noop */ }
168
+ res.json().then(function (cfg) {
169
+ if (!cfg || typeof cfg !== 'object') { finish(onFail, 'malformed config body'); return; }
170
+ finish(onOk, { config: cfg, etag: newEtag });
171
+ }, function () { finish(onFail, 'config is not valid JSON'); });
172
+ }, function (err) {
173
+ var aborted = err && (err.name === 'AbortError');
174
+ finish(onFail, aborted ? 'timeout after ' + CFG_TIMEOUT_MS + 'ms' : 'network error');
175
+ });
176
+ }
177
+
178
+ var cached = readCache();
179
+ if (cached) {
180
+ // Cache hit: init synchronously, then revalidate in the background.
181
+ initWith(cached.config, 'cache');
182
+ // cache:'no-cache' is REQUIRED here and deliberately differs from the cold
183
+ // path below. The server sends `Cache-Control: public, max-age=300`, so a
184
+ // plain fetch is answered by the browser's HTTP cache for five minutes and
185
+ // never reaches the origin — a freshly published config would stay
186
+ // invisible until that expired, and this revalidation would be a no-op.
187
+ // 'no-cache' means "always ask the origin, but a conditional request is
188
+ // fine": unchanged -> 304 (cheap), changed -> 200 with the new body.
189
+ // Do not "unify" the two modes: on the cold path the HTTP cache is a
190
+ // legitimate saving, because there is nothing cached to go stale against.
191
+ fetchConfig(cached.etag, 'no-cache', function (r) {
192
+ if (r.notModified) { return; }
193
+ // Fresh config is cached but NOT applied now: init() is idempotent and
194
+ // re-initialising would swap ConsentKit.config identity mid-session.
195
+ // It takes effect on the next page load.
196
+ writeCache(r.etag, r.config);
197
+ }, function (reason) {
198
+ warn('background revalidation failed (' + reason + '); continuing with cached config.');
199
+ });
200
+ } else {
201
+ // Cold path: no cached config exists, so the HTTP cache cannot serve a
202
+ // stale one. Default caching is the right economy here.
203
+ fetchConfig(null, null, function (r) {
204
+ if (r.notModified || !r.config) { initStrict('empty response without cache'); return; }
205
+ writeCache(r.etag, r.config);
206
+ initWith(r.config, 'network');
207
+ }, function (reason) {
208
+ initStrict(reason);
209
+ });
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Consent journal (POST /v1/consent)
214
+ // ---------------------------------------------------------------------------
215
+ var pending = []; // payloads not yet confirmed delivered
216
+ var seen = {}; // dedupe key -> true, per page load
217
+
218
+ function logTarget() {
219
+ var log = activeConfig && activeConfig.log;
220
+ if (!log || !log.endpoint) { return null; }
221
+ return log;
222
+ }
223
+
224
+ function resolvedLang() {
225
+ try {
226
+ var l = activeConfig && activeConfig.language;
227
+ if (l && l !== 'auto') { return String(l).slice(0, 8); }
228
+ var n = global.navigator && global.navigator.language;
229
+ return n ? String(n).slice(0, 8) : undefined;
230
+ } catch (e) { return undefined; }
231
+ }
232
+
233
+ function resolvedLayout() {
234
+ try {
235
+ var t = activeConfig && activeConfig.layout && activeConfig.layout.type;
236
+ return (t === 'bar' || t === 'box' || t === 'modal') ? t : undefined;
237
+ } catch (e) { return undefined; }
238
+ }
239
+
240
+ // Builds the §5 payload. Closed field list: anything extra is a 400.
241
+ function buildPayload(state, isWithdraw) {
242
+ var log = logTarget();
243
+ if (!log) { return null; }
244
+ var c = (state && state.categories) || {};
245
+ var body = {
246
+ siteId: siteId,
247
+ key: log.key,
248
+ cfg: activeConfig && activeConfig.v,
249
+ // Withdraw arrives with id/ts/method nulled by core, and needs a FRESH
250
+ // uuid: reusing the withdrawn record's PK would be swallowed server-side
251
+ // by ON CONFLICT DO NOTHING.
252
+ id: (!isWithdraw && state && state.id) ? state.id : uuid(),
253
+ ts: (!isWithdraw && state && state.ts) ? state.ts : new Date().toISOString(),
254
+ // Exactly three keys: 'necessary' is not part of the closed schema.
255
+ categories: {
256
+ functional: c.functional === true,
257
+ analytics: c.analytics === true,
258
+ marketing: c.marketing === true
259
+ },
260
+ method: isWithdraw ? 'withdraw' : (state && state.method) || 'custom'
261
+ };
262
+ var lang = resolvedLang();
263
+ if (lang) { body.lang = lang; }
264
+ var layout = resolvedLayout();
265
+ if (layout) { body.layout = layout; }
266
+ return body;
267
+ }
268
+
269
+ function drop(payload) {
270
+ var i = pending.indexOf(payload);
271
+ if (i > -1) { pending.splice(i, 1); }
272
+ }
273
+
274
+ // fetch(keepalive) with exactly one retry after 2s on network failure.
275
+ function send(payload, isRetry) {
276
+ var log = logTarget();
277
+ if (!log || typeof global.fetch !== 'function') { return; }
278
+ try {
279
+ global.fetch(log.endpoint, {
280
+ method: 'POST',
281
+ mode: 'cors',
282
+ credentials: 'omit',
283
+ keepalive: true,
284
+ headers: { 'Content-Type': 'application/json' },
285
+ body: JSON.stringify(payload)
286
+ }).then(function (res) {
287
+ // 4xx is terminal: retrying a rejected body cannot help.
288
+ if (res && (res.ok || (res.status >= 400 && res.status < 500))) { drop(payload); return; }
289
+ if (!isRetry) { scheduleRetry(payload); } else { drop(payload); }
290
+ }, function () {
291
+ if (!isRetry) { scheduleRetry(payload); } else { drop(payload); }
292
+ });
293
+ } catch (e) {
294
+ if (!isRetry) { scheduleRetry(payload); }
295
+ }
296
+ }
297
+
298
+ function scheduleRetry(payload) {
299
+ // The SAME payload object is resent: regenerating id/ts would create a new
300
+ // row instead of hitting the server's idempotency conflict.
301
+ try { global.setTimeout(function () { send(payload, true); }, RETRY_DELAY_MS); } catch (e) { /* noop */ }
302
+ }
303
+
304
+ function record(state, isWithdraw) {
305
+ try {
306
+ if (!logTarget()) { return; }
307
+ var payload = buildPayload(state, isWithdraw);
308
+ if (!payload) { return; }
309
+ // ck:consent and ck:change both fire for a first decision with identical
310
+ // (id, ts) — send once.
311
+ var k = payload.id + '|' + payload.ts + '|' + payload.method;
312
+ if (seen[k]) { return; }
313
+ seen[k] = true;
314
+ pending.push(payload);
315
+ send(payload, false);
316
+ } catch (e) { /* never break the host page */ }
317
+ }
318
+
319
+ try {
320
+ doc.addEventListener('ck:consent', function (e) {
321
+ var s = (e && e.detail && e.detail.state) || null;
322
+ if (s && s.decided) { record(s, false); }
323
+ }, false);
324
+
325
+ doc.addEventListener('ck:change', function (e) {
326
+ var s = (e && e.detail && e.detail.state) || null;
327
+ if (!s) { return; }
328
+ // decided === false on a change means withdraw (core nulls id/ts/method).
329
+ record(s, !s.decided);
330
+ }, false);
331
+ } catch (e) { error('could not subscribe to consent events.'); }
332
+
333
+ // Page unload: flush anything still pending via sendBeacon.
334
+ function flush() {
335
+ try {
336
+ var log = logTarget();
337
+ if (!log || !pending.length) { return; }
338
+ var nav = global.navigator;
339
+ if (!nav || typeof nav.sendBeacon !== 'function') { return; }
340
+ var list = pending.slice();
341
+ for (var i = 0; i < list.length; i++) {
342
+ var body = JSON.stringify(list[i]);
343
+ // text/plain keeps sendBeacon a CORS-simple request (no preflight,
344
+ // which beacons cannot perform). The server must accept this type.
345
+ var ok = false;
346
+ try { ok = nav.sendBeacon(log.endpoint, new global.Blob([body], { type: 'text/plain;charset=UTF-8' })); } catch (e2) { ok = false; }
347
+ if (ok) { drop(list[i]); }
348
+ }
349
+ } catch (e) { /* noop */ }
350
+ }
351
+
352
+ try {
353
+ global.addEventListener && global.addEventListener('pagehide', flush, false);
354
+ } catch (e) { /* noop */ }
355
+
356
+ // Minimal surface for the demo status panel; not a public API.
357
+ CK._saas = {
358
+ siteId: siteId,
359
+ api: apiBase,
360
+ pending: function () { return pending.length; },
361
+ config: function () { return activeConfig; }
362
+ };
363
+ })(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this));