@blamejs/blamejs-shop 0.0.64 → 0.0.66

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.
@@ -0,0 +1,605 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.cookieConsent
4
+ * @title Cookie consent — GDPR / ePrivacy per-session category opt-in
5
+ * records, DNT / Sec-GPC honored as implicit deny.
6
+ *
7
+ * @intro
8
+ * GDPR (EU 2016/679 art. 6 + 7), ePrivacy Directive (2002/58/EC
9
+ * art. 5(3)), German TDDDG §25, and UK PECR all require informed,
10
+ * specific, freely-given consent BEFORE any non-strictly-necessary
11
+ * cookie / tracker is set. This primitive is the durable record of
12
+ * the buyer's per-category decision; downstream middleware
13
+ * (analytics tag injection, marketing-pixel render, preference-
14
+ * cookie write) consults `categoryAllowed` before emitting a single
15
+ * byte.
16
+ *
17
+ * Five categories — only the first is always-on:
18
+ * - strictly-necessary always allowed (session, CSRF, load
19
+ * balancer affinity, fraud-screen — the
20
+ * cookies the site cannot function without)
21
+ * - functional remember-me, locale, currency selector
22
+ * - analytics Plausible / GA / Matomo
23
+ * - marketing Meta pixel, TikTok pixel, ad retargeting
24
+ * - preferences the buyer's saved UI tweaks (dark mode,
25
+ * list density) that aren't strictly required
26
+ *
27
+ * Default-deny. A session with no `cookie_consent_records` row gets
28
+ * `false` from `categoryAllowed` for every category except
29
+ * strictly-necessary. The banner UI surfaces an opt-in form; the
30
+ * form handler calls `recordConsent` with the four toggles the
31
+ * buyer set.
32
+ *
33
+ * DNT (Mozilla's Do-Not-Track) and Sec-GPC (Global Privacy Control
34
+ * — CCPA-aligned, mandated by the California AG, supported by
35
+ * Brave / Firefox / DuckDuckGo) are honored as IMPLICIT DENY for
36
+ * marketing AND analytics regardless of what the recorded consent
37
+ * says. A buyer's browser-level opt-out wins over a stale stored
38
+ * opt-in (e.g. the buyer enabled GPC after their original consent).
39
+ * The DNT / GPC values are recorded on each row so the operator can
40
+ * prove to a supervisory authority that the signal was respected.
41
+ *
42
+ * Re-prompt on policy bump. Operators set `policyVersion` to the
43
+ * active policy revision; `recordConsent` stamps the consent row
44
+ * with the version that was live when it was taken. When the
45
+ * operator bumps the policy (new tracker, expanded purpose),
46
+ * `getConsentFor` flags the consent as `needs_reprompt` so the
47
+ * banner middleware shows the form again.
48
+ *
49
+ * Withdrawal is non-destructive. `withdrawConsent` writes a NEW
50
+ * row with every non-essential category set to 0 — the audit trail
51
+ * shows the original opt-in AND the withdrawal that followed.
52
+ *
53
+ * Session id hashing. The raw session id NEVER lands in storage.
54
+ * The primitive runs every session id through
55
+ * `b.crypto.namespaceHash("cookie-consent-session", sessionId)`
56
+ * before any write or read; a database dump only ever exposes the
57
+ * hash + the decision shape.
58
+ *
59
+ * Composes:
60
+ * - `b.crypto.namespaceHash` — session-id hashing.
61
+ * - `b.uuid.v7` — row id; lexicographically
62
+ * sortable for `occurred_at`
63
+ * tiebreak on the audit walk.
64
+ *
65
+ * Surface:
66
+ * - `recordConsent({ session_id, categories, ip_hash?, ua_class?,
67
+ * dnt?, gpc? })`
68
+ * → the persisted row (with `needs_reprompt: false` —
69
+ * a freshly-stamped row is always against the live policy).
70
+ * - `getConsentFor(session_id)`
71
+ * → latest record for the session, or null. Carries
72
+ * `needs_reprompt` = true when the row's policy_version
73
+ * lags the active `policyVersion`.
74
+ * - `withdrawConsent({ session_id, reason? })`
75
+ * → the new withdrawal row, or null when no prior consent
76
+ * exists for the session.
77
+ * - `categoryAllowed({ session_id, category })`
78
+ * → bool. Strictly-necessary always true. Other categories
79
+ * consult the latest record AND short-circuit to false on
80
+ * DNT / GPC for analytics + marketing.
81
+ * - `metricsForBanner({ from, to })`
82
+ * → `{ accept_all, reject_all, mixed }` counts over the
83
+ * window (inclusive lower / exclusive upper, ms epoch).
84
+ * - `cleanupOlderThan(days)`
85
+ * → `{ deleted: <count> }`. Removes consent rows whose
86
+ * `occurred_at` is older than `days` days AND are not the
87
+ * latest record for their session (the latest must survive
88
+ * so the live state isn't silently revoked).
89
+ * - `policyVersion` (getter / setter pair)
90
+ * → operator sets the active version; older records are
91
+ * flagged for re-prompt.
92
+ * - `registerPolicyVersion({ version, summary, effective_from? })`
93
+ * → persists a new entry in `cookie_consent_policy_versions`
94
+ * and bumps `policyVersion` to the new value.
95
+ *
96
+ * Storage:
97
+ * - `cookie_consent_records`
98
+ * - `cookie_consent_policy_versions`
99
+ * (migration `0103_cookie_consent.sql`)
100
+ *
101
+ * @primitive cookieConsent
102
+ * @related b.crypto.namespaceHash, b.uuid.v7
103
+ */
104
+
105
+ var SESSION_HASH_NAMESPACE = "cookie-consent-session";
106
+ var SESSION_ID_MAX_LEN = 1024;
107
+ var REASON_MAX_LEN = 512;
108
+ var SUMMARY_MAX_LEN = 1024;
109
+ var IP_HASH_MAX_LEN = 256;
110
+ var POLICY_VERSION_MAX_LEN = 64;
111
+
112
+ var CATEGORY_KEYS = Object.freeze([
113
+ "strictly_necessary",
114
+ "functional",
115
+ "analytics",
116
+ "marketing",
117
+ "preferences",
118
+ ]);
119
+
120
+ // Buyer-toggleable categories — strictly-necessary is implicit and
121
+ // never appears in the per-row columns (it's always allowed). The
122
+ // banner UI surfaces these four toggles.
123
+ var TOGGLEABLE_CATEGORIES = Object.freeze([
124
+ "functional",
125
+ "analytics",
126
+ "marketing",
127
+ "preferences",
128
+ ]);
129
+
130
+ // Categories the browser-level DNT / GPC signal collapses to false
131
+ // regardless of stored consent. Functional + preferences are not
132
+ // covered by Sec-GPC's "sale or sharing of personal information"
133
+ // scope — those decisions stay with the recorded opt-in.
134
+ var DNT_GPC_IMPLICIT_DENY = Object.freeze(["analytics", "marketing"]);
135
+
136
+ var UA_CLASS_VALUES = Object.freeze([
137
+ "desktop",
138
+ "mobile",
139
+ "tablet",
140
+ "bot",
141
+ "unknown",
142
+ ]);
143
+
144
+ // Lazy framework handle — matches the rest of the shop primitives;
145
+ // avoids the require cycle that would otherwise arise from importing
146
+ // `./index` at module-eval time.
147
+ var bShop;
148
+ function _b() {
149
+ if (!bShop) bShop = require("./index");
150
+ return bShop.framework;
151
+ }
152
+
153
+ // ---- validators --------------------------------------------------------
154
+
155
+ function _sessionId(s) {
156
+ if (typeof s !== "string" || !s.length) {
157
+ throw new TypeError("cookie-consent: session_id must be a non-empty string");
158
+ }
159
+ if (s.length > SESSION_ID_MAX_LEN) {
160
+ throw new TypeError(
161
+ "cookie-consent: session_id must be <= " + SESSION_ID_MAX_LEN + " chars"
162
+ );
163
+ }
164
+ if (/[\x00-\x1f\x7f]/.test(s)) {
165
+ throw new TypeError("cookie-consent: session_id must not contain control bytes");
166
+ }
167
+ return s;
168
+ }
169
+
170
+ function _hashSession(sessionId) {
171
+ return _b().crypto.namespaceHash(SESSION_HASH_NAMESPACE, sessionId);
172
+ }
173
+
174
+ function _bool(v, label) {
175
+ if (typeof v !== "boolean") {
176
+ throw new TypeError("cookie-consent: " + label + " must be a boolean");
177
+ }
178
+ return v;
179
+ }
180
+
181
+ function _optBool(v, label) {
182
+ if (v == null) return false;
183
+ return _bool(v, label);
184
+ }
185
+
186
+ function _categories(input) {
187
+ if (!input || typeof input !== "object") {
188
+ throw new TypeError("cookie-consent: categories object required");
189
+ }
190
+ var out = { functional: false, analytics: false, marketing: false, preferences: false };
191
+ // Refuse unknown keys so a misspelt "marketting" can't silently
192
+ // default-deny when the caller thought they were opting in.
193
+ var seen = Object.keys(input);
194
+ for (var i = 0; i < seen.length; i += 1) {
195
+ var k = seen[i];
196
+ if (k === "strictly_necessary") {
197
+ // Strictly-necessary is implicit-on; accepting it explicitly is
198
+ // a no-op when truthy and refused otherwise so callers can't
199
+ // accidentally "opt out" of essential cookies.
200
+ if (input[k] !== true) {
201
+ throw new TypeError(
202
+ "cookie-consent: strictly_necessary is implicit-on; only true is accepted"
203
+ );
204
+ }
205
+ continue;
206
+ }
207
+ if (TOGGLEABLE_CATEGORIES.indexOf(k) === -1) {
208
+ throw new TypeError(
209
+ "cookie-consent: unknown category '" + k + "' — valid keys are " +
210
+ TOGGLEABLE_CATEGORIES.join(", ")
211
+ );
212
+ }
213
+ out[k] = _bool(input[k], "categories." + k);
214
+ }
215
+ return out;
216
+ }
217
+
218
+ function _category(s) {
219
+ if (CATEGORY_KEYS.indexOf(s) === -1) {
220
+ throw new TypeError(
221
+ "cookie-consent: category must be one of " + CATEGORY_KEYS.join(", ") +
222
+ ", got " + JSON.stringify(s)
223
+ );
224
+ }
225
+ return s;
226
+ }
227
+
228
+ function _optShortString(s, label, maxLen) {
229
+ if (s == null || s === "") return null;
230
+ if (typeof s !== "string") {
231
+ throw new TypeError("cookie-consent: " + label + " must be a string");
232
+ }
233
+ if (/[\x00-\x1f\x7f]/.test(s)) {
234
+ throw new TypeError("cookie-consent: " + label + " must not contain control bytes");
235
+ }
236
+ if (s.length > maxLen) {
237
+ throw new TypeError("cookie-consent: " + label + " must be <= " + maxLen + " chars");
238
+ }
239
+ return s;
240
+ }
241
+
242
+ function _optUaClass(s) {
243
+ if (s == null || s === "") return null;
244
+ if (typeof s !== "string") {
245
+ throw new TypeError("cookie-consent: ua_class must be a string");
246
+ }
247
+ if (UA_CLASS_VALUES.indexOf(s) === -1) {
248
+ throw new TypeError(
249
+ "cookie-consent: ua_class must be one of " + UA_CLASS_VALUES.join(", ") +
250
+ ", got " + JSON.stringify(s)
251
+ );
252
+ }
253
+ return s;
254
+ }
255
+
256
+ function _policyVersion(s) {
257
+ if (typeof s !== "string" || !s.length) {
258
+ throw new TypeError("cookie-consent: policy_version must be a non-empty string");
259
+ }
260
+ if (s.length > POLICY_VERSION_MAX_LEN) {
261
+ throw new TypeError(
262
+ "cookie-consent: policy_version must be <= " + POLICY_VERSION_MAX_LEN + " chars"
263
+ );
264
+ }
265
+ if (!/^[A-Za-z0-9._-]+$/.test(s)) {
266
+ throw new TypeError(
267
+ "cookie-consent: policy_version must match /^[A-Za-z0-9._-]+$/, got " +
268
+ JSON.stringify(s)
269
+ );
270
+ }
271
+ return s;
272
+ }
273
+
274
+ function _positiveInt(n, label) {
275
+ if (!Number.isInteger(n) || n <= 0) {
276
+ throw new TypeError("cookie-consent: " + label + " must be a positive integer");
277
+ }
278
+ return n;
279
+ }
280
+
281
+ function _tsBound(n, label) {
282
+ if (!Number.isInteger(n) || n < 0) {
283
+ throw new TypeError(
284
+ "cookie-consent: " + label + " must be a non-negative integer (ms epoch)"
285
+ );
286
+ }
287
+ return n;
288
+ }
289
+
290
+ var _lastTs = 0;
291
+ function _now() {
292
+ var t = Date.now();
293
+ if (t <= _lastTs) { t = _lastTs + 1; }
294
+ _lastTs = t;
295
+ return t;
296
+ }
297
+
298
+ // ---- row <-> wire conversions ------------------------------------------
299
+
300
+ function _rowToRecord(row, activePolicyVersion) {
301
+ if (!row) return null;
302
+ var rec = {
303
+ id: row.id,
304
+ session_id_hash: row.session_id_hash,
305
+ policy_version: row.policy_version,
306
+ categories: {
307
+ strictly_necessary: true,
308
+ functional: Number(row.functional) === 1,
309
+ analytics: Number(row.analytics) === 1,
310
+ marketing: Number(row.marketing) === 1,
311
+ preferences: Number(row.preferences) === 1,
312
+ },
313
+ dnt: Number(row.dnt) === 1,
314
+ gpc: Number(row.gpc) === 1,
315
+ ip_hash: row.ip_hash == null ? null : row.ip_hash,
316
+ ua_class: row.ua_class == null ? null : row.ua_class,
317
+ occurred_at: Number(row.occurred_at),
318
+ withdrawn_at: row.withdrawn_at == null ? null : Number(row.withdrawn_at),
319
+ withdrawal_reason: row.withdrawal_reason == null ? null : row.withdrawal_reason,
320
+ needs_reprompt: row.policy_version !== activePolicyVersion,
321
+ };
322
+ return rec;
323
+ }
324
+
325
+ // ---- factory -----------------------------------------------------------
326
+
327
+ function create(opts) {
328
+ opts = opts || {};
329
+ var query = opts.query;
330
+ if (!query) {
331
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
332
+ }
333
+
334
+ // The active policy version is a per-instance handle the operator
335
+ // mutates via the getter/setter pair. Initial value is "v1" so a
336
+ // fresh deploy has a defined version without a separate boot step;
337
+ // operators bump it through `registerPolicyVersion` when the
338
+ // policy text changes.
339
+ var activePolicyVersion = "v1";
340
+
341
+ async function _latestRowFor(sessionHash) {
342
+ var r = await query(
343
+ "SELECT * FROM cookie_consent_records " +
344
+ "WHERE session_id_hash = ?1 " +
345
+ "ORDER BY occurred_at DESC, id DESC LIMIT 1",
346
+ [sessionHash],
347
+ );
348
+ return r.rows[0] || null;
349
+ }
350
+
351
+ var api = {
352
+ CATEGORY_KEYS: CATEGORY_KEYS,
353
+ TOGGLEABLE_CATEGORIES: TOGGLEABLE_CATEGORIES,
354
+ DNT_GPC_IMPLICIT_DENY: DNT_GPC_IMPLICIT_DENY,
355
+ UA_CLASS_VALUES: UA_CLASS_VALUES,
356
+ SESSION_HASH_NAMESPACE: SESSION_HASH_NAMESPACE,
357
+
358
+ // Active policy version — operators read / write through these.
359
+ // `policyVersion` (the spec'd top-level field) is a getter that
360
+ // returns the current value; the setter validates the string
361
+ // shape but does NOT itself write a row to
362
+ // `cookie_consent_policy_versions` — operators wanting both at
363
+ // once use `registerPolicyVersion`.
364
+ get policyVersion() { return activePolicyVersion; },
365
+ set policyVersion(v) { activePolicyVersion = _policyVersion(v); },
366
+
367
+ // Persist a policy revision and switch the active version to it.
368
+ // `effective_from` defaults to now. The summary is operator-facing
369
+ // and surfaces in the banner UI's "what changed since you last
370
+ // consented" text alongside the re-prompt.
371
+ registerPolicyVersion: async function (input) {
372
+ if (!input || typeof input !== "object") {
373
+ throw new TypeError("cookie-consent.registerPolicyVersion: input object required");
374
+ }
375
+ var version = _policyVersion(input.version);
376
+ var summary = _optShortString(input.summary, "summary", SUMMARY_MAX_LEN);
377
+ if (summary == null) {
378
+ throw new TypeError(
379
+ "cookie-consent.registerPolicyVersion: summary required (non-empty string <= " +
380
+ SUMMARY_MAX_LEN + " chars)"
381
+ );
382
+ }
383
+ var now = _now();
384
+ var effective = input.effective_from == null ? now
385
+ : _tsBound(input.effective_from, "effective_from");
386
+
387
+ // INSERT OR IGNORE — the operator may pre-register multiple
388
+ // versions and only later flip the active one; double-registering
389
+ // the same version is a no-op rather than a throw so the call is
390
+ // idempotent.
391
+ await query(
392
+ "INSERT OR IGNORE INTO cookie_consent_policy_versions " +
393
+ "(version, summary, effective_from, created_at) " +
394
+ "VALUES (?1, ?2, ?3, ?4)",
395
+ [version, summary, effective, now],
396
+ );
397
+ activePolicyVersion = version;
398
+ return { version: version, summary: summary, effective_from: effective, created_at: now };
399
+ },
400
+
401
+ // Write a per-session consent record. Strictly-necessary is
402
+ // implicit-on and never appears in the per-row columns — the
403
+ // record carries the four toggleable categories plus the DNT /
404
+ // GPC values the browser sent. The session id is hashed before
405
+ // any storage touch.
406
+ recordConsent: async function (input) {
407
+ if (!input || typeof input !== "object") {
408
+ throw new TypeError("cookie-consent.recordConsent: input object required");
409
+ }
410
+ var sessionId = _sessionId(input.session_id);
411
+ var cats = _categories(input.categories);
412
+ var ipHash = _optShortString(input.ip_hash, "ip_hash", IP_HASH_MAX_LEN);
413
+ var uaClass = _optUaClass(input.ua_class);
414
+ var dnt = _optBool(input.dnt, "dnt");
415
+ var gpc = _optBool(input.gpc, "gpc");
416
+
417
+ var sessionHash = _hashSession(sessionId);
418
+ var id = _b().uuid.v7();
419
+ var now = _now();
420
+
421
+ await query(
422
+ "INSERT INTO cookie_consent_records " +
423
+ "(id, session_id_hash, policy_version, " +
424
+ " functional, analytics, marketing, preferences, " +
425
+ " dnt, gpc, ip_hash, ua_class, " +
426
+ " occurred_at, withdrawn_at, withdrawal_reason) " +
427
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, NULL, NULL)",
428
+ [
429
+ id, sessionHash, activePolicyVersion,
430
+ cats.functional ? 1 : 0,
431
+ cats.analytics ? 1 : 0,
432
+ cats.marketing ? 1 : 0,
433
+ cats.preferences ? 1 : 0,
434
+ dnt ? 1 : 0,
435
+ gpc ? 1 : 0,
436
+ ipHash, uaClass,
437
+ now,
438
+ ],
439
+ );
440
+ return _rowToRecord(await _latestRowFor(sessionHash), activePolicyVersion);
441
+ },
442
+
443
+ // Latest record for the session, or null when no consent has
444
+ // been recorded. The returned record carries `needs_reprompt =
445
+ // true` when its policy_version no longer matches the active
446
+ // one — the banner-gated middleware reads that flag to decide
447
+ // whether to surface the form again.
448
+ getConsentFor: async function (sessionId) {
449
+ var sessionHash = _hashSession(_sessionId(sessionId));
450
+ var row = await _latestRowFor(sessionHash);
451
+ return _rowToRecord(row, activePolicyVersion);
452
+ },
453
+
454
+ // Right-to-withdraw under GDPR art. 7(3). Non-destructive — a
455
+ // new row is written with every toggleable category set to 0
456
+ // and `withdrawn_at` / `withdrawal_reason` stamped. Returns null
457
+ // when no prior consent exists for the session (withdrawing
458
+ // nothing is a no-op).
459
+ withdrawConsent: async function (input) {
460
+ if (!input || typeof input !== "object") {
461
+ throw new TypeError("cookie-consent.withdrawConsent: input object required");
462
+ }
463
+ var sessionId = _sessionId(input.session_id);
464
+ var reason = _optShortString(input.reason, "reason", REASON_MAX_LEN);
465
+ var sessionHash = _hashSession(sessionId);
466
+ var prior = await _latestRowFor(sessionHash);
467
+ if (!prior) return null;
468
+
469
+ // Already withdrawn — return the prior withdrawal row rather
470
+ // than stacking duplicates. The operator's audit story still
471
+ // works (the original withdrawal moment is preserved); the
472
+ // table doesn't grow under a UI that double-clicks "withdraw".
473
+ if (prior.withdrawn_at != null) {
474
+ return _rowToRecord(prior, activePolicyVersion);
475
+ }
476
+
477
+ var id = _b().uuid.v7();
478
+ var now = _now();
479
+ await query(
480
+ "INSERT INTO cookie_consent_records " +
481
+ "(id, session_id_hash, policy_version, " +
482
+ " functional, analytics, marketing, preferences, " +
483
+ " dnt, gpc, ip_hash, ua_class, " +
484
+ " occurred_at, withdrawn_at, withdrawal_reason) " +
485
+ "VALUES (?1, ?2, ?3, 0, 0, 0, 0, ?4, ?5, ?6, ?7, ?8, ?8, ?9)",
486
+ [
487
+ id, sessionHash, activePolicyVersion,
488
+ Number(prior.dnt) === 1 ? 1 : 0,
489
+ Number(prior.gpc) === 1 ? 1 : 0,
490
+ prior.ip_hash, prior.ua_class,
491
+ now, reason,
492
+ ],
493
+ );
494
+ return _rowToRecord(await _latestRowFor(sessionHash), activePolicyVersion);
495
+ },
496
+
497
+ // Gate that downstream middleware consults before emitting a
498
+ // cookie / tag / pixel byte. Strictly-necessary is always true.
499
+ // DNT or GPC short-circuit analytics + marketing to false even
500
+ // when the stored consent says otherwise (browser-level opt-out
501
+ // wins). All other paths consult the latest record's per-
502
+ // category boolean; missing record = default deny.
503
+ categoryAllowed: async function (input) {
504
+ if (!input || typeof input !== "object") {
505
+ throw new TypeError("cookie-consent.categoryAllowed: input object required");
506
+ }
507
+ var sessionId = _sessionId(input.session_id);
508
+ var category = _category(input.category);
509
+ if (category === "strictly_necessary") return true;
510
+
511
+ var rec = await api.getConsentFor(sessionId);
512
+ if (!rec) return false;
513
+
514
+ // Browser-level opt-out short-circuit. The buyer's most-recent
515
+ // network signal wins over a stale stored opt-in.
516
+ if ((rec.dnt || rec.gpc) && DNT_GPC_IMPLICIT_DENY.indexOf(category) !== -1) {
517
+ return false;
518
+ }
519
+
520
+ // A withdrawn record always denies the toggleable categories;
521
+ // the row's category booleans already encode this (withdrawal
522
+ // writes them all to 0) but we re-assert here so a future row
523
+ // shape that diverges still gates correctly.
524
+ if (rec.withdrawn_at != null) return false;
525
+
526
+ return rec.categories[category] === true;
527
+ },
528
+
529
+ // Aggregate accept-all / reject-all / mixed counts across the
530
+ // window. "Accept-all" = every toggleable category is true on
531
+ // the row; "reject-all" = every toggleable category is false;
532
+ // "mixed" = anything in between. DNT / GPC do NOT collapse the
533
+ // bucketing — the buckets describe what the buyer SAID, not
534
+ // what the gate ALLOWED.
535
+ metricsForBanner: async function (input) {
536
+ if (!input || typeof input !== "object") {
537
+ throw new TypeError("cookie-consent.metricsForBanner: input object required");
538
+ }
539
+ var from = _tsBound(input.from, "from");
540
+ var to = _tsBound(input.to, "to");
541
+ if (to <= from) {
542
+ throw new TypeError("cookie-consent.metricsForBanner: to must be > from");
543
+ }
544
+ var r = await query(
545
+ "SELECT functional, analytics, marketing, preferences " +
546
+ "FROM cookie_consent_records " +
547
+ "WHERE occurred_at >= ?1 AND occurred_at < ?2",
548
+ [from, to],
549
+ );
550
+ var acceptAll = 0;
551
+ var rejectAll = 0;
552
+ var mixed = 0;
553
+ for (var i = 0; i < r.rows.length; i += 1) {
554
+ var row = r.rows[i];
555
+ var n = (Number(row.functional) === 1 ? 1 : 0) +
556
+ (Number(row.analytics) === 1 ? 1 : 0) +
557
+ (Number(row.marketing) === 1 ? 1 : 0) +
558
+ (Number(row.preferences) === 1 ? 1 : 0);
559
+ if (n === 4) acceptAll += 1;
560
+ else if (n === 0) rejectAll += 1;
561
+ else mixed += 1;
562
+ }
563
+ return { accept_all: acceptAll, reject_all: rejectAll, mixed: mixed };
564
+ },
565
+
566
+ // Delete consent rows whose `occurred_at` is older than `days`
567
+ // days. The latest record for each session is preserved
568
+ // regardless of age — deleting the live state would silently
569
+ // revoke a buyer's standing consent, which the GDPR audit trail
570
+ // forbids. The withdrawn-at column is not consulted for the age
571
+ // gate; a withdrawn record is still an audit-record and ages
572
+ // out the same way an active one does.
573
+ cleanupOlderThan: async function (days) {
574
+ _positiveInt(days, "days");
575
+ var cutoff = _now() - (days * 86400 * 1000);
576
+ // The subquery picks the freshest row for each
577
+ // `session_id_hash`; the outer DELETE removes every row older
578
+ // than the cutoff that isn't on that survivor list.
579
+ var r = await query(
580
+ "DELETE FROM cookie_consent_records " +
581
+ "WHERE occurred_at < ?1 " +
582
+ "AND id NOT IN (" +
583
+ " SELECT id FROM cookie_consent_records r2 " +
584
+ " WHERE r2.occurred_at = (" +
585
+ " SELECT MAX(occurred_at) FROM cookie_consent_records r3 " +
586
+ " WHERE r3.session_id_hash = r2.session_id_hash" +
587
+ " )" +
588
+ ")",
589
+ [cutoff],
590
+ );
591
+ return { deleted: Number(r.rowCount || 0) };
592
+ },
593
+ };
594
+
595
+ return api;
596
+ }
597
+
598
+ module.exports = {
599
+ create: create,
600
+ CATEGORY_KEYS: CATEGORY_KEYS,
601
+ TOGGLEABLE_CATEGORIES: TOGGLEABLE_CATEGORIES,
602
+ DNT_GPC_IMPLICIT_DENY: DNT_GPC_IMPLICIT_DENY,
603
+ UA_CLASS_VALUES: UA_CLASS_VALUES,
604
+ SESSION_HASH_NAMESPACE: SESSION_HASH_NAMESPACE,
605
+ };