@blamejs/blamejs-shop 0.0.64 → 0.0.65
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 +2 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +20 -0
- package/lib/metered-usage.js +782 -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/split-shipments.js +773 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.trustBadges
|
|
4
|
+
* @title Trust badges — operator-curated trust signals across the storefront
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operator-authored trust + certification badges that render at six
|
|
8
|
+
* fixed placements across the storefront:
|
|
9
|
+
*
|
|
10
|
+
* header — site-wide trust strip in the masthead.
|
|
11
|
+
* footer — trust band above the footer links.
|
|
12
|
+
* pdp — product-detail page trust column.
|
|
13
|
+
* cart_review — between cart line items and totals.
|
|
14
|
+
* checkout — payment-step reassurance band.
|
|
15
|
+
* order_confirmation — post-purchase "thank you" page.
|
|
16
|
+
*
|
|
17
|
+
* Each badge carries EITHER an inline SVG payload (sanitized through
|
|
18
|
+
* a strict whitelist that allows only `<svg>`, `<path>`, `<circle>`,
|
|
19
|
+
* `<rect>`, `<g>`, `<title>`, `<desc>` — every event-handler /
|
|
20
|
+
* `<script>` / `<foreignObject>` / animation element is refused at
|
|
21
|
+
* define time) OR a remote https:// image URL. Both is refused.
|
|
22
|
+
* `link_url` is optional and, when present, passes `b.safeUrl.parse`
|
|
23
|
+
* against the https-only allowlist (or a /-rooted storefront-internal
|
|
24
|
+
* path). `placements_json` is the set of placements the badge
|
|
25
|
+
* surfaces at; a badge can ride multiple placements simultaneously.
|
|
26
|
+
*
|
|
27
|
+
* Distinct from `promoBanners` (marketing) — trust badges are the
|
|
28
|
+
* reassurance layer, not the promotion layer. The two primitives
|
|
29
|
+
* share the storefront placement vocabulary in spirit but each
|
|
30
|
+
* carries its own placement enum because the placement positions
|
|
31
|
+
* themselves don't overlap (a trust-signal "header" strip is not
|
|
32
|
+
* the same surface as a "top_strip" promo announcement).
|
|
33
|
+
*
|
|
34
|
+
* Composes:
|
|
35
|
+
* - `b.guardSvg.sanitize` — `svg_payload` reaches the database
|
|
36
|
+
* sanitized through the strict element + attribute allowlist.
|
|
37
|
+
* `<script>` / `<foreignObject>` / animation tags / event-
|
|
38
|
+
* handler attributes / dangerous URL schemes inside the SVG are
|
|
39
|
+
* stripped before persistence; the raw operator input also
|
|
40
|
+
* passes through `validate` so any critical issue (DOCTYPE /
|
|
41
|
+
* SVGZ / bidi) refuses at define time rather than silently
|
|
42
|
+
* stripping operator intent.
|
|
43
|
+
* - `b.safeUrl.parse` — `link_url` and `image_url` reach the
|
|
44
|
+
* persisted row only after the https-only / /-rooted gate.
|
|
45
|
+
* javascript: / data: / vbscript: refused.
|
|
46
|
+
* - `b.template.escapeHtml` — `renderHtml` escapes the title /
|
|
47
|
+
* alt_text / placement / slug attributes so a hostile operator
|
|
48
|
+
* string lands as inert text.
|
|
49
|
+
* - `b.crypto.namespaceHash` + `b.uuid.v7` — `recordImpression` /
|
|
50
|
+
* `recordClick` hash the optional session_id under a primitive-
|
|
51
|
+
* local namespace so the event log can answer "how many unique
|
|
52
|
+
* sessions saw badge X" without storing raw session identifiers.
|
|
53
|
+
*
|
|
54
|
+
* Surface:
|
|
55
|
+
* - `defineBadge({ slug, title, svg_payload_or_image_url,
|
|
56
|
+
* link_url?, placements: [...], starts_at?,
|
|
57
|
+
* expires_at?, alt_text, priority })` — input
|
|
58
|
+
* accepts EITHER `svg_payload` OR `image_url` (the spec name
|
|
59
|
+
* `svg_payload_or_image_url` is destructured into the two
|
|
60
|
+
* columns; supplying both refuses).
|
|
61
|
+
* - `getBadge(slug)` / `listBadges({ active_only? })` /
|
|
62
|
+
* `updateBadge(slug, patch)` / `archiveBadge(slug)`.
|
|
63
|
+
* - `activeForPlacement({ placement, now? })` — priority-sorted
|
|
64
|
+
* list of active (non-archived, in-window, placement-matching)
|
|
65
|
+
* badges. A placement can stack multiple badges simultaneously,
|
|
66
|
+
* so the API returns an array, not a single winner (contrast
|
|
67
|
+
* with `promoBanners.activeForPlacement` which picks one).
|
|
68
|
+
* - `renderHtml({ slug })` — sanitized HTML string ready for
|
|
69
|
+
* inline insertion into a storefront template.
|
|
70
|
+
* - `recordImpression({ slug, placement, session_id? })` /
|
|
71
|
+
* `recordClick({ slug, placement, session_id? })` — drop-silent
|
|
72
|
+
* on unknown slug / bad-shape input (these run on the hot
|
|
73
|
+
* request path; throwing here would crash the storefront page
|
|
74
|
+
* that triggered the event).
|
|
75
|
+
*
|
|
76
|
+
* Storage:
|
|
77
|
+
* - `trust_badges` + `trust_badge_events`
|
|
78
|
+
* (migration `0111_trust_badges.sql`).
|
|
79
|
+
*
|
|
80
|
+
* @primitive trustBadges
|
|
81
|
+
* @related b.guardSvg, b.safeUrl, b.template.escapeHtml, b.crypto.namespaceHash
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
var MAX_SLUG_LEN = 80;
|
|
85
|
+
var MAX_TITLE_LEN = 200;
|
|
86
|
+
var MAX_ALT_TEXT_LEN = 300;
|
|
87
|
+
var MAX_LINK_URL_LEN = 2048;
|
|
88
|
+
var MAX_IMAGE_URL_LEN = 2048;
|
|
89
|
+
var MAX_SVG_PAYLOAD_BYTES = 65536;
|
|
90
|
+
var MAX_PLACEMENTS = 6;
|
|
91
|
+
|
|
92
|
+
var ALLOWED_PLACEMENTS = Object.freeze([
|
|
93
|
+
"header",
|
|
94
|
+
"footer",
|
|
95
|
+
"pdp",
|
|
96
|
+
"cart_review",
|
|
97
|
+
"checkout",
|
|
98
|
+
"order_confirmation",
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
// The strict element allowlist this primitive enforces. We constrain
|
|
102
|
+
// `b.guardSvg.sanitize` to this exact set so even the strict guard
|
|
103
|
+
// profile's default (`text` / `tspan` / `metadata` / `defs`) is
|
|
104
|
+
// further narrowed to the trust-badge palette.
|
|
105
|
+
var ALLOWED_SVG_TAGS = Object.freeze([
|
|
106
|
+
"svg", "path", "circle", "rect", "g", "title", "desc",
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// Event-type vocabulary for `trust_badge_events`. Mirrors the
|
|
110
|
+
// schema CHECK so a drift between the JS enum and the SQL CHECK is
|
|
111
|
+
// caught immediately by either the test suite or the constraint.
|
|
112
|
+
var EVENT_TYPES = Object.freeze(["impression", "click"]);
|
|
113
|
+
|
|
114
|
+
// Slug shape — alnum + hyphen + dot + underscore, leading char alnum,
|
|
115
|
+
// capped length. Same shape promoBanners + autoDiscount use.
|
|
116
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
117
|
+
|
|
118
|
+
// Refuse C0 control bytes + DEL in single-line operator strings.
|
|
119
|
+
var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
|
|
120
|
+
|
|
121
|
+
// Zero-width / direction-override family — same catalogue as the
|
|
122
|
+
// other operator-authored primitives.
|
|
123
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
124
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Hash namespace — opaque per-primitive constant so a session_id_hash
|
|
128
|
+
// emitted by trustBadges can't collide with one emitted by analytics
|
|
129
|
+
// or affiliates for the same underlying session_id.
|
|
130
|
+
var SESSION_NAMESPACE = "trust-badges-session";
|
|
131
|
+
|
|
132
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
133
|
+
"title",
|
|
134
|
+
"svg_payload",
|
|
135
|
+
"image_url",
|
|
136
|
+
"link_url",
|
|
137
|
+
"placements",
|
|
138
|
+
"starts_at",
|
|
139
|
+
"expires_at",
|
|
140
|
+
"alt_text",
|
|
141
|
+
"priority",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
var bShop;
|
|
145
|
+
function _b() {
|
|
146
|
+
if (!bShop) bShop = require("./index");
|
|
147
|
+
return bShop.framework;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---- validators ---------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
function _slug(s, label) {
|
|
153
|
+
label = label || "slug";
|
|
154
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
155
|
+
throw new TypeError("trustBadges: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
|
|
156
|
+
}
|
|
157
|
+
return s;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _placement(s) {
|
|
161
|
+
if (typeof s !== "string" || ALLOWED_PLACEMENTS.indexOf(s) === -1) {
|
|
162
|
+
throw new TypeError("trustBadges: placement must be one of " + JSON.stringify(ALLOWED_PLACEMENTS));
|
|
163
|
+
}
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _placements(arr) {
|
|
168
|
+
if (!Array.isArray(arr) || !arr.length) {
|
|
169
|
+
throw new TypeError("trustBadges: placements must be a non-empty array");
|
|
170
|
+
}
|
|
171
|
+
if (arr.length > MAX_PLACEMENTS) {
|
|
172
|
+
throw new TypeError("trustBadges: placements must have ≤ " + MAX_PLACEMENTS + " entries");
|
|
173
|
+
}
|
|
174
|
+
var seen = Object.create(null);
|
|
175
|
+
var out = [];
|
|
176
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
177
|
+
var p = _placement(arr[i]);
|
|
178
|
+
if (seen[p]) {
|
|
179
|
+
throw new TypeError("trustBadges: placements contains duplicate " + JSON.stringify(p));
|
|
180
|
+
}
|
|
181
|
+
seen[p] = true;
|
|
182
|
+
out.push(p);
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function _line(s, label, maxLen) {
|
|
188
|
+
if (typeof s !== "string" || !s.length || s.length > maxLen) {
|
|
189
|
+
throw new TypeError("trustBadges: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
|
|
190
|
+
}
|
|
191
|
+
if (CONTROL_BYTE_LINE_RE.test(s)) {
|
|
192
|
+
throw new TypeError("trustBadges: " + label + " contains control bytes (incl. CR/LF)");
|
|
193
|
+
}
|
|
194
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
195
|
+
throw new TypeError("trustBadges: " + label + " contains zero-width / direction-override characters");
|
|
196
|
+
}
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Link / image URL discipline — same envelope as promoBanners: either
|
|
201
|
+
// https:// (gated by safeUrl) or a /-rooted absolute path.
|
|
202
|
+
// Protocol-relative `//host/...` refused so a CDN mis-config can't
|
|
203
|
+
// downgrade the link.
|
|
204
|
+
function _httpsOrRootUrl(s, label, maxLen) {
|
|
205
|
+
if (typeof s !== "string" || !s.length || s.length > maxLen) {
|
|
206
|
+
throw new TypeError("trustBadges: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
|
|
207
|
+
}
|
|
208
|
+
if (CONTROL_BYTE_LINE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
209
|
+
throw new TypeError("trustBadges: " + label + " contains control / zero-width bytes");
|
|
210
|
+
}
|
|
211
|
+
if (s.charCodeAt(0) === 47 /* "/" */) {
|
|
212
|
+
if (s.length > 1 && s.charCodeAt(1) === 47) {
|
|
213
|
+
throw new TypeError("trustBadges: " + label + " protocol-relative `//host/...` refused — use absolute https://");
|
|
214
|
+
}
|
|
215
|
+
if (s.indexOf("..") !== -1) {
|
|
216
|
+
throw new TypeError("trustBadges: " + label + " path must not contain '..'");
|
|
217
|
+
}
|
|
218
|
+
return s;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
_b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
|
|
222
|
+
} catch (e) {
|
|
223
|
+
throw new TypeError("trustBadges: " + label + " — " + (e && e.message || "must be https:// or a /-rooted absolute path"));
|
|
224
|
+
}
|
|
225
|
+
return s;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function _linkUrl(s) {
|
|
229
|
+
if (s == null) return null;
|
|
230
|
+
return _httpsOrRootUrl(s, "link_url", MAX_LINK_URL_LEN);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function _imageUrl(s) {
|
|
234
|
+
if (s == null) return null;
|
|
235
|
+
return _httpsOrRootUrl(s, "image_url", MAX_IMAGE_URL_LEN);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// SVG payload — passes through `b.guardSvg` twice:
|
|
239
|
+
//
|
|
240
|
+
// 1. `validate` first against the strict profile constrained to the
|
|
241
|
+
// trust-badge tag whitelist. If any critical / high-severity
|
|
242
|
+
// issue is reported (DOCTYPE, SVGZ, bidi when policy=reject,
|
|
243
|
+
// tag-not-in-allowlist, dangerous URL scheme inside the SVG), we
|
|
244
|
+
// refuse the operator input at define time rather than silently
|
|
245
|
+
// stripping their intent.
|
|
246
|
+
//
|
|
247
|
+
// 2. `sanitize` produces the bytes that actually land in the
|
|
248
|
+
// database — even after validate passes, the sanitizer normalises
|
|
249
|
+
// attribute casing and drops any audit-level concerns (residual
|
|
250
|
+
// bidi codepoints stripped, etc.) so reads pass straight through
|
|
251
|
+
// to the storefront without re-sanitization.
|
|
252
|
+
function _svgPayload(s) {
|
|
253
|
+
if (s == null) return null;
|
|
254
|
+
if (typeof s !== "string" || !s.length) {
|
|
255
|
+
throw new TypeError("trustBadges: svg_payload must be a non-empty string when supplied");
|
|
256
|
+
}
|
|
257
|
+
if (Buffer.byteLength(s, "utf8") > MAX_SVG_PAYLOAD_BYTES) {
|
|
258
|
+
throw new TypeError("trustBadges: svg_payload exceeds " + MAX_SVG_PAYLOAD_BYTES + " bytes");
|
|
259
|
+
}
|
|
260
|
+
var guard = _b().guardSvg;
|
|
261
|
+
var rv = guard.validate(s, {
|
|
262
|
+
profile: "strict",
|
|
263
|
+
allowedTags: ALLOWED_SVG_TAGS,
|
|
264
|
+
});
|
|
265
|
+
if (!rv.ok) {
|
|
266
|
+
// Surface the first critical/high issue so the operator gets a
|
|
267
|
+
// diagnosable error rather than a generic refusal. The guard
|
|
268
|
+
// catalogue emits `kind` strings like "svg.dangerous-tag" /
|
|
269
|
+
// "svg.dangerous-scheme" / "svg.svgz" / "svg.doctype" — pass
|
|
270
|
+
// through the kind so the operator can fix the input precisely.
|
|
271
|
+
var critical = null;
|
|
272
|
+
for (var i = 0; i < rv.issues.length; i += 1) {
|
|
273
|
+
if (rv.issues[i].severity === "critical" || rv.issues[i].severity === "high") {
|
|
274
|
+
critical = rv.issues[i];
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
var kind = critical ? critical.kind : (rv.issues[0] && rv.issues[0].kind) || "unknown";
|
|
279
|
+
throw new TypeError("trustBadges: svg_payload refused by guardSvg (" + kind + ")");
|
|
280
|
+
}
|
|
281
|
+
// Sanitize to canonical form. The sanitizer can throw on SVGZ
|
|
282
|
+
// input; validate would already have caught that, but the
|
|
283
|
+
// try/wrap keeps the error shape consistent for any future
|
|
284
|
+
// guardSvg evolution that surfaces a new sanitize-throw class.
|
|
285
|
+
try {
|
|
286
|
+
return guard.sanitize(s, {
|
|
287
|
+
profile: "strict",
|
|
288
|
+
allowedTags: ALLOWED_SVG_TAGS,
|
|
289
|
+
});
|
|
290
|
+
} catch (e) {
|
|
291
|
+
throw new TypeError("trustBadges: svg_payload — " + (e && e.message || "guardSvg.sanitize threw"));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _priority(n) {
|
|
296
|
+
if (n == null) return 0;
|
|
297
|
+
if (!Number.isInteger(n) || n < 0 || n > 1000000) {
|
|
298
|
+
throw new TypeError("trustBadges: priority must be an integer in [0, 1000000]");
|
|
299
|
+
}
|
|
300
|
+
return n;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function _epochMsOptional(n, label) {
|
|
304
|
+
if (n == null) return null;
|
|
305
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
306
|
+
throw new TypeError("trustBadges: " + label + " must be a non-negative integer (epoch ms) or null");
|
|
307
|
+
}
|
|
308
|
+
return n;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function _now() { return Date.now(); }
|
|
312
|
+
|
|
313
|
+
// ---- row hydration ------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
function _hydrateRow(r) {
|
|
316
|
+
if (!r) return null;
|
|
317
|
+
var placements;
|
|
318
|
+
try { placements = JSON.parse(r.placements_json); }
|
|
319
|
+
catch (_e) { placements = []; }
|
|
320
|
+
return {
|
|
321
|
+
slug: r.slug,
|
|
322
|
+
title: r.title,
|
|
323
|
+
svg_payload: r.svg_payload,
|
|
324
|
+
image_url: r.image_url,
|
|
325
|
+
link_url: r.link_url,
|
|
326
|
+
placements: placements,
|
|
327
|
+
starts_at: r.starts_at == null ? null : Number(r.starts_at),
|
|
328
|
+
expires_at: r.expires_at == null ? null : Number(r.expires_at),
|
|
329
|
+
alt_text: r.alt_text,
|
|
330
|
+
priority: Number(r.priority),
|
|
331
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
332
|
+
impression_count: Number(r.impression_count),
|
|
333
|
+
click_count: Number(r.click_count),
|
|
334
|
+
created_at: Number(r.created_at),
|
|
335
|
+
updated_at: Number(r.updated_at),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function _inWindow(badge, nowTs) {
|
|
340
|
+
if (badge.starts_at != null && nowTs < badge.starts_at) return false;
|
|
341
|
+
if (badge.expires_at != null && nowTs >= badge.expires_at) return false;
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- factory ------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
function create(opts) {
|
|
348
|
+
opts = opts || {};
|
|
349
|
+
var query = opts.query;
|
|
350
|
+
if (!query) {
|
|
351
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// -- defineBadge -------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
async function defineBadge(input) {
|
|
357
|
+
if (!input || typeof input !== "object") {
|
|
358
|
+
throw new TypeError("trustBadges.defineBadge: input object required");
|
|
359
|
+
}
|
|
360
|
+
var slug = _slug(input.slug);
|
|
361
|
+
var title = _line(input.title, "title", MAX_TITLE_LEN);
|
|
362
|
+
|
|
363
|
+
var hasSvg = input.svg_payload != null;
|
|
364
|
+
var hasImage = input.image_url != null;
|
|
365
|
+
if (hasSvg && hasImage) {
|
|
366
|
+
throw new TypeError("trustBadges.defineBadge: supply either svg_payload OR image_url, not both");
|
|
367
|
+
}
|
|
368
|
+
if (!hasSvg && !hasImage) {
|
|
369
|
+
throw new TypeError("trustBadges.defineBadge: one of svg_payload / image_url is required");
|
|
370
|
+
}
|
|
371
|
+
var svgPayload = hasSvg ? _svgPayload(input.svg_payload) : null;
|
|
372
|
+
var imageUrl = hasImage ? _imageUrl(input.image_url) : null;
|
|
373
|
+
|
|
374
|
+
var linkUrl = _linkUrl(input.link_url);
|
|
375
|
+
var placements = _placements(input.placements);
|
|
376
|
+
var altText = _line(input.alt_text, "alt_text", MAX_ALT_TEXT_LEN);
|
|
377
|
+
var priority = _priority(input.priority);
|
|
378
|
+
var startsAt = _epochMsOptional(input.starts_at, "starts_at");
|
|
379
|
+
var expiresAt = _epochMsOptional(input.expires_at, "expires_at");
|
|
380
|
+
if (startsAt != null && expiresAt != null && expiresAt <= startsAt) {
|
|
381
|
+
throw new TypeError("trustBadges.defineBadge: expires_at must be strictly greater than starts_at");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
var ts = _now();
|
|
385
|
+
await query(
|
|
386
|
+
"INSERT INTO trust_badges (slug, title, svg_payload, image_url, link_url, placements_json, " +
|
|
387
|
+
"starts_at, expires_at, alt_text, priority, archived_at, impression_count, click_count, " +
|
|
388
|
+
"created_at, updated_at) " +
|
|
389
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, 0, 0, ?11, ?11)",
|
|
390
|
+
[slug, title, svgPayload, imageUrl, linkUrl, JSON.stringify(placements),
|
|
391
|
+
startsAt, expiresAt, altText, priority, ts],
|
|
392
|
+
);
|
|
393
|
+
return await getBadge(slug);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// -- listBadges / getBadge --------------------------------------------
|
|
397
|
+
|
|
398
|
+
async function listBadges(listOpts) {
|
|
399
|
+
listOpts = listOpts || {};
|
|
400
|
+
var activeOnly = false;
|
|
401
|
+
if (listOpts.active_only != null) {
|
|
402
|
+
if (typeof listOpts.active_only !== "boolean") {
|
|
403
|
+
throw new TypeError("trustBadges.listBadges: active_only must be a boolean");
|
|
404
|
+
}
|
|
405
|
+
activeOnly = listOpts.active_only;
|
|
406
|
+
}
|
|
407
|
+
var sql, params;
|
|
408
|
+
if (activeOnly) {
|
|
409
|
+
var nowTs = _now();
|
|
410
|
+
sql = "SELECT * FROM trust_badges WHERE archived_at IS NULL " +
|
|
411
|
+
"AND (starts_at IS NULL OR starts_at <= ?1) " +
|
|
412
|
+
"AND (expires_at IS NULL OR expires_at > ?1) " +
|
|
413
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC";
|
|
414
|
+
params = [nowTs];
|
|
415
|
+
} else {
|
|
416
|
+
sql = "SELECT * FROM trust_badges ORDER BY created_at ASC, slug ASC";
|
|
417
|
+
params = [];
|
|
418
|
+
}
|
|
419
|
+
var rows = (await query(sql, params)).rows;
|
|
420
|
+
return rows.map(_hydrateRow);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function getBadge(slug) {
|
|
424
|
+
_slug(slug);
|
|
425
|
+
var r = (await query(
|
|
426
|
+
"SELECT * FROM trust_badges WHERE slug = ?1 LIMIT 1",
|
|
427
|
+
[slug],
|
|
428
|
+
)).rows[0];
|
|
429
|
+
return _hydrateRow(r);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// -- updateBadge ------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
async function updateBadge(slug, patch) {
|
|
435
|
+
_slug(slug);
|
|
436
|
+
if (!patch || typeof patch !== "object") {
|
|
437
|
+
throw new TypeError("trustBadges.updateBadge: patch object required");
|
|
438
|
+
}
|
|
439
|
+
var keys = Object.keys(patch);
|
|
440
|
+
if (!keys.length) {
|
|
441
|
+
throw new TypeError("trustBadges.updateBadge: patch must include at least one column");
|
|
442
|
+
}
|
|
443
|
+
var current = await getBadge(slug);
|
|
444
|
+
if (!current) {
|
|
445
|
+
throw new TypeError("trustBadges.updateBadge: slug " + JSON.stringify(slug) + " not found");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
var sets = [];
|
|
449
|
+
var params = [];
|
|
450
|
+
var idx = 1;
|
|
451
|
+
var postSvgPayload = current.svg_payload;
|
|
452
|
+
var postImageUrl = current.image_url;
|
|
453
|
+
var postStartsAt = current.starts_at;
|
|
454
|
+
var postExpiresAt = current.expires_at;
|
|
455
|
+
|
|
456
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
457
|
+
var col = keys[i];
|
|
458
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
|
|
459
|
+
throw new TypeError("trustBadges.updateBadge: unsupported column " + JSON.stringify(col));
|
|
460
|
+
}
|
|
461
|
+
var v;
|
|
462
|
+
if (col === "title") { v = _line(patch[col], "title", MAX_TITLE_LEN); sets.push("title = ?" + idx); params.push(v); idx += 1; }
|
|
463
|
+
else if (col === "svg_payload") {
|
|
464
|
+
v = patch[col] == null ? null : _svgPayload(patch[col]);
|
|
465
|
+
postSvgPayload = v;
|
|
466
|
+
sets.push("svg_payload = ?" + idx); params.push(v); idx += 1;
|
|
467
|
+
}
|
|
468
|
+
else if (col === "image_url") {
|
|
469
|
+
v = patch[col] == null ? null : _imageUrl(patch[col]);
|
|
470
|
+
postImageUrl = v;
|
|
471
|
+
sets.push("image_url = ?" + idx); params.push(v); idx += 1;
|
|
472
|
+
}
|
|
473
|
+
else if (col === "link_url") {
|
|
474
|
+
v = patch[col] == null ? null : _linkUrl(patch[col]);
|
|
475
|
+
sets.push("link_url = ?" + idx); params.push(v); idx += 1;
|
|
476
|
+
}
|
|
477
|
+
else if (col === "placements") {
|
|
478
|
+
v = _placements(patch[col]);
|
|
479
|
+
sets.push("placements_json = ?" + idx); params.push(JSON.stringify(v)); idx += 1;
|
|
480
|
+
}
|
|
481
|
+
else if (col === "alt_text") { v = _line(patch[col], "alt_text", MAX_ALT_TEXT_LEN); sets.push("alt_text = ?" + idx); params.push(v); idx += 1; }
|
|
482
|
+
else if (col === "priority") { v = _priority(patch[col]); sets.push("priority = ?" + idx); params.push(v); idx += 1; }
|
|
483
|
+
else if (col === "starts_at") {
|
|
484
|
+
v = _epochMsOptional(patch[col], "starts_at");
|
|
485
|
+
postStartsAt = v;
|
|
486
|
+
sets.push("starts_at = ?" + idx); params.push(v); idx += 1;
|
|
487
|
+
}
|
|
488
|
+
else /* expires_at */ {
|
|
489
|
+
v = _epochMsOptional(patch[col], "expires_at");
|
|
490
|
+
postExpiresAt = v;
|
|
491
|
+
sets.push("expires_at = ?" + idx); params.push(v); idx += 1;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Cross-column invariants: exactly one of svg_payload / image_url
|
|
496
|
+
// is non-NULL on the resulting row; window monotonic when both
|
|
497
|
+
// bounds present.
|
|
498
|
+
if (postSvgPayload != null && postImageUrl != null) {
|
|
499
|
+
throw new TypeError("trustBadges.updateBadge: svg_payload and image_url are mutually exclusive");
|
|
500
|
+
}
|
|
501
|
+
if (postSvgPayload == null && postImageUrl == null) {
|
|
502
|
+
throw new TypeError("trustBadges.updateBadge: one of svg_payload / image_url must remain set");
|
|
503
|
+
}
|
|
504
|
+
if (postStartsAt != null && postExpiresAt != null && postExpiresAt <= postStartsAt) {
|
|
505
|
+
throw new TypeError("trustBadges.updateBadge: expires_at must be strictly greater than starts_at");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
sets.push("updated_at = ?" + idx);
|
|
509
|
+
params.push(_now());
|
|
510
|
+
idx += 1;
|
|
511
|
+
params.push(slug);
|
|
512
|
+
|
|
513
|
+
var r = await query(
|
|
514
|
+
"UPDATE trust_badges SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
515
|
+
params,
|
|
516
|
+
);
|
|
517
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
518
|
+
throw new TypeError("trustBadges.updateBadge: slug " + JSON.stringify(slug) + " not found");
|
|
519
|
+
}
|
|
520
|
+
return await getBadge(slug);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// -- archiveBadge -----------------------------------------------------
|
|
524
|
+
|
|
525
|
+
async function archiveBadge(slug) {
|
|
526
|
+
_slug(slug);
|
|
527
|
+
var ts = _now();
|
|
528
|
+
var r = await query(
|
|
529
|
+
"UPDATE trust_badges SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2 AND archived_at IS NULL",
|
|
530
|
+
[ts, slug],
|
|
531
|
+
);
|
|
532
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
533
|
+
var existing = await getBadge(slug);
|
|
534
|
+
if (!existing) {
|
|
535
|
+
throw new TypeError("trustBadges.archiveBadge: slug " + JSON.stringify(slug) + " not found");
|
|
536
|
+
}
|
|
537
|
+
// Already archived — idempotent return.
|
|
538
|
+
return existing;
|
|
539
|
+
}
|
|
540
|
+
return await getBadge(slug);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// -- activeForPlacement -----------------------------------------------
|
|
544
|
+
|
|
545
|
+
async function activeForPlacement(input) {
|
|
546
|
+
if (!input || typeof input !== "object") {
|
|
547
|
+
throw new TypeError("trustBadges.activeForPlacement: input object required");
|
|
548
|
+
}
|
|
549
|
+
var placement = _placement(input.placement);
|
|
550
|
+
var nowTs;
|
|
551
|
+
if (input.now == null) {
|
|
552
|
+
nowTs = _now();
|
|
553
|
+
} else {
|
|
554
|
+
if (!Number.isInteger(input.now) || input.now < 0) {
|
|
555
|
+
throw new TypeError("trustBadges.activeForPlacement: now must be a non-negative integer (epoch ms) when supplied");
|
|
556
|
+
}
|
|
557
|
+
nowTs = input.now;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Read every candidate non-archived in-window row, then filter
|
|
561
|
+
// placement at the JS layer (the small JSON array means a SQL
|
|
562
|
+
// LIKE-on-json would be both fragile and unnecessary; the row
|
|
563
|
+
// count for trust badges stays bounded by operator authoring).
|
|
564
|
+
var rows = (await query(
|
|
565
|
+
"SELECT * FROM trust_badges " +
|
|
566
|
+
"WHERE archived_at IS NULL " +
|
|
567
|
+
"AND (starts_at IS NULL OR starts_at <= ?1) " +
|
|
568
|
+
"AND (expires_at IS NULL OR expires_at > ?1) " +
|
|
569
|
+
"ORDER BY priority DESC, created_at ASC, slug ASC",
|
|
570
|
+
[nowTs],
|
|
571
|
+
)).rows;
|
|
572
|
+
|
|
573
|
+
var out = [];
|
|
574
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
575
|
+
var b = _hydrateRow(rows[i]);
|
|
576
|
+
if (b.placements.indexOf(placement) === -1) continue;
|
|
577
|
+
if (!_inWindow(b, nowTs)) continue;
|
|
578
|
+
out.push(b);
|
|
579
|
+
}
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// -- renderHtml -------------------------------------------------------
|
|
584
|
+
|
|
585
|
+
async function renderHtml(input) {
|
|
586
|
+
if (!input || typeof input !== "object") {
|
|
587
|
+
throw new TypeError("trustBadges.renderHtml: input object required");
|
|
588
|
+
}
|
|
589
|
+
var slug = _slug(input.slug);
|
|
590
|
+
var badge = await getBadge(slug);
|
|
591
|
+
if (!badge) {
|
|
592
|
+
throw new TypeError("trustBadges.renderHtml: slug " + JSON.stringify(slug) + " not found");
|
|
593
|
+
}
|
|
594
|
+
var escapeHtml = _b().template.escapeHtml;
|
|
595
|
+
|
|
596
|
+
var title = escapeHtml(badge.title);
|
|
597
|
+
var altText = escapeHtml(badge.alt_text);
|
|
598
|
+
var slugAttr = escapeHtml(badge.slug);
|
|
599
|
+
|
|
600
|
+
var parts = [];
|
|
601
|
+
var wrapperOpen;
|
|
602
|
+
if (badge.link_url) {
|
|
603
|
+
wrapperOpen = '<a class="trust-badge" data-trust-badge-slug="' + slugAttr +
|
|
604
|
+
'" href="' + escapeHtml(badge.link_url) +
|
|
605
|
+
'" title="' + title + '" rel="noopener">';
|
|
606
|
+
parts.push(wrapperOpen);
|
|
607
|
+
} else {
|
|
608
|
+
parts.push('<span class="trust-badge" data-trust-badge-slug="' + slugAttr +
|
|
609
|
+
'" title="' + title + '">');
|
|
610
|
+
}
|
|
611
|
+
if (badge.svg_payload) {
|
|
612
|
+
// svg_payload landed in the DB only after the strict sanitizer
|
|
613
|
+
// stripped every script / event-handler / dangerous URL scheme,
|
|
614
|
+
// so emitting it inline is safe. We wrap with role="img" +
|
|
615
|
+
// aria-label="<alt>" so assistive tech announces the trust
|
|
616
|
+
// signal even though the SVG itself doesn't carry an alt attr.
|
|
617
|
+
parts.push('<span class="trust-badge__svg" role="img" aria-label="' + altText + '">');
|
|
618
|
+
parts.push(badge.svg_payload);
|
|
619
|
+
parts.push('</span>');
|
|
620
|
+
} else {
|
|
621
|
+
parts.push('<img class="trust-badge__image" src="' + escapeHtml(badge.image_url) +
|
|
622
|
+
'" alt="' + altText + '" />');
|
|
623
|
+
}
|
|
624
|
+
parts.push(badge.link_url ? '</a>' : '</span>');
|
|
625
|
+
return parts.join("");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// -- analytics: impression + click counters ---------------------------
|
|
629
|
+
|
|
630
|
+
// Drop-silent on bad slug / missing row / bad placement — these
|
|
631
|
+
// run on the hot request path (impression on every storefront
|
|
632
|
+
// page render that surfaces the badge, click on the redirect
|
|
633
|
+
// handler before the customer reaches the trust-signal
|
|
634
|
+
// destination). Throwing here would crash the response the
|
|
635
|
+
// counter is observing.
|
|
636
|
+
function _normalizeEventInput(input) {
|
|
637
|
+
if (!input || typeof input !== "object") return null;
|
|
638
|
+
var slug = input.slug;
|
|
639
|
+
if (typeof slug !== "string" || !SLUG_RE.test(slug)) return null;
|
|
640
|
+
var placement = input.placement;
|
|
641
|
+
if (typeof placement !== "string" || ALLOWED_PLACEMENTS.indexOf(placement) === -1) return null;
|
|
642
|
+
var sessionId = null;
|
|
643
|
+
if (input.session_id != null) {
|
|
644
|
+
if (typeof input.session_id !== "string" || !input.session_id.length || input.session_id.length > 256) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
sessionId = input.session_id;
|
|
648
|
+
}
|
|
649
|
+
return { slug: slug, placement: placement, session_id: sessionId };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function _recordEvent(eventType, input) {
|
|
653
|
+
var n = _normalizeEventInput(input);
|
|
654
|
+
if (!n) return { recorded: false };
|
|
655
|
+
try {
|
|
656
|
+
var b = _b();
|
|
657
|
+
var sessionHash = n.session_id == null ? null
|
|
658
|
+
: b.crypto.namespaceHash(SESSION_NAMESPACE, n.session_id);
|
|
659
|
+
var ts = _now();
|
|
660
|
+
var id = b.uuid.v7();
|
|
661
|
+
var counterCol = eventType === "impression" ? "impression_count" : "click_count";
|
|
662
|
+
// Two-step write: bump the counter on the badge (only if the
|
|
663
|
+
// badge exists + isn't archived — `WHERE archived_at IS NULL`
|
|
664
|
+
// guards a hot-path counter from incrementing on a stale slug
|
|
665
|
+
// the storefront cached past archive time), then append to the
|
|
666
|
+
// event log when the counter bump landed.
|
|
667
|
+
var bump = await query(
|
|
668
|
+
"UPDATE trust_badges SET " + counterCol + " = " + counterCol + " + 1, " +
|
|
669
|
+
"updated_at = ?1 WHERE slug = ?2 AND archived_at IS NULL",
|
|
670
|
+
[ts, n.slug],
|
|
671
|
+
);
|
|
672
|
+
if (Number(bump.rowCount || 0) === 0) return { recorded: false };
|
|
673
|
+
await query(
|
|
674
|
+
"INSERT INTO trust_badge_events (id, badge_slug, placement, event_type, session_id_hash, occurred_at) " +
|
|
675
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
676
|
+
[id, n.slug, n.placement, eventType, sessionHash, ts],
|
|
677
|
+
);
|
|
678
|
+
return { recorded: true, id: id };
|
|
679
|
+
} catch (_e) {
|
|
680
|
+
return { recorded: false };
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function recordImpression(input) { return _recordEvent("impression", input); }
|
|
685
|
+
function recordClick(input) { return _recordEvent("click", input); }
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
689
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
690
|
+
MAX_ALT_TEXT_LEN: MAX_ALT_TEXT_LEN,
|
|
691
|
+
MAX_LINK_URL_LEN: MAX_LINK_URL_LEN,
|
|
692
|
+
MAX_IMAGE_URL_LEN: MAX_IMAGE_URL_LEN,
|
|
693
|
+
MAX_SVG_PAYLOAD_BYTES: MAX_SVG_PAYLOAD_BYTES,
|
|
694
|
+
ALLOWED_PLACEMENTS: ALLOWED_PLACEMENTS,
|
|
695
|
+
ALLOWED_SVG_TAGS: ALLOWED_SVG_TAGS,
|
|
696
|
+
EVENT_TYPES: EVENT_TYPES,
|
|
697
|
+
|
|
698
|
+
defineBadge: defineBadge,
|
|
699
|
+
getBadge: getBadge,
|
|
700
|
+
listBadges: listBadges,
|
|
701
|
+
updateBadge: updateBadge,
|
|
702
|
+
archiveBadge: archiveBadge,
|
|
703
|
+
activeForPlacement: activeForPlacement,
|
|
704
|
+
renderHtml: renderHtml,
|
|
705
|
+
recordImpression: recordImpression,
|
|
706
|
+
recordClick: recordClick,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
module.exports = {
|
|
711
|
+
create: create,
|
|
712
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
713
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
714
|
+
MAX_ALT_TEXT_LEN: MAX_ALT_TEXT_LEN,
|
|
715
|
+
MAX_LINK_URL_LEN: MAX_LINK_URL_LEN,
|
|
716
|
+
MAX_IMAGE_URL_LEN: MAX_IMAGE_URL_LEN,
|
|
717
|
+
MAX_SVG_PAYLOAD_BYTES: MAX_SVG_PAYLOAD_BYTES,
|
|
718
|
+
ALLOWED_PLACEMENTS: ALLOWED_PLACEMENTS,
|
|
719
|
+
ALLOWED_SVG_TAGS: ALLOWED_SVG_TAGS,
|
|
720
|
+
EVENT_TYPES: EVENT_TYPES,
|
|
721
|
+
};
|