@blamejs/blamejs-shop 0.0.129 → 0.1.1

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 (127) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/lib/admin.js +275 -9
  4. package/lib/affiliates.js +4 -3
  5. package/lib/analytics.js +3 -2
  6. package/lib/api-keys.js +1 -1
  7. package/lib/assembly-instructions.js +2 -1
  8. package/lib/auto-replenish.js +4 -3
  9. package/lib/backorder.js +2 -1
  10. package/lib/business-hours.js +8 -1
  11. package/lib/carrier-accounts.js +1 -1
  12. package/lib/carrier-rates.js +1 -1
  13. package/lib/cart-abandonment.js +3 -2
  14. package/lib/cart-bulk-ops.js +2 -1
  15. package/lib/cart-recovery.js +5 -4
  16. package/lib/cart.js +6 -2
  17. package/lib/catalog-drafts.js +1 -1
  18. package/lib/click-and-collect.js +3 -2
  19. package/lib/clickstream.js +4 -3
  20. package/lib/config.js +2 -1
  21. package/lib/cookie-consent.js +2 -1
  22. package/lib/credit-limits.js +2 -1
  23. package/lib/currency-display.js +2 -1
  24. package/lib/customer-activity.js +3 -2
  25. package/lib/customer-impersonation.js +3 -3
  26. package/lib/customer-merge.js +4 -3
  27. package/lib/customer-portal.js +4 -4
  28. package/lib/customer-risk-profile.js +2 -1
  29. package/lib/customer-segments.js +2 -1
  30. package/lib/customer-surveys.js +6 -3
  31. package/lib/delivery-estimate.js +2 -2
  32. package/lib/demand-forecast.js +2 -1
  33. package/lib/discount-analytics.js +2 -2
  34. package/lib/dunning.js +4 -1
  35. package/lib/email-warmup.js +6 -1
  36. package/lib/email.js +1 -8
  37. package/lib/error-log.js +3 -2
  38. package/lib/event-log.js +3 -2
  39. package/lib/fraud-screen.js +3 -1
  40. package/lib/fulfillment-sla.js +3 -1
  41. package/lib/index.js +11 -3
  42. package/lib/inventory-allocations.js +3 -0
  43. package/lib/inventory-snapshots.js +2 -1
  44. package/lib/invoice-renderer.js +2 -1
  45. package/lib/line-gift-wrap.js +6 -1
  46. package/lib/live-chat.js +2 -1
  47. package/lib/loyalty-redemption.js +2 -1
  48. package/lib/newsletter.js +6 -1
  49. package/lib/operator-activity-feed.js +4 -3
  50. package/lib/operator-sessions.js +7 -7
  51. package/lib/order-exchanges.js +1 -0
  52. package/lib/order-timeline.js +2 -1
  53. package/lib/payment-retries.js +2 -1
  54. package/lib/payment.js +5 -4
  55. package/lib/pixel-events.js +6 -5
  56. package/lib/preorder.js +2 -1
  57. package/lib/print-queue.js +2 -1
  58. package/lib/product-compare.js +2 -1
  59. package/lib/product-qa.js +2 -1
  60. package/lib/push-notifications.js +6 -5
  61. package/lib/recently-viewed.js +7 -2
  62. package/lib/recommendations.js +7 -2
  63. package/lib/referral-leaderboard.js +2 -1
  64. package/lib/refund-automation.js +1 -1
  65. package/lib/refund-policy.js +1 -1
  66. package/lib/reorder-reminders.js +2 -1
  67. package/lib/reorder-thresholds.js +2 -1
  68. package/lib/robots-config.js +1 -0
  69. package/lib/sales-reports.js +17 -14
  70. package/lib/sales-tax-filings.js +2 -1
  71. package/lib/save-for-later.js +2 -1
  72. package/lib/search-suggestions.js +1 -1
  73. package/lib/shipping-insurance.js +2 -1
  74. package/lib/shipping-labels.js +3 -2
  75. package/lib/shipping-zones.js +1 -0
  76. package/lib/shrinkage-report.js +9 -8
  77. package/lib/sms-dispatcher.js +6 -5
  78. package/lib/stock-alerts.js +1 -1
  79. package/lib/stock-receipts.js +2 -1
  80. package/lib/store-credit.js +2 -1
  81. package/lib/storefront-forms.js +1 -1
  82. package/lib/storefront.js +93 -112
  83. package/lib/subscription-analytics.js +7 -2
  84. package/lib/subscription-controls.js +9 -8
  85. package/lib/subscription-gifts.js +2 -1
  86. package/lib/subscriptions.js +2 -0
  87. package/lib/support-tickets.js +4 -4
  88. package/lib/tax-cert-renewals.js +2 -1
  89. package/lib/tax-remittance.js +2 -1
  90. package/lib/theme-assets.js +1 -1
  91. package/lib/vendor/MANIFEST.json +2 -2
  92. package/lib/vendor/blamejs/CHANGELOG.md +16 -0
  93. package/lib/vendor/blamejs/README.md +6 -4
  94. package/lib/vendor/blamejs/SECURITY.md +2 -0
  95. package/lib/vendor/blamejs/api-snapshot.json +255 -2
  96. package/lib/vendor/blamejs/index.js +1 -0
  97. package/lib/vendor/blamejs/lib/cose.js +284 -10
  98. package/lib/vendor/blamejs/lib/crypto.js +119 -0
  99. package/lib/vendor/blamejs/lib/did.js +416 -0
  100. package/lib/vendor/blamejs/lib/mdoc.js +122 -0
  101. package/lib/vendor/blamejs/lib/network-dnssec.js +328 -0
  102. package/lib/vendor/blamejs/lib/network.js +1 -0
  103. package/lib/vendor/blamejs/lib/vc.js +231 -33
  104. package/lib/vendor/blamejs/package.json +1 -1
  105. package/lib/vendor/blamejs/release-notes/v0.12.41.json +18 -0
  106. package/lib/vendor/blamejs/release-notes/v0.12.42.json +18 -0
  107. package/lib/vendor/blamejs/release-notes/v0.12.43.json +18 -0
  108. package/lib/vendor/blamejs/release-notes/v0.12.44.json +18 -0
  109. package/lib/vendor/blamejs/release-notes/v0.12.45.json +18 -0
  110. package/lib/vendor/blamejs/release-notes/v0.12.46.json +18 -0
  111. package/lib/vendor/blamejs/release-notes/v0.12.47.json +18 -0
  112. package/lib/vendor/blamejs/release-notes/v0.12.48.json +22 -0
  113. package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +47 -2
  114. package/lib/vendor/blamejs/test/layer-0-primitives/cose.test.js +101 -2
  115. package/lib/vendor/blamejs/test/layer-0-primitives/crypto-self-test.test.js +74 -0
  116. package/lib/vendor/blamejs/test/layer-0-primitives/did.test.js +176 -0
  117. package/lib/vendor/blamejs/test/layer-0-primitives/dnssec.test.js +130 -0
  118. package/lib/vendor/blamejs/test/layer-0-primitives/mdoc.test.js +52 -0
  119. package/lib/vendor/blamejs/test/layer-0-primitives/vc.test.js +63 -0
  120. package/lib/vendor-invoices.js +1 -1
  121. package/lib/webhook-receiver.js +8 -2
  122. package/lib/webhook-subscriptions.js +1 -1
  123. package/lib/webhooks.js +6 -5
  124. package/lib/winback-campaigns.js +2 -1
  125. package/lib/wishlist-alerts.js +2 -1
  126. package/lib/wishlist-digest.js +2 -1
  127. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.1.x
10
+
11
+ - v0.1.1 (2026-05-25) — **Admin setup wizard + a browser-accessible admin console.** The admin is now reachable from a browser, not just the bearer-token JSON API. Sign in once at /admin by pasting the ADMIN_API_KEY and a sealed, /admin-scoped session cookie carries you through the guided setup wizard (shop name, contact email, default currency, support URL — saved to shop config) and the analytics dashboard. The shop name set in the wizard drives the storefront header, page titles, and the admin header. **Added:** *Browser admin sign-in* — `/admin` renders a sign-in form; pasting the ADMIN_API_KEY sets a sealed `shop_admin` session cookie (SameSite=Strict, scoped to /admin) so the rendered admin pages are reachable from a browser. The JSON API stays bearer-only; the dashboard accepts either the cookie or the bearer token. · *Setup wizard* — `/admin/setup` is a guided form for the shop's core identity — name, contact email, default currency, support URL — validated (ISO-4217 currency, RFC-shaped email, http(s) support URL) and saved to shop config. The landing nags until setup is complete; the shop name then drives the storefront and admin headers.
12
+
13
+ - v0.1.0 (2026-05-25) — **Responsive cart + storefront cookie handling moved onto the framework primitive.** A storefront polish pass. The cart, order-confirmation, and account-history tables now reflow into stacked, labelled cards on phones and fit their column on wider screens — no more inner horizontal scroll. The quantity field reads clearly and accepts up to 99,999. The line actions (Update / Save for later / Remove) are compact with a clear hierarchy. Under the hood, all storefront cookie handling now composes the framework's cookie primitive (RFC 6265 parse/serialize plus vault-sealed read/write) instead of hand-built headers, and a malformed session cookie can no longer turn the cart — or any page that shows the cart count — into a 500. **Changed:** *Quantity field is readable and accepts up to 99,999* — The cart quantity input is wide enough to read a five-digit quantity, and the per-line maximum is raised to 99,999 (enforced on the server). · *Compact, clearly-ranked line actions* — Update is a small accent button, Save for later a quiet secondary, Remove a danger-tinted secondary — so the primary checkout call stays dominant. · *Cookie handling composes the framework primitive* — Session, authentication, WebAuthn-challenge, and payment cookies are all parsed and written through the framework cookie primitive (sealed read/write for the authenticated-session cookie), replacing hand-built Set-Cookie strings and manual header parsing. Cookie lifetimes are expressed through the framework's duration constants. **Fixed:** *Cart tables no longer scroll sideways* — The cart, order-confirmation, and account order-history tables reflow into one labelled card per row below 48rem and are sized to fit their column on wider layouts, so content is never trapped behind an inner horizontal scrollbar. · *Malformed session cookie no longer 500s the storefront* — A `shop_sid` cookie carrying a value that isn't a well-formed session id now reads as "no session" instead of reaching the cart lookup (which rejected it and surfaced a 500 on every page that renders the cart count). · *Account dashboard controls wrap on narrow screens* — The row of account actions (Wishlist / Saved for later / Recently viewed / Addresses / Returns / Sign out) now wraps instead of overflowing the viewport on a phone.
14
+
9
15
  ## v0.0.x
10
16
 
11
17
  - v0.0.129 (2026-05-25) — **Recently viewed — a signed-in customer's browse history at /account/recently-viewed.** Signed-in customers now have a browse history. Opening a product page records the view against the customer's account, and `/account/recently-viewed` lists those products newest-first as a grid, with a one-tap Clear history control. Recording is best-effort and never blocks the product page; archived products drop out of the grid; the page is login-gated like the rest of the account area. **Added:** *Recently viewed account page* — `GET /account/recently-viewed` renders the customer's most-recently-opened products newest-first, reusing the standard product card (image, title, price, link to the PDP). A `POST /account/recently-viewed/clear` control wipes the history. Linked from the account dashboard. · *Server-side view recording* — A signed-in customer's product-page visit records the view against their account — no client script. Recording is drop-silent: a write failure never breaks the product page. The history de-dupes per product and is capped per customer.
package/README.md CHANGED
@@ -72,7 +72,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
72
72
  | **`lib/collections.js`** | Curated + smart product groupings. `GET /collections` lists the shop's active collections; `GET /collections/:slug` renders the grid — manual collections list hand-picked members, smart collections evaluate stored rules against the active catalog and apply the collection's sort strategy. Each product resolves fresh, so archived products drop out. Public, no sign-in; a bad or unknown slug is a 404 (never a 500). Linked from the footer on every page. |
73
73
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. |
74
74
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
75
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. |
75
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), a guided **setup wizard** at `/admin/setup` that writes shop identity to config, and the analytics dashboard — all reachable by the cookie or the bearer token. |
76
76
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
77
77
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
78
78
 
package/lib/admin.js CHANGED
@@ -84,10 +84,9 @@ function _parseLimit(str, label, max, fallback) {
84
84
 
85
85
  // ---- HTML escape + dashboard layout ------------------------------------
86
86
 
87
- var HTML_ESCAPE_MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" };
88
87
  function _htmlEscape(s) {
89
88
  if (s == null) return "";
90
- return String(s).replace(/[&<>"']/g, function (c) { return HTML_ESCAPE_MAP[c]; });
89
+ return _b().template.escapeHtml(String(s));
91
90
  }
92
91
 
93
92
  // ---- bearer auth --------------------------------------------------------
@@ -106,6 +105,53 @@ function _authOk(token, expected) {
106
105
  return _b().crypto.timingSafeEqual(token, expected);
107
106
  }
108
107
 
108
+ // ---- admin browser session (sealed cookie) ------------------------------
109
+ //
110
+ // The JSON API (R/W wrappers) is bearer-token only — that's the contract
111
+ // for machine clients. The server-rendered admin pages (landing, setup
112
+ // wizard, dashboard) additionally accept a sealed `shop_admin` cookie so
113
+ // an operator can sign in from a browser by pasting the same token once.
114
+ // The cookie composes the framework cookie primitive (vault-sealed
115
+ // read/write) — scoped to /admin, SameSite=Strict, HttpOnly + Secure.
116
+ var ADMIN_COOKIE_NAME = "shop_admin";
117
+
118
+ var _adminJarMemo = null;
119
+ function _adminJar() {
120
+ if (!_adminJarMemo) {
121
+ _adminJarMemo = _b().cookies.create({
122
+ vault: _b().vault,
123
+ defaults: { httpOnly: true, secure: true, sameSite: "Strict", path: "/admin" },
124
+ });
125
+ }
126
+ return _adminJarMemo;
127
+ }
128
+
129
+ function _setAdminCookie(res) {
130
+ _adminJar().writeSealed(res, ADMIN_COOKIE_NAME, JSON.stringify({
131
+ admin: true,
132
+ exp: Date.now() + _b().constants.TIME.hours(12),
133
+ }), { expires: new Date(Date.now() + _b().constants.TIME.hours(12)) });
134
+ }
135
+ function _clearAdminCookie(res) {
136
+ _adminJar().clear(res, ADMIN_COOKIE_NAME);
137
+ }
138
+ function _adminCookieValid(req) {
139
+ var raw = _adminJar().readSealed(req, ADMIN_COOKIE_NAME);
140
+ if (raw === null) return false;
141
+ var env;
142
+ try { env = JSON.parse(raw); } catch (_e) { return false; }
143
+ return !!(env && env.admin === true && env.exp && env.exp > Date.now());
144
+ }
145
+
146
+ // HTML-page auth: a valid admin cookie OR the bearer token (so existing
147
+ // tooling that sends the header still reaches the dashboard). Never
148
+ // throws — a missing vault surfaces as "not authed" so the caller can
149
+ // render the login form rather than 500.
150
+ function _htmlAuthed(req, expectedToken) {
151
+ if (_authOk(_readBearer(req), expectedToken)) return true;
152
+ try { return _adminCookieValid(req); } catch (_e) { return false; }
153
+ }
154
+
109
155
  function _problem(res, status, code, detail) {
110
156
  return _b().problemDetails.send(res, {
111
157
  type: "/problems/" + code,
@@ -115,6 +161,21 @@ function _problem(res, status, code, detail) {
115
161
  });
116
162
  }
117
163
 
164
+ function _sendHtml(res, status, html) {
165
+ res.status(status);
166
+ if (res.setHeader) {
167
+ res.setHeader("content-type", "text/html; charset=utf-8");
168
+ res.setHeader("x-robots-tag", "noindex, nofollow");
169
+ }
170
+ if (res.end) res.end(html); else res.send(html);
171
+ }
172
+
173
+ function _redirect(res, location) {
174
+ res.status(303);
175
+ if (res.setHeader) res.setHeader("location", location);
176
+ if (res.end) res.end(); else res.send("");
177
+ }
178
+
118
179
  function _wrap(handler, opts) {
119
180
  // Every admin handler routes through this wrapper: bearer-token
120
181
  // gate, error-to-problem-details translation, audit write on the
@@ -814,24 +875,26 @@ function mount(router, deps) {
814
875
  _json(res, 200, { rows: rows });
815
876
  }));
816
877
 
817
- router.get("/admin/dashboard", R(async function (req, res) {
878
+ // HTML page — accepts the admin browser cookie OR the bearer token,
879
+ // so it's reachable both from a signed-in browser and from tooling.
880
+ router.get("/admin/dashboard", async function (req, res) {
881
+ if (!_htmlAuthed(req, expectedToken)) {
882
+ return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
883
+ }
818
884
  var url = req.url ? new URL(req.url, "http://localhost") : null;
819
885
  var w = _parseWindow(url);
820
886
  var summary = await analytics.summary(w);
821
887
  var byDay = await analytics.revenueByDay(w);
822
888
  var top = await analytics.topSKUs(Object.assign({}, w, { limit: 10 }));
823
889
  var recent = await analytics.recentOrders({ limit: 20 });
824
- var html = renderDashboard({
890
+ _sendHtml(res, 200, renderDashboard({
825
891
  summary: summary,
826
892
  by_day: byDay,
827
893
  top_skus: top,
828
894
  recent: recent,
829
895
  shop_name: (deps.shop_name || "blamejs.shop"),
830
- });
831
- res.status(200);
832
- if (res.setHeader) res.setHeader("content-type", "text/html; charset=utf-8");
833
- if (res.end) res.end(html); else res.send(html);
834
- }));
896
+ }));
897
+ });
835
898
  }
836
899
 
837
900
  // ---- subscriptions --------------------------------------------------
@@ -901,6 +964,112 @@ function mount(router, deps) {
901
964
  }));
902
965
  }
903
966
 
967
+ // ---- admin web pages (browser session + setup wizard) ---------------
968
+ //
969
+ // The operator signs in by pasting the ADMIN_API_KEY once; that sets a
970
+ // sealed, SameSite=Strict, /admin-scoped cookie so the rendered pages
971
+ // (landing, dashboard, setup) are reachable from a browser. The JSON
972
+ // API stays bearer-only. POST routes follow the storefront's
973
+ // form-POST pattern (SameSite cookie + the app-level origin /
974
+ // fetch-metadata guards), no separate CSRF token field.
975
+
976
+ async function _setupComplete() {
977
+ if (!config) return false;
978
+ try { return (await config.get("setup.completed", false)) === true; }
979
+ catch (_e) { return false; }
980
+ }
981
+
982
+ router.get("/admin", async function (req, res) {
983
+ if (!_htmlAuthed(req, expectedToken)) {
984
+ return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
985
+ }
986
+ _sendHtml(res, 200, renderAdminLanding({
987
+ shop_name: deps.shop_name,
988
+ setup_complete: await _setupComplete(),
989
+ }));
990
+ });
991
+
992
+ router.post("/admin/login", async function (req, res) {
993
+ var body = req.body || {};
994
+ var token = typeof body.token === "string" ? body.token : "";
995
+ if (!_authOk(token, expectedToken)) {
996
+ return _sendHtml(res, 401, renderAdminLogin({ shop_name: deps.shop_name, error: true }));
997
+ }
998
+ try { _setAdminCookie(res); }
999
+ catch (e) {
1000
+ // Sealed cookies need an initialized vault — surface 503 rather
1001
+ // than 500 so the operator knows to configure VAULT_PASSPHRASE.
1002
+ if (e && e.code === "vault/not-initialized") {
1003
+ return _sendHtml(res, 503, renderAdminLogin({ shop_name: deps.shop_name }));
1004
+ }
1005
+ throw e;
1006
+ }
1007
+ _redirect(res, (await _setupComplete()) ? "/admin" : "/admin/setup");
1008
+ });
1009
+
1010
+ router.post("/admin/logout", async function (_req, res) {
1011
+ _clearAdminCookie(res);
1012
+ _redirect(res, "/admin");
1013
+ });
1014
+
1015
+ if (config) {
1016
+ router.get("/admin/setup", async function (req, res) {
1017
+ if (!_htmlAuthed(req, expectedToken)) return _redirect(res, "/admin");
1018
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
1019
+ var saved = !!(url && url.searchParams.get("saved"));
1020
+ var values = {};
1021
+ try {
1022
+ values.shop_name = await config.get("shop.name", deps.shop_name || "");
1023
+ values.contact_email = await config.get("shop.contact_email", "");
1024
+ values.currency = await config.get("shop.currency", "");
1025
+ values.support_url = await config.get("shop.support_url", "");
1026
+ } catch (_e) { /* unconfigured — render an empty form */ }
1027
+ _sendHtml(res, 200, renderAdminSetup({ shop_name: deps.shop_name, values: values, saved: saved }));
1028
+ });
1029
+
1030
+ router.post("/admin/setup", async function (req, res) {
1031
+ if (!_htmlAuthed(req, expectedToken)) return _redirect(res, "/admin");
1032
+ var body = req.body || {};
1033
+ var values = {
1034
+ shop_name: (typeof body.shop_name === "string" ? body.shop_name : "").trim(),
1035
+ contact_email: (typeof body.contact_email === "string" ? body.contact_email : "").trim(),
1036
+ currency: (typeof body.currency === "string" ? body.currency : "").trim().toUpperCase(),
1037
+ support_url: (typeof body.support_url === "string" ? body.support_url : "").trim(),
1038
+ };
1039
+ // Defensive request-shape reader: bad input re-renders the form
1040
+ // with a notice (400), never a 500.
1041
+ var notice = null;
1042
+ if (!values.shop_name) notice = "Shop name is required.";
1043
+ else if (values.shop_name.length > 80) notice = "Shop name is too long (max 80 characters).";
1044
+ else if (values.currency && !/^[A-Z]{3}$/.test(values.currency)) notice = "Currency must be a 3-letter ISO 4217 code (e.g. USD).";
1045
+ else if (values.contact_email) {
1046
+ var emailReport = _b().guardEmail.validate(values.contact_email, { profile: "strict" });
1047
+ if (!emailReport || emailReport.ok === false) notice = "That contact email doesn't look valid.";
1048
+ }
1049
+ if (!notice && values.support_url) {
1050
+ var u = _b().safeUrl.parse(values.support_url);
1051
+ if (!u || (u.protocol !== "https:" && u.protocol !== "http:")) notice = "Support URL must be a valid http(s) URL.";
1052
+ }
1053
+ if (notice) {
1054
+ return _sendHtml(res, 400, renderAdminSetup({ shop_name: deps.shop_name, values: values, notice: notice }));
1055
+ }
1056
+ try {
1057
+ await config.put("shop.name", values.shop_name);
1058
+ if (values.contact_email) await config.put("shop.contact_email", values.contact_email);
1059
+ if (values.currency) await config.put("shop.currency", values.currency);
1060
+ if (values.support_url) await config.put("shop.support_url", values.support_url);
1061
+ await config.put("setup.completed", true);
1062
+ } catch (e) {
1063
+ return _sendHtml(res, 500, renderAdminSetup({
1064
+ shop_name: deps.shop_name, values: values,
1065
+ notice: "Couldn't save — " + ((e && e.message) || "please try again."),
1066
+ }));
1067
+ }
1068
+ _b().audit.safeEmit({ action: AUDIT_NAMESPACE + ".setup.save", outcome: "success", metadata: {} });
1069
+ _redirect(res, "/admin/setup?saved=1");
1070
+ });
1071
+ }
1072
+
904
1073
  // ---- ping (auth check) ----------------------------------------------
905
1074
 
906
1075
  router.get("/admin/ping", R(async function (_req, res) {
@@ -963,6 +1132,25 @@ var DASHBOARD_LAYOUT =
963
1132
  " .empty { color:var(--mute); font-style:italic; padding:1rem 0; text-align:center; }\n" +
964
1133
  " .meta { color:var(--mute); font-size:.85rem; margin-bottom:1rem; }\n" +
965
1134
  " .order-id { font-family:ui-monospace, SFMono-Regular, Menlo, monospace; font-size:.78rem; color:var(--ink-2); }\n" +
1135
+ " .form-field { display:block; margin-bottom:1.1rem; }\n" +
1136
+ " .form-field span { display:block; font-size:.78rem; text-transform:uppercase; letter-spacing:.05em; color:var(--mute); font-weight:600; margin-bottom:.35rem; }\n" +
1137
+ " .form-field input { width:100%; max-width:28rem; padding:.6rem .75rem; border:1px solid var(--hair); border-radius:6px; font:inherit; }\n" +
1138
+ " .form-field input:focus { outline:2px solid var(--accent); outline-offset:1px; border-color:var(--accent); }\n" +
1139
+ " .form-field small { display:block; color:var(--mute); font-size:.78rem; margin-top:.3rem; }\n" +
1140
+ " .btn { display:inline-flex; align-items:center; gap:.4rem; background:var(--accent); color:var(--paper); border:1px solid var(--accent); padding:.6rem 1.1rem; border-radius:6px; font-family:'Montserrat',sans-serif; font-weight:700; font-size:.82rem; letter-spacing:.04em; text-transform:uppercase; text-decoration:none; cursor:pointer; }\n" +
1141
+ " .btn:hover { background:var(--accent-d); border-color:var(--accent-d); }\n" +
1142
+ " .btn--ghost { background:transparent; color:var(--ink); border-color:var(--ink); }\n" +
1143
+ " .btn--ghost:hover { background:var(--ink); color:var(--paper); }\n" +
1144
+ " .nav-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(14rem,1fr)); gap:1rem; }\n" +
1145
+ " .nav-card { display:block; background:var(--paper); border:1px solid var(--hair); border-radius:8px; padding:1.4rem; text-decoration:none; color:var(--ink); }\n" +
1146
+ " .nav-card:hover { border-color:var(--accent); box-shadow:0 8px 20px -12px rgba(0,0,0,.25); }\n" +
1147
+ " .nav-card h3 { margin:0 0 .35rem; font-size:1.05rem; }\n" +
1148
+ " .nav-card p { margin:0; color:var(--mute); font-size:.88rem; }\n" +
1149
+ " .banner { padding:.9rem 1.1rem; border-radius:8px; margin-bottom:1.5rem; font-size:.92rem; }\n" +
1150
+ " .banner--warn { background:#fff8e1; border:1px solid #f1e1a8; color:#7a5d0f; }\n" +
1151
+ " .banner--ok { background:#e9f5ec; border:1px solid #bfe1c9; color:#1f6b3a; }\n" +
1152
+ " .banner--err { background:#fff1eb; border:1px solid #f6c5af; color:var(--accent-d); }\n" +
1153
+ " .actions-row { display:flex; gap:.75rem; flex-wrap:wrap; align-items:center; margin-top:1.5rem; }\n" +
966
1154
  " </style>\n" +
967
1155
  "</head>\n" +
968
1156
  "<body>\n" +
@@ -1113,8 +1301,86 @@ function _statCard(label, value, accent) {
1113
1301
  "<div class=\"value" + (accent ? " accent" : "") + "\">" + _htmlEscape(value) + "</div></div>";
1114
1302
  }
1115
1303
 
1304
+ // ---- admin web pages (login / landing / setup wizard) -------------------
1305
+
1306
+ function _renderAdminShell(shopName, subtitle, bodyHtml) {
1307
+ return _renderTemplate(DASHBOARD_LAYOUT, {
1308
+ shop_name: shopName || "blamejs.shop",
1309
+ window_label: subtitle || "",
1310
+ body: "RAW_BODY",
1311
+ }).replace("RAW_BODY", bodyHtml);
1312
+ }
1313
+
1314
+ function renderAdminLogin(opts) {
1315
+ opts = opts || {};
1316
+ var err = opts.error
1317
+ ? "<div class=\"banner banner--err\">That key didn't match. Check the ADMIN_API_KEY this deployment was started with.</div>"
1318
+ : "";
1319
+ var body =
1320
+ "<section style=\"max-width:30rem;\">" +
1321
+ "<h2>Sign in</h2>" + err +
1322
+ "<form method=\"post\" action=\"/admin/login\">" +
1323
+ "<label class=\"form-field\"><span>Admin API key</span>" +
1324
+ "<input type=\"password\" name=\"token\" autocomplete=\"off\" autofocus required>" +
1325
+ "<small>Paste the ADMIN_API_KEY this deployment was started with.</small>" +
1326
+ "</label>" +
1327
+ "<button type=\"submit\" class=\"btn\">Sign in</button>" +
1328
+ "</form>" +
1329
+ "</section>";
1330
+ return _renderAdminShell(opts.shop_name, "Sign in", body);
1331
+ }
1332
+
1333
+ function renderAdminLanding(opts) {
1334
+ opts = opts || {};
1335
+ var setupBanner = opts.setup_complete
1336
+ ? ""
1337
+ : "<div class=\"banner banner--warn\">Your shop isn't set up yet. <a href=\"/admin/setup\">Finish setup &rarr;</a></div>";
1338
+ var body =
1339
+ "<section>" + setupBanner +
1340
+ "<h2>Admin</h2>" +
1341
+ "<div class=\"nav-cards\">" +
1342
+ "<a class=\"nav-card\" href=\"/admin/setup\"><h3>Setup wizard</h3><p>Shop identity, currency, and contact details.</p></a>" +
1343
+ "<a class=\"nav-card\" href=\"/admin/dashboard\"><h3>Dashboard</h3><p>Sales, revenue, and recent orders at a glance.</p></a>" +
1344
+ "</div>" +
1345
+ "<div class=\"actions-row\"><form method=\"post\" action=\"/admin/logout\"><button type=\"submit\" class=\"btn btn--ghost\">Sign out</button></form></div>" +
1346
+ "</section>";
1347
+ return _renderAdminShell(opts.shop_name, "", body);
1348
+ }
1349
+
1350
+ function _setupField(label, name, value, type, hint, extra) {
1351
+ return "<label class=\"form-field\"><span>" + _htmlEscape(label) + "</span>" +
1352
+ "<input type=\"" + (type || "text") + "\" name=\"" + _htmlEscape(name) + "\" value=\"" + _htmlEscape(value || "") + "\"" + (extra || "") + ">" +
1353
+ (hint ? "<small>" + _htmlEscape(hint) + "</small>" : "") +
1354
+ "</label>";
1355
+ }
1356
+
1357
+ function renderAdminSetup(opts) {
1358
+ opts = opts || {};
1359
+ var v = opts.values || {};
1360
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Saved. Your shop details are live.</div>" : "";
1361
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
1362
+ var body =
1363
+ "<section style=\"max-width:34rem;\">" +
1364
+ "<h2>Shop setup</h2>" +
1365
+ "<p class=\"meta\">Set the basics customers see across the storefront. You can change these any time.</p>" +
1366
+ saved + notice +
1367
+ "<form method=\"post\" action=\"/admin/setup\">" +
1368
+ _setupField("Shop name", "shop_name", v.shop_name, "text", "Shown in the header, page titles, and emails.", " maxlength=\"80\" required") +
1369
+ _setupField("Contact email", "contact_email", v.contact_email, "email", "Where customer replies and operational mail land.", " maxlength=\"160\"") +
1370
+ _setupField("Default currency", "currency", v.currency, "text", "3-letter ISO 4217 code (e.g. USD, EUR, GBP).", " maxlength=\"3\" style=\"text-transform:uppercase;max-width:8rem;\"") +
1371
+ _setupField("Support URL", "support_url", v.support_url, "url", "Linked from the storefront footer (help centre, contact page).", " maxlength=\"300\"") +
1372
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save shop details</button>" +
1373
+ "<a class=\"btn btn--ghost\" href=\"/admin\">Back</a></div>" +
1374
+ "</form>" +
1375
+ "</section>";
1376
+ return _renderAdminShell(opts.shop_name, "Setup", body);
1377
+ }
1378
+
1116
1379
  module.exports = {
1117
1380
  mount: mount,
1118
1381
  AUDIT_NAMESPACE: AUDIT_NAMESPACE,
1119
1382
  renderDashboard: renderDashboard,
1383
+ renderAdminLogin: renderAdminLogin,
1384
+ renderAdminLanding: renderAdminLanding,
1385
+ renderAdminSetup: renderAdminSetup,
1120
1386
  };
package/lib/affiliates.js CHANGED
@@ -135,6 +135,7 @@ function _b() {
135
135
  if (!bShop) bShop = require("./index");
136
136
  return bShop.framework;
137
137
  }
138
+ var C = _b().constants;
138
139
 
139
140
  // ---- validators ---------------------------------------------------------
140
141
 
@@ -677,10 +678,10 @@ function create(opts) {
677
678
  throw paused;
678
679
  }
679
680
 
680
- // Dedupe within one calendar minute (60000 ms). A refresh in
681
+ // Dedupe within one calendar minute. A refresh in
681
682
  // the same minute collapses to a single visit; later traffic
682
683
  // gets its own row so funnel-stats stay coherent.
683
- var dedupeWindow = 60000;
684
+ var dedupeWindow = C.TIME.minutes(1);
684
685
  var existing = await query(
685
686
  "SELECT id FROM affiliate_visits " +
686
687
  "WHERE visitor_session_id_hash = ?1 AND code = ?2 AND occurred_at >= ?3 " +
@@ -741,7 +742,7 @@ function create(opts) {
741
742
  );
742
743
  for (var i = 0; i < r.rows.length; i += 1) {
743
744
  var row = r.rows[i];
744
- var windowMs = Number(row.attribution_window_days) * 24 * 3600 * 1000;
745
+ var windowMs = Number(row.attribution_window_days) * C.TIME.days(1);
745
746
  if (now - Number(row.occurred_at) <= windowMs) {
746
747
  return {
747
748
  visit_id: row.visit_id,
package/lib/analytics.js CHANGED
@@ -92,9 +92,10 @@ function _b() {
92
92
  if (!bShop) bShop = require("./index");
93
93
  return bShop.framework;
94
94
  }
95
+ var C = _b().constants;
95
96
 
96
- var ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
97
- var DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
97
+ var ONE_YEAR_MS = C.TIME.days(365);
98
+ var DEFAULT_WINDOW_MS = C.TIME.days(30);
98
99
 
99
100
  // ---- validators ---------------------------------------------------------
100
101
 
package/lib/api-keys.js CHANGED
@@ -76,7 +76,7 @@ var TOKEN_BYTE_LEN = 32;
76
76
  var TOKEN_PLAINTEXT_LEN = 43;
77
77
  var TOKEN_PLAINTEXT_RE = /^[A-Za-z0-9_-]{43}$/;
78
78
 
79
- var ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
79
+ var ROTATION_GRACE_MS = _b().constants.TIME.days(1);
80
80
 
81
81
  var OWNER_TYPES = ["operator", "app", "affiliate", "tenant"];
82
82
  var STATUSES = ["active", "rotated", "revoked", "expired"];
@@ -110,6 +110,7 @@ function _b() {
110
110
  if (!bShop) bShop = require("./index");
111
111
  return bShop.framework;
112
112
  }
113
+ var C = _b().constants;
113
114
 
114
115
  // ---- constants ----------------------------------------------------------
115
116
 
@@ -126,7 +127,7 @@ var MAX_LIMIT = 500;
126
127
  var DEFAULT_LIMIT = 50;
127
128
  var MAX_DAYS = 3650;
128
129
  var SESSION_NAMESPACE = "assembly-instructions-session";
129
- var DAY_MS = 24 * 60 * 60 * 1000;
130
+ var DAY_MS = C.TIME.days(1);
130
131
 
131
132
  // ---- monotonic clock ----------------------------------------------------
132
133
  //
@@ -91,6 +91,7 @@ function _b() {
91
91
  if (!bShop) bShop = require("./index");
92
92
  return bShop.framework;
93
93
  }
94
+ var C = _b().constants;
94
95
 
95
96
  // ---- constants ----------------------------------------------------------
96
97
 
@@ -110,9 +111,9 @@ var MAX_LIMIT = 500;
110
111
  // again. Stored as constants so tests can read them when constructing
111
112
  // "tick just-after-last-run" scenarios.
112
113
  var SCHEDULE_INTERVAL_MS = Object.freeze({
113
- hourly: 60 * 60 * 1000,
114
- daily: 24 * 60 * 60 * 1000,
115
- weekly: 7 * 24 * 60 * 60 * 1000,
114
+ hourly: C.TIME.hours(1),
115
+ daily: C.TIME.days(1),
116
+ weekly: C.TIME.days(7),
116
117
  });
117
118
 
118
119
  // ---- monotonic clock ----------------------------------------------------
package/lib/backorder.js CHANGED
@@ -82,13 +82,14 @@ function _b() {
82
82
  if (!bShop) bShop = require("./index");
83
83
  return bShop.framework;
84
84
  }
85
+ var C = _b().constants;
85
86
 
86
87
  // ---- constants ----------------------------------------------------------
87
88
 
88
89
  var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
89
90
  var MAX_MESSAGE_LEN = 280;
90
91
  var MAX_REASON_LEN = 280;
91
- var WEEK_MS = 7 * 24 * 60 * 60 * 1000;
92
+ var WEEK_MS = C.TIME.days(7);
92
93
 
93
94
  var BACKORDER_STATUSES = Object.freeze(["pending", "fulfilled", "cancelled"]);
94
95
 
@@ -69,6 +69,13 @@
69
69
  * @related shop.deliveryEstimate
70
70
  */
71
71
 
72
+ var bShop;
73
+ function _b() {
74
+ if (!bShop) bShop = require("./index");
75
+ return bShop.framework;
76
+ }
77
+ var C = _b().constants;
78
+
72
79
  var MAX_SLUG_LEN = 64;
73
80
  var SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
74
81
 
@@ -81,7 +88,7 @@ var YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
81
88
  var MAX_WEEKLY_HOURS = 64; // 7 days x 8 split-shifts each is more than any real schedule needs
82
89
  var MAX_NAME_LEN = 200;
83
90
 
84
- var DAY_MS = 24 * 60 * 60 * 1000;
91
+ var DAY_MS = C.TIME.days(1);
85
92
  var SEARCH_DAYS = 366 * 2; // worst-case "find next open" walk — 2 calendar years of closures
86
93
 
87
94
  // ---- validators ---------------------------------------------------------
@@ -99,7 +99,7 @@ var CARRIERS = Object.freeze([
99
99
 
100
100
  var STATUSES = Object.freeze(["active", "disabled", "rotating"]);
101
101
 
102
- var ROTATION_GRACE_MS = 24 * 60 * 60 * 1000;
102
+ var ROTATION_GRACE_MS = _b().constants.TIME.days(1);
103
103
 
104
104
  var NS_ACCOUNT_NUMBER = "carrier-account-account-number";
105
105
  var NS_API_KEY = "carrier-account-api-key";
@@ -70,7 +70,7 @@ var DEFAULT_RECENT_LIMIT = 25;
70
70
  var MAX_RECENT_LIMIT = 200;
71
71
 
72
72
  var MAX_DIMENSION_MM = 5000; // 5 m — generous, catches absurd input
73
- var MAX_WEIGHT_GRAMS = 1000 * 1000; // 1 t — generous, catches absurd input
73
+ var MAX_WEIGHT_GRAMS = 1000 * 1000; // allow:raw-time-literal — grams, not a duration (1 t — generous, catches absurd input)
74
74
 
75
75
  // Lazy framework handle — matches the pattern every other shop
76
76
  // primitive uses; avoids the require cycle that would arise from
@@ -53,6 +53,7 @@ function _b() {
53
53
  if (!bShop) bShop = require("./index");
54
54
  return bShop.framework;
55
55
  }
56
+ var C = _b().constants;
56
57
 
57
58
  // ---- constants ----------------------------------------------------------
58
59
 
@@ -71,8 +72,8 @@ var SKIP_REASONS = Object.freeze([
71
72
  "opted-out",
72
73
  ]);
73
74
 
74
- var DEFAULT_IDLE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24h
75
- var DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d
75
+ var DEFAULT_IDLE_THRESHOLD_MS = C.TIME.days(1); // 24h
76
+ var DEFAULT_MAX_AGE_MS = C.TIME.days(30); // 30d
76
77
  var DEFAULT_MAX_CARTS = 500;
77
78
  var MAX_MAX_CARTS = 5000;
78
79
 
@@ -64,6 +64,7 @@ function _b() {
64
64
  if (!bShop) bShop = require("./index");
65
65
  return bShop.framework;
66
66
  }
67
+ var C = _b().constants;
67
68
 
68
69
  var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,198}[a-z0-9])?$/;
69
70
  var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -530,7 +531,7 @@ function create(opts) {
530
531
  var groupKey = groupOrder[gi];
531
532
  var childId = _b().uuid.v7();
532
533
  var childSession = "sess_" + _b().uuid.v7().replace(/-/g, "").slice(0, 24);
533
- var childExpires = ts + 24 * 60 * 60 * 1000;
534
+ var childExpires = ts + C.TIME.days(1);
534
535
  await query(
535
536
  "INSERT INTO carts (id, session_id, customer_id, currency, status, created_at, updated_at, expires_at) " +
536
537
  "VALUES (?1, ?2, ?3, ?4, 'active', ?5, ?5, ?6)",
@@ -30,9 +30,9 @@
30
30
  * slug: "default-recovery",
31
31
  * title: "Default 3-step recovery",
32
32
  * steps: [
33
- * { step_index: 0, offset_ms: 1 * 3600 * 1000, kind: "reminder" },
34
- * { step_index: 1, offset_ms: 24 * 3600 * 1000, kind: "discount" },
35
- * { step_index: 2, offset_ms: 72 * 3600 * 1000, kind: "last_chance" },
33
+ * { step_index: 0, offset_ms: C.TIME.hours(1), kind: "reminder" },
34
+ * { step_index: 1, offset_ms: C.TIME.hours(24), kind: "discount" },
35
+ * { step_index: 2, offset_ms: C.TIME.hours(72), kind: "last_chance" },
36
36
  * ],
37
37
  * });
38
38
  *
@@ -115,6 +115,7 @@ function _b() {
115
115
  if (!bShop) bShop = require("./index");
116
116
  return bShop.framework;
117
117
  }
118
+ var C = _b().constants;
118
119
 
119
120
  // ---- constants ----------------------------------------------------------
120
121
 
@@ -732,7 +733,7 @@ function create(opts) {
732
733
  "UPDATE cart_recovery_enrollments SET " +
733
734
  "next_step_at = ?1, updated_at = ?2 " +
734
735
  "WHERE id = ?3 AND status = 'enrolled'",
735
- [now + 3600 * 1000, now, enr.id],
736
+ [now + C.TIME.hours(1), now, enr.id],
736
737
  );
737
738
  } else {
738
739
  // Clean send. Advance.
package/lib/cart.js CHANGED
@@ -29,10 +29,14 @@ function _b() {
29
29
  if (!bShop) bShop = require("./index");
30
30
  return bShop.framework;
31
31
  }
32
+ // Framework constants (C.TIME / C.BYTES duration + byte helpers). The
33
+ // index entry point exposes `framework` before the require cascade, so
34
+ // resolving this at module-eval is safe.
35
+ var C = _b().constants;
32
36
 
33
37
  var CART_STATUSES = Object.freeze(["active", "abandoned", "converted"]);
34
- var DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
35
- var MAX_QTY = 9999;
38
+ var DEFAULT_TTL_MS = C.TIME.days(30);
39
+ var MAX_QTY = 99999;
36
40
  var SESSION_ID_RE = /^[A-Za-z0-9_-]{16,64}$/; // shape-only; sealed-cookie origin
37
41
  var CURRENCY_RE = /^[A-Z]{3}$/;
38
42
 
@@ -104,7 +104,7 @@ var MAX_LIST_LIMIT = 200;
104
104
  var MAX_TAG_LEN = 64;
105
105
  var MAX_CHANGES_PER_DRAFT = 5000;
106
106
 
107
- var ROLLBACK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
107
+ var ROLLBACK_WINDOW_MS = _b().constants.TIME.days(7);
108
108
 
109
109
  // Slug shape matches the project's recurring slug convention used
110
110
  // across coupon-stacking / refund-policy / customer-segments — alnum
@@ -97,6 +97,7 @@ function _b() {
97
97
  if (!bShop) bShop = require("./index");
98
98
  return bShop.framework;
99
99
  }
100
+ var C = _b().constants;
100
101
 
101
102
  // ---- constants ----------------------------------------------------------
102
103
 
@@ -107,8 +108,8 @@ var MAX_NAME_LEN = 200;
107
108
  var MAX_REASON_LEN = 280;
108
109
  var MAX_SIGNATURE_LEN = 8192;
109
110
  var MAX_LIST_LIMIT = 200;
110
- var HOUR_MS = 3600 * 1000;
111
- var NO_SHOW_ESCALATE_MS = 7 * 24 * 60 * 60 * 1000;
111
+ var HOUR_MS = C.TIME.hours(1);
112
+ var NO_SHOW_ESCALATE_MS = C.TIME.days(7);
112
113
  var SIGNATURE_NAMESPACE = "click-and-collect-signature";
113
114
 
114
115
  var PICKUP_STATUSES = Object.freeze([