@blamejs/blamejs-shop 0.4.13 → 0.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/lib/admin.js +640 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +51 -1
- package/lib/inventory-locations.js +103 -66
- package/lib/security-middleware.js +28 -1
- package/lib/storefront.js +112 -48
- package/lib/webhooks.js +4 -2
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
package/lib/catalog.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - `variants` — create, get, listForProduct, update, delete
|
|
12
12
|
* - `prices` — set (versioned), current, history
|
|
13
13
|
* - `inventory` — create, get, list, hold, decrement, release,
|
|
14
|
-
* restock, setThreshold, checkLowStock
|
|
14
|
+
* restock, adjustOnHand, setThreshold, checkLowStock
|
|
15
15
|
* - `media` — attach, get, listForProduct, listForVariant, delete,
|
|
16
16
|
* reorder, setPrimary
|
|
17
17
|
*
|
|
@@ -706,6 +706,56 @@ function _inventoryModule(query, opts) {
|
|
|
706
706
|
return await this.get(sku);
|
|
707
707
|
},
|
|
708
708
|
|
|
709
|
+
// Signed-delta adjustment of the storefront aggregate, fired by the
|
|
710
|
+
// inventory-ops back-office when a per-location move changes the
|
|
711
|
+
// total a single-bucket storefront sells against (a receive credits
|
|
712
|
+
// the aggregate, a write-off / shrinkage debits it). `restock` only
|
|
713
|
+
// grows on-hand and `decrement` only consumes a matching hold;
|
|
714
|
+
// neither expresses "the warehouse count just dropped by 5 because a
|
|
715
|
+
// pallet was damaged." A positive delta is an unconditional credit.
|
|
716
|
+
// A negative delta is an atomic conditional UPDATE — the
|
|
717
|
+
// `stock_on_hand - stock_held >= need` guard refuses (zero rows →
|
|
718
|
+
// `{ adjusted: false }`) when the debit would eat into stock that is
|
|
719
|
+
// already held for in-flight orders, so a write-off can never
|
|
720
|
+
// oversell a paid-but-unfulfilled line. Returns `{ adjusted, sku,
|
|
721
|
+
// delta }`; `null` when the SKU has no inventory row at all
|
|
722
|
+
// (un-tracked SKUs are unlimited everywhere in the storefront, so an
|
|
723
|
+
// aggregate adjustment simply doesn't apply). Fires the low-stock
|
|
724
|
+
// observer on success so a debit that crosses the threshold alerts.
|
|
725
|
+
adjustOnHand: async function (sku, delta) {
|
|
726
|
+
_sku(sku);
|
|
727
|
+
if (!Number.isInteger(delta) || delta === 0) {
|
|
728
|
+
throw new TypeError("catalog.inventory.adjustOnHand: delta must be a non-zero integer");
|
|
729
|
+
}
|
|
730
|
+
var ts = _now();
|
|
731
|
+
if (delta > 0) {
|
|
732
|
+
var rUp = await query(
|
|
733
|
+
"UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
|
|
734
|
+
[delta, ts, sku],
|
|
735
|
+
);
|
|
736
|
+
if (rUp.rowCount === 0) return null;
|
|
737
|
+
await _afterMutation(sku);
|
|
738
|
+
return { adjusted: true, sku: sku, delta: delta };
|
|
739
|
+
}
|
|
740
|
+
var need = -delta;
|
|
741
|
+
var rDown = await query(
|
|
742
|
+
"UPDATE inventory SET stock_on_hand = stock_on_hand - ?1, updated_at = ?2 " +
|
|
743
|
+
"WHERE sku = ?3 AND (stock_on_hand - stock_held) >= ?1",
|
|
744
|
+
[need, ts, sku],
|
|
745
|
+
);
|
|
746
|
+
if (rDown.rowCount === 1) {
|
|
747
|
+
await _afterMutation(sku);
|
|
748
|
+
return { adjusted: true, sku: sku, delta: delta };
|
|
749
|
+
}
|
|
750
|
+
// Zero rows: either no inventory row (un-tracked SKU) or the debit
|
|
751
|
+
// would eat into held stock. Distinguish so the caller can let an
|
|
752
|
+
// un-tracked SKU through while refusing a tracked-but-insufficient
|
|
753
|
+
// one.
|
|
754
|
+
var existing = await this.get(sku);
|
|
755
|
+
if (!existing) return null;
|
|
756
|
+
return { adjusted: false, sku: sku, delta: delta };
|
|
757
|
+
},
|
|
758
|
+
|
|
709
759
|
release: async function (sku, qty) {
|
|
710
760
|
_sku(sku);
|
|
711
761
|
_positiveInt(qty, "qty");
|
|
@@ -32,12 +32,20 @@
|
|
|
32
32
|
* duplicate code, invalid type, etc).
|
|
33
33
|
* listLocations / getLocation / updateLocation / deactivateLocation
|
|
34
34
|
* — operator CRUD over the location set.
|
|
35
|
-
* setStock — absolute set (overwrite)
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
35
|
+
* setStock — absolute set (overwrite) via an
|
|
36
|
+
* atomic upsert.
|
|
37
|
+
* adjustStock — relative delta with audit row. Debits
|
|
38
|
+
* run as a single conditional UPDATE
|
|
39
|
+
* (`quantity >= need`); credits as an
|
|
40
|
+
* upsert. No read-then-write window — a
|
|
41
|
+
* debit that would drive the row below
|
|
42
|
+
* zero matches no rows and refuses.
|
|
43
|
+
* transferStock — moves qty from one location to
|
|
44
|
+
* another; the source debit is the same
|
|
45
|
+
* conditional UPDATE guard, so a transfer
|
|
46
|
+
* racing a checkout debit (or another
|
|
47
|
+
* transfer) for the last unit can't
|
|
48
|
+
* oversell — the loser refuses (no money
|
|
41
49
|
* created, no row half-committed).
|
|
42
50
|
* stockForSku — `{ total, by_location: [...] }`.
|
|
43
51
|
* totalForSku — sum across every location.
|
|
@@ -521,20 +529,21 @@ function create(opts) {
|
|
|
521
529
|
throw new TypeError("inventory-locations.setStock: location_code " +
|
|
522
530
|
JSON.stringify(input.location_code) + " not found");
|
|
523
531
|
}
|
|
532
|
+
// Absolute set is last-writer-wins by contract — the operator is
|
|
533
|
+
// declaring the authoritative count, not applying a relative
|
|
534
|
+
// change. The prior value is read only to record the signed
|
|
535
|
+
// delta on the audit row; the upsert itself is a single atomic
|
|
536
|
+
// statement so a first-touch insert and an overwrite never
|
|
537
|
+
// collide on the composite PK.
|
|
524
538
|
var prev = await _getStockRow(input.sku, input.location_code);
|
|
525
539
|
var prevQty = prev ? prev.quantity : 0;
|
|
526
540
|
var ts = _now();
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
await query(
|
|
534
|
-
"INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
|
|
535
|
-
[input.sku, input.location_code, input.quantity, ts],
|
|
536
|
-
);
|
|
537
|
-
}
|
|
541
|
+
await query(
|
|
542
|
+
"INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
|
|
543
|
+
"VALUES (?1, ?2, ?3, ?4) " +
|
|
544
|
+
"ON CONFLICT(sku, location_code) DO UPDATE SET quantity = ?3, updated_at = ?4",
|
|
545
|
+
[input.sku, input.location_code, input.quantity, ts],
|
|
546
|
+
);
|
|
538
547
|
var delta = input.quantity - prevQty;
|
|
539
548
|
await _audit(input.sku, input.location_code, delta, _reason(input.reason));
|
|
540
549
|
return { sku: input.sku, location_code: input.location_code, quantity: input.quantity, delta: delta };
|
|
@@ -545,6 +554,18 @@ function create(opts) {
|
|
|
545
554
|
// row below zero refuses the whole operation. Inserts a row
|
|
546
555
|
// at quantity 0 + delta if the (sku, location_code) pair has
|
|
547
556
|
// never been touched before (and delta is positive).
|
|
557
|
+
//
|
|
558
|
+
// Concurrency: the negative-delta path is a single atomic
|
|
559
|
+
// conditional UPDATE — `SET quantity = quantity + delta WHERE
|
|
560
|
+
// quantity + delta >= 0` — mirroring the catalog hold/decrement
|
|
561
|
+
// guards. D1 evaluates the predicate and applies the write inside
|
|
562
|
+
// one statement, so two racing debits against the last unit can't
|
|
563
|
+
// both succeed: the second racer's predicate sees the first's
|
|
564
|
+
// decrement and matches zero rows. The positive-delta path is an
|
|
565
|
+
// upsert (ON CONFLICT DO UPDATE) so a credit and a fresh-row
|
|
566
|
+
// insert never collide. Both paths re-read the post-write quantity
|
|
567
|
+
// for the audit row + return value rather than trusting a stale
|
|
568
|
+
// pre-read.
|
|
548
569
|
adjustStock: async function (input) {
|
|
549
570
|
if (!input || typeof input !== "object") {
|
|
550
571
|
throw new TypeError("inventory-locations.adjustStock: input object required");
|
|
@@ -560,28 +581,42 @@ function create(opts) {
|
|
|
560
581
|
throw new TypeError("inventory-locations.adjustStock: location_code " +
|
|
561
582
|
JSON.stringify(input.location_code) + " not found");
|
|
562
583
|
}
|
|
563
|
-
var prev = await _getStockRow(input.sku, input.location_code);
|
|
564
|
-
var prevQty = prev ? prev.quantity : 0;
|
|
565
|
-
var next = prevQty + input.delta;
|
|
566
|
-
if (next < 0) {
|
|
567
|
-
throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
|
|
568
|
-
" would drive stock below zero (current=" + prevQty + ", sku=" + input.sku +
|
|
569
|
-
", location=" + input.location_code + ")");
|
|
570
|
-
}
|
|
571
584
|
var ts = _now();
|
|
572
|
-
if (
|
|
585
|
+
if (input.delta > 0) {
|
|
586
|
+
// Credit: upsert in one atomic statement. A concurrent credit
|
|
587
|
+
// + first-touch insert serialize on the composite PK.
|
|
573
588
|
await query(
|
|
574
|
-
"
|
|
575
|
-
|
|
589
|
+
"INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
|
|
590
|
+
"VALUES (?1, ?2, ?3, ?4) " +
|
|
591
|
+
"ON CONFLICT(sku, location_code) DO UPDATE SET " +
|
|
592
|
+
"quantity = quantity + ?3, updated_at = ?4",
|
|
593
|
+
[input.sku, input.location_code, input.delta, ts],
|
|
576
594
|
);
|
|
577
595
|
} else {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
596
|
+
// Debit: atomic conditional UPDATE. The WHERE predicate is the
|
|
597
|
+
// serialization point — it refuses the write (zero rows) when
|
|
598
|
+
// the shelf lacks enough to cover the debit, so the row never
|
|
599
|
+
// goes negative even under concurrent debits. A SKU with no
|
|
600
|
+
// row at this location can't be debited (no negative
|
|
601
|
+
// first-touch); zero rows there is the insufficient case too.
|
|
602
|
+
var need = -input.delta;
|
|
603
|
+
var r = await query(
|
|
604
|
+
"UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
|
|
605
|
+
"WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?5",
|
|
606
|
+
[input.delta, ts, input.sku, input.location_code, need],
|
|
581
607
|
);
|
|
608
|
+
if (r.rowCount === 0) {
|
|
609
|
+
var cur = await _getStockRow(input.sku, input.location_code);
|
|
610
|
+
var have = cur ? cur.quantity : 0;
|
|
611
|
+
throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
|
|
612
|
+
" would drive stock below zero (current=" + have + ", sku=" + input.sku +
|
|
613
|
+
", location=" + input.location_code + ")");
|
|
614
|
+
}
|
|
582
615
|
}
|
|
616
|
+
var after = await _getStockRow(input.sku, input.location_code);
|
|
617
|
+
var nextQty = after ? after.quantity : 0;
|
|
583
618
|
await _audit(input.sku, input.location_code, input.delta, _reason(input.reason));
|
|
584
|
-
return { sku: input.sku, location_code: input.location_code, quantity:
|
|
619
|
+
return { sku: input.sku, location_code: input.location_code, quantity: nextQty, delta: input.delta };
|
|
585
620
|
},
|
|
586
621
|
|
|
587
622
|
// Atomic two-row move. Reads the source quantity; if
|
|
@@ -612,44 +647,44 @@ function create(opts) {
|
|
|
612
647
|
JSON.stringify(input.to_location) + " not found");
|
|
613
648
|
}
|
|
614
649
|
|
|
615
|
-
var fromRow = await _getStockRow(input.sku, input.from_location);
|
|
616
|
-
var fromQty = fromRow ? fromRow.quantity : 0;
|
|
617
|
-
if (fromQty < input.quantity) {
|
|
618
|
-
throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
|
|
619
|
-
input.from_location + " (have " + fromQty + ", need " + input.quantity + ")");
|
|
620
|
-
}
|
|
621
|
-
var toRow = await _getStockRow(input.sku, input.to_location);
|
|
622
|
-
var toQty = toRow ? toRow.quantity : 0;
|
|
623
|
-
|
|
624
650
|
var ts = _now();
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
651
|
+
// Debit the source with a single atomic conditional UPDATE. The
|
|
652
|
+
// `quantity >= need` predicate is the serialization point: two
|
|
653
|
+
// racing transfers (or a transfer racing a checkout debit)
|
|
654
|
+
// against the last unit can't both succeed — the second sees the
|
|
655
|
+
// first's decrement and matches zero rows, which we surface as
|
|
656
|
+
// the insufficient-stock refusal. No read-then-write window.
|
|
657
|
+
var srcDebit = await query(
|
|
658
|
+
"UPDATE inventory_stock SET quantity = quantity - ?1, updated_at = ?2 " +
|
|
659
|
+
"WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?1",
|
|
660
|
+
[input.quantity, ts, input.sku, input.from_location],
|
|
631
661
|
);
|
|
662
|
+
if (srcDebit.rowCount === 0) {
|
|
663
|
+
var fromRow = await _getStockRow(input.sku, input.from_location);
|
|
664
|
+
var fromHave = fromRow ? fromRow.quantity : 0;
|
|
665
|
+
throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
|
|
666
|
+
input.from_location + " (have " + fromHave + ", need " + input.quantity + ")");
|
|
667
|
+
}
|
|
632
668
|
try {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
)
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
);
|
|
643
|
-
}
|
|
669
|
+
// Credit the destination via an atomic upsert. A concurrent
|
|
670
|
+
// credit + first-touch insert serialize on the composite PK.
|
|
671
|
+
await query(
|
|
672
|
+
"INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
|
|
673
|
+
"VALUES (?1, ?2, ?3, ?4) " +
|
|
674
|
+
"ON CONFLICT(sku, location_code) DO UPDATE SET " +
|
|
675
|
+
"quantity = quantity + ?3, updated_at = ?4",
|
|
676
|
+
[input.sku, input.to_location, input.quantity, ts],
|
|
677
|
+
);
|
|
644
678
|
} catch (e) {
|
|
645
|
-
// Best-effort compensating restore on the source so the
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
679
|
+
// Best-effort compensating restore on the source so the pair
|
|
680
|
+
// stays balanced. The restore is itself an atomic increment
|
|
681
|
+
// (no read-then-write), so it can't clobber a concurrent
|
|
682
|
+
// mutation that landed between the debit and this catch.
|
|
649
683
|
try {
|
|
650
684
|
await query(
|
|
651
|
-
"UPDATE inventory_stock SET quantity = ?1, updated_at = ?2
|
|
652
|
-
|
|
685
|
+
"UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
|
|
686
|
+
"WHERE sku = ?3 AND location_code = ?4",
|
|
687
|
+
[input.quantity, _now(), input.sku, input.from_location],
|
|
653
688
|
);
|
|
654
689
|
} catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
|
|
655
690
|
throw e;
|
|
@@ -659,13 +694,15 @@ function create(opts) {
|
|
|
659
694
|
await _audit(input.sku, input.from_location, -input.quantity, reason);
|
|
660
695
|
await _audit(input.sku, input.to_location, input.quantity, reason);
|
|
661
696
|
|
|
697
|
+
var fromAfterRow = await _getStockRow(input.sku, input.from_location);
|
|
698
|
+
var toAfterRow = await _getStockRow(input.sku, input.to_location);
|
|
662
699
|
return {
|
|
663
700
|
sku: input.sku,
|
|
664
701
|
from_location: input.from_location,
|
|
665
702
|
to_location: input.to_location,
|
|
666
703
|
quantity: input.quantity,
|
|
667
|
-
from_after:
|
|
668
|
-
to_after:
|
|
704
|
+
from_after: fromAfterRow ? fromAfterRow.quantity : 0,
|
|
705
|
+
to_after: toAfterRow ? toAfterRow.quantity : 0,
|
|
669
706
|
};
|
|
670
707
|
},
|
|
671
708
|
|
|
@@ -561,10 +561,21 @@ function globalRateLimitOpts() {
|
|
|
561
561
|
* are machine-to-machine and authenticate with the shared
|
|
562
562
|
* D1_BRIDGE_SECRET, so on those paths the secret gate — not a browser
|
|
563
563
|
* fingerprint — is the deciding check (see INTERNAL_BRIDGE_PATHS).
|
|
564
|
+
*
|
|
565
|
+
* `/admin` is skipped HERE but re-guarded in tag mode inside
|
|
566
|
+
* `mountRouteGuards`: every admin route is gated on the timing-safe
|
|
567
|
+
* bearer-key check, and the documented way to drive the admin JSON
|
|
568
|
+
* surface is curl — whose User-Agent is on the vendored deny list, and
|
|
569
|
+
* the UA check fires before auth is ever consulted. In block mode the
|
|
570
|
+
* README / onboarding curl examples answer 403 "Forbidden" instead of
|
|
571
|
+
* reaching the 401/200 bearer gate; in tag mode automation is audited
|
|
572
|
+
* (`system.botguard.tag` + `req.suspectedBot`) while the bearer key
|
|
573
|
+
* stays the deciding check. The regex matches `/admin` and
|
|
574
|
+
* `/admin/...` only — not other `/admin…`-prefixed names.
|
|
564
575
|
*/
|
|
565
576
|
function botGuardOpts() {
|
|
566
577
|
return {
|
|
567
|
-
skipPaths: INTERNAL_BRIDGE_PATHS.slice(),
|
|
578
|
+
skipPaths: INTERNAL_BRIDGE_PATHS.slice().concat([/^\/admin(\/|$)/]),
|
|
568
579
|
};
|
|
569
580
|
}
|
|
570
581
|
|
|
@@ -584,6 +595,22 @@ function _hasPrefix(pathname, prefixes) {
|
|
|
584
595
|
* @param r the blamejs Router passed to createApp's routes(r) callback.
|
|
585
596
|
*/
|
|
586
597
|
function mountRouteGuards(r) {
|
|
598
|
+
// --- bot-guard, tag mode, /admin only -------------------------------
|
|
599
|
+
//
|
|
600
|
+
// The app-level bot-guard (block mode) skips /admin — see
|
|
601
|
+
// botGuardOpts: curl is the documented admin client and the UA
|
|
602
|
+
// deny-list check fires before the bearer gate, so block mode turns
|
|
603
|
+
// every documented example into a 403. The surface still wants the
|
|
604
|
+
// signal, though: this tag-mode instance audits automation
|
|
605
|
+
// (system.botguard.tag + req.suspectedBot) without refusing it, and
|
|
606
|
+
// the timing-safe bearer key stays the deciding check.
|
|
607
|
+
var adminBotTag = b.middleware.botGuard({ mode: "tag" });
|
|
608
|
+
r.use(function adminBotTagGuard(req, res, next) {
|
|
609
|
+
var pathname = req.pathname || req.url || "/";
|
|
610
|
+
if (!/^\/admin(\/|$)/.test(pathname)) return next();
|
|
611
|
+
return adminBotTag(req, res, next);
|
|
612
|
+
});
|
|
613
|
+
|
|
587
614
|
// --- CSRF: double-submit token on authenticated state-changing POSTs ---
|
|
588
615
|
//
|
|
589
616
|
// The vendored createApp enforces CSRF by default; the entry points pass
|
package/lib/storefront.js
CHANGED
|
@@ -8085,9 +8085,18 @@ function _cartGiftBlock(opts) {
|
|
|
8085
8085
|
(w.wrap_sku === current ? " selected" : "") + ">" +
|
|
8086
8086
|
esc(String(w.title)) + " (" + esc(feeStr) + ")</option>";
|
|
8087
8087
|
}).join("");
|
|
8088
|
+
// Over-limit (or any rejected /cart/gift apply) renders an inline error
|
|
8089
|
+
// banner. The message carries the operator-authored wrap title, so it is
|
|
8090
|
+
// escaped at the sink — the cap value the route interpolates is a plain
|
|
8091
|
+
// integer, but escaping the whole string is the safe default. `role=alert`
|
|
8092
|
+
// so a screen reader announces the rejection.
|
|
8093
|
+
var giftError = (typeof opts.gift_error === "string" && opts.gift_error)
|
|
8094
|
+
? "<p class=\"cart-gift__error\" role=\"alert\">" + esc(opts.gift_error) + "</p>"
|
|
8095
|
+
: "";
|
|
8088
8096
|
return "<section class=\"cart-gift\">" +
|
|
8089
|
-
"<details class=\"cart-gift__details\"" + (current ? " open" : "") + ">" +
|
|
8097
|
+
"<details class=\"cart-gift__details\"" + (current || giftError ? " open" : "") + ">" +
|
|
8090
8098
|
"<summary class=\"cart-gift__summary\">Add a gift wrap</summary>" +
|
|
8099
|
+
giftError +
|
|
8091
8100
|
"<form method=\"post\" action=\"/cart/gift\" class=\"cart-gift__form\">" +
|
|
8092
8101
|
"<label class=\"form-field\"><span>Gift wrap</span>" +
|
|
8093
8102
|
"<select name=\"wrap_sku\">" + options + "</select>" +
|
|
@@ -10023,11 +10032,13 @@ function renderAddPaymentMethod(opts) {
|
|
|
10023
10032
|
// ---- profile edit ------------------------------------------------------
|
|
10024
10033
|
//
|
|
10025
10034
|
// Display-name edit for the signed-in customer. Email is stored hash-only
|
|
10026
|
-
// and is the OAuth account-linking
|
|
10027
|
-
//
|
|
10028
|
-
//
|
|
10029
|
-
//
|
|
10030
|
-
//
|
|
10035
|
+
// (a one-way hash, never the plaintext) and is the OAuth account-linking
|
|
10036
|
+
// key, so it is shown read-only and genuinely cannot be changed — there is
|
|
10037
|
+
// no readable address to edit. The field's hint tells the customer what to
|
|
10038
|
+
// do instead (sign in with the registered address; create a new account to
|
|
10039
|
+
// use a different one; contact support to move order history). The form is
|
|
10040
|
+
// a plain server-rendered POST with a PRG `?ok=updated` success notice,
|
|
10041
|
+
// matching the addresses pattern.
|
|
10031
10042
|
function renderProfile(opts) {
|
|
10032
10043
|
opts = opts || {};
|
|
10033
10044
|
var esc = b.template.escapeHtml;
|
|
@@ -10054,7 +10065,7 @@ function renderProfile(opts) {
|
|
|
10054
10065
|
"<div class=\"form-row\"><label class=\"form-field\"><span class=\"form-field__label\">Email</span>" +
|
|
10055
10066
|
"<input type=\"text\" value=\"Hidden for privacy — stored as a one-way hash\" disabled aria-describedby=\"email-note\"></label></div>" +
|
|
10056
10067
|
"<p id=\"email-note\" class=\"form-field__hint\">Your email address is never stored in readable form, so it can't be changed or shown here. " +
|
|
10057
|
-
"Sign in with the address you registered.</p>" +
|
|
10068
|
+
"Sign in with the address you registered. To use a different address, create a new account with it, or contact support if you need help moving your order history.</p>" +
|
|
10058
10069
|
"<div class=\"form-actions\"><button type=\"submit\" class=\"btn-primary\">Save changes</button> " +
|
|
10059
10070
|
"<a class=\"btn-ghost\" href=\"/account\">Cancel</a></div>" +
|
|
10060
10071
|
"</form>" +
|
|
@@ -13088,38 +13099,27 @@ function mount(router, deps) {
|
|
|
13088
13099
|
return res.end ? res.end(payload) : res.send(payload);
|
|
13089
13100
|
});
|
|
13090
13101
|
|
|
13091
|
-
|
|
13102
|
+
// Assemble the full cart-page render and emit it at `status`. Shared by
|
|
13103
|
+
// GET /cart and by POST /cart/gift's over-limit rejection (a 4xx that must
|
|
13104
|
+
// still show the customer their cart, with a gift error banner), so the two
|
|
13105
|
+
// surfaces never drift. `extraOpts` overlays render flags the caller knows
|
|
13106
|
+
// (the post-add banner, the coupon PRG notices, a gift_error message).
|
|
13107
|
+
async function _renderCartResponse(req, res, status, extraOpts) {
|
|
13108
|
+
extraOpts = extraOpts || {};
|
|
13092
13109
|
var ccy = await _currencyForReq(req);
|
|
13093
13110
|
var sid = _readSidCookie(req);
|
|
13094
|
-
// `?added=1` after a POST /cart/lines redirect — drives the
|
|
13095
|
-
// "Added to cart" status banner. Read from the parsed query when the
|
|
13096
|
-
// router populated it, else from the raw URL.
|
|
13097
|
-
var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
13098
|
-
var added = (req.query && req.query.added === "1") ||
|
|
13099
|
-
(cartUrl && cartUrl.searchParams.get("added") === "1") || false;
|
|
13100
|
-
// Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
|
|
13101
|
-
// applied / removed / err so the cart shows an inline notice. `?code_err`
|
|
13102
|
-
// carries no detail beyond "couldn't apply" — a uniform message, no
|
|
13103
|
-
// code-existence oracle.
|
|
13104
|
-
function _cartQp(name) {
|
|
13105
|
-
return (req.query && req.query[name] === "1") ||
|
|
13106
|
-
(cartUrl && cartUrl.searchParams.get(name) === "1") || false;
|
|
13107
|
-
}
|
|
13108
|
-
var codeApplied = _cartQp("code_applied");
|
|
13109
|
-
var codeRemoved = _cartQp("code_removed");
|
|
13110
|
-
var codeErr = _cartQp("code_err");
|
|
13111
13111
|
if (!sid) {
|
|
13112
|
-
return _send(res,
|
|
13112
|
+
return _send(res, status, renderCart(Object.assign({
|
|
13113
13113
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
13114
13114
|
shop_name: shopName, theme: theme,
|
|
13115
|
-
}, ccy)));
|
|
13115
|
+
}, ccy, extraOpts)));
|
|
13116
13116
|
}
|
|
13117
13117
|
var c = await deps.cart.bySession(sid);
|
|
13118
13118
|
if (!c) {
|
|
13119
|
-
return _send(res,
|
|
13119
|
+
return _send(res, status, renderCart(Object.assign({
|
|
13120
13120
|
lines: [], totals: { subtotal_minor: 0, grand_total_minor: 0, currency: "USD" },
|
|
13121
13121
|
shop_name: shopName, theme: theme,
|
|
13122
|
-
}, ccy)));
|
|
13122
|
+
}, ccy, extraOpts)));
|
|
13123
13123
|
}
|
|
13124
13124
|
var rawLines = await deps.cart.listLines(c.id);
|
|
13125
13125
|
// Reapply the active quantity-break for each line at its current
|
|
@@ -13197,7 +13197,7 @@ function mount(router, deps) {
|
|
|
13197
13197
|
// the primitive falls back to its weight-agnostic transit rows. Drop-
|
|
13198
13198
|
// silent → null, and the summary renders no estimate.
|
|
13199
13199
|
var cartEstimate = await _resolveDeliveryEstimate(req, { dest: estimateDest });
|
|
13200
|
-
_send(res,
|
|
13200
|
+
_send(res, status, renderCart(Object.assign({
|
|
13201
13201
|
lines: lines,
|
|
13202
13202
|
totals: totals,
|
|
13203
13203
|
totals_detail: totalsDetail,
|
|
@@ -13208,19 +13208,38 @@ function mount(router, deps) {
|
|
|
13208
13208
|
checkout_available: !!(deps.checkout && deps.order),
|
|
13209
13209
|
gift_wraps: giftWraps,
|
|
13210
13210
|
gift_wrap_in_cart: giftWrapInCart,
|
|
13211
|
-
added: added,
|
|
13212
13211
|
// Coupon entry: surfaced only when the discount engine is wired (the
|
|
13213
13212
|
// POST routes mount on the same condition). `applied_codes` echoes the
|
|
13214
13213
|
// typed codes so each gets a remove control; the *_notice flags drive
|
|
13215
13214
|
// the inline PRG banner.
|
|
13216
13215
|
coupon_enabled: !!(deps.autoDiscount && typeof deps.cart.listDiscountCodes === "function"),
|
|
13217
13216
|
applied_codes: appliedCodes,
|
|
13218
|
-
code_applied: codeApplied,
|
|
13219
|
-
code_removed: codeRemoved,
|
|
13220
|
-
code_err: codeErr,
|
|
13221
13217
|
shop_name: shopName,
|
|
13222
13218
|
theme: theme,
|
|
13223
|
-
}, ccy)));
|
|
13219
|
+
}, ccy, extraOpts)));
|
|
13220
|
+
}
|
|
13221
|
+
|
|
13222
|
+
router.get("/cart", async function (req, res) {
|
|
13223
|
+
// `?added=1` after a POST /cart/lines redirect — drives the
|
|
13224
|
+
// "Added to cart" status banner. Read from the parsed query when the
|
|
13225
|
+
// router populated it, else from the raw URL.
|
|
13226
|
+
var cartUrl = req.url ? new URL(req.url, "http://localhost") : null;
|
|
13227
|
+
var added = (req.query && req.query.added === "1") ||
|
|
13228
|
+
(cartUrl && cartUrl.searchParams.get("added") === "1") || false;
|
|
13229
|
+
// Coupon-entry PRG outcomes (set by POST /cart/coupon[/remove]). One of
|
|
13230
|
+
// applied / removed / err so the cart shows an inline notice. `?code_err`
|
|
13231
|
+
// carries no detail beyond "couldn't apply" — a uniform message, no
|
|
13232
|
+
// code-existence oracle.
|
|
13233
|
+
function _cartQp(name) {
|
|
13234
|
+
return (req.query && req.query[name] === "1") ||
|
|
13235
|
+
(cartUrl && cartUrl.searchParams.get(name) === "1") || false;
|
|
13236
|
+
}
|
|
13237
|
+
return await _renderCartResponse(req, res, 200, {
|
|
13238
|
+
added: added,
|
|
13239
|
+
code_applied: _cartQp("code_applied"),
|
|
13240
|
+
code_removed: _cartQp("code_removed"),
|
|
13241
|
+
code_err: _cartQp("code_err"),
|
|
13242
|
+
});
|
|
13224
13243
|
});
|
|
13225
13244
|
|
|
13226
13245
|
// ---- checkout flow -------------------------------------------------
|
|
@@ -15393,9 +15412,10 @@ function mount(router, deps) {
|
|
|
15393
15412
|
}
|
|
15394
15413
|
|
|
15395
15414
|
// Profile edit — display-name only. Email is hash-only + the OAuth
|
|
15396
|
-
// linking key, so
|
|
15397
|
-
//
|
|
15398
|
-
// ?ok=updated success notice, matching the addresses
|
|
15415
|
+
// linking key, so it genuinely can't be changed (no readable address to
|
|
15416
|
+
// edit); the form shows it read-only and the route never accepts an email
|
|
15417
|
+
// field. PRG with a ?ok=updated success notice, matching the addresses
|
|
15418
|
+
// pattern.
|
|
15399
15419
|
async function _renderProfilePage(req, res, auth, customer, notice, code) {
|
|
15400
15420
|
var cartCount = await _cartCountForReq(req);
|
|
15401
15421
|
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
@@ -19007,21 +19027,59 @@ function mount(router, deps) {
|
|
|
19007
19027
|
// a REAL cart line so its fee flows through pricing.totals and is charged
|
|
19008
19028
|
// by checkout.confirm (NEVER a post-commit hook — that would mis-charge).
|
|
19009
19029
|
// Selecting "No gift wrap" removes any wrap line. Selecting a wrap removes
|
|
19010
|
-
// any prior wrap line then adds the chosen one
|
|
19011
|
-
//
|
|
19012
|
-
//
|
|
19030
|
+
// any prior wrap line then adds the chosen one.
|
|
19031
|
+
//
|
|
19032
|
+
// Authoritative fee: the wrap line's price snapshot is the operator-
|
|
19033
|
+
// configured gift_wraps.fee_minor — NOT the wrap variant's catalog price.
|
|
19034
|
+
// fee_minor is the single source the admin sets and the cart selector
|
|
19035
|
+
// displays, so the charge must read it too (an explicit unit_amount_minor
|
|
19036
|
+
// snapshot on cart.addLine), or the displayed fee and the charged fee would
|
|
19037
|
+
// silently diverge whenever the catalog price differs from the wrap fee.
|
|
19038
|
+
//
|
|
19039
|
+
// Cap: gift_wraps.max_per_order bounds how many of that wrap one order may
|
|
19040
|
+
// carry. The request quantity (defensive reader, default 1) is checked
|
|
19041
|
+
// against it BEFORE any mutation — over the cap is rejected with a 422 and
|
|
19042
|
+
// the cart re-rendered with a customer-readable error, the cart unchanged.
|
|
19043
|
+
//
|
|
19044
|
+
// The message / recipient are collected at checkout (they need the order
|
|
19045
|
+
// id). Mounts only when the gift-options primitive is wired.
|
|
19013
19046
|
if (deps.giftOptions) {
|
|
19014
19047
|
router.post("/cart/gift", async function (req, res) {
|
|
19015
19048
|
var body = req.body || {};
|
|
19016
19049
|
var wrapSku = typeof body.wrap_sku === "string" ? body.wrap_sku.trim() : "";
|
|
19050
|
+
// Defensive request-shape reader: qty defaults to 1, and any non-
|
|
19051
|
+
// positive-integer field (blank, garbage, fractional, negative) falls
|
|
19052
|
+
// back to 1 rather than throwing — the cap check below is the only gate
|
|
19053
|
+
// that rejects, and it rejects on "too many", never on a malformed qty.
|
|
19054
|
+
var reqQty = 1;
|
|
19055
|
+
var rawQty = body.qty;
|
|
19056
|
+
if (rawQty != null && rawQty !== "") {
|
|
19057
|
+
var s = String(rawQty).trim();
|
|
19058
|
+
if (/^\d+$/.test(s)) {
|
|
19059
|
+
var n = Number(s);
|
|
19060
|
+
if (Number.isInteger(n) && n > 0) reqQty = n;
|
|
19061
|
+
}
|
|
19062
|
+
}
|
|
19017
19063
|
var resolved = await _getOrCreateCart(req, res, "USD");
|
|
19018
|
-
var
|
|
19064
|
+
var cart = resolved.cart;
|
|
19065
|
+
var cartId = cart.id;
|
|
19019
19066
|
try {
|
|
19020
19067
|
// Resolve the active wrap catalog so we know every wrap sku (to
|
|
19021
|
-
// remove a stale wrap line) and the selected wrap's cap.
|
|
19068
|
+
// remove a stale wrap line) and the selected wrap's fee + cap.
|
|
19022
19069
|
var wraps = await deps.giftOptions.listWraps({ active_only: true });
|
|
19023
19070
|
var wrapBySku = {};
|
|
19024
19071
|
for (var i = 0; i < wraps.length; i += 1) wrapBySku[wraps[i].wrap_sku] = wraps[i];
|
|
19072
|
+
var chosen = (wrapSku && wrapBySku[wrapSku]) ? wrapBySku[wrapSku] : null;
|
|
19073
|
+
// Enforce max_per_order before mutating anything. A null cap means
|
|
19074
|
+
// "no limit"; a set cap rejects a request for more wraps than the
|
|
19075
|
+
// order may carry. The cart is left untouched so the customer can
|
|
19076
|
+
// resubmit with a smaller quantity.
|
|
19077
|
+
if (chosen && chosen.max_per_order != null && reqQty > chosen.max_per_order) {
|
|
19078
|
+
return await _renderCartResponse(req, res, 422, {
|
|
19079
|
+
gift_error: "You can add at most " + chosen.max_per_order + " of “" +
|
|
19080
|
+
chosen.title + "” to one order. Please choose a smaller quantity.",
|
|
19081
|
+
});
|
|
19082
|
+
}
|
|
19025
19083
|
// Remove any existing wrap line first (every active wrap's sku).
|
|
19026
19084
|
var existingLines = await deps.cart.listLines(cartId);
|
|
19027
19085
|
for (var li = 0; li < existingLines.length; li += 1) {
|
|
@@ -19030,13 +19088,19 @@ function mount(router, deps) {
|
|
|
19030
19088
|
}
|
|
19031
19089
|
}
|
|
19032
19090
|
// Add the selected wrap (when one was chosen + it's a real active
|
|
19033
|
-
// wrap). The wrap_sku is a real catalog variant
|
|
19034
|
-
// id
|
|
19035
|
-
//
|
|
19036
|
-
|
|
19091
|
+
// wrap). The wrap_sku is a real catalog variant — resolve its variant
|
|
19092
|
+
// id, then add it as a line whose unit price is the AUTHORITATIVE
|
|
19093
|
+
// gift_wraps.fee_minor (the configured + displayed wrap fee), pinned
|
|
19094
|
+
// via an explicit price snapshot so the charge matches the display.
|
|
19095
|
+
if (chosen) {
|
|
19037
19096
|
var variant = await deps.catalog.variants.bySku(wrapSku);
|
|
19038
19097
|
if (variant) {
|
|
19039
|
-
await deps.cart.addLine(cartId, {
|
|
19098
|
+
await deps.cart.addLine(cartId, {
|
|
19099
|
+
variant_id: variant.id,
|
|
19100
|
+
qty: reqQty,
|
|
19101
|
+
unit_amount_minor: chosen.fee_minor,
|
|
19102
|
+
unit_currency: cart.currency || "USD",
|
|
19103
|
+
});
|
|
19040
19104
|
}
|
|
19041
19105
|
}
|
|
19042
19106
|
} catch (e) {
|
package/lib/webhooks.js
CHANGED
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
* `events` is either the wildcard `*` or a comma-separated allowlist
|
|
19
19
|
* of recognized event types — `order.mark_paid`,
|
|
20
20
|
* `order.start_fulfillment`, `order.mark_shipped`,
|
|
21
|
-
* `order.mark_delivered`, `order.cancel`, `order.refund
|
|
22
|
-
* only receive event types they
|
|
21
|
+
* `order.mark_delivered`, `order.cancel`, `order.refund`,
|
|
22
|
+
* `inventory.low_stock`. Endpoints only receive event types they
|
|
23
|
+
* subscribed to.
|
|
23
24
|
*
|
|
24
25
|
* `send(eventType, payload)` is fire-and-flag-failure: every active
|
|
25
26
|
* endpoint subscribed to the event gets a delivery row before the
|
|
@@ -65,6 +66,7 @@ var KNOWN_EVENTS = Object.freeze([
|
|
|
65
66
|
"order.mark_delivered",
|
|
66
67
|
"order.cancel",
|
|
67
68
|
"order.refund",
|
|
69
|
+
"inventory.low_stock",
|
|
68
70
|
]);
|
|
69
71
|
|
|
70
72
|
var SECRET_BYTES = 32;
|
package/package.json
CHANGED