@blamejs/blamejs-shop 0.4.21 → 0.4.23

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/lib/storefront.js CHANGED
@@ -9085,13 +9085,70 @@ function _setSidCookie(req, res, sid) {
9085
9085
  });
9086
9086
  }
9087
9087
 
9088
+ // Store-free device-fingerprint binding for the sealed auth cookie. The
9089
+ // sealed envelope is tamper-proof but device-PORTABLE: a cookie lifted
9090
+ // off one device replays for the full 14-day life on any other. We bind
9091
+ // it softly to a SHAKE256 fingerprint of the device shape (User-Agent +
9092
+ // sorted Accept-Language / Accept-Encoding) carried INSIDE the sealed
9093
+ // envelope, recomputed + constant-time-compared on read. No external
9094
+ // store: `binding.fingerprint(req)` is a pure function of the request, so
9095
+ // the fingerprint lives in the cookie itself rather than a session table.
9096
+ //
9097
+ // The IP component is deliberately disabled (`ipPrefixBits {v4:0, v6:0}`):
9098
+ // a mobile or VPN visitor roams networks constantly, and signing them out
9099
+ // on a network hop is a worse outcome than the residual portability a
9100
+ // UA+Accept-only fingerprint leaves. Drift in the device shape is the
9101
+ // signal; a network change is not.
9102
+ //
9103
+ // `b.sessionDeviceBinding.create()` refuses to construct without either a
9104
+ // bindingStore or storeInSession — neither of which the store-free
9105
+ // `fingerprint()` path touches. Pass a b.cache-shaped no-op so the
9106
+ // constructor's opt-shape gate is satisfied; its methods are never called.
9107
+ var _NOOP_BINDING_STORE = {
9108
+ get: function () { return undefined; },
9109
+ set: function () { return undefined; },
9110
+ del: function () { return undefined; },
9111
+ };
9112
+ var _deviceBinding = null;
9113
+ function _deviceBindingInstance() {
9114
+ if (!_deviceBinding) {
9115
+ _deviceBinding = b.sessionDeviceBinding.create({
9116
+ bindingStore: _NOOP_BINDING_STORE,
9117
+ ipPrefixBits: { v4: 0, v6: 0 },
9118
+ });
9119
+ }
9120
+ return _deviceBinding;
9121
+ }
9122
+
9123
+ // The hex device fingerprint for THIS request, or null when it can't be
9124
+ // computed (a request shape the primitive refuses). A null fingerprint is
9125
+ // stored as "no binding" — the read side skips the check rather than
9126
+ // signing the visitor out over a missing signal.
9127
+ function _authDeviceFingerprint(req) {
9128
+ try {
9129
+ var fp = _deviceBindingInstance().fingerprint(req);
9130
+ return fp ? fp.toString("hex") : null;
9131
+ } catch (_e) {
9132
+ return null;
9133
+ }
9134
+ }
9135
+
9088
9136
  // Auth + WebAuthn-challenge cookies carry a vault-sealed JSON envelope.
9089
9137
  // writeSealed/readSealed handle the seal + the on-wire prefix; the
9090
9138
  // caller works in plain objects.
9091
9139
  function _setAuthCookie(req, res, env) {
9092
9140
  var T = b.constants.TIME;
9093
9141
  var secure = _secureForReq(req);
9094
- _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(env), {
9142
+ // Stash the device fingerprint inside the sealed envelope at mint time
9143
+ // so a later read can detect a cookie that has moved to a different
9144
+ // device shape. Additive: an env handed in WITHOUT `fp` (a caller that
9145
+ // doesn't know about binding) just gets it filled here.
9146
+ var sealed = env;
9147
+ if (env && env.fp == null) {
9148
+ var fp = _authDeviceFingerprint(req);
9149
+ if (fp) sealed = Object.assign({}, env, { fp: fp });
9150
+ }
9151
+ _cookieJar().writeSealed(res, _authCookieName(secure), JSON.stringify(sealed), {
9095
9152
  expires: new Date(Date.now() + T.days(14)),
9096
9153
  secure: secure,
9097
9154
  });
@@ -9570,6 +9627,14 @@ var LOGIN_ERROR_MESSAGES = {
9570
9627
  link: "That sign-in link is invalid or has expired. Request a fresh one.",
9571
9628
  };
9572
9629
 
9630
+ // Neutral (non-error) notices surfaced on the sign-in screen. The
9631
+ // device-binding soft sign-out lands here: a reassuring "sign in again"
9632
+ // message, never an alarming error, and it discloses nothing about WHY
9633
+ // (no "your session looked suspicious" — the visitor just signs in again).
9634
+ var LOGIN_NOTICE_MESSAGES = {
9635
+ device: "You've been signed out for your security. Please sign in again.",
9636
+ };
9637
+
9573
9638
  function renderAccountLogin(opts) {
9574
9639
  opts = opts || {};
9575
9640
  var oauthButtons = "";
@@ -9588,6 +9653,12 @@ function renderAccountLogin(opts) {
9588
9653
  var errHtml = (opts.error && LOGIN_ERROR_MESSAGES[opts.error])
9589
9654
  ? "<p class=\"auth-form__message auth-form__message--err\">" + b.template.escapeHtml(LOGIN_ERROR_MESSAGES[opts.error]) + "</p>"
9590
9655
  : "";
9656
+ // Neutral notice (e.g. the device-binding soft sign-out) renders in the
9657
+ // same slot with non-error styling. Error wins if both are somehow set.
9658
+ if (!errHtml && opts.notice && LOGIN_NOTICE_MESSAGES[opts.notice]) {
9659
+ errHtml = "<p class=\"auth-form__message\" role=\"status\">" +
9660
+ b.template.escapeHtml(LOGIN_NOTICE_MESSAGES[opts.notice]) + "</p>";
9661
+ }
9591
9662
  // Render the email-link path INLINE (a working no-JS form), not as a link
9592
9663
  // to a separate page, so both passwordless paths live on one screen.
9593
9664
  var magicHtml = opts.magic_link_enabled ? LOGIN_MAGIC_INLINE : "";
@@ -11910,9 +11981,28 @@ function mount(router, deps) {
11910
11981
  // block) and the account routes inside it, so there's one auth-cookie
11911
11982
  // reader rather than a copy per call site. A missing / malformed /
11912
11983
  // expired cookie returns null — never throws.
11984
+ //
11985
+ // Device-binding (soft): an envelope carrying a stashed `fp` is checked
11986
+ // against THIS request's recomputed fingerprint with a constant-time
11987
+ // compare. On drift the visitor reads as signed-out (return null) and a
11988
+ // one-shot `req._authDeviceDrift` flag is set so the page-level gates
11989
+ // clear the now-stale cookie and bounce to a neutral sign-in — never a
11990
+ // hard 401 mid-page. A pre-binding envelope (no `fp`, minted before this
11991
+ // shipped) passes through unchanged until its natural expiry, so a
11992
+ // deploy never mass-signs-out live sessions.
11913
11993
  function _currentCustomerEnv(req) {
11914
11994
  var env = _readAuthEnv(req);
11915
11995
  if (!env || !env.customer_id || !env.exp || env.exp < Date.now()) return null;
11996
+ if (typeof env.fp === "string" && env.fp.length > 0) {
11997
+ var current = _authDeviceFingerprint(req);
11998
+ // A request whose fingerprint can't be recomputed (current === null)
11999
+ // is NOT treated as drift — absence of signal is not evidence of a
12000
+ // moved cookie. Only a present-but-mismatching fingerprint signs out.
12001
+ if (current !== null && !b.crypto.timingSafeEqual(env.fp, current)) {
12002
+ if (req) req._authDeviceDrift = true;
12003
+ return null;
12004
+ }
12005
+ }
11916
12006
  return env;
11917
12007
  }
11918
12008
 
@@ -14808,6 +14898,9 @@ function mount(router, deps) {
14808
14898
  res.status(303); res.setHeader && res.setHeader("location", "/account");
14809
14899
  return res.end ? res.end() : res.send("");
14810
14900
  }
14901
+ // A drifted cookie that reached the sign-in screen directly still gets
14902
+ // cleared so the next request carries no stale envelope.
14903
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
14811
14904
  var cartCount = await _cartCountForReq(req);
14812
14905
  var url = req.url ? new URL(req.url, "http://localhost") : null;
14813
14906
  // Login captcha is opt-in (CAPTCHA_GATE_LOGIN). The widget + the scoped
@@ -14827,6 +14920,7 @@ function mount(router, deps) {
14827
14920
  apple_enabled: !!deps.oauthApple,
14828
14921
  magic_link_enabled: !!(deps.customerPortal && deps.customerPortalEmail),
14829
14922
  error: url && url.searchParams.get("error"),
14923
+ notice: url && url.searchParams.get("signed_out"),
14830
14924
  captcha_kind: captchaLoginOn ? captchaKind : null,
14831
14925
  captcha_public_key: captchaLoginOn ? captchaPubKey : null,
14832
14926
  }));
@@ -15247,7 +15341,12 @@ function mount(router, deps) {
15247
15341
  throw e;
15248
15342
  }
15249
15343
  if (!auth) {
15250
- res.status(303); res.setHeader && res.setHeader("location", "/account/login");
15344
+ // On device-binding drift, clear the stale cookie + surface a
15345
+ // neutral sign-in notice (matches _accountAuth's soft sign-out).
15346
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
15347
+ res.status(303);
15348
+ res.setHeader && res.setHeader("location",
15349
+ (req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
15251
15350
  return res.end ? res.end() : res.send("");
15252
15351
  }
15253
15352
  var customer = await deps.customers.get(auth.customer_id);
@@ -15505,7 +15604,14 @@ function mount(router, deps) {
15505
15604
  throw e;
15506
15605
  }
15507
15606
  if (!auth) {
15508
- res.status(303); res.setHeader && res.setHeader("location", "/account/login");
15607
+ // Device-binding drift: clear the now-stale cookie and bounce to a
15608
+ // neutral sign-in notice (never a hard 401 mid-page). Any other
15609
+ // not-signed-in case (no cookie, expired) bounces to the plain
15610
+ // login with no notice.
15611
+ if (req && req._authDeviceDrift) _clearAuthCookie(req, res);
15612
+ res.status(303);
15613
+ res.setHeader && res.setHeader("location",
15614
+ (req && req._authDeviceDrift) ? "/account/login?signed_out=device" : "/account/login");
15509
15615
  res.end ? res.end() : res.send("");
15510
15616
  return null;
15511
15617
  }
@@ -19993,7 +20099,22 @@ function mount(router, deps) {
19993
20099
 
19994
20100
  router.post("/unsubscribe", async function (req, res) {
19995
20101
  var body = req.body || {};
19996
- var token = typeof body.token === "string" ? body.token : "";
20102
+ // RFC 8058 one-click: the mail client POSTs to the EXACT URL in the
20103
+ // List-Unsubscribe header — token in the `?token=` query string —
20104
+ // with a `List-Unsubscribe=One-Click` form body and NOTHING else
20105
+ // (no token of its own). So the URL token is authoritative; the
20106
+ // body `token` is only the fallback the on-page confirm form POSTs
20107
+ // from its hidden field. Reading body.token alone (the prior shape)
20108
+ // meant a native one-click POST carried no token -> "not-found" ->
20109
+ // the recipient was never unsubscribed. Parse the token off req.url
20110
+ // (the router only populates req.query when a route declares a query
20111
+ // validator), then fall back to the confirm-form body field.
20112
+ var urlToken = "";
20113
+ try {
20114
+ var u = req.url ? new URL(req.url, "http://localhost") : null;
20115
+ if (u) urlToken = u.searchParams.get("token") || "";
20116
+ } catch (_eUrl) { urlToken = ""; }
20117
+ var token = urlToken || (typeof body.token === "string" ? body.token : "");
19997
20118
  var cartCount = 0;
19998
20119
  try { cartCount = await _cartCountForReq(req); } catch (_e) { /* drop-silent — empty cart fallback */ }
19999
20120
  var outcome;
@@ -20001,6 +20122,9 @@ function mount(router, deps) {
20001
20122
  // `consumeUnsubscribeToken` returns a structured result (it does
20002
20123
  // not throw on a bad/missing token — it returns `{ ok:false,
20003
20124
  // error:"not-found" }`). An empty token is handled the same way.
20125
+ // It is single-use, so the one-click POST and a later confirm-form
20126
+ // POST of the same token are idempotent (the second reads
20127
+ // "already" — still a success page, no error).
20004
20128
  var result = await deps.newsletter.consumeUnsubscribeToken(token);
20005
20129
  outcome = _unsubscribeOutcome(result);
20006
20130
  } catch (e) {
@@ -435,11 +435,18 @@ function create(opts) {
435
435
  return await _refresh(id);
436
436
  },
437
437
 
438
- // Append a message to the ticket thread. Operator replies flip
439
- // `new -> in_progress` and stamp `first_response_at` if unset.
440
- // Operator replies also bump `last_action_at`; customer replies
441
- // do NOT advance the SLA clock (the clock measures operator
442
- // responsiveness, not customer chatter).
438
+ // Append a message to the ticket thread.
439
+ // * A CUSTOMER-VISIBLE operator reply (internal=false) flips
440
+ // `new -> in_progress`, stamps `first_response_at` if unset, and
441
+ // bumps `last_action_at`. An INTERNAL operator note (internal=true)
442
+ // is operator-to-operator: append-only, no status flip, no
443
+ // first-response stamp (the customer never sees it, so it can't
444
+ // satisfy the response SLA).
445
+ // * A customer reply doesn't advance `last_action_at` on the
446
+ // operator's-clock states, but DOES requeue the ticket: a reply to
447
+ // `waiting_customer` returns it to `in_progress`, and a reply to a
448
+ // `resolved` ticket reopens it (`resolved -> reopened`, with a
449
+ // fresh SLA clock) so the operator's pushback never goes unseen.
443
450
  reply: async function (input) {
444
451
  if (!input || typeof input !== "object") {
445
452
  throw new TypeError("supportTickets.reply: input object required");
@@ -479,30 +486,57 @@ function create(opts) {
479
486
  );
480
487
 
481
488
  if (author === "operator") {
482
- // Operator reply flips `new -> in_progress` automatically.
483
- // Other states keep their status; SLA timer + first-response
484
- // stamp still advance.
485
- var newStatus = ticket.status;
486
- if (ticket.status === "new") {
487
- newStatus = "in_progress";
488
- await _writeStatusHistory(ticketId, ticket.status, newStatus, "operator-reply", ts);
489
+ // Only a CUSTOMER-VISIBLE operator reply counts as a first
490
+ // response or advances the workflow. An INTERNAL note (internal=1)
491
+ // is operator-to-operator — the customer never sees it, so
492
+ // stamping first_response_at off it would satisfy the SLA with
493
+ // content that never reached the person waiting. An internal note
494
+ // is append-only here: no status flip, no first_response_at, no
495
+ // last_action_at bump (it isn't operator responsiveness TO the
496
+ // customer). The message row was already inserted above.
497
+ if (!internal) {
498
+ // A customer-visible operator reply flips `new -> in_progress`
499
+ // automatically; other states keep their status. The
500
+ // first-response stamp + SLA timer advance only on this path.
501
+ var newStatus = ticket.status;
502
+ if (ticket.status === "new") {
503
+ newStatus = "in_progress";
504
+ await _writeStatusHistory(ticketId, ticket.status, newStatus, "operator-reply", ts);
505
+ }
506
+ var firstResp = ticket.first_response_at == null ? ts : ticket.first_response_at;
507
+ await query(
508
+ "UPDATE support_tickets SET status = ?1, first_response_at = ?2, last_action_at = ?3 WHERE id = ?4",
509
+ [newStatus, firstResp, ts, ticketId],
510
+ );
489
511
  }
490
- var firstResp = ticket.first_response_at == null ? ts : ticket.first_response_at;
491
- await query(
492
- "UPDATE support_tickets SET status = ?1, first_response_at = ?2, last_action_at = ?3 WHERE id = ?4",
493
- [newStatus, firstResp, ts, ticketId],
494
- );
495
512
  } else if (author === "customer") {
496
- // Customer replies don't advance last_action_at that
497
- // would mask SLA breach. They DO move the ticket out of
498
- // `waiting_customer` back into `in_progress` because the
499
- // operator now owes the next move.
513
+ // Customer replies don't advance last_action_at on the
514
+ // operator's-clock states — that would mask an SLA breach. They DO
515
+ // move the ticket back into a queue the operator owes the next
516
+ // move on:
517
+ // * waiting_customer -> in_progress (the customer answered)
518
+ // * resolved -> reopened (the customer pushed back; an
519
+ // FSM-legal edge, resolved ->
520
+ // reopened). Without this, a
521
+ // reply to a resolved ticket
522
+ // was silently dropped from
523
+ // every operator queue.
524
+ // last_action_at bumps ONLY on the resolved->reopened move so the
525
+ // reopened ticket surfaces with a fresh SLA clock (the operator now
526
+ // owes a response); the waiting_customer->in_progress move keeps the
527
+ // existing clock (the operator's responsiveness window never paused).
500
528
  if (ticket.status === "waiting_customer") {
501
529
  await _writeStatusHistory(ticketId, ticket.status, "in_progress", "customer-reply", ts);
502
530
  await query(
503
531
  "UPDATE support_tickets SET status = 'in_progress' WHERE id = ?1",
504
532
  [ticketId],
505
533
  );
534
+ } else if (ticket.status === "resolved") {
535
+ await _writeStatusHistory(ticketId, ticket.status, "reopened", "customer-reply", ts);
536
+ await query(
537
+ "UPDATE support_tickets SET status = 'reopened', last_action_at = ?1 WHERE id = ?2",
538
+ [ts, ticketId],
539
+ );
506
540
  }
507
541
  }
508
542
  // system author: append-only; no state mutation.
@@ -596,56 +630,82 @@ function create(opts) {
596
630
  return await _refresh(ticketId);
597
631
  },
598
632
 
633
+ // Add a tag. The mutation is a SINGLE atomic JSON1 statement —
634
+ // `json_insert(..., '$[#]', ?)` appends only when the tag isn't
635
+ // already present (the json_each NOT-EXISTS guard) AND the ticket is
636
+ // under the cap (json_array_length guard). A prior read-modify-write
637
+ // (decode -> push in JS -> write the whole array back) lost one of two
638
+ // concurrent addTag writes: both read the same array, both appended
639
+ // their own tag, the last write clobbered the other. Doing the append
640
+ // inside SQLite removes the read-then-write window entirely. The read
641
+ // that remains exists ONLY to classify a zero-row update (idempotent
642
+ // dup vs cap-exceeded error) — it never feeds the write.
599
643
  addTag: async function (input) {
600
644
  if (!input || typeof input !== "object") {
601
645
  throw new TypeError("supportTickets.addTag: input object required");
602
646
  }
603
647
  var ticketId = _uuid(input.ticket_id, "ticket_id");
604
648
  var tag = _singleTag(input.tag);
605
- var ticket = await _getRaw(ticketId);
606
- if (!ticket) {
607
- var err = new Error("supportTickets.addTag: ticket " + ticketId + " not found");
608
- err.code = "SUPPORT_TICKET_NOT_FOUND";
609
- throw err;
610
- }
611
- var tags;
612
- try { tags = JSON.parse(ticket.tags_json || "[]"); }
613
- catch (_e) { tags = []; }
614
- if (tags.indexOf(tag) === -1) {
615
- if (tags.length >= MAX_TAG_COUNT) {
649
+ var res = await query(
650
+ "UPDATE support_tickets " +
651
+ "SET tags_json = json_insert(COALESCE(tags_json, '[]'), '$[#]', ?1) " +
652
+ "WHERE id = ?2 " +
653
+ " AND (SELECT COUNT(*) FROM json_each(COALESCE(tags_json, '[]')) WHERE value = ?1) = 0 " +
654
+ " AND json_array_length(COALESCE(tags_json, '[]')) < ?3",
655
+ [tag, ticketId, MAX_TAG_COUNT],
656
+ );
657
+ if (Number((res && res.rowCount) || 0) === 0) {
658
+ // The atomic UPDATE matched no row. Read once to classify: a
659
+ // missing ticket is a hard error; an already-present tag is an
660
+ // idempotent no-op; otherwise the ticket is at the tag cap.
661
+ var ticket = await _getRaw(ticketId);
662
+ if (!ticket) {
663
+ var err = new Error("supportTickets.addTag: ticket " + ticketId + " not found");
664
+ err.code = "SUPPORT_TICKET_NOT_FOUND";
665
+ throw err;
666
+ }
667
+ var tags;
668
+ try { tags = JSON.parse(ticket.tags_json || "[]"); }
669
+ catch (_e) { tags = []; }
670
+ if (tags.indexOf(tag) === -1 && tags.length >= MAX_TAG_COUNT) {
616
671
  throw new TypeError("supportTickets.addTag: ticket already has " + MAX_TAG_COUNT + " tags");
617
672
  }
618
- tags.push(tag);
619
- await query(
620
- "UPDATE support_tickets SET tags_json = ?1 WHERE id = ?2",
621
- [JSON.stringify(tags), ticketId],
622
- );
673
+ // else: tag already present — idempotent success, nothing to do.
623
674
  }
624
675
  return await _refresh(ticketId);
625
676
  },
626
677
 
678
+ // Remove a tag. Single atomic JSON1 statement — rebuild the array
679
+ // from `json_each` minus the target value. Same lost-update hazard as
680
+ // addTag if done read-modify-write; doing it in SQLite removes the
681
+ // window. Idempotent: a tag that isn't present matches no row in the
682
+ // EXISTS guard and the update is a no-op. A missing ticket is read
683
+ // back only to raise the not-found error (the update wrote nothing).
627
684
  removeTag: async function (input) {
628
685
  if (!input || typeof input !== "object") {
629
686
  throw new TypeError("supportTickets.removeTag: input object required");
630
687
  }
631
688
  var ticketId = _uuid(input.ticket_id, "ticket_id");
632
689
  var tag = _singleTag(input.tag);
633
- var ticket = await _getRaw(ticketId);
634
- if (!ticket) {
635
- var err = new Error("supportTickets.removeTag: ticket " + ticketId + " not found");
636
- err.code = "SUPPORT_TICKET_NOT_FOUND";
637
- throw err;
638
- }
639
- var tags;
640
- try { tags = JSON.parse(ticket.tags_json || "[]"); }
641
- catch (_e) { tags = []; }
642
- var idx = tags.indexOf(tag);
643
- if (idx !== -1) {
644
- tags.splice(idx, 1);
645
- await query(
646
- "UPDATE support_tickets SET tags_json = ?1 WHERE id = ?2",
647
- [JSON.stringify(tags), ticketId],
648
- );
690
+ var res = await query(
691
+ "UPDATE support_tickets " +
692
+ "SET tags_json = (" +
693
+ " SELECT COALESCE(json_group_array(value), '[]') " +
694
+ " FROM json_each(COALESCE(tags_json, '[]')) WHERE value <> ?1" +
695
+ ") " +
696
+ "WHERE id = ?2 " +
697
+ " AND EXISTS (SELECT 1 FROM json_each(COALESCE(tags_json, '[]')) WHERE value = ?1)",
698
+ [tag, ticketId],
699
+ );
700
+ if (Number((res && res.rowCount) || 0) === 0) {
701
+ // No row changed — either the ticket is missing (hard error) or
702
+ // the tag simply wasn't present (idempotent no-op).
703
+ var ticket = await _getRaw(ticketId);
704
+ if (!ticket) {
705
+ var err = new Error("supportTickets.removeTag: ticket " + ticketId + " not found");
706
+ err.code = "SUPPORT_TICKET_NOT_FOUND";
707
+ throw err;
708
+ }
649
709
  }
650
710
  return await _refresh(ticketId);
651
711
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.21",
3
+ "version": "0.4.23",
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": {