@asteby/storefront 0.16.0 → 0.17.0

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/dist/mock.cjs CHANGED
@@ -1757,6 +1757,9 @@ function createMockStorefront(options = {}) {
1757
1757
  online_verify: false
1758
1758
  },
1759
1759
  fiscal_profiles: { max: 10 },
1760
+ notifications: { channels: ["email"] },
1761
+ data_export: true,
1762
+ account_delete: true,
1760
1763
  ...options.customerCapabilities
1761
1764
  };
1762
1765
  const missingScopes = options.missingScopes ?? [];
@@ -1808,6 +1811,10 @@ function createMockStorefront(options = {}) {
1808
1811
  pendingRegistrations: /* @__PURE__ */ new Map(),
1809
1812
  loginFailures: /* @__PURE__ */ new Map(),
1810
1813
  sessions: /* @__PURE__ */ new Map(),
1814
+ sessionInfo: /* @__PURE__ */ new Map(),
1815
+ reauthFailures: /* @__PURE__ */ new Map(),
1816
+ exportsServed: /* @__PURE__ */ new Map(),
1817
+ deletedTokens: /* @__PURE__ */ new Set(),
1811
1818
  idempotency: /* @__PURE__ */ new Map(),
1812
1819
  checkouts: /* @__PURE__ */ new Map(),
1813
1820
  payments: /* @__PURE__ */ new Map(),
@@ -2158,8 +2165,13 @@ function createMockStorefront(options = {}) {
2158
2165
  }
2159
2166
  const token = `ct_${customer.id}_${Math.random().toString(36).slice(2)}`;
2160
2167
  state.sessions.set(token, customer.id);
2168
+ trackSession(token, "password");
2161
2169
  return { token, expires_at: new Date(Date.now() + 30 * 864e5).toISOString(), customer };
2162
2170
  }
2171
+ function trackSession(token, loginMethod) {
2172
+ const at = now();
2173
+ state.sessionInfo.set(token, { id: nextId("ses"), customer_id: state.sessions.get(token) ?? "", created_at: at, last_used_at: at, login_method: loginMethod, reauth_at: null });
2174
+ }
2163
2175
  function validateAddress(body, partial = false) {
2164
2176
  const required = ["address1", "city", "zip", "country_code"];
2165
2177
  if (!partial) {
@@ -2587,6 +2599,7 @@ function createMockStorefront(options = {}) {
2587
2599
  }
2588
2600
  const token = `ct_${customer.id}_${Math.random().toString(36).slice(2)}`;
2589
2601
  state.sessions.set(token, customer.id);
2602
+ trackSession(token, kind);
2590
2603
  return {
2591
2604
  status: 201,
2592
2605
  body: { token, expires_at: new Date(Date.now() + 30 * 864e5).toISOString(), customer },
@@ -2661,7 +2674,8 @@ function createMockStorefront(options = {}) {
2661
2674
  state.loginFailures.delete(email);
2662
2675
  return { status: 204 };
2663
2676
  });
2664
- const publicCustomer = (c) => {
2677
+ const publicCustomer = (c0) => {
2678
+ const c = { ...c0, locale: c0.locale ?? null, preferences: c0.preferences ?? { notifications: {} } };
2665
2679
  if (!c.fiscal?.document) return c;
2666
2680
  const { sha256: _sha, ...document } = c.fiscal.document;
2667
2681
  return { ...c, fiscal: { ...c.fiscal, document } };
@@ -2673,6 +2687,8 @@ function createMockStorefront(options = {}) {
2673
2687
  if ("first_name" in body) c.first_name = text(body.first_name);
2674
2688
  if ("last_name" in body) c.last_name = text(body.last_name);
2675
2689
  if ("phone" in body) c.phone = text(body.phone);
2690
+ if ("locale" in body) c.locale = parseLocale(body.locale);
2691
+ if ("preferences" in body) c.preferences = parsePreferences(c.preferences, body.preferences);
2676
2692
  if ("fiscal" in body) {
2677
2693
  if (body.fiscal === null) c.fiscal = null;
2678
2694
  else {
@@ -2692,6 +2708,144 @@ function createMockStorefront(options = {}) {
2692
2708
  }
2693
2709
  return { body: publicCustomer(c), cache: "private" };
2694
2710
  });
2711
+ const OPTIONAL_EVENTS = /* @__PURE__ */ new Set(["order_updates", "shipping", "restock"]);
2712
+ const MANDATORY_EVENTS = /* @__PURE__ */ new Set(["order_confirmation", "payment", "invoice", "security"]);
2713
+ const CHANNELS = /* @__PURE__ */ new Set(["email", "whatsapp"]);
2714
+ const REAUTH_WINDOW_MS = 5 * 6e4;
2715
+ const supportedLocales = () => [state.shop.locale, ...state.shop.locales].filter((l, i, a) => l && a.indexOf(l) === i);
2716
+ function parseLocale(raw) {
2717
+ if (raw === null || typeof raw === "string" && !raw.trim()) return null;
2718
+ if (typeof raw !== "string") throw new HttpError(400, "validation", "locale must be a string", "locale");
2719
+ const tag = raw.trim().replace("_", "-");
2720
+ const [lang = "", ...rest] = tag.split("-");
2721
+ const canonical = [lang.toLowerCase(), ...rest.map((p) => p.toUpperCase())].join("-");
2722
+ const supported = supportedLocales();
2723
+ const hit = supported.find((l) => l.toLowerCase() === canonical.toLowerCase()) ?? (rest.length === 0 ? supported.find((l) => l.split("-")[0].toLowerCase() === canonical) : void 0);
2724
+ if (!hit) throw new HttpError(400, "validation", "this store is not available in that language", "locale");
2725
+ return hit;
2726
+ }
2727
+ function parsePreferences(current, raw) {
2728
+ if (raw === null) return { notifications: {} };
2729
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(400, "validation", "preferences must be an object", "preferences");
2730
+ const merged = structuredClone(current?.notifications ?? {});
2731
+ for (const [key, value] of Object.entries(raw)) {
2732
+ if (key !== "notifications") throw new HttpError(400, "validation", "unknown preference", `preferences.${key}`);
2733
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new HttpError(400, "validation", "notifications must map each event to its channels", "preferences.notifications");
2734
+ for (const [event, channels] of Object.entries(value)) {
2735
+ const field = `preferences.notifications.${event}`;
2736
+ if (MANDATORY_EVENTS.has(event)) throw new HttpError(400, "validation", "this notification is required and cannot be turned off", field);
2737
+ if (!OPTIONAL_EVENTS.has(event)) throw new HttpError(400, "validation", "unknown notification", field);
2738
+ if (!channels || typeof channels !== "object" || Array.isArray(channels)) throw new HttpError(400, "validation", "each event maps its channels to true or false", field);
2739
+ for (const [channel, on] of Object.entries(channels)) {
2740
+ if (!CHANNELS.has(channel)) throw new HttpError(400, "validation", "unknown channel", `${field}.${channel}`);
2741
+ if (typeof on !== "boolean") throw new HttpError(400, "validation", "each channel must be true or false", `${field}.${channel}`);
2742
+ (merged[event] ??= {})[channel] = on;
2743
+ }
2744
+ }
2745
+ }
2746
+ return { notifications: merged };
2747
+ }
2748
+ function sessionOf(headers) {
2749
+ const c = requireCustomer(headers);
2750
+ const token = headers.get("x-customer-token");
2751
+ if (!state.sessionInfo.has(token)) trackSession(token, "email");
2752
+ return { c, token, info: state.sessionInfo.get(token) };
2753
+ }
2754
+ function requireRecentReauth(info) {
2755
+ if (info.reauth_at === null || Date.now() - info.reauth_at > REAUTH_WINDOW_MS) {
2756
+ throw new HttpError(403, "reauth_required", "confirm your password or a code first (customer.reauth)");
2757
+ }
2758
+ }
2759
+ route("GET", "/customer/sessions", "customer:read", ({ headers }) => {
2760
+ const { c, token } = sessionOf(headers);
2761
+ const nodes = [...state.sessions.entries()].filter(([, id]) => id === c.id).map(([t]) => ({ t, info: state.sessionInfo.get(t) })).sort((a, b) => b.info.last_used_at.localeCompare(a.info.last_used_at)).map(({ t, info }) => ({
2762
+ id: info.id,
2763
+ current: t === token,
2764
+ device_label: "Mock browser \xB7 Test",
2765
+ login_method: info.login_method,
2766
+ last_used_at: info.last_used_at,
2767
+ created_at: info.created_at
2768
+ }));
2769
+ return { body: { nodes }, cache: "private" };
2770
+ });
2771
+ route("DELETE", "/customer/sessions/:id", "customer:write", ({ headers, params }) => {
2772
+ const { c, token } = sessionOf(headers);
2773
+ const mine = [...state.sessions.entries()].filter(([, id]) => id === c.id).map(([t]) => t);
2774
+ if (params[0] === "others") {
2775
+ for (const t of mine) if (t !== token) state.sessions.delete(t);
2776
+ return { status: 204 };
2777
+ }
2778
+ const entry = [...state.sessionInfo.entries()].find(([, i]) => i.id === params[0]);
2779
+ if (!entry || entry[1].customer_id !== c.id) throw new HttpError(404, "not_found", "Session not found", "id");
2780
+ state.sessions.delete(entry[0]);
2781
+ return { status: 204 };
2782
+ });
2783
+ route("POST", "/customer/reauth", "customer:write", ({ headers, body }) => {
2784
+ const { c, info } = sessionOf(headers);
2785
+ const hasPassword = typeof body.password === "string" && body.password !== "";
2786
+ const hasCode = typeof body.otp_code === "string" && body.otp_code !== "";
2787
+ if (hasPassword === hasCode) throw new HttpError(400, "validation", "send either password or otp_code", "password");
2788
+ if ((state.reauthFailures.get(c.id) ?? 0) >= 5) throw new HttpError(429, "rate_limited", "Too many attempts, try again later");
2789
+ const field = hasPassword ? "password" : "otp_code";
2790
+ const ok = hasPassword ? c.email !== null && state.passwords.get(c.email) === body.password : [c.email && `email:${c.email}`, c.phone && `phone:${c.phone}`].some((id) => !!id && state.otps.get(id) === body.otp_code);
2791
+ if (!ok) {
2792
+ state.reauthFailures.set(c.id, (state.reauthFailures.get(c.id) ?? 0) + 1);
2793
+ throw new HttpError(400, "validation", "could not verify; check the credential and try again", field);
2794
+ }
2795
+ state.reauthFailures.delete(c.id);
2796
+ info.reauth_at = Date.now();
2797
+ return { body: { reauth_at: new Date(info.reauth_at).toISOString(), valid_for_seconds: REAUTH_WINDOW_MS / 1e3 }, cache: "private" };
2798
+ });
2799
+ route("POST", "/customer/data-export", "customer:write", ({ headers }) => {
2800
+ const { c, info } = sessionOf(headers);
2801
+ requireRecentReauth(info);
2802
+ if ((state.exportsServed.get(c.id) ?? 0) >= 3) throw new HttpError(429, "rate_limited", "Three exports a day; try again tomorrow");
2803
+ state.exportsServed.set(c.id, (state.exportsServed.get(c.id) ?? 0) + 1);
2804
+ const doc = {
2805
+ format: "storefront-customer-data/1",
2806
+ exported_at: now(),
2807
+ account: { id: c.id, email: c.email, phone: c.phone, first_name: c.first_name, last_name: c.last_name, locale: c.locale ?? null, created_at: c.created_at },
2808
+ preferences: c.preferences ?? { notifications: {} },
2809
+ addresses: state.addresses.get(c.id) ?? [],
2810
+ fiscal_profiles: state.fiscalProfiles.get(c.id) ?? [],
2811
+ orders: state.orders.filter((o) => o.customer_id === c.id).map((o) => ({ id: o.id, number: o.number, created_at: o.created_at, status: o.status, total: o.total }))
2812
+ };
2813
+ return {
2814
+ body: {
2815
+ filename: `account-data-${now().slice(0, 10)}.json`,
2816
+ content_type: "application/json",
2817
+ content_base64: btoa(unescape(encodeURIComponent(JSON.stringify(doc, null, 2))))
2818
+ },
2819
+ cache: "private"
2820
+ };
2821
+ });
2822
+ route("DELETE", "/customer", "customer:write", ({ headers, body }) => {
2823
+ const token = headers.get("x-customer-token");
2824
+ if (token && state.deletedTokens.has(token)) return { status: 204 };
2825
+ const { c, info } = sessionOf(headers);
2826
+ requireRecentReauth(info);
2827
+ if (typeof body.confirm !== "string" || !body.confirm.trim() || body.confirm.length > 100) {
2828
+ throw new HttpError(400, "validation", "confirm is required: the text the buyer typed", "confirm");
2829
+ }
2830
+ const closed = /* @__PURE__ */ new Set(["fulfilled", "delivered", "cancelled"]);
2831
+ if (state.orders.some((o) => o.customer_id === c.id && !closed.has(o.status))) {
2832
+ throw new HttpError(409, "conflict", "you have an order in progress; you can delete the account once it is delivered", "orders");
2833
+ }
2834
+ if (c.id === "cus_1" && state.pendingPayments.some((p) => p.status === "pending_payment")) {
2835
+ throw new HttpError(409, "conflict", "you have a payment waiting to be completed", "pending_payments");
2836
+ }
2837
+ for (const [t, id] of [...state.sessions.entries()]) {
2838
+ if (id === c.id) {
2839
+ state.sessions.delete(t);
2840
+ state.deletedTokens.add(t);
2841
+ }
2842
+ }
2843
+ if (c.email) state.passwords.delete(c.email);
2844
+ state.addresses.delete(c.id);
2845
+ state.fiscalProfiles.delete(c.id);
2846
+ state.customers = state.customers.filter((x) => x.id !== c.id);
2847
+ return { status: 204 };
2848
+ });
2695
2849
  const orderView = ({ customer_id: _c, ...o }, detail) => {
2696
2850
  const order = { ...o, invoice: orderInvoice(o) };
2697
2851
  if (order.shipments) order.shipments = order.shipments.map((s) => isTestShipment(s) ? { ...s, tracking_number: null, tracking_url: null } : { ...s, events: sortTrackingEvents(s.events) });
@@ -2944,7 +3098,7 @@ function createMockStorefront(options = {}) {
2944
3098
  return errorResponse(new HttpError(403, "insufficient_scope", `Token lacks scope ${r.scope}`, void 0, void 0, [r.scope]));
2945
3099
  }
2946
3100
  let body = {};
2947
- if (method !== "GET" && method !== "DELETE") {
3101
+ if (method !== "GET") {
2948
3102
  const text = await request.text();
2949
3103
  if (text) {
2950
3104
  try {