@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.
- package/CHANGELOG.md +4 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|