@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,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
+ };