@asteby/storefront 0.16.1 → 0.18.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.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { a2 as IdentityRulesV1, ba as RegionFiscalId, a0 as IdentityDocumentSpec, b8 as Region, br as Shop, aX as Product, x as Collection, by as Theme, aC as Page, al as Menu, b as Article, C as Cart, A as Address, ab as InvoiceRequest, z as Customer, X as FiscalProfile, as as Order, aF as PaymentAttemptResult, aU as PendingPayment, ad as InvoiceStage, B as CustomerCapabilities, b2 as PromotionAnnouncement, bq as ShippingRate, aM as PaymentMethod } from './types-C5nEQicj.cjs';
1
+ import { bM as SubjectKind, a6 as IdentityRulesV1, bg as RegionFiscalId, a4 as IdentityDocumentSpec, be as Region, bx as Shop, a$ as Product, y as Collection, bP as Theme, aG as Page, ap as Menu, c as Article, C as Cart, a as Address, af as InvoiceRequest, B as Customer, $ as FiscalProfile, bG as Subject, aw as Order, aJ as PaymentAttemptResult, aY as PendingPayment, ah as InvoiceStage, D as CustomerCapabilities, b6 as PromotionAnnouncement, bw as ShippingRate, aQ as PaymentMethod } from './account-settings-DAzmXHIn.cjs';
2
2
  import './geo.cjs';
3
3
 
4
4
  /**
@@ -16,6 +16,8 @@ declare const MOCK_POSTAL_CENTROIDS: Record<string, {
16
16
  lng: number;
17
17
  }>;
18
18
 
19
+ declare const MOCK_SUBJECT_KINDS: SubjectKind[];
20
+
19
21
  /**
20
22
  * Region fixture of the mock: Mexico exactly as `GET /storefront/region` publishes it (see `region-mx.seed.ts`),
21
23
  * identity document `MX:csf` included, so a storefront can be built and tested without a server. It lives here,
@@ -89,6 +91,11 @@ type MockStorefrontOptions = {
89
91
  * carriers, ETAs and quote deadlines). `expires_at` missing: 24 h from the request. Free shipping still applies.
90
92
  */
91
93
  shippingRates?: ShippingRate[];
94
+ /**
95
+ * `fixed_when_free`: past `policies.free_shipping_min` `delivery-options` serves ONLY the shop's free rate and flags
96
+ * `free` / `fixed` (what the `storefront_cart` addon does with the setting of the same name). Default: the usual list.
97
+ */
98
+ shippingQuoteMode?: 'carriers' | 'fixed_when_free';
92
99
  /** Methods served by `payment-methods` (default one builtin `mock`). */
93
100
  paymentMethods?: PaymentMethod[];
94
101
  /**
@@ -96,6 +103,11 @@ type MockStorefrontOptions = {
96
103
  * organization has no region, the route answers `404` and invoice requests are not validated against a region.
97
104
  */
98
105
  region?: Region | null;
106
+ /**
107
+ * The kinds of buyer item («My items») the store declares, served by `GET /subject-kinds` and enforced by
108
+ * `customer.subjects`. Default none: no such section, as in a store that declares nothing (`MOCK_SUBJECT_KINDS` has two).
109
+ */
110
+ subjectKinds?: SubjectKind[];
99
111
  };
100
112
  type CartRecord = {
101
113
  id: string;
@@ -128,6 +140,10 @@ type MockState = {
128
140
  addresses: Map<string, Address[]>;
129
141
  /** Saved fiscal profiles by customer id (contract 1.9). */
130
142
  fiscalProfiles: Map<string, FiscalProfile[]>;
143
+ /** Saved items by customer id (contract 1.9); `private` holds the values the API only returns masked. */
144
+ subjects: Map<string, (Subject & {
145
+ private: Record<string, string>;
146
+ })[]>;
131
147
  orders: (Order & {
132
148
  customer_id: string | null;
133
149
  })[];
@@ -143,6 +159,21 @@ type MockState = {
143
159
  /** Consecutive failed password logins by email. */
144
160
  loginFailures: Map<string, number>;
145
161
  sessions: Map<string, string>;
162
+ /** What «Devices» shows for each session token (contract 1.9). */
163
+ sessionInfo: Map<string, {
164
+ id: string;
165
+ customer_id: string;
166
+ created_at: string;
167
+ last_used_at: string;
168
+ login_method: string;
169
+ reauth_at: number | null;
170
+ }>;
171
+ /** Wrong re-authentications per customer (5 and the mock answers 429). */
172
+ reauthFailures: Map<string, number>;
173
+ /** Data exports served per customer (3 a day). */
174
+ exportsServed: Map<string, number>;
175
+ /** Tokens of accounts that were deleted: deleting again answers 204, as the server does. */
176
+ deletedTokens: Set<string>;
146
177
  idempotency: Map<string, {
147
178
  status: number;
148
179
  body: unknown;
@@ -176,4 +207,4 @@ declare function createMockStorefront(options?: MockStorefrontOptions): {
176
207
  };
177
208
  type MockStorefront = ReturnType<typeof createMockStorefront>;
178
209
 
179
- export { MOCK_CSF_RULES, MOCK_CURRENCY, MOCK_CUSTOMER_EMAIL, MOCK_FISCAL_ID_MX, MOCK_IDENTITY_DOCUMENT_MX_CSF, MOCK_IMAGE_URL, MOCK_POSTAL_CENTROIDS, MOCK_REGION_MX, type MockImageUrl, type MockState, type MockStorefront, type MockStorefrontOptions, createMockStorefront };
210
+ export { MOCK_CSF_RULES, MOCK_CURRENCY, MOCK_CUSTOMER_EMAIL, MOCK_FISCAL_ID_MX, MOCK_IDENTITY_DOCUMENT_MX_CSF, MOCK_IMAGE_URL, MOCK_POSTAL_CENTROIDS, MOCK_REGION_MX, MOCK_SUBJECT_KINDS, type MockImageUrl, type MockState, type MockStorefront, type MockStorefrontOptions, createMockStorefront };
package/dist/mock.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a2 as IdentityRulesV1, ba as RegionFiscalId, a0 as IdentityDocumentSpec, b8 as Region, br as Shop, aX as Product, x as Collection, by as Theme, aC as Page, al as Menu, b as Article, C as Cart, A as Address, ab as InvoiceRequest, z as Customer, X as FiscalProfile, as as Order, aF as PaymentAttemptResult, aU as PendingPayment, ad as InvoiceStage, B as CustomerCapabilities, b2 as PromotionAnnouncement, bq as ShippingRate, aM as PaymentMethod } from './types-DSHzYSHU.js';
1
+ import { bM as SubjectKind, a6 as IdentityRulesV1, bg as RegionFiscalId, a4 as IdentityDocumentSpec, be as Region, bx as Shop, a$ as Product, y as Collection, bP as Theme, aG as Page, ap as Menu, c as Article, C as Cart, a as Address, af as InvoiceRequest, B as Customer, $ as FiscalProfile, bG as Subject, aw as Order, aJ as PaymentAttemptResult, aY as PendingPayment, ah as InvoiceStage, D as CustomerCapabilities, b6 as PromotionAnnouncement, bw as ShippingRate, aQ as PaymentMethod } from './account-settings-Cw3PamUA.js';
2
2
  import './geo.js';
3
3
 
4
4
  /**
@@ -16,6 +16,8 @@ declare const MOCK_POSTAL_CENTROIDS: Record<string, {
16
16
  lng: number;
17
17
  }>;
18
18
 
19
+ declare const MOCK_SUBJECT_KINDS: SubjectKind[];
20
+
19
21
  /**
20
22
  * Region fixture of the mock: Mexico exactly as `GET /storefront/region` publishes it (see `region-mx.seed.ts`),
21
23
  * identity document `MX:csf` included, so a storefront can be built and tested without a server. It lives here,
@@ -89,6 +91,11 @@ type MockStorefrontOptions = {
89
91
  * carriers, ETAs and quote deadlines). `expires_at` missing: 24 h from the request. Free shipping still applies.
90
92
  */
91
93
  shippingRates?: ShippingRate[];
94
+ /**
95
+ * `fixed_when_free`: past `policies.free_shipping_min` `delivery-options` serves ONLY the shop's free rate and flags
96
+ * `free` / `fixed` (what the `storefront_cart` addon does with the setting of the same name). Default: the usual list.
97
+ */
98
+ shippingQuoteMode?: 'carriers' | 'fixed_when_free';
92
99
  /** Methods served by `payment-methods` (default one builtin `mock`). */
93
100
  paymentMethods?: PaymentMethod[];
94
101
  /**
@@ -96,6 +103,11 @@ type MockStorefrontOptions = {
96
103
  * organization has no region, the route answers `404` and invoice requests are not validated against a region.
97
104
  */
98
105
  region?: Region | null;
106
+ /**
107
+ * The kinds of buyer item («My items») the store declares, served by `GET /subject-kinds` and enforced by
108
+ * `customer.subjects`. Default none: no such section, as in a store that declares nothing (`MOCK_SUBJECT_KINDS` has two).
109
+ */
110
+ subjectKinds?: SubjectKind[];
99
111
  };
100
112
  type CartRecord = {
101
113
  id: string;
@@ -128,6 +140,10 @@ type MockState = {
128
140
  addresses: Map<string, Address[]>;
129
141
  /** Saved fiscal profiles by customer id (contract 1.9). */
130
142
  fiscalProfiles: Map<string, FiscalProfile[]>;
143
+ /** Saved items by customer id (contract 1.9); `private` holds the values the API only returns masked. */
144
+ subjects: Map<string, (Subject & {
145
+ private: Record<string, string>;
146
+ })[]>;
131
147
  orders: (Order & {
132
148
  customer_id: string | null;
133
149
  })[];
@@ -143,6 +159,21 @@ type MockState = {
143
159
  /** Consecutive failed password logins by email. */
144
160
  loginFailures: Map<string, number>;
145
161
  sessions: Map<string, string>;
162
+ /** What «Devices» shows for each session token (contract 1.9). */
163
+ sessionInfo: Map<string, {
164
+ id: string;
165
+ customer_id: string;
166
+ created_at: string;
167
+ last_used_at: string;
168
+ login_method: string;
169
+ reauth_at: number | null;
170
+ }>;
171
+ /** Wrong re-authentications per customer (5 and the mock answers 429). */
172
+ reauthFailures: Map<string, number>;
173
+ /** Data exports served per customer (3 a day). */
174
+ exportsServed: Map<string, number>;
175
+ /** Tokens of accounts that were deleted: deleting again answers 204, as the server does. */
176
+ deletedTokens: Set<string>;
146
177
  idempotency: Map<string, {
147
178
  status: number;
148
179
  body: unknown;
@@ -176,4 +207,4 @@ declare function createMockStorefront(options?: MockStorefrontOptions): {
176
207
  };
177
208
  type MockStorefront = ReturnType<typeof createMockStorefront>;
178
209
 
179
- export { MOCK_CSF_RULES, MOCK_CURRENCY, MOCK_CUSTOMER_EMAIL, MOCK_FISCAL_ID_MX, MOCK_IDENTITY_DOCUMENT_MX_CSF, MOCK_IMAGE_URL, MOCK_POSTAL_CENTROIDS, MOCK_REGION_MX, type MockImageUrl, type MockState, type MockStorefront, type MockStorefrontOptions, createMockStorefront };
210
+ export { MOCK_CSF_RULES, MOCK_CURRENCY, MOCK_CUSTOMER_EMAIL, MOCK_FISCAL_ID_MX, MOCK_IDENTITY_DOCUMENT_MX_CSF, MOCK_IMAGE_URL, MOCK_POSTAL_CENTROIDS, MOCK_REGION_MX, MOCK_SUBJECT_KINDS, type MockImageUrl, type MockState, type MockStorefront, type MockStorefrontOptions, createMockStorefront };
package/dist/mock.js CHANGED
@@ -1,4 +1,4 @@
1
- import { resolveAvailability, isTestShipment, sortTrackingEvents, orderProgress, nextAction, orderShipments, shipmentsStatus } from './chunk-IPUAZ3LQ.js';
1
+ import { resolveAvailability, isMasked, isTestShipment, sortTrackingEvents, orderProgress, nextAction, orderShipments, shipmentsStatus, PRIVATE_MASK, resolveSubject } from './chunk-5BJRZIS3.js';
2
2
  import { isValidPoint, rankByDistance } from './chunk-45R47HYO.js';
3
3
 
4
4
  // src/mock/account-seed.ts
@@ -1511,6 +1511,55 @@ function buildCustomers() {
1511
1511
  ];
1512
1512
  }
1513
1513
 
1514
+ // src/mock/subjects.ts
1515
+ var MOCK_SUBJECT_KINDS = [
1516
+ {
1517
+ key: "vehicle",
1518
+ label: "Veh\xEDculo",
1519
+ label_plural: "Mi garage",
1520
+ max_per_account: 3,
1521
+ attach_to_order: true,
1522
+ fit_key_template: "{make}/{model}/{year}/{engine}",
1523
+ fields: [
1524
+ { key: "make", label: "Marca", type: "select", source: "catalog", required: true },
1525
+ { key: "model", label: "Modelo", type: "select", source: "catalog", depends_on: "make", required: true },
1526
+ { key: "year", label: "A\xF1o", type: "year", source: "catalog", depends_on: "model", required: true },
1527
+ { key: "engine", label: "Versi\xF3n / motor", type: "select", source: "catalog", depends_on: "year" },
1528
+ { key: "plate", label: "Placas (opcional)", type: "text", max: 12, private: true }
1529
+ ],
1530
+ catalog: {
1531
+ version: "mock-1",
1532
+ tree: {
1533
+ nissan: {
1534
+ label: "Nissan",
1535
+ children: {
1536
+ np300: {
1537
+ label: "NP300 Frontier",
1538
+ from: 2008,
1539
+ to: 2022,
1540
+ children: { "2.5": { label: "2.5 L", ranges: [[2010, 2015]] }, "2.4": { label: "2.4 L" } }
1541
+ },
1542
+ versa: { label: "Versa", from: 2012, to: 2024, children: { "1.6": { label: "1.6 L" } } }
1543
+ }
1544
+ },
1545
+ ford: { label: "Ford", children: { f150: { label: "F-150", from: 2e3, to: 2020, children: { "5.0": { label: "5.0 L" } } } } }
1546
+ }
1547
+ }
1548
+ },
1549
+ {
1550
+ key: "size",
1551
+ label: "Talla",
1552
+ label_plural: "Mis tallas",
1553
+ max_per_account: 5,
1554
+ fit_key_template: "{garment}/{value}",
1555
+ fields: [
1556
+ { key: "garment", label: "Prenda", type: "text", required: true },
1557
+ { key: "value", label: "Talla", type: "text", required: true, max: 8 }
1558
+ ],
1559
+ catalog: null
1560
+ }
1561
+ ];
1562
+
1514
1563
  // src/mock/index.ts
1515
1564
  var HttpError = class extends Error {
1516
1565
  constructor(status, code, message, field, details, requiredScopes) {
@@ -1585,6 +1634,10 @@ function createMockStorefront(options = {}) {
1585
1634
  online_verify: false
1586
1635
  },
1587
1636
  fiscal_profiles: { max: 10 },
1637
+ notifications: { channels: ["email"] },
1638
+ data_export: true,
1639
+ account_delete: true,
1640
+ ...(options.subjectKinds?.length ?? 0) > 0 ? { subjects: { kinds: options.subjectKinds.map((k) => k.key), share_with_workshop: false } } : {},
1588
1641
  ...options.customerCapabilities
1589
1642
  };
1590
1643
  const missingScopes = options.missingScopes ?? [];
@@ -1616,6 +1669,7 @@ function createMockStorefront(options = {}) {
1616
1669
  customers: buildCustomers(),
1617
1670
  addresses: /* @__PURE__ */ new Map(),
1618
1671
  fiscalProfiles: /* @__PURE__ */ new Map(),
1672
+ subjects: /* @__PURE__ */ new Map(),
1619
1673
  orders: [
1620
1674
  {
1621
1675
  id: "ord_seed_1",
@@ -1636,6 +1690,10 @@ function createMockStorefront(options = {}) {
1636
1690
  pendingRegistrations: /* @__PURE__ */ new Map(),
1637
1691
  loginFailures: /* @__PURE__ */ new Map(),
1638
1692
  sessions: /* @__PURE__ */ new Map(),
1693
+ sessionInfo: /* @__PURE__ */ new Map(),
1694
+ reauthFailures: /* @__PURE__ */ new Map(),
1695
+ exportsServed: /* @__PURE__ */ new Map(),
1696
+ deletedTokens: /* @__PURE__ */ new Set(),
1639
1697
  idempotency: /* @__PURE__ */ new Map(),
1640
1698
  checkouts: /* @__PURE__ */ new Map(),
1641
1699
  payments: /* @__PURE__ */ new Map(),
@@ -1717,6 +1775,9 @@ function createMockStorefront(options = {}) {
1717
1775
  if (list.length > 100) throw new HttpError(400, "validation", "At most 100 handles", "handles");
1718
1776
  items = items.filter((p) => list.includes(p.handle));
1719
1777
  }
1778
+ const tagsAny = (search.get("tags_any") ?? "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
1779
+ if (tagsAny.length > 5 || tagsAny.some((t) => t.length > 120)) throw new HttpError(400, "validation", "tags_any takes at most 5 tags of 120 characters", "tags_any");
1780
+ if (tagsAny.length > 0) items = items.filter((p) => p.tags.some((t) => tagsAny.includes(t.toLowerCase())));
1720
1781
  const base = items;
1721
1782
  if (search.get("available") === "true") items = items.filter((p) => p.available_for_sale);
1722
1783
  const priceMin = search.get("price_min");
@@ -1844,6 +1905,7 @@ function createMockStorefront(options = {}) {
1844
1905
  const expires = new Date(Date.now() + 24 * 36e5).toISOString();
1845
1906
  return options.shippingRates.map((r) => ({ ...r, expires_at: r.expires_at ?? expires, cost: free ? fromCents(0, r.cost.currency_code) : r.cost }));
1846
1907
  }
1908
+ if (free && options.shippingQuoteMode === "fixed_when_free") return [{ id: "flat", title: "Env\xEDo gratis", cost: fromCents(0, currency), eta: "2 a 5 d\xEDas h\xE1biles" }];
1847
1909
  return [
1848
1910
  { id: "flat", title: free ? "Env\xEDo est\xE1ndar (gratis)" : "Env\xEDo est\xE1ndar", cost: fromCents(free ? 0 : flatShippingCents, currency), eta: "2 a 5 d\xEDas h\xE1biles" },
1849
1911
  { id: "express", title: "Env\xEDo expr\xE9s", cost: fromCents(flatShippingCents + 1e4, currency), eta: "1 a 2 d\xEDas h\xE1biles" }
@@ -1986,8 +2048,13 @@ function createMockStorefront(options = {}) {
1986
2048
  }
1987
2049
  const token = `ct_${customer.id}_${Math.random().toString(36).slice(2)}`;
1988
2050
  state.sessions.set(token, customer.id);
2051
+ trackSession(token, "password");
1989
2052
  return { token, expires_at: new Date(Date.now() + 30 * 864e5).toISOString(), customer };
1990
2053
  }
2054
+ function trackSession(token, loginMethod) {
2055
+ const at = now();
2056
+ 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 });
2057
+ }
1991
2058
  function validateAddress(body, partial = false) {
1992
2059
  const required = ["address1", "city", "zip", "country_code"];
1993
2060
  if (!partial) {
@@ -2142,8 +2209,10 @@ function createMockStorefront(options = {}) {
2142
2209
  route("GET", "/carts/:id/delivery-options", "cart:write", ({ params }) => {
2143
2210
  const cart = renderCart(getCartRecord(params[0]));
2144
2211
  const net = toCents(cart.cost.subtotal.amount) - toCents(cart.cost.discount.amount);
2212
+ const rates = shippingRates(net);
2213
+ const free = options.shippingQuoteMode === "fixed_when_free" && rates.length > 0 && rates.every((r) => toCents(r.cost.amount) === 0);
2145
2214
  return {
2146
- body: { rates: shippingRates(net), pickup: state.shop.branches.filter((b) => b.pickup?.enabled !== false).map((b) => ({ branch_id: b.id, name: b.name, address: b.address, city: b.city })), partial: false, unavailable: [] },
2215
+ body: { rates, free, fixed: free, pickup: state.shop.branches.filter((b) => b.pickup?.enabled !== false).map((b) => ({ branch_id: b.id, name: b.name, address: b.address, city: b.city })), partial: false, unavailable: [] },
2147
2216
  cache: "private"
2148
2217
  };
2149
2218
  });
@@ -2415,6 +2484,7 @@ function createMockStorefront(options = {}) {
2415
2484
  }
2416
2485
  const token = `ct_${customer.id}_${Math.random().toString(36).slice(2)}`;
2417
2486
  state.sessions.set(token, customer.id);
2487
+ trackSession(token, kind);
2418
2488
  return {
2419
2489
  status: 201,
2420
2490
  body: { token, expires_at: new Date(Date.now() + 30 * 864e5).toISOString(), customer },
@@ -2489,7 +2559,8 @@ function createMockStorefront(options = {}) {
2489
2559
  state.loginFailures.delete(email);
2490
2560
  return { status: 204 };
2491
2561
  });
2492
- const publicCustomer = (c) => {
2562
+ const publicCustomer = (c0) => {
2563
+ const c = { ...c0, locale: c0.locale ?? null, preferences: c0.preferences ?? { notifications: {} } };
2493
2564
  if (!c.fiscal?.document) return c;
2494
2565
  const { sha256: _sha, ...document } = c.fiscal.document;
2495
2566
  return { ...c, fiscal: { ...c.fiscal, document } };
@@ -2501,6 +2572,8 @@ function createMockStorefront(options = {}) {
2501
2572
  if ("first_name" in body) c.first_name = text(body.first_name);
2502
2573
  if ("last_name" in body) c.last_name = text(body.last_name);
2503
2574
  if ("phone" in body) c.phone = text(body.phone);
2575
+ if ("locale" in body) c.locale = parseLocale(body.locale);
2576
+ if ("preferences" in body) c.preferences = parsePreferences(c.preferences, body.preferences);
2504
2577
  if ("fiscal" in body) {
2505
2578
  if (body.fiscal === null) c.fiscal = null;
2506
2579
  else {
@@ -2520,6 +2593,144 @@ function createMockStorefront(options = {}) {
2520
2593
  }
2521
2594
  return { body: publicCustomer(c), cache: "private" };
2522
2595
  });
2596
+ const OPTIONAL_EVENTS = /* @__PURE__ */ new Set(["order_updates", "shipping", "restock"]);
2597
+ const MANDATORY_EVENTS = /* @__PURE__ */ new Set(["order_confirmation", "payment", "invoice", "security"]);
2598
+ const CHANNELS = /* @__PURE__ */ new Set(["email", "whatsapp"]);
2599
+ const REAUTH_WINDOW_MS = 5 * 6e4;
2600
+ const supportedLocales = () => [state.shop.locale, ...state.shop.locales].filter((l, i, a) => l && a.indexOf(l) === i);
2601
+ function parseLocale(raw) {
2602
+ if (raw === null || typeof raw === "string" && !raw.trim()) return null;
2603
+ if (typeof raw !== "string") throw new HttpError(400, "validation", "locale must be a string", "locale");
2604
+ const tag = raw.trim().replace("_", "-");
2605
+ const [lang = "", ...rest] = tag.split("-");
2606
+ const canonical = [lang.toLowerCase(), ...rest.map((p) => p.toUpperCase())].join("-");
2607
+ const supported = supportedLocales();
2608
+ const hit = supported.find((l) => l.toLowerCase() === canonical.toLowerCase()) ?? (rest.length === 0 ? supported.find((l) => l.split("-")[0].toLowerCase() === canonical) : void 0);
2609
+ if (!hit) throw new HttpError(400, "validation", "this store is not available in that language", "locale");
2610
+ return hit;
2611
+ }
2612
+ function parsePreferences(current, raw) {
2613
+ if (raw === null) return { notifications: {} };
2614
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(400, "validation", "preferences must be an object", "preferences");
2615
+ const merged = structuredClone(current?.notifications ?? {});
2616
+ for (const [key, value] of Object.entries(raw)) {
2617
+ if (key !== "notifications") throw new HttpError(400, "validation", "unknown preference", `preferences.${key}`);
2618
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new HttpError(400, "validation", "notifications must map each event to its channels", "preferences.notifications");
2619
+ for (const [event, channels] of Object.entries(value)) {
2620
+ const field = `preferences.notifications.${event}`;
2621
+ if (MANDATORY_EVENTS.has(event)) throw new HttpError(400, "validation", "this notification is required and cannot be turned off", field);
2622
+ if (!OPTIONAL_EVENTS.has(event)) throw new HttpError(400, "validation", "unknown notification", field);
2623
+ if (!channels || typeof channels !== "object" || Array.isArray(channels)) throw new HttpError(400, "validation", "each event maps its channels to true or false", field);
2624
+ for (const [channel, on] of Object.entries(channels)) {
2625
+ if (!CHANNELS.has(channel)) throw new HttpError(400, "validation", "unknown channel", `${field}.${channel}`);
2626
+ if (typeof on !== "boolean") throw new HttpError(400, "validation", "each channel must be true or false", `${field}.${channel}`);
2627
+ (merged[event] ??= {})[channel] = on;
2628
+ }
2629
+ }
2630
+ }
2631
+ return { notifications: merged };
2632
+ }
2633
+ function sessionOf(headers) {
2634
+ const c = requireCustomer(headers);
2635
+ const token = headers.get("x-customer-token");
2636
+ if (!state.sessionInfo.has(token)) trackSession(token, "email");
2637
+ return { c, token, info: state.sessionInfo.get(token) };
2638
+ }
2639
+ function requireRecentReauth(info) {
2640
+ if (info.reauth_at === null || Date.now() - info.reauth_at > REAUTH_WINDOW_MS) {
2641
+ throw new HttpError(403, "reauth_required", "confirm your password or a code first (customer.reauth)");
2642
+ }
2643
+ }
2644
+ route("GET", "/customer/sessions", "customer:read", ({ headers }) => {
2645
+ const { c, token } = sessionOf(headers);
2646
+ 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 }) => ({
2647
+ id: info.id,
2648
+ current: t === token,
2649
+ device_label: "Mock browser \xB7 Test",
2650
+ login_method: info.login_method,
2651
+ last_used_at: info.last_used_at,
2652
+ created_at: info.created_at
2653
+ }));
2654
+ return { body: { nodes }, cache: "private" };
2655
+ });
2656
+ route("DELETE", "/customer/sessions/:id", "customer:write", ({ headers, params }) => {
2657
+ const { c, token } = sessionOf(headers);
2658
+ const mine = [...state.sessions.entries()].filter(([, id]) => id === c.id).map(([t]) => t);
2659
+ if (params[0] === "others") {
2660
+ for (const t of mine) if (t !== token) state.sessions.delete(t);
2661
+ return { status: 204 };
2662
+ }
2663
+ const entry = [...state.sessionInfo.entries()].find(([, i]) => i.id === params[0]);
2664
+ if (!entry || entry[1].customer_id !== c.id) throw new HttpError(404, "not_found", "Session not found", "id");
2665
+ state.sessions.delete(entry[0]);
2666
+ return { status: 204 };
2667
+ });
2668
+ route("POST", "/customer/reauth", "customer:write", ({ headers, body }) => {
2669
+ const { c, info } = sessionOf(headers);
2670
+ const hasPassword = typeof body.password === "string" && body.password !== "";
2671
+ const hasCode = typeof body.otp_code === "string" && body.otp_code !== "";
2672
+ if (hasPassword === hasCode) throw new HttpError(400, "validation", "send either password or otp_code", "password");
2673
+ if ((state.reauthFailures.get(c.id) ?? 0) >= 5) throw new HttpError(429, "rate_limited", "Too many attempts, try again later");
2674
+ const field = hasPassword ? "password" : "otp_code";
2675
+ 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);
2676
+ if (!ok) {
2677
+ state.reauthFailures.set(c.id, (state.reauthFailures.get(c.id) ?? 0) + 1);
2678
+ throw new HttpError(400, "validation", "could not verify; check the credential and try again", field);
2679
+ }
2680
+ state.reauthFailures.delete(c.id);
2681
+ info.reauth_at = Date.now();
2682
+ return { body: { reauth_at: new Date(info.reauth_at).toISOString(), valid_for_seconds: REAUTH_WINDOW_MS / 1e3 }, cache: "private" };
2683
+ });
2684
+ route("POST", "/customer/data-export", "customer:write", ({ headers }) => {
2685
+ const { c, info } = sessionOf(headers);
2686
+ requireRecentReauth(info);
2687
+ if ((state.exportsServed.get(c.id) ?? 0) >= 3) throw new HttpError(429, "rate_limited", "Three exports a day; try again tomorrow");
2688
+ state.exportsServed.set(c.id, (state.exportsServed.get(c.id) ?? 0) + 1);
2689
+ const doc = {
2690
+ format: "storefront-customer-data/1",
2691
+ exported_at: now(),
2692
+ 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 },
2693
+ preferences: c.preferences ?? { notifications: {} },
2694
+ addresses: state.addresses.get(c.id) ?? [],
2695
+ fiscal_profiles: state.fiscalProfiles.get(c.id) ?? [],
2696
+ 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 }))
2697
+ };
2698
+ return {
2699
+ body: {
2700
+ filename: `account-data-${now().slice(0, 10)}.json`,
2701
+ content_type: "application/json",
2702
+ content_base64: btoa(unescape(encodeURIComponent(JSON.stringify(doc, null, 2))))
2703
+ },
2704
+ cache: "private"
2705
+ };
2706
+ });
2707
+ route("DELETE", "/customer", "customer:write", ({ headers, body }) => {
2708
+ const token = headers.get("x-customer-token");
2709
+ if (token && state.deletedTokens.has(token)) return { status: 204 };
2710
+ const { c, info } = sessionOf(headers);
2711
+ requireRecentReauth(info);
2712
+ if (typeof body.confirm !== "string" || !body.confirm.trim() || body.confirm.length > 100) {
2713
+ throw new HttpError(400, "validation", "confirm is required: the text the buyer typed", "confirm");
2714
+ }
2715
+ const closed = /* @__PURE__ */ new Set(["fulfilled", "delivered", "cancelled"]);
2716
+ if (state.orders.some((o) => o.customer_id === c.id && !closed.has(o.status))) {
2717
+ throw new HttpError(409, "conflict", "you have an order in progress; you can delete the account once it is delivered", "orders");
2718
+ }
2719
+ if (c.id === "cus_1" && state.pendingPayments.some((p) => p.status === "pending_payment")) {
2720
+ throw new HttpError(409, "conflict", "you have a payment waiting to be completed", "pending_payments");
2721
+ }
2722
+ for (const [t, id] of [...state.sessions.entries()]) {
2723
+ if (id === c.id) {
2724
+ state.sessions.delete(t);
2725
+ state.deletedTokens.add(t);
2726
+ }
2727
+ }
2728
+ if (c.email) state.passwords.delete(c.email);
2729
+ state.addresses.delete(c.id);
2730
+ state.fiscalProfiles.delete(c.id);
2731
+ state.customers = state.customers.filter((x) => x.id !== c.id);
2732
+ return { status: 204 };
2733
+ });
2523
2734
  const orderView = ({ customer_id: _c, ...o }, detail) => {
2524
2735
  const order = { ...o, invoice: orderInvoice(o) };
2525
2736
  if (order.shipments) order.shipments = order.shipments.map((s) => isTestShipment(s) ? { ...s, tracking_number: null, tracking_url: null } : { ...s, events: sortTrackingEvents(s.events) });
@@ -2676,6 +2887,89 @@ function createMockStorefront(options = {}) {
2676
2887
  mirrorDefaultProfile(c);
2677
2888
  return { status: 204 };
2678
2889
  });
2890
+ const MAX_SUBJECTS = 50;
2891
+ const subjectsOf = (c) => state.subjects.get(c.id) ?? [];
2892
+ const kinds = () => options.subjectKinds ?? [];
2893
+ const publicSubject = (s) => {
2894
+ const { private: priv, ...rest } = s;
2895
+ return { ...rest, attributes: { ...s.attributes, ...Object.fromEntries(Object.keys(priv).map((k) => [k, PRIVATE_MASK + (priv[k].length > 3 ? priv[k].slice(-2) : "")])) } };
2896
+ };
2897
+ const ownSubject = (c, id) => {
2898
+ const s = subjectsOf(c).find((x) => x.id === id);
2899
+ if (!s) throw new HttpError(404, "not_found", "Item not found", "id");
2900
+ return s;
2901
+ };
2902
+ const setDefaultSubject = (c, id) => {
2903
+ const kind = ownSubject(c, id).kind;
2904
+ state.subjects.set(c.id, subjectsOf(c).map((s) => s.kind !== kind ? s : s.id === id ? { ...s, is_default: true, last_used_at: now() } : { ...s, is_default: false }));
2905
+ };
2906
+ const resolveOrThrow = (kind, values) => {
2907
+ const r = resolveSubject(kind, values);
2908
+ if (!r.ok) {
2909
+ const [field, code] = Object.entries(r.problems)[0];
2910
+ throw new HttpError(422, "validation", `${field}: ${code}`, field === "_" ? "attributes" : `attributes.${field}`);
2911
+ }
2912
+ return r.subject;
2913
+ };
2914
+ const cleanNickname = (v) => {
2915
+ if (v === void 0 || v === null || v === "") return null;
2916
+ if (typeof v !== "string" || [...v.trim()].length > 40) throw new HttpError(422, "validation", "nickname is too long (max 40)", "nickname");
2917
+ return v.trim().split(/\s+/).join(" ");
2918
+ };
2919
+ route("GET", "/subject-kinds", "content:read", () => ({ body: { nodes: kinds() }, cache: "public" }));
2920
+ route("GET", "/customer/subjects", "customer:read", ({ headers, search }) => {
2921
+ const c = requireCustomer(headers);
2922
+ const kind = search.get("kind");
2923
+ const nodes = subjectsOf(c).filter((s) => !kind || s.kind === kind).sort((a, b) => a.kind.localeCompare(b.kind) || Number(b.is_default) - Number(a.is_default) || (b.last_used_at ?? "").localeCompare(a.last_used_at ?? ""));
2924
+ return { body: { nodes: nodes.map(publicSubject) }, cache: "private" };
2925
+ });
2926
+ route("POST", "/customer/subjects", "customer:write", ({ headers, body }) => {
2927
+ const c = requireCustomer(headers);
2928
+ const kind = kinds().find((k) => k.key === body.kind);
2929
+ if (!kind) throw new HttpError(422, "validation", "This store has no such kind", "kind");
2930
+ const attrs = body.attributes;
2931
+ if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) throw new HttpError(422, "validation", "attributes must be an object", "attributes");
2932
+ const r = resolveOrThrow(kind, attrs);
2933
+ const list = subjectsOf(c);
2934
+ if (list.length >= MAX_SUBJECTS || list.filter((s) => s.kind === kind.key).length >= kind.max_per_account) throw new HttpError(409, "conflict", "Limit reached; delete one first", "kind");
2935
+ if (r.fit_key && list.some((s) => s.kind === kind.key && s.fit_key === r.fit_key)) throw new HttpError(409, "conflict", "You already saved this item", "attributes");
2936
+ const subject = { id: nextId("subj"), kind: kind.key, label: r.label, nickname: cleanNickname(body.nickname), fit_key: r.fit_key, attributes: r.attributes, private: r.private, is_default: false, last_used_at: null, created_at: now() };
2937
+ state.subjects.set(c.id, [...list, subject]);
2938
+ if (!list.some((s) => s.kind === kind.key) || body.is_default === true) setDefaultSubject(c, subject.id);
2939
+ return { status: 201, body: publicSubject(ownSubject(c, subject.id)), cache: "private" };
2940
+ });
2941
+ route("PATCH", "/customer/subjects/:id", "customer:write", ({ headers, params, body }) => {
2942
+ const c = requireCustomer(headers);
2943
+ const cur = ownSubject(c, params[0]);
2944
+ if (body.is_default === false && cur.is_default) throw new HttpError(422, "validation", "Make another item the default instead", "is_default");
2945
+ let next = { ...cur };
2946
+ if ("nickname" in body) next.nickname = cleanNickname(body.nickname);
2947
+ if ("attributes" in body) {
2948
+ const kind = kinds().find((k) => k.key === cur.kind);
2949
+ if (!kind) throw new HttpError(422, "validation", "This store no longer offers this kind", "kind");
2950
+ const sent = body.attributes ?? {};
2951
+ const merged = { ...cur.attributes, ...cur.private };
2952
+ for (const [k, v] of Object.entries(sent)) {
2953
+ if (k in cur.private && isMasked(v)) continue;
2954
+ if (v === null) delete merged[k];
2955
+ else merged[k] = v;
2956
+ }
2957
+ const r = resolveOrThrow(kind, merged);
2958
+ if (r.fit_key && subjectsOf(c).some((s) => s.id !== cur.id && s.kind === cur.kind && s.fit_key === r.fit_key)) throw new HttpError(409, "conflict", "You already saved this item", "attributes");
2959
+ next = { ...next, label: r.label, fit_key: r.fit_key, attributes: r.attributes, private: r.private };
2960
+ }
2961
+ state.subjects.set(c.id, subjectsOf(c).map((s) => s.id === cur.id ? next : s));
2962
+ if (body.is_default === true && !cur.is_default) setDefaultSubject(c, cur.id);
2963
+ return { body: publicSubject(ownSubject(c, cur.id)), cache: "private" };
2964
+ });
2965
+ route("DELETE", "/customer/subjects/:id", "customer:write", ({ headers, params }) => {
2966
+ const c = requireCustomer(headers);
2967
+ const cur = ownSubject(c, params[0]);
2968
+ state.subjects.set(c.id, subjectsOf(c).filter((s) => s.id !== cur.id));
2969
+ const heir = subjectsOf(c).filter((s) => s.kind === cur.kind).sort((a, b) => (b.last_used_at ?? "").localeCompare(a.last_used_at ?? ""))[0];
2970
+ if (cur.is_default && heir) setDefaultSubject(c, heir.id);
2971
+ return { status: 204 };
2972
+ });
2679
2973
  route("GET", "/customer/addresses", "customer:read", ({ headers }) => {
2680
2974
  const c = requireCustomer(headers);
2681
2975
  return { body: { nodes: state.addresses.get(c.id) ?? [] }, cache: "private" };
@@ -2772,7 +3066,7 @@ function createMockStorefront(options = {}) {
2772
3066
  return errorResponse(new HttpError(403, "insufficient_scope", `Token lacks scope ${r.scope}`, void 0, void 0, [r.scope]));
2773
3067
  }
2774
3068
  let body = {};
2775
- if (method !== "GET" && method !== "DELETE") {
3069
+ if (method !== "GET") {
2776
3070
  const text = await request.text();
2777
3071
  if (text) {
2778
3072
  try {
@@ -2825,6 +3119,6 @@ function createMockStorefront(options = {}) {
2825
3119
  };
2826
3120
  }
2827
3121
 
2828
- export { MOCK_CSF_RULES, MOCK_CURRENCY, MOCK_CUSTOMER_EMAIL, MOCK_FISCAL_ID_MX, MOCK_IDENTITY_DOCUMENT_MX_CSF, MOCK_IMAGE_URL, MOCK_POSTAL_CENTROIDS, MOCK_REGION_MX, createMockStorefront };
3122
+ export { MOCK_CSF_RULES, MOCK_CURRENCY, MOCK_CUSTOMER_EMAIL, MOCK_FISCAL_ID_MX, MOCK_IDENTITY_DOCUMENT_MX_CSF, MOCK_IMAGE_URL, MOCK_POSTAL_CENTROIDS, MOCK_REGION_MX, MOCK_SUBJECT_KINDS, createMockStorefront };
2829
3123
  //# sourceMappingURL=mock.js.map
2830
3124
  //# sourceMappingURL=mock.js.map