@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.cjs CHANGED
@@ -1347,6 +1347,111 @@ function maskTaxId(taxId) {
1347
1347
  return v.length <= 4 ? "*".repeat(v.length) : `${v.slice(0, 2)}${"*".repeat(v.length - 5)}${v.slice(-3)}`;
1348
1348
  }
1349
1349
 
1350
+ // src/subjects.ts
1351
+ var MIN_SUBJECT_YEAR = 1900;
1352
+ var MAX_SUBJECT_YEAR = 2100;
1353
+ var MAX_VALUE_LENGTH = 80;
1354
+ var MAX_LABEL_LENGTH = 120;
1355
+ var MAX_FIT_KEY_LENGTH = 100;
1356
+ var isPresent = (v) => v !== null && v !== void 0 && v !== "";
1357
+ var asYear = (v) => {
1358
+ const n = typeof v === "number" ? v : typeof v === "string" && /^\s*\d+\s*$/.test(v) ? Number(v) : NaN;
1359
+ return Number.isInteger(n) && n >= MIN_SUBJECT_YEAR && n <= MAX_SUBJECT_YEAR ? n : null;
1360
+ };
1361
+ var cleanText = (v, max) => {
1362
+ if (typeof v !== "string") return null;
1363
+ const s = v.split(/\s+/).filter(Boolean).join(" ");
1364
+ return [...s].length > max || /[\u0000-\u001f\u007f-\u009f]/.test(s) ? null : s;
1365
+ };
1366
+ var slug = (s) => s.toLowerCase().replace(/[^a-z0-9._+-]+/g, "-").replace(/^-+|-+$/g, "");
1367
+ function coversYear(node, year) {
1368
+ if (node.ranges && node.ranges.length > 0) return node.ranges.some(([a, b]) => year >= a && year <= b);
1369
+ if (node.from !== void 0 && year < node.from) return false;
1370
+ if (node.to !== void 0 && year > node.to) return false;
1371
+ return true;
1372
+ }
1373
+ function resolveSubject(kind, values) {
1374
+ const problems = {};
1375
+ const known = new Set(kind.fields.map((f) => f.key));
1376
+ for (const key2 of Object.keys(values)) if (isPresent(values[key2]) && !known.has(key2)) problems[key2] = "unknown";
1377
+ const attributes = {};
1378
+ const priv = {};
1379
+ const display = {};
1380
+ const ids = {};
1381
+ const path = [];
1382
+ let tree = kind.catalog?.tree;
1383
+ const years = [];
1384
+ const put = (f, v) => {
1385
+ if (f.private) priv[f.key] = String(v);
1386
+ else attributes[f.key] = v;
1387
+ };
1388
+ for (const f of kind.fields) {
1389
+ const raw = values[f.key];
1390
+ if (!isPresent(raw)) {
1391
+ if (f.required) problems[f.key] = "required";
1392
+ if (f.source === "catalog" && f.type !== "year") tree = void 0;
1393
+ continue;
1394
+ }
1395
+ const max = f.max ?? MAX_VALUE_LENGTH;
1396
+ if (f.type === "year") {
1397
+ const y = asYear(raw);
1398
+ if (y === null) {
1399
+ problems[f.key] = "invalid";
1400
+ continue;
1401
+ }
1402
+ put(f, y);
1403
+ display[f.key] = ids[f.key] = String(y);
1404
+ if (f.source === "catalog") years.push(f.key);
1405
+ } else if (f.source === "catalog") {
1406
+ if (!kind.catalog) {
1407
+ problems[f.key] = "catalog_unavailable";
1408
+ continue;
1409
+ }
1410
+ const s = cleanText(raw, max);
1411
+ if (s === null) {
1412
+ problems[f.key] = "invalid";
1413
+ continue;
1414
+ }
1415
+ const node = tree?.[s] ?? tree?.[s.toLowerCase()];
1416
+ if (!node) {
1417
+ problems[f.key] = "not_in_catalog";
1418
+ continue;
1419
+ }
1420
+ const id = tree?.[s] ? s : s.toLowerCase();
1421
+ path.push(node);
1422
+ tree = node.children;
1423
+ put(f, id);
1424
+ display[f.key] = node.label;
1425
+ ids[f.key] = id;
1426
+ } else {
1427
+ const s = cleanText(raw, max);
1428
+ if (s === null) {
1429
+ problems[f.key] = "invalid";
1430
+ continue;
1431
+ }
1432
+ put(f, s);
1433
+ display[f.key] = s;
1434
+ ids[f.key] = slug(s);
1435
+ }
1436
+ }
1437
+ for (const key2 of years) {
1438
+ const y = attributes[key2];
1439
+ if (y !== void 0 && !problems[key2] && !path.every((n) => coversYear(n, y))) problems[key2] = "year_range";
1440
+ }
1441
+ if (Object.keys(problems).length > 0) return { ok: false, problems };
1442
+ const label = [...kind.fields.filter((f) => !f.private).map((f) => display[f.key]).filter(Boolean).join(" ")].slice(0, MAX_LABEL_LENGTH).join("").trim();
1443
+ if (!label) return { ok: false, problems: { _: "required" } };
1444
+ let complete = true;
1445
+ const key = kind.fit_key_template.replace(/\{([a-z][a-z0-9_]*)\}/g, (_m, name) => {
1446
+ const id = ids[name];
1447
+ if (!id) complete = false;
1448
+ return id ?? "";
1449
+ });
1450
+ return { ok: true, subject: { label, fit_key: complete && key.length <= MAX_FIT_KEY_LENGTH ? key.toLowerCase() : "", attributes, private: priv } };
1451
+ }
1452
+ var PRIVATE_MASK = "\u2022\u2022";
1453
+ var isMasked = (v) => typeof v === "string" && v.startsWith(PRIVATE_MASK);
1454
+
1350
1455
  // src/mock/seed.ts
1351
1456
  var MOCK_IMAGE_URL = (seed, width = 800, height = 800) => `https://picsum.photos/seed/${encodeURIComponent(seed)}/${width}/${height}`;
1352
1457
  var MOCK_CURRENCY = "MXN";
@@ -1683,6 +1788,55 @@ function buildCustomers() {
1683
1788
  ];
1684
1789
  }
1685
1790
 
1791
+ // src/mock/subjects.ts
1792
+ var MOCK_SUBJECT_KINDS = [
1793
+ {
1794
+ key: "vehicle",
1795
+ label: "Veh\xEDculo",
1796
+ label_plural: "Mi garage",
1797
+ max_per_account: 3,
1798
+ attach_to_order: true,
1799
+ fit_key_template: "{make}/{model}/{year}/{engine}",
1800
+ fields: [
1801
+ { key: "make", label: "Marca", type: "select", source: "catalog", required: true },
1802
+ { key: "model", label: "Modelo", type: "select", source: "catalog", depends_on: "make", required: true },
1803
+ { key: "year", label: "A\xF1o", type: "year", source: "catalog", depends_on: "model", required: true },
1804
+ { key: "engine", label: "Versi\xF3n / motor", type: "select", source: "catalog", depends_on: "year" },
1805
+ { key: "plate", label: "Placas (opcional)", type: "text", max: 12, private: true }
1806
+ ],
1807
+ catalog: {
1808
+ version: "mock-1",
1809
+ tree: {
1810
+ nissan: {
1811
+ label: "Nissan",
1812
+ children: {
1813
+ np300: {
1814
+ label: "NP300 Frontier",
1815
+ from: 2008,
1816
+ to: 2022,
1817
+ children: { "2.5": { label: "2.5 L", ranges: [[2010, 2015]] }, "2.4": { label: "2.4 L" } }
1818
+ },
1819
+ versa: { label: "Versa", from: 2012, to: 2024, children: { "1.6": { label: "1.6 L" } } }
1820
+ }
1821
+ },
1822
+ ford: { label: "Ford", children: { f150: { label: "F-150", from: 2e3, to: 2020, children: { "5.0": { label: "5.0 L" } } } } }
1823
+ }
1824
+ }
1825
+ },
1826
+ {
1827
+ key: "size",
1828
+ label: "Talla",
1829
+ label_plural: "Mis tallas",
1830
+ max_per_account: 5,
1831
+ fit_key_template: "{garment}/{value}",
1832
+ fields: [
1833
+ { key: "garment", label: "Prenda", type: "text", required: true },
1834
+ { key: "value", label: "Talla", type: "text", required: true, max: 8 }
1835
+ ],
1836
+ catalog: null
1837
+ }
1838
+ ];
1839
+
1686
1840
  // src/mock/index.ts
1687
1841
  var HttpError = class extends Error {
1688
1842
  constructor(status, code, message, field, details, requiredScopes) {
@@ -1757,6 +1911,10 @@ function createMockStorefront(options = {}) {
1757
1911
  online_verify: false
1758
1912
  },
1759
1913
  fiscal_profiles: { max: 10 },
1914
+ notifications: { channels: ["email"] },
1915
+ data_export: true,
1916
+ account_delete: true,
1917
+ ...(options.subjectKinds?.length ?? 0) > 0 ? { subjects: { kinds: options.subjectKinds.map((k) => k.key), share_with_workshop: false } } : {},
1760
1918
  ...options.customerCapabilities
1761
1919
  };
1762
1920
  const missingScopes = options.missingScopes ?? [];
@@ -1788,6 +1946,7 @@ function createMockStorefront(options = {}) {
1788
1946
  customers: buildCustomers(),
1789
1947
  addresses: /* @__PURE__ */ new Map(),
1790
1948
  fiscalProfiles: /* @__PURE__ */ new Map(),
1949
+ subjects: /* @__PURE__ */ new Map(),
1791
1950
  orders: [
1792
1951
  {
1793
1952
  id: "ord_seed_1",
@@ -1808,6 +1967,10 @@ function createMockStorefront(options = {}) {
1808
1967
  pendingRegistrations: /* @__PURE__ */ new Map(),
1809
1968
  loginFailures: /* @__PURE__ */ new Map(),
1810
1969
  sessions: /* @__PURE__ */ new Map(),
1970
+ sessionInfo: /* @__PURE__ */ new Map(),
1971
+ reauthFailures: /* @__PURE__ */ new Map(),
1972
+ exportsServed: /* @__PURE__ */ new Map(),
1973
+ deletedTokens: /* @__PURE__ */ new Set(),
1811
1974
  idempotency: /* @__PURE__ */ new Map(),
1812
1975
  checkouts: /* @__PURE__ */ new Map(),
1813
1976
  payments: /* @__PURE__ */ new Map(),
@@ -1889,6 +2052,9 @@ function createMockStorefront(options = {}) {
1889
2052
  if (list.length > 100) throw new HttpError(400, "validation", "At most 100 handles", "handles");
1890
2053
  items = items.filter((p) => list.includes(p.handle));
1891
2054
  }
2055
+ const tagsAny = (search.get("tags_any") ?? "").split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
2056
+ 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");
2057
+ if (tagsAny.length > 0) items = items.filter((p) => p.tags.some((t) => tagsAny.includes(t.toLowerCase())));
1892
2058
  const base = items;
1893
2059
  if (search.get("available") === "true") items = items.filter((p) => p.available_for_sale);
1894
2060
  const priceMin = search.get("price_min");
@@ -2016,6 +2182,7 @@ function createMockStorefront(options = {}) {
2016
2182
  const expires = new Date(Date.now() + 24 * 36e5).toISOString();
2017
2183
  return options.shippingRates.map((r) => ({ ...r, expires_at: r.expires_at ?? expires, cost: free ? fromCents(0, r.cost.currency_code) : r.cost }));
2018
2184
  }
2185
+ 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" }];
2019
2186
  return [
2020
2187
  { 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" },
2021
2188
  { id: "express", title: "Env\xEDo expr\xE9s", cost: fromCents(flatShippingCents + 1e4, currency), eta: "1 a 2 d\xEDas h\xE1biles" }
@@ -2158,8 +2325,13 @@ function createMockStorefront(options = {}) {
2158
2325
  }
2159
2326
  const token = `ct_${customer.id}_${Math.random().toString(36).slice(2)}`;
2160
2327
  state.sessions.set(token, customer.id);
2328
+ trackSession(token, "password");
2161
2329
  return { token, expires_at: new Date(Date.now() + 30 * 864e5).toISOString(), customer };
2162
2330
  }
2331
+ function trackSession(token, loginMethod) {
2332
+ const at = now();
2333
+ 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 });
2334
+ }
2163
2335
  function validateAddress(body, partial = false) {
2164
2336
  const required = ["address1", "city", "zip", "country_code"];
2165
2337
  if (!partial) {
@@ -2314,8 +2486,10 @@ function createMockStorefront(options = {}) {
2314
2486
  route("GET", "/carts/:id/delivery-options", "cart:write", ({ params }) => {
2315
2487
  const cart = renderCart(getCartRecord(params[0]));
2316
2488
  const net = toCents(cart.cost.subtotal.amount) - toCents(cart.cost.discount.amount);
2489
+ const rates = shippingRates(net);
2490
+ const free = options.shippingQuoteMode === "fixed_when_free" && rates.length > 0 && rates.every((r) => toCents(r.cost.amount) === 0);
2317
2491
  return {
2318
- 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: [] },
2492
+ 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: [] },
2319
2493
  cache: "private"
2320
2494
  };
2321
2495
  });
@@ -2587,6 +2761,7 @@ function createMockStorefront(options = {}) {
2587
2761
  }
2588
2762
  const token = `ct_${customer.id}_${Math.random().toString(36).slice(2)}`;
2589
2763
  state.sessions.set(token, customer.id);
2764
+ trackSession(token, kind);
2590
2765
  return {
2591
2766
  status: 201,
2592
2767
  body: { token, expires_at: new Date(Date.now() + 30 * 864e5).toISOString(), customer },
@@ -2661,7 +2836,8 @@ function createMockStorefront(options = {}) {
2661
2836
  state.loginFailures.delete(email);
2662
2837
  return { status: 204 };
2663
2838
  });
2664
- const publicCustomer = (c) => {
2839
+ const publicCustomer = (c0) => {
2840
+ const c = { ...c0, locale: c0.locale ?? null, preferences: c0.preferences ?? { notifications: {} } };
2665
2841
  if (!c.fiscal?.document) return c;
2666
2842
  const { sha256: _sha, ...document } = c.fiscal.document;
2667
2843
  return { ...c, fiscal: { ...c.fiscal, document } };
@@ -2673,6 +2849,8 @@ function createMockStorefront(options = {}) {
2673
2849
  if ("first_name" in body) c.first_name = text(body.first_name);
2674
2850
  if ("last_name" in body) c.last_name = text(body.last_name);
2675
2851
  if ("phone" in body) c.phone = text(body.phone);
2852
+ if ("locale" in body) c.locale = parseLocale(body.locale);
2853
+ if ("preferences" in body) c.preferences = parsePreferences(c.preferences, body.preferences);
2676
2854
  if ("fiscal" in body) {
2677
2855
  if (body.fiscal === null) c.fiscal = null;
2678
2856
  else {
@@ -2692,6 +2870,144 @@ function createMockStorefront(options = {}) {
2692
2870
  }
2693
2871
  return { body: publicCustomer(c), cache: "private" };
2694
2872
  });
2873
+ const OPTIONAL_EVENTS = /* @__PURE__ */ new Set(["order_updates", "shipping", "restock"]);
2874
+ const MANDATORY_EVENTS = /* @__PURE__ */ new Set(["order_confirmation", "payment", "invoice", "security"]);
2875
+ const CHANNELS = /* @__PURE__ */ new Set(["email", "whatsapp"]);
2876
+ const REAUTH_WINDOW_MS = 5 * 6e4;
2877
+ const supportedLocales = () => [state.shop.locale, ...state.shop.locales].filter((l, i, a) => l && a.indexOf(l) === i);
2878
+ function parseLocale(raw) {
2879
+ if (raw === null || typeof raw === "string" && !raw.trim()) return null;
2880
+ if (typeof raw !== "string") throw new HttpError(400, "validation", "locale must be a string", "locale");
2881
+ const tag = raw.trim().replace("_", "-");
2882
+ const [lang = "", ...rest] = tag.split("-");
2883
+ const canonical = [lang.toLowerCase(), ...rest.map((p) => p.toUpperCase())].join("-");
2884
+ const supported = supportedLocales();
2885
+ const hit = supported.find((l) => l.toLowerCase() === canonical.toLowerCase()) ?? (rest.length === 0 ? supported.find((l) => l.split("-")[0].toLowerCase() === canonical) : void 0);
2886
+ if (!hit) throw new HttpError(400, "validation", "this store is not available in that language", "locale");
2887
+ return hit;
2888
+ }
2889
+ function parsePreferences(current, raw) {
2890
+ if (raw === null) return { notifications: {} };
2891
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(400, "validation", "preferences must be an object", "preferences");
2892
+ const merged = structuredClone(current?.notifications ?? {});
2893
+ for (const [key, value] of Object.entries(raw)) {
2894
+ if (key !== "notifications") throw new HttpError(400, "validation", "unknown preference", `preferences.${key}`);
2895
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new HttpError(400, "validation", "notifications must map each event to its channels", "preferences.notifications");
2896
+ for (const [event, channels] of Object.entries(value)) {
2897
+ const field = `preferences.notifications.${event}`;
2898
+ if (MANDATORY_EVENTS.has(event)) throw new HttpError(400, "validation", "this notification is required and cannot be turned off", field);
2899
+ if (!OPTIONAL_EVENTS.has(event)) throw new HttpError(400, "validation", "unknown notification", field);
2900
+ if (!channels || typeof channels !== "object" || Array.isArray(channels)) throw new HttpError(400, "validation", "each event maps its channels to true or false", field);
2901
+ for (const [channel, on] of Object.entries(channels)) {
2902
+ if (!CHANNELS.has(channel)) throw new HttpError(400, "validation", "unknown channel", `${field}.${channel}`);
2903
+ if (typeof on !== "boolean") throw new HttpError(400, "validation", "each channel must be true or false", `${field}.${channel}`);
2904
+ (merged[event] ??= {})[channel] = on;
2905
+ }
2906
+ }
2907
+ }
2908
+ return { notifications: merged };
2909
+ }
2910
+ function sessionOf(headers) {
2911
+ const c = requireCustomer(headers);
2912
+ const token = headers.get("x-customer-token");
2913
+ if (!state.sessionInfo.has(token)) trackSession(token, "email");
2914
+ return { c, token, info: state.sessionInfo.get(token) };
2915
+ }
2916
+ function requireRecentReauth(info) {
2917
+ if (info.reauth_at === null || Date.now() - info.reauth_at > REAUTH_WINDOW_MS) {
2918
+ throw new HttpError(403, "reauth_required", "confirm your password or a code first (customer.reauth)");
2919
+ }
2920
+ }
2921
+ route("GET", "/customer/sessions", "customer:read", ({ headers }) => {
2922
+ const { c, token } = sessionOf(headers);
2923
+ 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 }) => ({
2924
+ id: info.id,
2925
+ current: t === token,
2926
+ device_label: "Mock browser \xB7 Test",
2927
+ login_method: info.login_method,
2928
+ last_used_at: info.last_used_at,
2929
+ created_at: info.created_at
2930
+ }));
2931
+ return { body: { nodes }, cache: "private" };
2932
+ });
2933
+ route("DELETE", "/customer/sessions/:id", "customer:write", ({ headers, params }) => {
2934
+ const { c, token } = sessionOf(headers);
2935
+ const mine = [...state.sessions.entries()].filter(([, id]) => id === c.id).map(([t]) => t);
2936
+ if (params[0] === "others") {
2937
+ for (const t of mine) if (t !== token) state.sessions.delete(t);
2938
+ return { status: 204 };
2939
+ }
2940
+ const entry = [...state.sessionInfo.entries()].find(([, i]) => i.id === params[0]);
2941
+ if (!entry || entry[1].customer_id !== c.id) throw new HttpError(404, "not_found", "Session not found", "id");
2942
+ state.sessions.delete(entry[0]);
2943
+ return { status: 204 };
2944
+ });
2945
+ route("POST", "/customer/reauth", "customer:write", ({ headers, body }) => {
2946
+ const { c, info } = sessionOf(headers);
2947
+ const hasPassword = typeof body.password === "string" && body.password !== "";
2948
+ const hasCode = typeof body.otp_code === "string" && body.otp_code !== "";
2949
+ if (hasPassword === hasCode) throw new HttpError(400, "validation", "send either password or otp_code", "password");
2950
+ if ((state.reauthFailures.get(c.id) ?? 0) >= 5) throw new HttpError(429, "rate_limited", "Too many attempts, try again later");
2951
+ const field = hasPassword ? "password" : "otp_code";
2952
+ 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);
2953
+ if (!ok) {
2954
+ state.reauthFailures.set(c.id, (state.reauthFailures.get(c.id) ?? 0) + 1);
2955
+ throw new HttpError(400, "validation", "could not verify; check the credential and try again", field);
2956
+ }
2957
+ state.reauthFailures.delete(c.id);
2958
+ info.reauth_at = Date.now();
2959
+ return { body: { reauth_at: new Date(info.reauth_at).toISOString(), valid_for_seconds: REAUTH_WINDOW_MS / 1e3 }, cache: "private" };
2960
+ });
2961
+ route("POST", "/customer/data-export", "customer:write", ({ headers }) => {
2962
+ const { c, info } = sessionOf(headers);
2963
+ requireRecentReauth(info);
2964
+ if ((state.exportsServed.get(c.id) ?? 0) >= 3) throw new HttpError(429, "rate_limited", "Three exports a day; try again tomorrow");
2965
+ state.exportsServed.set(c.id, (state.exportsServed.get(c.id) ?? 0) + 1);
2966
+ const doc = {
2967
+ format: "storefront-customer-data/1",
2968
+ exported_at: now(),
2969
+ 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 },
2970
+ preferences: c.preferences ?? { notifications: {} },
2971
+ addresses: state.addresses.get(c.id) ?? [],
2972
+ fiscal_profiles: state.fiscalProfiles.get(c.id) ?? [],
2973
+ 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 }))
2974
+ };
2975
+ return {
2976
+ body: {
2977
+ filename: `account-data-${now().slice(0, 10)}.json`,
2978
+ content_type: "application/json",
2979
+ content_base64: btoa(unescape(encodeURIComponent(JSON.stringify(doc, null, 2))))
2980
+ },
2981
+ cache: "private"
2982
+ };
2983
+ });
2984
+ route("DELETE", "/customer", "customer:write", ({ headers, body }) => {
2985
+ const token = headers.get("x-customer-token");
2986
+ if (token && state.deletedTokens.has(token)) return { status: 204 };
2987
+ const { c, info } = sessionOf(headers);
2988
+ requireRecentReauth(info);
2989
+ if (typeof body.confirm !== "string" || !body.confirm.trim() || body.confirm.length > 100) {
2990
+ throw new HttpError(400, "validation", "confirm is required: the text the buyer typed", "confirm");
2991
+ }
2992
+ const closed = /* @__PURE__ */ new Set(["fulfilled", "delivered", "cancelled"]);
2993
+ if (state.orders.some((o) => o.customer_id === c.id && !closed.has(o.status))) {
2994
+ throw new HttpError(409, "conflict", "you have an order in progress; you can delete the account once it is delivered", "orders");
2995
+ }
2996
+ if (c.id === "cus_1" && state.pendingPayments.some((p) => p.status === "pending_payment")) {
2997
+ throw new HttpError(409, "conflict", "you have a payment waiting to be completed", "pending_payments");
2998
+ }
2999
+ for (const [t, id] of [...state.sessions.entries()]) {
3000
+ if (id === c.id) {
3001
+ state.sessions.delete(t);
3002
+ state.deletedTokens.add(t);
3003
+ }
3004
+ }
3005
+ if (c.email) state.passwords.delete(c.email);
3006
+ state.addresses.delete(c.id);
3007
+ state.fiscalProfiles.delete(c.id);
3008
+ state.customers = state.customers.filter((x) => x.id !== c.id);
3009
+ return { status: 204 };
3010
+ });
2695
3011
  const orderView = ({ customer_id: _c, ...o }, detail) => {
2696
3012
  const order = { ...o, invoice: orderInvoice(o) };
2697
3013
  if (order.shipments) order.shipments = order.shipments.map((s) => isTestShipment(s) ? { ...s, tracking_number: null, tracking_url: null } : { ...s, events: sortTrackingEvents(s.events) });
@@ -2848,6 +3164,89 @@ function createMockStorefront(options = {}) {
2848
3164
  mirrorDefaultProfile(c);
2849
3165
  return { status: 204 };
2850
3166
  });
3167
+ const MAX_SUBJECTS = 50;
3168
+ const subjectsOf = (c) => state.subjects.get(c.id) ?? [];
3169
+ const kinds = () => options.subjectKinds ?? [];
3170
+ const publicSubject = (s) => {
3171
+ const { private: priv, ...rest } = s;
3172
+ return { ...rest, attributes: { ...s.attributes, ...Object.fromEntries(Object.keys(priv).map((k) => [k, PRIVATE_MASK + (priv[k].length > 3 ? priv[k].slice(-2) : "")])) } };
3173
+ };
3174
+ const ownSubject = (c, id) => {
3175
+ const s = subjectsOf(c).find((x) => x.id === id);
3176
+ if (!s) throw new HttpError(404, "not_found", "Item not found", "id");
3177
+ return s;
3178
+ };
3179
+ const setDefaultSubject = (c, id) => {
3180
+ const kind = ownSubject(c, id).kind;
3181
+ 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 }));
3182
+ };
3183
+ const resolveOrThrow = (kind, values) => {
3184
+ const r = resolveSubject(kind, values);
3185
+ if (!r.ok) {
3186
+ const [field, code] = Object.entries(r.problems)[0];
3187
+ throw new HttpError(422, "validation", `${field}: ${code}`, field === "_" ? "attributes" : `attributes.${field}`);
3188
+ }
3189
+ return r.subject;
3190
+ };
3191
+ const cleanNickname = (v) => {
3192
+ if (v === void 0 || v === null || v === "") return null;
3193
+ if (typeof v !== "string" || [...v.trim()].length > 40) throw new HttpError(422, "validation", "nickname is too long (max 40)", "nickname");
3194
+ return v.trim().split(/\s+/).join(" ");
3195
+ };
3196
+ route("GET", "/subject-kinds", "content:read", () => ({ body: { nodes: kinds() }, cache: "public" }));
3197
+ route("GET", "/customer/subjects", "customer:read", ({ headers, search }) => {
3198
+ const c = requireCustomer(headers);
3199
+ const kind = search.get("kind");
3200
+ 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 ?? ""));
3201
+ return { body: { nodes: nodes.map(publicSubject) }, cache: "private" };
3202
+ });
3203
+ route("POST", "/customer/subjects", "customer:write", ({ headers, body }) => {
3204
+ const c = requireCustomer(headers);
3205
+ const kind = kinds().find((k) => k.key === body.kind);
3206
+ if (!kind) throw new HttpError(422, "validation", "This store has no such kind", "kind");
3207
+ const attrs = body.attributes;
3208
+ if (!attrs || typeof attrs !== "object" || Array.isArray(attrs)) throw new HttpError(422, "validation", "attributes must be an object", "attributes");
3209
+ const r = resolveOrThrow(kind, attrs);
3210
+ const list = subjectsOf(c);
3211
+ 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");
3212
+ 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");
3213
+ 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() };
3214
+ state.subjects.set(c.id, [...list, subject]);
3215
+ if (!list.some((s) => s.kind === kind.key) || body.is_default === true) setDefaultSubject(c, subject.id);
3216
+ return { status: 201, body: publicSubject(ownSubject(c, subject.id)), cache: "private" };
3217
+ });
3218
+ route("PATCH", "/customer/subjects/:id", "customer:write", ({ headers, params, body }) => {
3219
+ const c = requireCustomer(headers);
3220
+ const cur = ownSubject(c, params[0]);
3221
+ if (body.is_default === false && cur.is_default) throw new HttpError(422, "validation", "Make another item the default instead", "is_default");
3222
+ let next = { ...cur };
3223
+ if ("nickname" in body) next.nickname = cleanNickname(body.nickname);
3224
+ if ("attributes" in body) {
3225
+ const kind = kinds().find((k) => k.key === cur.kind);
3226
+ if (!kind) throw new HttpError(422, "validation", "This store no longer offers this kind", "kind");
3227
+ const sent = body.attributes ?? {};
3228
+ const merged = { ...cur.attributes, ...cur.private };
3229
+ for (const [k, v] of Object.entries(sent)) {
3230
+ if (k in cur.private && isMasked(v)) continue;
3231
+ if (v === null) delete merged[k];
3232
+ else merged[k] = v;
3233
+ }
3234
+ const r = resolveOrThrow(kind, merged);
3235
+ 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");
3236
+ next = { ...next, label: r.label, fit_key: r.fit_key, attributes: r.attributes, private: r.private };
3237
+ }
3238
+ state.subjects.set(c.id, subjectsOf(c).map((s) => s.id === cur.id ? next : s));
3239
+ if (body.is_default === true && !cur.is_default) setDefaultSubject(c, cur.id);
3240
+ return { body: publicSubject(ownSubject(c, cur.id)), cache: "private" };
3241
+ });
3242
+ route("DELETE", "/customer/subjects/:id", "customer:write", ({ headers, params }) => {
3243
+ const c = requireCustomer(headers);
3244
+ const cur = ownSubject(c, params[0]);
3245
+ state.subjects.set(c.id, subjectsOf(c).filter((s) => s.id !== cur.id));
3246
+ const heir = subjectsOf(c).filter((s) => s.kind === cur.kind).sort((a, b) => (b.last_used_at ?? "").localeCompare(a.last_used_at ?? ""))[0];
3247
+ if (cur.is_default && heir) setDefaultSubject(c, heir.id);
3248
+ return { status: 204 };
3249
+ });
2851
3250
  route("GET", "/customer/addresses", "customer:read", ({ headers }) => {
2852
3251
  const c = requireCustomer(headers);
2853
3252
  return { body: { nodes: state.addresses.get(c.id) ?? [] }, cache: "private" };
@@ -2944,7 +3343,7 @@ function createMockStorefront(options = {}) {
2944
3343
  return errorResponse(new HttpError(403, "insufficient_scope", `Token lacks scope ${r.scope}`, void 0, void 0, [r.scope]));
2945
3344
  }
2946
3345
  let body = {};
2947
- if (method !== "GET" && method !== "DELETE") {
3346
+ if (method !== "GET") {
2948
3347
  const text = await request.text();
2949
3348
  if (text) {
2950
3349
  try {
@@ -3005,6 +3404,7 @@ exports.MOCK_IDENTITY_DOCUMENT_MX_CSF = MOCK_IDENTITY_DOCUMENT_MX_CSF;
3005
3404
  exports.MOCK_IMAGE_URL = MOCK_IMAGE_URL;
3006
3405
  exports.MOCK_POSTAL_CENTROIDS = MOCK_POSTAL_CENTROIDS;
3007
3406
  exports.MOCK_REGION_MX = MOCK_REGION_MX;
3407
+ exports.MOCK_SUBJECT_KINDS = MOCK_SUBJECT_KINDS;
3008
3408
  exports.createMockStorefront = createMockStorefront;
3009
3409
  //# sourceMappingURL=mock.cjs.map
3010
3410
  //# sourceMappingURL=mock.cjs.map