@blamejs/blamejs-shop 0.4.54 → 0.4.56

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/lib/admin.js +385 -2
  3. package/lib/asset-manifest.json +1 -1
  4. package/lib/vendor/MANIFEST.json +41 -35
  5. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  6. package/lib/vendor/blamejs/SECURITY.md +1 -0
  7. package/lib/vendor/blamejs/api-snapshot.json +10 -2
  8. package/lib/vendor/blamejs/examples/wiki/lib/html-entities.js +24 -0
  9. package/lib/vendor/blamejs/examples/wiki/lib/symbol-index.js +7 -5
  10. package/lib/vendor/blamejs/examples/wiki/test/e2e.js +9 -1
  11. package/lib/vendor/blamejs/examples/wiki/test/validate-nav-coverage.js +2 -8
  12. package/lib/vendor/blamejs/lib/acme.js +7 -11
  13. package/lib/vendor/blamejs/lib/client-hints.js +3 -1
  14. package/lib/vendor/blamejs/lib/cluster.js +4 -2
  15. package/lib/vendor/blamejs/lib/guard-filename.js +6 -2
  16. package/lib/vendor/blamejs/lib/http-client-cache.js +3 -1
  17. package/lib/vendor/blamejs/lib/http-message-signature.js +25 -8
  18. package/lib/vendor/blamejs/lib/log-stream-otlp-grpc.js +12 -1
  19. package/lib/vendor/blamejs/lib/log-stream-syslog.js +6 -0
  20. package/lib/vendor/blamejs/lib/log.js +24 -2
  21. package/lib/vendor/blamejs/lib/mail.js +5 -0
  22. package/lib/vendor/blamejs/lib/middleware/body-parser.js +48 -6
  23. package/lib/vendor/blamejs/lib/network-dns.js +22 -26
  24. package/lib/vendor/blamejs/lib/network-heartbeat.js +3 -3
  25. package/lib/vendor/blamejs/lib/network-proxy.js +3 -7
  26. package/lib/vendor/blamejs/lib/network-tls.js +34 -13
  27. package/lib/vendor/blamejs/lib/network.js +2 -6
  28. package/lib/vendor/blamejs/lib/notify.js +7 -12
  29. package/lib/vendor/blamejs/lib/seeders.js +5 -10
  30. package/lib/vendor/blamejs/lib/structured-fields.js +38 -1
  31. package/lib/vendor/blamejs/package.json +1 -1
  32. package/lib/vendor/blamejs/release-notes/v0.15.12.json +47 -0
  33. package/lib/vendor/blamejs/test/00-primitives.js +24 -0
  34. package/lib/vendor/blamejs/test/layer-0-primitives/body-parser-error-redaction.test.js +74 -0
  35. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +18 -8
  36. package/lib/vendor/blamejs/test/layer-0-primitives/guard-filename.test.js +11 -0
  37. package/lib/vendor/blamejs/test/layer-0-primitives/http-message-signature.test.js +33 -0
  38. package/lib/vendor/blamejs/test/layer-0-primitives/log-stream-otlp-grpc.test.js +27 -0
  39. package/lib/vendor/blamejs/test/layer-0-primitives/network-tls.test.js +31 -0
  40. package/lib/vendor/blamejs/test/layer-0-primitives/structured-fields.test.js +14 -0
  41. package/lib/winback-campaigns.js +202 -42
  42. package/package.json +1 -1
@@ -185,6 +185,32 @@ async function testValidationRejectsBadUrl() {
185
185
  check("missing url throws", threw && threw.code === "BAD_OPT");
186
186
  }
187
187
 
188
+ // The insecure-TLS audit must only fire on an actual TLS session. An h2c
189
+ // endpoint (http://, cleartext HTTP/2) creates no TLS session, so allowInsecure
190
+ // there skips no certificate and must NOT emit tls.insecure_skip_verify — a
191
+ // false security/compliance event. https:// + allowInsecure still emits it.
192
+ async function testInsecureTlsAuditGatedToHttps() {
193
+ var observability = require("../../lib/observability");
194
+ function capture(cfg) {
195
+ var events = [];
196
+ observability.setTap(function (name) { if (name === "tls.insecure_skip_verify") events.push(name); });
197
+ var session;
198
+ try { session = grpc._makeClient(cfg); }
199
+ finally { observability.setTap(null); }
200
+ if (session) {
201
+ session.on("error", function () { /* dead address — expected */ });
202
+ try { session.destroy(); } catch (_e) { /* best-effort */ }
203
+ }
204
+ return events.length;
205
+ }
206
+
207
+ var h2c = capture({ url: "http://127.0.0.1:1", allowInsecure: true, allowedProtocols: ["http:", "https:"] });
208
+ check("h2c (cleartext) endpoint with allowInsecure emits NO insecure-TLS audit", h2c === 0);
209
+
210
+ var tls = capture({ url: "https://127.0.0.1:1", allowInsecure: true, allowedProtocols: ["http:", "https:"] });
211
+ check("https endpoint with allowInsecure DOES emit the insecure-TLS audit", tls === 1);
212
+ }
213
+
188
214
  async function run() {
189
215
  await testFramingShape();
190
216
  await testEncodeLogRecord();
@@ -192,6 +218,7 @@ async function run() {
192
218
  await testGrpcRoundTrip();
193
219
  await testGrpcServerErrorTrailer();
194
220
  await testValidationRejectsBadUrl();
221
+ await testInsecureTlsAuditGatedToHttps();
195
222
  }
196
223
 
197
224
  module.exports = { run: run };
@@ -422,7 +422,38 @@ function testPkixHostShape() {
422
422
  err2 && err2.code === "tls/pkix-hostname-mismatch");
423
423
  }
424
424
 
425
+ // v0.15.12 (#143) — an outbound TLS connection that honors rejectUnauthorized:
426
+ // false (operator opt-in to disable peer-cert validation) must emit an audit +
427
+ // observability event so the degraded posture is observable. Capture the event
428
+ // through the real operator tap (observability.setTap) — observability has no
429
+ // `emit`, so the emit must land on the safeEvent → tap path that an operator
430
+ // actually wires (the live connect path is covered in the integration suite
431
+ // alongside tls.classical_downgrade).
432
+ function testInsecureTlsAudit() {
433
+ var nt = b.network.tls;
434
+ check("auditInsecureTls is exported", typeof nt.auditInsecureTls === "function");
435
+
436
+ var observability = require("../../lib/observability");
437
+ var captured = [];
438
+ observability.setTap(function (name, value, labels) { captured.push({ name: name, labels: labels }); });
439
+ try {
440
+ nt.auditInsecureTls({ host: "peer.example", port: 8443, source: "network.tls.connectWithEch" });
441
+ } finally {
442
+ observability.setTap(null);
443
+ }
444
+ var ev = captured.filter(function (c) { return c.name === "tls.insecure_skip_verify"; });
445
+ check("auditInsecureTls emits tls.insecure_skip_verify", ev.length >= 1);
446
+ check("audit event carries host/port/source",
447
+ ev.length >= 1 && ev[0].labels.host === "peer.example" &&
448
+ ev[0].labels.port === 8443 && ev[0].labels.source === "network.tls.connectWithEch");
449
+
450
+ var threw = false;
451
+ try { nt.auditInsecureTls(null); } catch (_e) { threw = true; }
452
+ check("auditInsecureTls is drop-silent on bad input (never throws into a connect)", threw === false);
453
+ }
454
+
425
455
  async function run() {
456
+ testInsecureTlsAudit();
426
457
  testEchSurface();
427
458
  testEchParseDraft22();
428
459
  testEchParseAcceptsBase64();
@@ -156,6 +156,20 @@ function testUnquoteSfString() {
156
156
  b.structuredFields.unquoteSfString("bare-token") === "bare-token");
157
157
  check("unquoteSfString: unterminated quote returns null",
158
158
  b.structuredFields.unquoteSfString('"oops') === null);
159
+ // v0.15.12 (#77) — adjacent / repeated escapes the old two-pass .replace()
160
+ // decode mangled. unquoteSfString routes through the single-pass
161
+ // unescapeSfStringBody; the two-pass form returned a DOUBLED backslash for a
162
+ // lone escaped backslash.
163
+ check("unquoteSfString: lone escaped backslash decodes to a single backslash",
164
+ b.structuredFields.unquoteSfString('"\\\\"') === "\\");
165
+ check("unquoteSfString: escaped backslash adjacent to escaped quote",
166
+ b.structuredFields.unquoteSfString('"\\\\\\""') === "\\\"");
167
+ check("unescapeSfStringBody: lone escaped backslash -> single",
168
+ b.structuredFields.unescapeSfStringBody("\\\\") === "\\");
169
+ check("unescapeSfStringBody: two escaped backslashes -> two",
170
+ b.structuredFields.unescapeSfStringBody("\\\\\\\\") === "\\\\");
171
+ check("unescapeSfStringBody: non-string passthrough",
172
+ b.structuredFields.unescapeSfStringBody(42) === 42);
159
173
  check("unquoteSfString: empty returns empty",
160
174
  b.structuredFields.unquoteSfString("") === "");
161
175
  check("unquoteSfString: whitespace-only returns empty",
@@ -592,6 +592,93 @@ function create(opts) {
592
592
  return _rowToCampaign(await _getCampaignRow(slug));
593
593
  },
594
594
 
595
+ // Re-activate an archived campaign — clears `archived_at` so the
596
+ // scan path picks it up again. The inverse of `archiveCampaign`.
597
+ // Idempotent: re-activating an already-active campaign is a no-op
598
+ // that returns the current shape. Throws when the campaign doesn't
599
+ // exist (config-time tier — an operator acting on a typo'd slug
600
+ // should hear about it).
601
+ activateCampaign: async function (slug, activateOpts) {
602
+ _validateSlug(slug, "slug");
603
+ activateOpts = activateOpts || {};
604
+ var now = activateOpts.now == null ? _now() : activateOpts.now;
605
+ _validateNonNegInt(now, "now");
606
+ var existing = await _getCampaignRow(slug);
607
+ if (!existing) {
608
+ throw new TypeError(
609
+ "winbackCampaigns.activateCampaign: campaign '" + slug + "' not found"
610
+ );
611
+ }
612
+ if (existing.archived_at == null) {
613
+ return _rowToCampaign(existing);
614
+ }
615
+ await query(
616
+ "UPDATE winback_campaigns SET archived_at = NULL, updated_at = ?1 WHERE slug = ?2",
617
+ [now, slug],
618
+ );
619
+ return _rowToCampaign(await _getCampaignRow(slug));
620
+ },
621
+
622
+ // List every campaign, newest-first, with an optional status
623
+ // filter ("active" | "archived"). The operator console reads this
624
+ // for the campaign table; absent a filter every campaign returns.
625
+ listCampaigns: async function (listOpts) {
626
+ listOpts = listOpts || {};
627
+ var status = listOpts.status;
628
+ var sql = "SELECT * FROM winback_campaigns";
629
+ if (status === "active") {
630
+ sql += " WHERE archived_at IS NULL";
631
+ } else if (status === "archived") {
632
+ sql += " WHERE archived_at IS NOT NULL";
633
+ } else if (status != null) {
634
+ throw new TypeError(
635
+ "winbackCampaigns.listCampaigns: status must be 'active' or 'archived' when supplied"
636
+ );
637
+ }
638
+ sql += " ORDER BY created_at DESC";
639
+ var rows = (await query(sql, [])).rows;
640
+ var out = [];
641
+ for (var i = 0; i < rows.length; i += 1) out.push(_rowToCampaign(rows[i]));
642
+ return out;
643
+ },
644
+
645
+ // Read the most-recent deliveries across a campaign's enrollments,
646
+ // newest-first, bounded by `limit` (default 100). Joins each
647
+ // delivery to its enrollment so the console can show which
648
+ // customer received which step + coupon at which time. The
649
+ // operator-facing delivery-log panel reads this.
650
+ recentDeliveriesForCampaign: async function (input) {
651
+ if (!input || typeof input !== "object") {
652
+ throw new TypeError("winbackCampaigns.recentDeliveriesForCampaign: input object required");
653
+ }
654
+ var slug = _validateSlug(input.slug, "slug");
655
+ var limit = input.limit == null ? 100 : input.limit;
656
+ _validatePositiveInt(limit, "limit");
657
+ if (limit > 500) limit = 500;
658
+ var rows = (await query(
659
+ "SELECT d.id AS id, d.enrollment_id AS enrollment_id, " +
660
+ " d.step_index AS step_index, d.coupon_code AS coupon_code, " +
661
+ " d.delivered_at AS delivered_at, e.customer_id AS customer_id " +
662
+ "FROM winback_deliveries d " +
663
+ "JOIN winback_enrollments e ON e.id = d.enrollment_id " +
664
+ "WHERE e.campaign_slug = ?1 " +
665
+ "ORDER BY d.delivered_at DESC, d.step_index DESC LIMIT ?2",
666
+ [slug, limit],
667
+ )).rows;
668
+ var out = [];
669
+ for (var i = 0; i < rows.length; i += 1) {
670
+ out.push({
671
+ id: rows[i].id,
672
+ enrollment_id: rows[i].enrollment_id,
673
+ customer_id: rows[i].customer_id,
674
+ step_index: Number(rows[i].step_index),
675
+ coupon_code: rows[i].coupon_code || null,
676
+ delivered_at: Number(rows[i].delivered_at),
677
+ });
678
+ }
679
+ return out;
680
+ },
681
+
595
682
  // Scan the operator's order history for customers whose last
596
683
  // paid order landed inside the lapse window for each active
597
684
  // campaign + who aren't already enrolled. Returns a flat array
@@ -869,8 +956,12 @@ function create(opts) {
869
956
  suppressionReason = sup.suppression_type || "suppressed";
870
957
  }
871
958
  } catch (_e) {
872
- // drop-silentsuppressions outage errs toward sending
873
- // (the operator's mailer is the next gate).
959
+ // FAIL-CLOSEDa suppression-list outage must NOT let a
960
+ // marketing send slip past. Treat the unavailable check as a
961
+ // hit and cancel the enrollment rather than email an address
962
+ // that may have opted out.
963
+ suppressed = true;
964
+ suppressionReason = "suppression-check-unavailable";
874
965
  }
875
966
  }
876
967
 
@@ -887,11 +978,45 @@ function create(opts) {
887
978
  continue;
888
979
  }
889
980
 
890
- // Mint a coupon for the step when applicable + route the
891
- // send through the operator's email primitive. The actual
892
- // send is a best-effort hook; failure here doesn't block the
893
- // delivery row (the operator's worker can retry from the
894
- // enrollment row).
981
+ // ATOMIC CLAIM advance the FSM FIRST, so the conditional UPDATE
982
+ // is the single point that decides which of two overlapping ticks
983
+ // owns this step. The send + coupon mint happen ONLY after a
984
+ // winning claim (changes === 1), so a step is at-most-once: a
985
+ // marketing message is better missed than doubled, and a
986
+ // concurrent tick can't double-send or double-mint. The loser
987
+ // (changes === 0) skips — the winner already owns the step.
988
+ var nextIdx = stepIdx + 1;
989
+ var claim;
990
+ if (nextIdx >= steps.length) {
991
+ claim = await query(
992
+ "UPDATE winback_enrollments SET " +
993
+ "status = 'exhausted', current_step_index = ?1, " +
994
+ "next_step_at = NULL, updated_at = ?2 " +
995
+ "WHERE id = ?3 AND status = 'active' AND current_step_index = ?4",
996
+ [nextIdx, now, enr.id, stepIdx],
997
+ );
998
+ } else {
999
+ var createdAt = Number(enr.created_at);
1000
+ var nextAt = _nextStepAt(steps, createdAt, nextIdx);
1001
+ claim = await query(
1002
+ "UPDATE winback_enrollments SET " +
1003
+ "current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
1004
+ "WHERE id = ?4 AND status = 'active' AND current_step_index = ?5",
1005
+ [nextIdx, nextAt, now, enr.id, stepIdx],
1006
+ );
1007
+ }
1008
+ var claimed = Number((claim && (claim.changes != null ? claim.changes : claim.rowCount)) || 0);
1009
+ if (claimed !== 1) {
1010
+ // A concurrent tick already advanced this step — it owns the
1011
+ // send; skip without re-sending or re-minting.
1012
+ continue;
1013
+ }
1014
+
1015
+ // Claimed. Mint the per-step coupon (the primitive's core step
1016
+ // action, independent of whether an address resolved — the
1017
+ // delivery row records the coupon for a mailer retry), then route
1018
+ // the send through the operator's email primitive. The send is
1019
+ // best-effort: a failure doesn't roll back the claim.
895
1020
  var couponCode = await _resolveCouponForStep(step, customerId);
896
1021
  if (
897
1022
  email &&
@@ -910,22 +1035,15 @@ function create(opts) {
910
1035
  },
911
1036
  });
912
1037
  } catch (_e) {
913
- // drop-silent — the delivery row + FSM advance still
914
- // commit; the operator's mailer retries from the
915
- // delivery row's coupon_code if it minted one.
1038
+ // drop-silent — the claim already committed; the operator's
1039
+ // mailer retries from the delivery row's coupon_code if any.
916
1040
  }
917
1041
  }
918
1042
 
919
- // Idempotency: if a delivery row already exists for this
920
- // (enrollment, step_index) pair we don't write a second
921
- // one. The UNIQUE index on (enrollment_id, step_index)
922
- // backstops this with a hard constraint at the storage
923
- // layer.
924
- var prior = await query(
925
- "SELECT id FROM winback_deliveries WHERE enrollment_id = ?1 AND step_index = ?2 LIMIT 1",
926
- [enr.id, stepIdx],
927
- );
928
- if (!prior.rows[0]) {
1043
+ // Record the delivery. Only the claiming tick reaches here, so the
1044
+ // UNIQUE(enrollment_id, step_index) index can't collide; the
1045
+ // try/catch is a defensive backstop that never strands the batch.
1046
+ try {
929
1047
  var deliveryId = b.uuid.v7();
930
1048
  await query(
931
1049
  "INSERT INTO winback_deliveries " +
@@ -933,28 +1051,7 @@ function create(opts) {
933
1051
  "VALUES (?1, ?2, ?3, ?4, ?5)",
934
1052
  [deliveryId, enr.id, stepIdx, couponCode, now],
935
1053
  );
936
- }
937
-
938
- // Advance the FSM.
939
- var nextIdx = stepIdx + 1;
940
- if (nextIdx >= steps.length) {
941
- await query(
942
- "UPDATE winback_enrollments SET " +
943
- "status = 'exhausted', current_step_index = ?1, " +
944
- "next_step_at = NULL, updated_at = ?2 " +
945
- "WHERE id = ?3 AND status = 'active' AND current_step_index = ?4",
946
- [nextIdx, now, enr.id, stepIdx],
947
- );
948
- } else {
949
- var createdAt = Number(enr.created_at);
950
- var nextAt = _nextStepAt(steps, createdAt, nextIdx);
951
- await query(
952
- "UPDATE winback_enrollments SET " +
953
- "current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
954
- "WHERE id = ?4 AND status = 'active' AND current_step_index = ?5",
955
- [nextIdx, nextAt, now, enr.id, stepIdx],
956
- );
957
- }
1054
+ } catch (_e) { /* drop-silent — the claim already owns the step; an audit-row collision cannot cause a double-send */ }
958
1055
 
959
1056
  deliveries.push({
960
1057
  enrollment_id: enr.id,
@@ -1274,6 +1371,69 @@ function create(opts) {
1274
1371
  };
1275
1372
  },
1276
1373
 
1374
+ // One bounded cron pass over the already-wired instance: scan the
1375
+ // order history for lapsed customers, enroll the eligible ones,
1376
+ // then dispatch every due step through the suppression/consent-
1377
+ // gated send path. Composes `scanForLapsedCustomers` +
1378
+ // `enrollCustomer` + `dispatchTick` so the operator's worker fires
1379
+ // a single call per tick rather than re-deriving the loop.
1380
+ //
1381
+ // Drop-silent per recipient: the dispatch loop already swallows a
1382
+ // single send / resolver / coupon failure so one bad address never
1383
+ // strands the batch; enroll failures are caught per-candidate here
1384
+ // (a UNIQUE-collision from a concurrent tick is a no-op, not a
1385
+ // batch-killer). The whole pass is idempotent — re-running it (or
1386
+ // an overlapping tick) cannot double-enroll a customer (UNIQUE
1387
+ // (customer_id, campaign_slug) + the existing-row return) nor
1388
+ // double-send a step (UNIQUE (enrollment_id, step_index) + the
1389
+ // conditional FSM advance).
1390
+ runTick: async function (runTickOpts) {
1391
+ runTickOpts = runTickOpts || {};
1392
+ var now = runTickOpts.now == null ? _now() : runTickOpts.now;
1393
+ _validateNonNegInt(now, "now");
1394
+ var batchSize = runTickOpts.batch_size == null ? 500 : runTickOpts.batch_size;
1395
+ _validatePositiveInt(batchSize, "batch_size");
1396
+ var resolveEmail = runTickOpts.resolveEmail || null;
1397
+ if (resolveEmail != null && typeof resolveEmail !== "function") {
1398
+ throw new TypeError(
1399
+ "winbackCampaigns.runTick: resolveEmail must be a function (enrollment) => Promise<string|null>"
1400
+ );
1401
+ }
1402
+
1403
+ var candidates = await this.scanForLapsedCustomers({
1404
+ as_of: now,
1405
+ max_batch: batchSize,
1406
+ });
1407
+ var enrolledN = 0;
1408
+ for (var i = 0; i < candidates.length; i += 1) {
1409
+ try {
1410
+ await this.enrollCustomer({
1411
+ campaign_slug: candidates[i].campaign_slug,
1412
+ customer_id: candidates[i].customer_id,
1413
+ now: now,
1414
+ });
1415
+ enrolledN += 1;
1416
+ } catch (_e) {
1417
+ // drop-silent — a concurrent tick that already enrolled this
1418
+ // (customer, campaign) loses the UNIQUE race; the row exists,
1419
+ // so this pass simply moves on. Any other per-candidate fault
1420
+ // (a vanished campaign mid-scan) likewise must not strand the
1421
+ // rest of the batch.
1422
+ }
1423
+ }
1424
+ var dispatch = await this.dispatchTick({
1425
+ now: now,
1426
+ batch_size: batchSize,
1427
+ resolveEmail: resolveEmail,
1428
+ });
1429
+ return {
1430
+ now: now,
1431
+ candidates: candidates.length,
1432
+ enrolled: enrolledN,
1433
+ dispatched: dispatch.dispatched,
1434
+ };
1435
+ },
1436
+
1277
1437
  // Expose the optional deps so a wiring sanity check can assert
1278
1438
  // they reached the factory.
1279
1439
  _deps: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.54",
3
+ "version": "0.4.56",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {