@moonbase.sh/storefront-api 3.1.0 → 3.3.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/README.md CHANGED
@@ -76,6 +76,40 @@ const redeemed = await client.vouchers.redeem('MY-CODE')
76
76
  console.log(redeemed)
77
77
  ```
78
78
 
79
+ ### Enroll a customer in a group
80
+
81
+ Merchant-owned customer lists that have been opened to public sign-ups can be
82
+ named by ID on sign-up, on a newsletter subscription, and on an order:
83
+
84
+ ```ts
85
+ await client.identity.signUp('Jane', 'jane@example.com', 'password', null, true, true, true, {
86
+ groupIds: ['newsletter-2026'],
87
+ })
88
+
89
+ await client.communications.subscribe({ email: 'jane@example.com', groupIds: ['newsletter-2026'] })
90
+
91
+ // On an order, after it has been pushed and before it is paid. Additive: each
92
+ // call adds to what the order already carries.
93
+ await client.orders.joinGroups(order.id, ['buyers-2026'])
94
+ ```
95
+
96
+ None of those three report which groups were applied. An ID naming a list that
97
+ is closed, deleted, rule-driven or another tenant's is skipped in silence, so a
98
+ stale ID can never fail a registration or a sale. IDs are matched lowercase and
99
+ at most five per request are applied.
100
+
101
+ For a customer who is already signed in, joining is a request of its own, and it
102
+ is the one that does report failure:
103
+
104
+ ```ts
105
+ await client.groups.join('beta') // throws NotFoundError if it isn't joinable
106
+ ```
107
+
108
+ Every reason it can be refused (no such group, deleted, closed, a segment,
109
+ another tenant's) arrives as the same `NotFoundError`, so that a signed-in
110
+ customer cannot use it to enumerate a merchant's group IDs. Joining twice
111
+ succeeds and changes nothing.
112
+
79
113
  ## Inventory flow
80
114
 
81
115
  Most inventory endpoints require an authenticated user:
@@ -97,7 +131,7 @@ while (page.hasMore && page.next) {
97
131
 
98
132
  ## Endpoint groups
99
133
 
100
- `client.storefront`, `client.identity`, `client.orders`, `client.vouchers`, `client.inventory`, `client.activationRequests`, `client.vendor`.
134
+ `client.storefront`, `client.identity`, `client.orders`, `client.vouchers`, `client.inventory`, `client.activationRequests`, `client.vendor`, `client.communications`, `client.groups`.
101
135
 
102
136
  ## Image renditions
103
137
 
@@ -122,7 +156,17 @@ host — is returned untouched and logs a warning, since it will be served at fu
122
156
 
123
157
  ## Token storage and logging
124
158
 
125
- By default, credentials are stored in localStorage (or in-memory if localStorage is unavailable).
159
+ By default, credentials are stored in localStorage, falling back to memory only when localStorage
160
+ cannot be reached at all. Even that check sits inside a `try`, because reading
161
+ `window.localStorage` itself throws in Firefox when the buyer has blocked cookies and site data.
162
+
163
+ Reachability, not writability, is the test on purpose: an origin that is merely full is still
164
+ holding the buyer's existing session, so it is used rather than discarded. Individual reads and
165
+ writes that fail degrade to an in-memory copy and warn through the configured logger. The session
166
+ stays valid for that page load; it just doesn't survive a reload, and
167
+ `client.tokenStore.isPersistent` goes false once that has happened if you want to tell the buyer.
168
+ A failure to *persist* credentials is never reported as a failure to sign in, so a locked-down
169
+ browser can't strand someone in a sign-in loop.
126
170
 
127
171
  You can inject your own storage/logger implementations:
128
172
 
package/dist/index.cjs CHANGED
@@ -49,9 +49,11 @@ __export(index_exports, {
49
49
  SubscriptionStatus: () => SubscriptionStatus,
50
50
  TokenStore: () => TokenStore,
51
51
  imageWidths: () => imageWidths,
52
+ isLocalStorageAvailable: () => isLocalStorageAvailable,
52
53
  isPromotionDisplay: () => isPromotionDisplay,
53
54
  isTransformableImageUrl: () => isTransformableImageUrl,
54
55
  maxImageDimension: () => maxImageDimension,
56
+ normalizeGroupIds: () => normalizeGroupIds,
55
57
  objectToQuery: () => objectToQuery,
56
58
  parseOffSiteReferrer: () => parseOffSiteReferrer,
57
59
  problemDetailsSchema: () => problemDetailsSchema,
@@ -489,6 +491,19 @@ var ActivationRequestEndpoints = class {
489
491
  }
490
492
  };
491
493
 
494
+ // src/utils/groups.ts
495
+ function normalizeGroupIds(groupIds) {
496
+ if (!groupIds)
497
+ return void 0;
498
+ const normalized = [];
499
+ for (const groupId of groupIds) {
500
+ const trimmed = groupId == null ? void 0 : groupId.trim().toLowerCase();
501
+ if (trimmed && !normalized.includes(trimmed))
502
+ normalized.push(trimmed);
503
+ }
504
+ return normalized.length > 0 ? normalized : void 0;
505
+ }
506
+
492
507
  // src/communications/schemas.ts
493
508
  var schemas_exports4 = {};
494
509
  __export(schemas_exports4, {
@@ -522,7 +537,8 @@ var CommunicationsEndpoints = class {
522
537
  email: input.email,
523
538
  name: ((_a = input.name) == null ? void 0 : _a.trim()) || null,
524
539
  newsletter: (_b = input.newsletter) != null ? _b : true,
525
- productUpdates: (_c = input.productUpdates) != null ? _c : true
540
+ productUpdates: (_c = input.productUpdates) != null ? _c : true,
541
+ groupIds: normalizeGroupIds(input.groupIds)
526
542
  }
527
543
  });
528
544
  return response.data;
@@ -564,6 +580,37 @@ var CommunicationsEndpoints = class {
564
580
  }
565
581
  };
566
582
 
583
+ // src/groups/endpoints.ts
584
+ var GroupEndpoints = class {
585
+ constructor(api) {
586
+ this.api = api;
587
+ }
588
+ /**
589
+ * Joins the signed-in customer to a merchant-owned group that has been opened
590
+ * to public sign-ups.
591
+ *
592
+ * The one group call that reports failure. Group IDs sent along with sign-up,
593
+ * a newsletter subscription or a checkout are passengers on a request that
594
+ * wanted something else, so an unusable one is skipped rather than allowed to
595
+ * fail the account, the subscription or the sale. Here joining *is* the
596
+ * request, so it throws.
597
+ *
598
+ * Every reason it can fail (no such group, deleted, closed, a segment, a
599
+ * system group, another tenant's) arrives as the same `NotFoundError`, so
600
+ * that a customer of one tenant can't enumerate that merchant's group IDs.
601
+ *
602
+ * Idempotent: joining a group the customer is already in succeeds and changes
603
+ * nothing.
604
+ */
605
+ async join(groupId) {
606
+ await this.api.authenticatedFetch(
607
+ `/api/customer/groups/${encodeURIComponent(groupId)}/join`,
608
+ null,
609
+ { method: "POST" }
610
+ );
611
+ }
612
+ };
613
+
567
614
  // src/utils/errors.ts
568
615
  var NotAuthorizedError = class extends Error {
569
616
  constructor() {
@@ -757,17 +804,17 @@ var IdentityEndpoints = class {
757
804
  throw new NotFoundError("User not found");
758
805
  if (response.status >= 400)
759
806
  await handleResponseProblem(response, this.logger);
807
+ let identity;
760
808
  try {
761
- const data = await response.json();
762
- const user = identitySchema.parse(data);
763
- this.tokenStore.setUser(user);
764
- return userSchema.parse(user);
809
+ identity = identitySchema.parse(await response.json());
765
810
  } catch (err) {
766
811
  this.logger.warn("Could not sign in user", { email, response, err });
767
812
  throw new MoonbaseError("Bad response", "Could not sign in user", response.status);
768
813
  }
814
+ this.tokenStore.setUser(identity);
815
+ return userSchema.parse(identity);
769
816
  }
770
- async signUp(name, email, password, address, acceptedPrivacyPolicy, acceptedTermsAndConditions, communicationOptIn) {
817
+ async signUp(name, email, password, address, acceptedPrivacyPolicy, acceptedTermsAndConditions, communicationOptIn, options) {
771
818
  const path = `/api/customer/identity/sign-up?scheme=JWT&communicationOptIn=${communicationOptIn ? "true" : "false"}`;
772
819
  const response = await fetch(`${this.api.baseUrl}${path}`, {
773
820
  method: "POST",
@@ -782,7 +829,8 @@ var IdentityEndpoints = class {
782
829
  password,
783
830
  address,
784
831
  acceptedPrivacyPolicy,
785
- acceptedTermsAndConditions
832
+ acceptedTermsAndConditions,
833
+ groupIds: normalizeGroupIds(options == null ? void 0 : options.groupIds)
786
834
  })
787
835
  });
788
836
  if (response.status >= 400)
@@ -792,14 +840,15 @@ var IdentityEndpoints = class {
792
840
  const parsed = pendingActivationSchema.parse(data);
793
841
  return { status: "activation-pending", email: parsed.email };
794
842
  }
843
+ let identity;
795
844
  try {
796
- const identity = identitySchema.parse(data);
797
- this.tokenStore.setUser(identity);
798
- return { status: "signed-in", user: userSchema.parse(identity) };
845
+ identity = identitySchema.parse(data);
799
846
  } catch (err) {
800
847
  this.logger.warn("Could not sign up user", { email, response, err });
801
848
  throw new MoonbaseError("Bad response", "Could not sign up user", response.status);
802
849
  }
850
+ this.tokenStore.setUser(identity);
851
+ return { status: "signed-in", user: userSchema.parse(identity) };
803
852
  }
804
853
  async signOut() {
805
854
  this.tokenStore.clear();
@@ -1079,6 +1128,15 @@ var MoonbaseApi = class {
1079
1128
  status: response.status,
1080
1129
  duration: finishedAt.getTime() - startedAt.getTime()
1081
1130
  });
1131
+ if (response.type === "opaqueredirect" || response.status === 0) {
1132
+ const err = new MoonbaseError(
1133
+ "Unexpected redirect",
1134
+ `The Moonbase API redirected ${path}, which the SDK is not configured to follow`,
1135
+ response.status
1136
+ );
1137
+ this.reportRequestProblem(path, request, response, err);
1138
+ throw err;
1139
+ }
1082
1140
  if (response.status >= 400) {
1083
1141
  try {
1084
1142
  await handleResponseProblem(response, this.logger);
@@ -1494,6 +1552,28 @@ var OrderEndpoints = class {
1494
1552
  const response = await this.api.fetch(`/api/customer/orders/${orderId}/coupons`, openOrderSchema, { method: "DELETE" });
1495
1553
  return response.data;
1496
1554
  }
1555
+ /**
1556
+ * Records merchant-owned customer groups the buyer should join once this order
1557
+ * completes. The order has to exist server-side first, so call it after a
1558
+ * `pushContent`, and before the payment rather than after: the enrollment
1559
+ * rides on the order until the payment webhook settles it.
1560
+ *
1561
+ * Additive rather than a replacement: each call adds to what the order already
1562
+ * carries, and the accumulated set is capped server-side. Groups the merchant
1563
+ * has not opened to public sign-ups are skipped in silence, so a stale ID in a
1564
+ * checkout form can never fail a sale.
1565
+ *
1566
+ * Kept apart from the marketing opt-in on purpose: a checkout can offer both
1567
+ * independently, and merging them would opt a buyer who only asked to join a
1568
+ * list into email they never asked for.
1569
+ */
1570
+ async joinGroups(orderId, groupIds) {
1571
+ const response = await this.api.fetch(`/api/customer/orders/${orderId}/groups`, openOrderSchema, {
1572
+ method: "POST",
1573
+ body: { groupIds: normalizeGroupIds(groupIds) }
1574
+ });
1575
+ return response.data;
1576
+ }
1497
1577
  };
1498
1578
 
1499
1579
  // src/storefront/endpoints.ts
@@ -1543,28 +1623,81 @@ var ConsoleLogger = class {
1543
1623
  };
1544
1624
 
1545
1625
  // src/utils/store.ts
1626
+ function isLocalStorageAvailable() {
1627
+ try {
1628
+ return typeof window !== "undefined" && !!window.localStorage;
1629
+ } catch (e) {
1630
+ return false;
1631
+ }
1632
+ }
1546
1633
  var LocalStorageStore = class {
1634
+ constructor(onDegraded) {
1635
+ this.onDegraded = onDegraded;
1636
+ // Values whose write to localStorage failed. Read back in preference to
1637
+ // localStorage so the rest of the session sees what it just wrote.
1638
+ this.fallback = /* @__PURE__ */ new Map();
1639
+ this.degraded = false;
1640
+ }
1641
+ get isDegraded() {
1642
+ return this.degraded;
1643
+ }
1547
1644
  get(key) {
1548
- const item = localStorage.getItem(key);
1549
- if (item) {
1550
- return JSON.parse(item);
1645
+ if (this.fallback.has(key))
1646
+ return this.fallback.get(key);
1647
+ try {
1648
+ const item = localStorage.getItem(key);
1649
+ if (item) {
1650
+ return JSON.parse(item);
1651
+ }
1652
+ return null;
1653
+ } catch (err) {
1654
+ this.reportDegraded(err);
1655
+ return null;
1551
1656
  }
1552
- return null;
1553
1657
  }
1554
1658
  set(key, item) {
1555
- localStorage.setItem(key, JSON.stringify(item));
1659
+ try {
1660
+ localStorage.setItem(key, JSON.stringify(item));
1661
+ this.fallback.delete(key);
1662
+ } catch (err) {
1663
+ this.reportDegraded(err);
1664
+ this.fallback.set(key, item);
1665
+ }
1556
1666
  }
1557
1667
  remove(key) {
1558
- localStorage.removeItem(key);
1668
+ try {
1669
+ localStorage.removeItem(key);
1670
+ this.fallback.delete(key);
1671
+ return;
1672
+ } catch (err) {
1673
+ this.reportDegraded(err);
1674
+ }
1675
+ this.fallback.set(key, null);
1676
+ try {
1677
+ localStorage.setItem(key, "");
1678
+ } catch (e) {
1679
+ }
1559
1680
  }
1560
1681
  listen(key, callback) {
1561
- if (typeof window !== "undefined") {
1562
- window.addEventListener("storage", (event) => {
1563
- if (event.key === key) {
1564
- callback(event.newValue ? JSON.parse(event.newValue) : null);
1565
- }
1566
- });
1567
- }
1682
+ if (typeof window === "undefined")
1683
+ return;
1684
+ window.addEventListener("storage", (event) => {
1685
+ if (event.key !== key)
1686
+ return;
1687
+ this.fallback.delete(key);
1688
+ try {
1689
+ callback(event.newValue ? JSON.parse(event.newValue) : null);
1690
+ } catch (err) {
1691
+ console.warn(`Moonbase could not parse the stored value for ${key}`, err);
1692
+ }
1693
+ });
1694
+ }
1695
+ reportDegraded(err) {
1696
+ var _a;
1697
+ if (this.degraded)
1698
+ return;
1699
+ this.degraded = true;
1700
+ (_a = this.onDegraded) == null ? void 0 : _a.call(this, err);
1568
1701
  }
1569
1702
  };
1570
1703
  var InMemoryStore = class {
@@ -1602,8 +1735,11 @@ var _TokenStore = class _TokenStore {
1602
1735
  this.tokens = null;
1603
1736
  this.refreshTimeoutId = null;
1604
1737
  this.refreshPromise = null;
1605
- var _a;
1606
- this.store = (_a = configuration.store) != null ? _a : typeof window !== "undefined" && localStorage ? new LocalStorageStore() : new InMemoryStore();
1738
+ var _a, _b;
1739
+ this.logger = (_a = configuration.logger) != null ? _a : new ConsoleLogger();
1740
+ this.store = (_b = configuration.store) != null ? _b : isLocalStorageAvailable() ? new LocalStorageStore((err) => this.warnAboutStorage(err)) : new InMemoryStore();
1741
+ if (typeof window !== "undefined" && !configuration.store && !(this.store instanceof LocalStorageStore))
1742
+ this.warnAboutStorage();
1607
1743
  const storedToken = this.store.get(_TokenStore.storageKey);
1608
1744
  if (storedToken) {
1609
1745
  this.tokens = {
@@ -1618,18 +1754,17 @@ var _TokenStore = class _TokenStore {
1618
1754
  return this.tokens;
1619
1755
  return null;
1620
1756
  }
1757
+ get isPersistent() {
1758
+ return this.store instanceof LocalStorageStore && !this.store.isDegraded;
1759
+ }
1621
1760
  async getAccessToken() {
1622
1761
  var _a, _b, _c;
1623
- if (this.isExpired) {
1624
- if (this.refreshPromise) {
1625
- const tokens2 = await this.refreshPromise;
1626
- return (_a = tokens2 == null ? void 0 : tokens2.accessToken) != null ? _a : null;
1627
- }
1628
- this.refreshPromise = this.refreshTokens();
1629
- const tokens = await this.refreshPromise;
1630
- return (_b = tokens == null ? void 0 : tokens.accessToken) != null ? _b : null;
1762
+ if (!this.isExpired) {
1763
+ return ((_a = this.tokens) == null ? void 0 : _a.accessToken) || null;
1631
1764
  }
1632
- return ((_c = this.tokens) == null ? void 0 : _c.accessToken) || null;
1765
+ const pending = (_b = this.refreshPromise) != null ? _b : this.startRefresh();
1766
+ const tokens = await pending;
1767
+ return (_c = tokens == null ? void 0 : tokens.accessToken) != null ? _c : null;
1633
1768
  }
1634
1769
  setUser(user) {
1635
1770
  const identity = user;
@@ -1654,7 +1789,7 @@ var _TokenStore = class _TokenStore {
1654
1789
  if (this.refreshTimeoutId != null)
1655
1790
  window.clearTimeout(this.refreshTimeoutId);
1656
1791
  this.refreshTimeoutId = window.setTimeout(() => {
1657
- this.refreshPromise = this.refreshTokens();
1792
+ this.startRefresh().catch((err) => this.logger.warn("Could not refresh access token", err));
1658
1793
  }, 10 * 60 * 1e3);
1659
1794
  }
1660
1795
  return this.tokens;
@@ -1669,10 +1804,25 @@ var _TokenStore = class _TokenStore {
1669
1804
  get isExpired() {
1670
1805
  return this.tokens != null && this.tokens.expiresAt < /* @__PURE__ */ new Date();
1671
1806
  }
1807
+ /**
1808
+ * Start a refresh and hold it so concurrent callers join it rather than firing
1809
+ * their own. The slot is cleared as soon as it settles: a retained promise
1810
+ * would hand every later expiry the same stale access token, and a rejected one
1811
+ * that stayed put would re-throw on every subsequent request (including the
1812
+ * anonymous ones) for the rest of the session.
1813
+ */
1814
+ startRefresh() {
1815
+ const pending = this.refreshTokens().finally(() => {
1816
+ if (this.refreshPromise === pending)
1817
+ this.refreshPromise = null;
1818
+ });
1819
+ this.refreshPromise = pending;
1820
+ return pending;
1821
+ }
1672
1822
  async refreshTokens() {
1673
1823
  if (!this.tokens)
1674
1824
  throw new Error("No tokens found to refresh");
1675
- const response = await fetch(`${this.configuration.endpoint}/api/customer/identity/refresh?token=${this.tokens.refreshToken}`, {
1825
+ const response = await fetch(`${this.configuration.endpoint}/api/customer/identity/refresh?token=${encodeURIComponent(this.tokens.refreshToken)}`, {
1676
1826
  method: "POST",
1677
1827
  headers: {
1678
1828
  "Accept": "application/json",
@@ -1694,12 +1844,18 @@ var _TokenStore = class _TokenStore {
1694
1844
  }
1695
1845
  handleStorageUpdate(updatedTokens) {
1696
1846
  this.tokens = updatedTokens;
1697
- if (this.refreshTimeoutId != null)
1847
+ if (this.refreshTimeoutId != null && typeof window !== "undefined")
1698
1848
  window.clearTimeout(this.refreshTimeoutId);
1699
1849
  if (this.tokens) {
1700
1850
  this.tokens.expiresAt = new Date(this.tokens.expiresAt);
1701
1851
  }
1702
1852
  }
1853
+ warnAboutStorage(err) {
1854
+ this.logger.warn(
1855
+ "Moonbase could not persist credentials to localStorage: this browser is blocking or has exhausted site storage. Sign-in will work for this page load but will not survive a reload.",
1856
+ err
1857
+ );
1858
+ }
1703
1859
  };
1704
1860
  _TokenStore.storageKey = "moonbase_auth";
1705
1861
  var TokenStore = _TokenStore;
@@ -2394,6 +2550,7 @@ var MoonbaseClient = class {
2394
2550
  this.storefront = new StorefrontEndpoints(this.api, this.configuration);
2395
2551
  this.identity = new IdentityEndpoints(this.api, this.tokenStore, this.logger);
2396
2552
  this.communications = new CommunicationsEndpoints(this.api);
2553
+ this.groups = new GroupEndpoints(this.api);
2397
2554
  this.vouchers = new VoucherEndpoints(this.api);
2398
2555
  this.orders = new OrderEndpoints(this.api);
2399
2556
  this.inventory = new InventoryEndpoints(this.api, this.configuration);
@@ -2432,9 +2589,11 @@ var MoonbaseClient = class {
2432
2589
  SubscriptionStatus,
2433
2590
  TokenStore,
2434
2591
  imageWidths,
2592
+ isLocalStorageAvailable,
2435
2593
  isPromotionDisplay,
2436
2594
  isTransformableImageUrl,
2437
2595
  maxImageDimension,
2596
+ normalizeGroupIds,
2438
2597
  objectToQuery,
2439
2598
  parseOffSiteReferrer,
2440
2599
  problemDetailsSchema,
package/dist/index.d.cts CHANGED
@@ -26,12 +26,47 @@ interface IStore {
26
26
  set: <TItem>(key: string, item: TItem) => void;
27
27
  remove: (key: string) => void;
28
28
  listen: <TItem>(key: string, callback: (item: TItem | null) => void) => void;
29
+ /**
30
+ * True once any storage operation has failed and the store has fallen back to
31
+ * memory. Optional so custom stores don't have to implement it.
32
+ */
33
+ readonly isDegraded?: boolean;
29
34
  }
35
+ /**
36
+ * Whether `localStorage` can be reached at all.
37
+ *
38
+ * Deliberately *not* a write probe. Writability is not the right question here,
39
+ * because `LocalStorageStore` degrades per operation when a write fails, so a
40
+ * storage that reads fine but is at quota is still worth using: it is holding the
41
+ * buyer's existing session, and refusing it would throw away a perfectly readable
42
+ * sign-in and send them back to the login form. The only thing that makes
43
+ * `localStorage` unusable is not being able to touch it, which is what Firefox
44
+ * does (`SecurityError` from the property getter) when the buyer has blocked
45
+ * cookies and site data. That is why even the truthiness check sits inside a
46
+ * `try`.
47
+ */
48
+ declare function isLocalStorageAvailable(): boolean;
49
+ /**
50
+ * `localStorage`-backed store that degrades to memory instead of throwing.
51
+ *
52
+ * Storage can start failing at any point in a session, not just at construction:
53
+ * the origin hits quota, an extension clears site data, the profile's storage
54
+ * file goes corrupt. Since this is where the auth tokens live, an unhandled
55
+ * throw here surfaces as a failed sign-in for a buyer whose credentials were
56
+ * accepted. So every operation falls back to an in-memory copy. The session
57
+ * keeps working; it just stops surviving a reload.
58
+ */
30
59
  declare class LocalStorageStore implements IStore {
60
+ private readonly onDegraded?;
61
+ private readonly fallback;
62
+ private degraded;
63
+ constructor(onDegraded?: ((err: unknown) => void) | undefined);
64
+ get isDegraded(): boolean;
31
65
  get<TItem>(key: string): TItem | null;
32
66
  set<TItem>(key: string, item: TItem): void;
33
67
  remove(key: string): void;
34
68
  listen<TItem>(key: string, callback: (item: TItem | null) => void): void;
69
+ private reportDegraded;
35
70
  }
36
71
  declare class InMemoryStore implements IStore {
37
72
  private readonly store;
@@ -47,6 +82,7 @@ type IdentityWithExpiry = Identity & {
47
82
  };
48
83
  interface ITokenStore {
49
84
  get user(): User | null;
85
+ get isPersistent(): boolean;
50
86
  getAccessToken: () => Promise<string | null>;
51
87
  setUser: (user: Identity | User) => User | null;
52
88
  clear: () => void;
@@ -57,15 +93,26 @@ declare class TokenStore implements ITokenStore {
57
93
  private refreshTimeoutId;
58
94
  private refreshPromise;
59
95
  private readonly store;
96
+ private readonly logger;
60
97
  static readonly storageKey = "moonbase_auth";
61
98
  constructor(configuration: MoonbaseConfiguration);
62
99
  get user(): User | null;
100
+ get isPersistent(): boolean;
63
101
  getAccessToken(): Promise<string | null>;
64
102
  setUser(user: Identity | User): IdentityWithExpiry | null;
65
103
  clear(): null;
66
104
  private get isExpired();
105
+ /**
106
+ * Start a refresh and hold it so concurrent callers join it rather than firing
107
+ * their own. The slot is cleared as soon as it settles: a retained promise
108
+ * would hand every later expiry the same stale access token, and a rejected one
109
+ * that stayed put would re-throw on every subsequent request (including the
110
+ * anonymous ones) for the rest of the session.
111
+ */
112
+ private startRefresh;
67
113
  private refreshTokens;
68
114
  private handleStorageUpdate;
115
+ private warnAboutStorage;
69
116
  }
70
117
 
71
118
  type HttpMethods = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -1076,6 +1123,13 @@ interface SubscribeInput {
1076
1123
  name?: string | null;
1077
1124
  newsletter?: boolean;
1078
1125
  productUpdates?: boolean;
1126
+ /**
1127
+ * Merchant-owned customer groups to enroll the subscriber into. Applied on
1128
+ * the same terms as the opt-in beside them: on a tenant that requires double
1129
+ * opt-in they land when the confirmation link is clicked, not here. Groups the
1130
+ * merchant has not opened to public sign-ups are skipped in silence.
1131
+ */
1132
+ groupIds?: string[];
1079
1133
  }
1080
1134
  interface CommunicationPreferencesInput {
1081
1135
  newsletter: boolean;
@@ -1092,6 +1146,29 @@ declare class CommunicationsEndpoints {
1092
1146
  unsubscribe(email: string, token: string): Promise<void>;
1093
1147
  }
1094
1148
 
1149
+ declare class GroupEndpoints {
1150
+ private api;
1151
+ constructor(api: MoonbaseApi);
1152
+ /**
1153
+ * Joins the signed-in customer to a merchant-owned group that has been opened
1154
+ * to public sign-ups.
1155
+ *
1156
+ * The one group call that reports failure. Group IDs sent along with sign-up,
1157
+ * a newsletter subscription or a checkout are passengers on a request that
1158
+ * wanted something else, so an unusable one is skipped rather than allowed to
1159
+ * fail the account, the subscription or the sale. Here joining *is* the
1160
+ * request, so it throws.
1161
+ *
1162
+ * Every reason it can fail (no such group, deleted, closed, a segment, a
1163
+ * system group, another tenant's) arrives as the same `NotFoundError`, so
1164
+ * that a customer of one tenant can't enumerate that merchant's group IDs.
1165
+ *
1166
+ * Idempotent: joining a group the customer is already in succeeds and changes
1167
+ * nothing.
1168
+ */
1169
+ join(groupId: string): Promise<void>;
1170
+ }
1171
+
1095
1172
  declare const addressSchema: z.ZodObject<{
1096
1173
  countryCode: z.ZodString;
1097
1174
  streetAddress1: z.ZodString;
@@ -1584,6 +1661,17 @@ type SignUpResult = {
1584
1661
  status: 'activation-pending';
1585
1662
  email: string;
1586
1663
  };
1664
+ interface SignUpOptions {
1665
+ /**
1666
+ * Merchant-owned customer groups to enroll the new account into.
1667
+ *
1668
+ * Only groups the merchant has opened to public sign-ups are applied, and the
1669
+ * response does not say which ones were: an ID naming a group that is closed,
1670
+ * deleted, rule-driven or another tenant's is skipped in silence, so a stale
1671
+ * ID left in a form can never fail a registration.
1672
+ */
1673
+ groupIds?: string[];
1674
+ }
1587
1675
 
1588
1676
  declare class IdentityEndpoints {
1589
1677
  private api;
@@ -1592,7 +1680,7 @@ declare class IdentityEndpoints {
1592
1680
  constructor(api: MoonbaseApi, tokenStore: ITokenStore, logger: ILogger);
1593
1681
  get(): Promise<User>;
1594
1682
  signIn(email: string, password: string): Promise<User>;
1595
- signUp(name: string, email: string, password: string, address: Address | null | undefined, acceptedPrivacyPolicy: boolean, acceptedTermsAndConditions: boolean, communicationOptIn?: boolean): Promise<SignUpResult>;
1683
+ signUp(name: string, email: string, password: string, address: Address | null | undefined, acceptedPrivacyPolicy: boolean, acceptedTermsAndConditions: boolean, communicationOptIn?: boolean, options?: SignUpOptions): Promise<SignUpResult>;
1596
1684
  signOut(): Promise<void>;
1597
1685
  update(name: string | null, email: string, emailConfirmationToken?: string, communicationPreferences?: CommunicationPreferences): Promise<{
1598
1686
  needsEmailConfirmationToken: boolean;
@@ -24142,6 +24230,22 @@ declare class OrderEndpoints {
24142
24230
  addCouponCode(orderId: string, code: string): Promise<OpenOrder>;
24143
24231
  removeCouponCode(orderId: string, code: string): Promise<OpenOrder>;
24144
24232
  clearCouponCodes(orderId: string): Promise<OpenOrder>;
24233
+ /**
24234
+ * Records merchant-owned customer groups the buyer should join once this order
24235
+ * completes. The order has to exist server-side first, so call it after a
24236
+ * `pushContent`, and before the payment rather than after: the enrollment
24237
+ * rides on the order until the payment webhook settles it.
24238
+ *
24239
+ * Additive rather than a replacement: each call adds to what the order already
24240
+ * carries, and the accumulated set is capped server-side. Groups the merchant
24241
+ * has not opened to public sign-ups are skipped in silence, so a stale ID in a
24242
+ * checkout form can never fail a sale.
24243
+ *
24244
+ * Kept apart from the marketing opt-in on purpose: a checkout can offer both
24245
+ * independently, and merging them would opt a buyer who only asked to join a
24246
+ * list into email they never asked for.
24247
+ */
24248
+ joinGroups(orderId: string, groupIds: string[]): Promise<OpenOrder>;
24145
24249
  }
24146
24250
 
24147
24251
  declare class StorefrontEndpoints {
@@ -46885,6 +46989,26 @@ declare class MoonbaseError extends Error {
46885
46989
  constructor(title: string, detail: string | undefined, status?: number | undefined, errors?: Record<string, string> | undefined);
46886
46990
  }
46887
46991
 
46992
+ /**
46993
+ * Cleans up merchant-authored group IDs before they go on the wire.
46994
+ *
46995
+ * Deliberately normalizes without validating. The server sanitizes these itself,
46996
+ * dropping IDs that are too long, malformed, closed, or belonging to another
46997
+ * tenant, and capping the request at five. It does so in silence, so the
46998
+ * endpoints can't be used to enumerate a tenant's groups. Re-implementing those
46999
+ * rules here would only mean a merchant's typo disappears in the one place they
47000
+ * can't see it, so anything that survives trimming is sent and the server has
47001
+ * the last word.
47002
+ *
47003
+ * Lowercasing is the exception, and it earns its place by making the de-dupe
47004
+ * correct: group IDs are lowercase server-side, so `VIP` and `vip` are one
47005
+ * group and should only cost one of the five slots.
47006
+ *
47007
+ * Returns `undefined` rather than `[]` when nothing survives, so the field is
47008
+ * omitted from the request body entirely for every caller not using this.
47009
+ */
47010
+ declare function normalizeGroupIds(groupIds?: readonly string[] | null): string[] | undefined;
47011
+
46888
47012
  /**
46889
47013
  * Builds URLs for on-the-fly renditions of images stored in Moonbase's asset CDN.
46890
47014
  *
@@ -47025,6 +47149,7 @@ declare class MoonbaseClient {
47025
47149
  storefront: StorefrontEndpoints;
47026
47150
  identity: IdentityEndpoints;
47027
47151
  communications: CommunicationsEndpoints;
47152
+ groups: GroupEndpoints;
47028
47153
  vouchers: VoucherEndpoints;
47029
47154
  activationRequests: ActivationRequestEndpoints;
47030
47155
  inventory: InventoryEndpoints;
@@ -47032,4 +47157,4 @@ declare class MoonbaseClient {
47032
47157
  orders: OrderEndpoints;
47033
47158
  }
47034
47159
 
47035
- export { type Activation, ActivationMethod, type ActivationRequest, ActivationRequestFulfillmentType, ActivationRequestStatus, ActivationStatus, type Address, Architecture, type BundleLineItem, type CartCatalog, type CartOrder, type CartTotal, type CartTotalCondition, CartUtils, type CommunicationPreferences, type CommunicationPreferencesInput, type CommunicationPreferencesView, type CompletedOrder, ConnectableAccountProvider, type ConnectedAccount, ConsoleLogger, CycleLength, type Discount, DiscountUtils, type Download, type DownloadManifest, type ILogger, type IRecurrence, type IStore, type ITokenStore, type Identity, type ImageFit, type ImageFormat, type ImageTransform, ImageUtils, InMemoryStore, type License, LicenseStatus, type LineItem, type LineItemTotal, LocalStorageStore, LogLevel, MarketingConsentType, type MilestoneProgress, type Money, type MoneyCollection, MoneyCollectionUtils, MoonbaseApi, MoonbaseClient, type MoonbaseConfiguration, MoonbaseError, NotAuthenticatedError, NotAuthorizedError, NotFoundError, type OfferCondition, type OfferConditionContext, type OfferProgress, type OfferScope, OfferUtils, type OpenOrder, type Order, OrderStatus, type OrderTotal, type OwnedProduct, type Page, Platform, type PricingTier, type PricingVariation, type ProblemDetails, type ProductLineItem, type PromotionDisplay, type Quantifiable, type RawPropertyArray, type RawPropertyObject, type RawPropertyValue, type SignUpResult, type SingleOrMultiple, type Storefront, type StorefrontBundle, type StorefrontOffer, type StorefrontOfferTarget, type StorefrontProduct, type StorefrontPromotion, type StorefrontPromotionCta, type StorefrontPromotionTarget, type SubscribeInput, type SubscribeResponse, type Subscription, SubscriptionStatus, type TaxEstimate, TokenStore, type UrchinTrackingModule, type User, type UserAccountConfirmed, type UserAccountConfirmedStatus, type Vendor, type Voucher, imageWidths, isPromotionDisplay, isTransformableImageUrl, maxImageDimension, objectToQuery, parseOffSiteReferrer, problemDetailsSchema, promotionDisplays, resolveUtm, schemas, utmToObject };
47160
+ export { type Activation, ActivationMethod, type ActivationRequest, ActivationRequestFulfillmentType, ActivationRequestStatus, ActivationStatus, type Address, Architecture, type BundleLineItem, type CartCatalog, type CartOrder, type CartTotal, type CartTotalCondition, CartUtils, type CommunicationPreferences, type CommunicationPreferencesInput, type CommunicationPreferencesView, type CompletedOrder, ConnectableAccountProvider, type ConnectedAccount, ConsoleLogger, CycleLength, type Discount, DiscountUtils, type Download, type DownloadManifest, type ILogger, type IRecurrence, type IStore, type ITokenStore, type Identity, type ImageFit, type ImageFormat, type ImageTransform, ImageUtils, InMemoryStore, type License, LicenseStatus, type LineItem, type LineItemTotal, LocalStorageStore, LogLevel, MarketingConsentType, type MilestoneProgress, type Money, type MoneyCollection, MoneyCollectionUtils, MoonbaseApi, MoonbaseClient, type MoonbaseConfiguration, MoonbaseError, NotAuthenticatedError, NotAuthorizedError, NotFoundError, type OfferCondition, type OfferConditionContext, type OfferProgress, type OfferScope, OfferUtils, type OpenOrder, type Order, OrderStatus, type OrderTotal, type OwnedProduct, type Page, Platform, type PricingTier, type PricingVariation, type ProblemDetails, type ProductLineItem, type PromotionDisplay, type Quantifiable, type RawPropertyArray, type RawPropertyObject, type RawPropertyValue, type SignUpOptions, type SignUpResult, type SingleOrMultiple, type Storefront, type StorefrontBundle, type StorefrontOffer, type StorefrontOfferTarget, type StorefrontProduct, type StorefrontPromotion, type StorefrontPromotionCta, type StorefrontPromotionTarget, type SubscribeInput, type SubscribeResponse, type Subscription, SubscriptionStatus, type TaxEstimate, TokenStore, type UrchinTrackingModule, type User, type UserAccountConfirmed, type UserAccountConfirmedStatus, type Vendor, type Voucher, imageWidths, isLocalStorageAvailable, isPromotionDisplay, isTransformableImageUrl, maxImageDimension, normalizeGroupIds, objectToQuery, parseOffSiteReferrer, problemDetailsSchema, promotionDisplays, resolveUtm, schemas, utmToObject };
package/dist/index.d.ts CHANGED
@@ -26,12 +26,47 @@ interface IStore {
26
26
  set: <TItem>(key: string, item: TItem) => void;
27
27
  remove: (key: string) => void;
28
28
  listen: <TItem>(key: string, callback: (item: TItem | null) => void) => void;
29
+ /**
30
+ * True once any storage operation has failed and the store has fallen back to
31
+ * memory. Optional so custom stores don't have to implement it.
32
+ */
33
+ readonly isDegraded?: boolean;
29
34
  }
35
+ /**
36
+ * Whether `localStorage` can be reached at all.
37
+ *
38
+ * Deliberately *not* a write probe. Writability is not the right question here,
39
+ * because `LocalStorageStore` degrades per operation when a write fails, so a
40
+ * storage that reads fine but is at quota is still worth using: it is holding the
41
+ * buyer's existing session, and refusing it would throw away a perfectly readable
42
+ * sign-in and send them back to the login form. The only thing that makes
43
+ * `localStorage` unusable is not being able to touch it, which is what Firefox
44
+ * does (`SecurityError` from the property getter) when the buyer has blocked
45
+ * cookies and site data. That is why even the truthiness check sits inside a
46
+ * `try`.
47
+ */
48
+ declare function isLocalStorageAvailable(): boolean;
49
+ /**
50
+ * `localStorage`-backed store that degrades to memory instead of throwing.
51
+ *
52
+ * Storage can start failing at any point in a session, not just at construction:
53
+ * the origin hits quota, an extension clears site data, the profile's storage
54
+ * file goes corrupt. Since this is where the auth tokens live, an unhandled
55
+ * throw here surfaces as a failed sign-in for a buyer whose credentials were
56
+ * accepted. So every operation falls back to an in-memory copy. The session
57
+ * keeps working; it just stops surviving a reload.
58
+ */
30
59
  declare class LocalStorageStore implements IStore {
60
+ private readonly onDegraded?;
61
+ private readonly fallback;
62
+ private degraded;
63
+ constructor(onDegraded?: ((err: unknown) => void) | undefined);
64
+ get isDegraded(): boolean;
31
65
  get<TItem>(key: string): TItem | null;
32
66
  set<TItem>(key: string, item: TItem): void;
33
67
  remove(key: string): void;
34
68
  listen<TItem>(key: string, callback: (item: TItem | null) => void): void;
69
+ private reportDegraded;
35
70
  }
36
71
  declare class InMemoryStore implements IStore {
37
72
  private readonly store;
@@ -47,6 +82,7 @@ type IdentityWithExpiry = Identity & {
47
82
  };
48
83
  interface ITokenStore {
49
84
  get user(): User | null;
85
+ get isPersistent(): boolean;
50
86
  getAccessToken: () => Promise<string | null>;
51
87
  setUser: (user: Identity | User) => User | null;
52
88
  clear: () => void;
@@ -57,15 +93,26 @@ declare class TokenStore implements ITokenStore {
57
93
  private refreshTimeoutId;
58
94
  private refreshPromise;
59
95
  private readonly store;
96
+ private readonly logger;
60
97
  static readonly storageKey = "moonbase_auth";
61
98
  constructor(configuration: MoonbaseConfiguration);
62
99
  get user(): User | null;
100
+ get isPersistent(): boolean;
63
101
  getAccessToken(): Promise<string | null>;
64
102
  setUser(user: Identity | User): IdentityWithExpiry | null;
65
103
  clear(): null;
66
104
  private get isExpired();
105
+ /**
106
+ * Start a refresh and hold it so concurrent callers join it rather than firing
107
+ * their own. The slot is cleared as soon as it settles: a retained promise
108
+ * would hand every later expiry the same stale access token, and a rejected one
109
+ * that stayed put would re-throw on every subsequent request (including the
110
+ * anonymous ones) for the rest of the session.
111
+ */
112
+ private startRefresh;
67
113
  private refreshTokens;
68
114
  private handleStorageUpdate;
115
+ private warnAboutStorage;
69
116
  }
70
117
 
71
118
  type HttpMethods = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
@@ -1076,6 +1123,13 @@ interface SubscribeInput {
1076
1123
  name?: string | null;
1077
1124
  newsletter?: boolean;
1078
1125
  productUpdates?: boolean;
1126
+ /**
1127
+ * Merchant-owned customer groups to enroll the subscriber into. Applied on
1128
+ * the same terms as the opt-in beside them: on a tenant that requires double
1129
+ * opt-in they land when the confirmation link is clicked, not here. Groups the
1130
+ * merchant has not opened to public sign-ups are skipped in silence.
1131
+ */
1132
+ groupIds?: string[];
1079
1133
  }
1080
1134
  interface CommunicationPreferencesInput {
1081
1135
  newsletter: boolean;
@@ -1092,6 +1146,29 @@ declare class CommunicationsEndpoints {
1092
1146
  unsubscribe(email: string, token: string): Promise<void>;
1093
1147
  }
1094
1148
 
1149
+ declare class GroupEndpoints {
1150
+ private api;
1151
+ constructor(api: MoonbaseApi);
1152
+ /**
1153
+ * Joins the signed-in customer to a merchant-owned group that has been opened
1154
+ * to public sign-ups.
1155
+ *
1156
+ * The one group call that reports failure. Group IDs sent along with sign-up,
1157
+ * a newsletter subscription or a checkout are passengers on a request that
1158
+ * wanted something else, so an unusable one is skipped rather than allowed to
1159
+ * fail the account, the subscription or the sale. Here joining *is* the
1160
+ * request, so it throws.
1161
+ *
1162
+ * Every reason it can fail (no such group, deleted, closed, a segment, a
1163
+ * system group, another tenant's) arrives as the same `NotFoundError`, so
1164
+ * that a customer of one tenant can't enumerate that merchant's group IDs.
1165
+ *
1166
+ * Idempotent: joining a group the customer is already in succeeds and changes
1167
+ * nothing.
1168
+ */
1169
+ join(groupId: string): Promise<void>;
1170
+ }
1171
+
1095
1172
  declare const addressSchema: z.ZodObject<{
1096
1173
  countryCode: z.ZodString;
1097
1174
  streetAddress1: z.ZodString;
@@ -1584,6 +1661,17 @@ type SignUpResult = {
1584
1661
  status: 'activation-pending';
1585
1662
  email: string;
1586
1663
  };
1664
+ interface SignUpOptions {
1665
+ /**
1666
+ * Merchant-owned customer groups to enroll the new account into.
1667
+ *
1668
+ * Only groups the merchant has opened to public sign-ups are applied, and the
1669
+ * response does not say which ones were: an ID naming a group that is closed,
1670
+ * deleted, rule-driven or another tenant's is skipped in silence, so a stale
1671
+ * ID left in a form can never fail a registration.
1672
+ */
1673
+ groupIds?: string[];
1674
+ }
1587
1675
 
1588
1676
  declare class IdentityEndpoints {
1589
1677
  private api;
@@ -1592,7 +1680,7 @@ declare class IdentityEndpoints {
1592
1680
  constructor(api: MoonbaseApi, tokenStore: ITokenStore, logger: ILogger);
1593
1681
  get(): Promise<User>;
1594
1682
  signIn(email: string, password: string): Promise<User>;
1595
- signUp(name: string, email: string, password: string, address: Address | null | undefined, acceptedPrivacyPolicy: boolean, acceptedTermsAndConditions: boolean, communicationOptIn?: boolean): Promise<SignUpResult>;
1683
+ signUp(name: string, email: string, password: string, address: Address | null | undefined, acceptedPrivacyPolicy: boolean, acceptedTermsAndConditions: boolean, communicationOptIn?: boolean, options?: SignUpOptions): Promise<SignUpResult>;
1596
1684
  signOut(): Promise<void>;
1597
1685
  update(name: string | null, email: string, emailConfirmationToken?: string, communicationPreferences?: CommunicationPreferences): Promise<{
1598
1686
  needsEmailConfirmationToken: boolean;
@@ -24142,6 +24230,22 @@ declare class OrderEndpoints {
24142
24230
  addCouponCode(orderId: string, code: string): Promise<OpenOrder>;
24143
24231
  removeCouponCode(orderId: string, code: string): Promise<OpenOrder>;
24144
24232
  clearCouponCodes(orderId: string): Promise<OpenOrder>;
24233
+ /**
24234
+ * Records merchant-owned customer groups the buyer should join once this order
24235
+ * completes. The order has to exist server-side first, so call it after a
24236
+ * `pushContent`, and before the payment rather than after: the enrollment
24237
+ * rides on the order until the payment webhook settles it.
24238
+ *
24239
+ * Additive rather than a replacement: each call adds to what the order already
24240
+ * carries, and the accumulated set is capped server-side. Groups the merchant
24241
+ * has not opened to public sign-ups are skipped in silence, so a stale ID in a
24242
+ * checkout form can never fail a sale.
24243
+ *
24244
+ * Kept apart from the marketing opt-in on purpose: a checkout can offer both
24245
+ * independently, and merging them would opt a buyer who only asked to join a
24246
+ * list into email they never asked for.
24247
+ */
24248
+ joinGroups(orderId: string, groupIds: string[]): Promise<OpenOrder>;
24145
24249
  }
24146
24250
 
24147
24251
  declare class StorefrontEndpoints {
@@ -46885,6 +46989,26 @@ declare class MoonbaseError extends Error {
46885
46989
  constructor(title: string, detail: string | undefined, status?: number | undefined, errors?: Record<string, string> | undefined);
46886
46990
  }
46887
46991
 
46992
+ /**
46993
+ * Cleans up merchant-authored group IDs before they go on the wire.
46994
+ *
46995
+ * Deliberately normalizes without validating. The server sanitizes these itself,
46996
+ * dropping IDs that are too long, malformed, closed, or belonging to another
46997
+ * tenant, and capping the request at five. It does so in silence, so the
46998
+ * endpoints can't be used to enumerate a tenant's groups. Re-implementing those
46999
+ * rules here would only mean a merchant's typo disappears in the one place they
47000
+ * can't see it, so anything that survives trimming is sent and the server has
47001
+ * the last word.
47002
+ *
47003
+ * Lowercasing is the exception, and it earns its place by making the de-dupe
47004
+ * correct: group IDs are lowercase server-side, so `VIP` and `vip` are one
47005
+ * group and should only cost one of the five slots.
47006
+ *
47007
+ * Returns `undefined` rather than `[]` when nothing survives, so the field is
47008
+ * omitted from the request body entirely for every caller not using this.
47009
+ */
47010
+ declare function normalizeGroupIds(groupIds?: readonly string[] | null): string[] | undefined;
47011
+
46888
47012
  /**
46889
47013
  * Builds URLs for on-the-fly renditions of images stored in Moonbase's asset CDN.
46890
47014
  *
@@ -47025,6 +47149,7 @@ declare class MoonbaseClient {
47025
47149
  storefront: StorefrontEndpoints;
47026
47150
  identity: IdentityEndpoints;
47027
47151
  communications: CommunicationsEndpoints;
47152
+ groups: GroupEndpoints;
47028
47153
  vouchers: VoucherEndpoints;
47029
47154
  activationRequests: ActivationRequestEndpoints;
47030
47155
  inventory: InventoryEndpoints;
@@ -47032,4 +47157,4 @@ declare class MoonbaseClient {
47032
47157
  orders: OrderEndpoints;
47033
47158
  }
47034
47159
 
47035
- export { type Activation, ActivationMethod, type ActivationRequest, ActivationRequestFulfillmentType, ActivationRequestStatus, ActivationStatus, type Address, Architecture, type BundleLineItem, type CartCatalog, type CartOrder, type CartTotal, type CartTotalCondition, CartUtils, type CommunicationPreferences, type CommunicationPreferencesInput, type CommunicationPreferencesView, type CompletedOrder, ConnectableAccountProvider, type ConnectedAccount, ConsoleLogger, CycleLength, type Discount, DiscountUtils, type Download, type DownloadManifest, type ILogger, type IRecurrence, type IStore, type ITokenStore, type Identity, type ImageFit, type ImageFormat, type ImageTransform, ImageUtils, InMemoryStore, type License, LicenseStatus, type LineItem, type LineItemTotal, LocalStorageStore, LogLevel, MarketingConsentType, type MilestoneProgress, type Money, type MoneyCollection, MoneyCollectionUtils, MoonbaseApi, MoonbaseClient, type MoonbaseConfiguration, MoonbaseError, NotAuthenticatedError, NotAuthorizedError, NotFoundError, type OfferCondition, type OfferConditionContext, type OfferProgress, type OfferScope, OfferUtils, type OpenOrder, type Order, OrderStatus, type OrderTotal, type OwnedProduct, type Page, Platform, type PricingTier, type PricingVariation, type ProblemDetails, type ProductLineItem, type PromotionDisplay, type Quantifiable, type RawPropertyArray, type RawPropertyObject, type RawPropertyValue, type SignUpResult, type SingleOrMultiple, type Storefront, type StorefrontBundle, type StorefrontOffer, type StorefrontOfferTarget, type StorefrontProduct, type StorefrontPromotion, type StorefrontPromotionCta, type StorefrontPromotionTarget, type SubscribeInput, type SubscribeResponse, type Subscription, SubscriptionStatus, type TaxEstimate, TokenStore, type UrchinTrackingModule, type User, type UserAccountConfirmed, type UserAccountConfirmedStatus, type Vendor, type Voucher, imageWidths, isPromotionDisplay, isTransformableImageUrl, maxImageDimension, objectToQuery, parseOffSiteReferrer, problemDetailsSchema, promotionDisplays, resolveUtm, schemas, utmToObject };
47160
+ export { type Activation, ActivationMethod, type ActivationRequest, ActivationRequestFulfillmentType, ActivationRequestStatus, ActivationStatus, type Address, Architecture, type BundleLineItem, type CartCatalog, type CartOrder, type CartTotal, type CartTotalCondition, CartUtils, type CommunicationPreferences, type CommunicationPreferencesInput, type CommunicationPreferencesView, type CompletedOrder, ConnectableAccountProvider, type ConnectedAccount, ConsoleLogger, CycleLength, type Discount, DiscountUtils, type Download, type DownloadManifest, type ILogger, type IRecurrence, type IStore, type ITokenStore, type Identity, type ImageFit, type ImageFormat, type ImageTransform, ImageUtils, InMemoryStore, type License, LicenseStatus, type LineItem, type LineItemTotal, LocalStorageStore, LogLevel, MarketingConsentType, type MilestoneProgress, type Money, type MoneyCollection, MoneyCollectionUtils, MoonbaseApi, MoonbaseClient, type MoonbaseConfiguration, MoonbaseError, NotAuthenticatedError, NotAuthorizedError, NotFoundError, type OfferCondition, type OfferConditionContext, type OfferProgress, type OfferScope, OfferUtils, type OpenOrder, type Order, OrderStatus, type OrderTotal, type OwnedProduct, type Page, Platform, type PricingTier, type PricingVariation, type ProblemDetails, type ProductLineItem, type PromotionDisplay, type Quantifiable, type RawPropertyArray, type RawPropertyObject, type RawPropertyValue, type SignUpOptions, type SignUpResult, type SingleOrMultiple, type Storefront, type StorefrontBundle, type StorefrontOffer, type StorefrontOfferTarget, type StorefrontProduct, type StorefrontPromotion, type StorefrontPromotionCta, type StorefrontPromotionTarget, type SubscribeInput, type SubscribeResponse, type Subscription, SubscriptionStatus, type TaxEstimate, TokenStore, type UrchinTrackingModule, type User, type UserAccountConfirmed, type UserAccountConfirmedStatus, type Vendor, type Voucher, imageWidths, isLocalStorageAvailable, isPromotionDisplay, isTransformableImageUrl, maxImageDimension, normalizeGroupIds, objectToQuery, parseOffSiteReferrer, problemDetailsSchema, promotionDisplays, resolveUtm, schemas, utmToObject };
package/dist/index.js CHANGED
@@ -431,6 +431,19 @@ var ActivationRequestEndpoints = class {
431
431
  }
432
432
  };
433
433
 
434
+ // src/utils/groups.ts
435
+ function normalizeGroupIds(groupIds) {
436
+ if (!groupIds)
437
+ return void 0;
438
+ const normalized = [];
439
+ for (const groupId of groupIds) {
440
+ const trimmed = groupId == null ? void 0 : groupId.trim().toLowerCase();
441
+ if (trimmed && !normalized.includes(trimmed))
442
+ normalized.push(trimmed);
443
+ }
444
+ return normalized.length > 0 ? normalized : void 0;
445
+ }
446
+
434
447
  // src/communications/schemas.ts
435
448
  var schemas_exports4 = {};
436
449
  __export(schemas_exports4, {
@@ -464,7 +477,8 @@ var CommunicationsEndpoints = class {
464
477
  email: input.email,
465
478
  name: ((_a = input.name) == null ? void 0 : _a.trim()) || null,
466
479
  newsletter: (_b = input.newsletter) != null ? _b : true,
467
- productUpdates: (_c = input.productUpdates) != null ? _c : true
480
+ productUpdates: (_c = input.productUpdates) != null ? _c : true,
481
+ groupIds: normalizeGroupIds(input.groupIds)
468
482
  }
469
483
  });
470
484
  return response.data;
@@ -506,6 +520,37 @@ var CommunicationsEndpoints = class {
506
520
  }
507
521
  };
508
522
 
523
+ // src/groups/endpoints.ts
524
+ var GroupEndpoints = class {
525
+ constructor(api) {
526
+ this.api = api;
527
+ }
528
+ /**
529
+ * Joins the signed-in customer to a merchant-owned group that has been opened
530
+ * to public sign-ups.
531
+ *
532
+ * The one group call that reports failure. Group IDs sent along with sign-up,
533
+ * a newsletter subscription or a checkout are passengers on a request that
534
+ * wanted something else, so an unusable one is skipped rather than allowed to
535
+ * fail the account, the subscription or the sale. Here joining *is* the
536
+ * request, so it throws.
537
+ *
538
+ * Every reason it can fail (no such group, deleted, closed, a segment, a
539
+ * system group, another tenant's) arrives as the same `NotFoundError`, so
540
+ * that a customer of one tenant can't enumerate that merchant's group IDs.
541
+ *
542
+ * Idempotent: joining a group the customer is already in succeeds and changes
543
+ * nothing.
544
+ */
545
+ async join(groupId) {
546
+ await this.api.authenticatedFetch(
547
+ `/api/customer/groups/${encodeURIComponent(groupId)}/join`,
548
+ null,
549
+ { method: "POST" }
550
+ );
551
+ }
552
+ };
553
+
509
554
  // src/utils/errors.ts
510
555
  var NotAuthorizedError = class extends Error {
511
556
  constructor() {
@@ -699,17 +744,17 @@ var IdentityEndpoints = class {
699
744
  throw new NotFoundError("User not found");
700
745
  if (response.status >= 400)
701
746
  await handleResponseProblem(response, this.logger);
747
+ let identity;
702
748
  try {
703
- const data = await response.json();
704
- const user = identitySchema.parse(data);
705
- this.tokenStore.setUser(user);
706
- return userSchema.parse(user);
749
+ identity = identitySchema.parse(await response.json());
707
750
  } catch (err) {
708
751
  this.logger.warn("Could not sign in user", { email, response, err });
709
752
  throw new MoonbaseError("Bad response", "Could not sign in user", response.status);
710
753
  }
754
+ this.tokenStore.setUser(identity);
755
+ return userSchema.parse(identity);
711
756
  }
712
- async signUp(name, email, password, address, acceptedPrivacyPolicy, acceptedTermsAndConditions, communicationOptIn) {
757
+ async signUp(name, email, password, address, acceptedPrivacyPolicy, acceptedTermsAndConditions, communicationOptIn, options) {
713
758
  const path = `/api/customer/identity/sign-up?scheme=JWT&communicationOptIn=${communicationOptIn ? "true" : "false"}`;
714
759
  const response = await fetch(`${this.api.baseUrl}${path}`, {
715
760
  method: "POST",
@@ -724,7 +769,8 @@ var IdentityEndpoints = class {
724
769
  password,
725
770
  address,
726
771
  acceptedPrivacyPolicy,
727
- acceptedTermsAndConditions
772
+ acceptedTermsAndConditions,
773
+ groupIds: normalizeGroupIds(options == null ? void 0 : options.groupIds)
728
774
  })
729
775
  });
730
776
  if (response.status >= 400)
@@ -734,14 +780,15 @@ var IdentityEndpoints = class {
734
780
  const parsed = pendingActivationSchema.parse(data);
735
781
  return { status: "activation-pending", email: parsed.email };
736
782
  }
783
+ let identity;
737
784
  try {
738
- const identity = identitySchema.parse(data);
739
- this.tokenStore.setUser(identity);
740
- return { status: "signed-in", user: userSchema.parse(identity) };
785
+ identity = identitySchema.parse(data);
741
786
  } catch (err) {
742
787
  this.logger.warn("Could not sign up user", { email, response, err });
743
788
  throw new MoonbaseError("Bad response", "Could not sign up user", response.status);
744
789
  }
790
+ this.tokenStore.setUser(identity);
791
+ return { status: "signed-in", user: userSchema.parse(identity) };
745
792
  }
746
793
  async signOut() {
747
794
  this.tokenStore.clear();
@@ -1021,6 +1068,15 @@ var MoonbaseApi = class {
1021
1068
  status: response.status,
1022
1069
  duration: finishedAt.getTime() - startedAt.getTime()
1023
1070
  });
1071
+ if (response.type === "opaqueredirect" || response.status === 0) {
1072
+ const err = new MoonbaseError(
1073
+ "Unexpected redirect",
1074
+ `The Moonbase API redirected ${path}, which the SDK is not configured to follow`,
1075
+ response.status
1076
+ );
1077
+ this.reportRequestProblem(path, request, response, err);
1078
+ throw err;
1079
+ }
1024
1080
  if (response.status >= 400) {
1025
1081
  try {
1026
1082
  await handleResponseProblem(response, this.logger);
@@ -1436,6 +1492,28 @@ var OrderEndpoints = class {
1436
1492
  const response = await this.api.fetch(`/api/customer/orders/${orderId}/coupons`, openOrderSchema, { method: "DELETE" });
1437
1493
  return response.data;
1438
1494
  }
1495
+ /**
1496
+ * Records merchant-owned customer groups the buyer should join once this order
1497
+ * completes. The order has to exist server-side first, so call it after a
1498
+ * `pushContent`, and before the payment rather than after: the enrollment
1499
+ * rides on the order until the payment webhook settles it.
1500
+ *
1501
+ * Additive rather than a replacement: each call adds to what the order already
1502
+ * carries, and the accumulated set is capped server-side. Groups the merchant
1503
+ * has not opened to public sign-ups are skipped in silence, so a stale ID in a
1504
+ * checkout form can never fail a sale.
1505
+ *
1506
+ * Kept apart from the marketing opt-in on purpose: a checkout can offer both
1507
+ * independently, and merging them would opt a buyer who only asked to join a
1508
+ * list into email they never asked for.
1509
+ */
1510
+ async joinGroups(orderId, groupIds) {
1511
+ const response = await this.api.fetch(`/api/customer/orders/${orderId}/groups`, openOrderSchema, {
1512
+ method: "POST",
1513
+ body: { groupIds: normalizeGroupIds(groupIds) }
1514
+ });
1515
+ return response.data;
1516
+ }
1439
1517
  };
1440
1518
 
1441
1519
  // src/storefront/endpoints.ts
@@ -1485,28 +1563,81 @@ var ConsoleLogger = class {
1485
1563
  };
1486
1564
 
1487
1565
  // src/utils/store.ts
1566
+ function isLocalStorageAvailable() {
1567
+ try {
1568
+ return typeof window !== "undefined" && !!window.localStorage;
1569
+ } catch (e) {
1570
+ return false;
1571
+ }
1572
+ }
1488
1573
  var LocalStorageStore = class {
1574
+ constructor(onDegraded) {
1575
+ this.onDegraded = onDegraded;
1576
+ // Values whose write to localStorage failed. Read back in preference to
1577
+ // localStorage so the rest of the session sees what it just wrote.
1578
+ this.fallback = /* @__PURE__ */ new Map();
1579
+ this.degraded = false;
1580
+ }
1581
+ get isDegraded() {
1582
+ return this.degraded;
1583
+ }
1489
1584
  get(key) {
1490
- const item = localStorage.getItem(key);
1491
- if (item) {
1492
- return JSON.parse(item);
1585
+ if (this.fallback.has(key))
1586
+ return this.fallback.get(key);
1587
+ try {
1588
+ const item = localStorage.getItem(key);
1589
+ if (item) {
1590
+ return JSON.parse(item);
1591
+ }
1592
+ return null;
1593
+ } catch (err) {
1594
+ this.reportDegraded(err);
1595
+ return null;
1493
1596
  }
1494
- return null;
1495
1597
  }
1496
1598
  set(key, item) {
1497
- localStorage.setItem(key, JSON.stringify(item));
1599
+ try {
1600
+ localStorage.setItem(key, JSON.stringify(item));
1601
+ this.fallback.delete(key);
1602
+ } catch (err) {
1603
+ this.reportDegraded(err);
1604
+ this.fallback.set(key, item);
1605
+ }
1498
1606
  }
1499
1607
  remove(key) {
1500
- localStorage.removeItem(key);
1608
+ try {
1609
+ localStorage.removeItem(key);
1610
+ this.fallback.delete(key);
1611
+ return;
1612
+ } catch (err) {
1613
+ this.reportDegraded(err);
1614
+ }
1615
+ this.fallback.set(key, null);
1616
+ try {
1617
+ localStorage.setItem(key, "");
1618
+ } catch (e) {
1619
+ }
1501
1620
  }
1502
1621
  listen(key, callback) {
1503
- if (typeof window !== "undefined") {
1504
- window.addEventListener("storage", (event) => {
1505
- if (event.key === key) {
1506
- callback(event.newValue ? JSON.parse(event.newValue) : null);
1507
- }
1508
- });
1509
- }
1622
+ if (typeof window === "undefined")
1623
+ return;
1624
+ window.addEventListener("storage", (event) => {
1625
+ if (event.key !== key)
1626
+ return;
1627
+ this.fallback.delete(key);
1628
+ try {
1629
+ callback(event.newValue ? JSON.parse(event.newValue) : null);
1630
+ } catch (err) {
1631
+ console.warn(`Moonbase could not parse the stored value for ${key}`, err);
1632
+ }
1633
+ });
1634
+ }
1635
+ reportDegraded(err) {
1636
+ var _a;
1637
+ if (this.degraded)
1638
+ return;
1639
+ this.degraded = true;
1640
+ (_a = this.onDegraded) == null ? void 0 : _a.call(this, err);
1510
1641
  }
1511
1642
  };
1512
1643
  var InMemoryStore = class {
@@ -1544,8 +1675,11 @@ var _TokenStore = class _TokenStore {
1544
1675
  this.tokens = null;
1545
1676
  this.refreshTimeoutId = null;
1546
1677
  this.refreshPromise = null;
1547
- var _a;
1548
- this.store = (_a = configuration.store) != null ? _a : typeof window !== "undefined" && localStorage ? new LocalStorageStore() : new InMemoryStore();
1678
+ var _a, _b;
1679
+ this.logger = (_a = configuration.logger) != null ? _a : new ConsoleLogger();
1680
+ this.store = (_b = configuration.store) != null ? _b : isLocalStorageAvailable() ? new LocalStorageStore((err) => this.warnAboutStorage(err)) : new InMemoryStore();
1681
+ if (typeof window !== "undefined" && !configuration.store && !(this.store instanceof LocalStorageStore))
1682
+ this.warnAboutStorage();
1549
1683
  const storedToken = this.store.get(_TokenStore.storageKey);
1550
1684
  if (storedToken) {
1551
1685
  this.tokens = {
@@ -1560,18 +1694,17 @@ var _TokenStore = class _TokenStore {
1560
1694
  return this.tokens;
1561
1695
  return null;
1562
1696
  }
1697
+ get isPersistent() {
1698
+ return this.store instanceof LocalStorageStore && !this.store.isDegraded;
1699
+ }
1563
1700
  async getAccessToken() {
1564
1701
  var _a, _b, _c;
1565
- if (this.isExpired) {
1566
- if (this.refreshPromise) {
1567
- const tokens2 = await this.refreshPromise;
1568
- return (_a = tokens2 == null ? void 0 : tokens2.accessToken) != null ? _a : null;
1569
- }
1570
- this.refreshPromise = this.refreshTokens();
1571
- const tokens = await this.refreshPromise;
1572
- return (_b = tokens == null ? void 0 : tokens.accessToken) != null ? _b : null;
1702
+ if (!this.isExpired) {
1703
+ return ((_a = this.tokens) == null ? void 0 : _a.accessToken) || null;
1573
1704
  }
1574
- return ((_c = this.tokens) == null ? void 0 : _c.accessToken) || null;
1705
+ const pending = (_b = this.refreshPromise) != null ? _b : this.startRefresh();
1706
+ const tokens = await pending;
1707
+ return (_c = tokens == null ? void 0 : tokens.accessToken) != null ? _c : null;
1575
1708
  }
1576
1709
  setUser(user) {
1577
1710
  const identity = user;
@@ -1596,7 +1729,7 @@ var _TokenStore = class _TokenStore {
1596
1729
  if (this.refreshTimeoutId != null)
1597
1730
  window.clearTimeout(this.refreshTimeoutId);
1598
1731
  this.refreshTimeoutId = window.setTimeout(() => {
1599
- this.refreshPromise = this.refreshTokens();
1732
+ this.startRefresh().catch((err) => this.logger.warn("Could not refresh access token", err));
1600
1733
  }, 10 * 60 * 1e3);
1601
1734
  }
1602
1735
  return this.tokens;
@@ -1611,10 +1744,25 @@ var _TokenStore = class _TokenStore {
1611
1744
  get isExpired() {
1612
1745
  return this.tokens != null && this.tokens.expiresAt < /* @__PURE__ */ new Date();
1613
1746
  }
1747
+ /**
1748
+ * Start a refresh and hold it so concurrent callers join it rather than firing
1749
+ * their own. The slot is cleared as soon as it settles: a retained promise
1750
+ * would hand every later expiry the same stale access token, and a rejected one
1751
+ * that stayed put would re-throw on every subsequent request (including the
1752
+ * anonymous ones) for the rest of the session.
1753
+ */
1754
+ startRefresh() {
1755
+ const pending = this.refreshTokens().finally(() => {
1756
+ if (this.refreshPromise === pending)
1757
+ this.refreshPromise = null;
1758
+ });
1759
+ this.refreshPromise = pending;
1760
+ return pending;
1761
+ }
1614
1762
  async refreshTokens() {
1615
1763
  if (!this.tokens)
1616
1764
  throw new Error("No tokens found to refresh");
1617
- const response = await fetch(`${this.configuration.endpoint}/api/customer/identity/refresh?token=${this.tokens.refreshToken}`, {
1765
+ const response = await fetch(`${this.configuration.endpoint}/api/customer/identity/refresh?token=${encodeURIComponent(this.tokens.refreshToken)}`, {
1618
1766
  method: "POST",
1619
1767
  headers: {
1620
1768
  "Accept": "application/json",
@@ -1636,12 +1784,18 @@ var _TokenStore = class _TokenStore {
1636
1784
  }
1637
1785
  handleStorageUpdate(updatedTokens) {
1638
1786
  this.tokens = updatedTokens;
1639
- if (this.refreshTimeoutId != null)
1787
+ if (this.refreshTimeoutId != null && typeof window !== "undefined")
1640
1788
  window.clearTimeout(this.refreshTimeoutId);
1641
1789
  if (this.tokens) {
1642
1790
  this.tokens.expiresAt = new Date(this.tokens.expiresAt);
1643
1791
  }
1644
1792
  }
1793
+ warnAboutStorage(err) {
1794
+ this.logger.warn(
1795
+ "Moonbase could not persist credentials to localStorage: this browser is blocking or has exhausted site storage. Sign-in will work for this page load but will not survive a reload.",
1796
+ err
1797
+ );
1798
+ }
1645
1799
  };
1646
1800
  _TokenStore.storageKey = "moonbase_auth";
1647
1801
  var TokenStore = _TokenStore;
@@ -2336,6 +2490,7 @@ var MoonbaseClient = class {
2336
2490
  this.storefront = new StorefrontEndpoints(this.api, this.configuration);
2337
2491
  this.identity = new IdentityEndpoints(this.api, this.tokenStore, this.logger);
2338
2492
  this.communications = new CommunicationsEndpoints(this.api);
2493
+ this.groups = new GroupEndpoints(this.api);
2339
2494
  this.vouchers = new VoucherEndpoints(this.api);
2340
2495
  this.orders = new OrderEndpoints(this.api);
2341
2496
  this.inventory = new InventoryEndpoints(this.api, this.configuration);
@@ -2373,9 +2528,11 @@ export {
2373
2528
  SubscriptionStatus,
2374
2529
  TokenStore,
2375
2530
  imageWidths,
2531
+ isLocalStorageAvailable,
2376
2532
  isPromotionDisplay,
2377
2533
  isTransformableImageUrl,
2378
2534
  maxImageDimension,
2535
+ normalizeGroupIds,
2379
2536
  objectToQuery,
2380
2537
  parseOffSiteReferrer,
2381
2538
  problemDetailsSchema,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@moonbase.sh/storefront-api",
3
3
  "type": "module",
4
- "version": "3.1.0",
4
+ "version": "3.3.0",
5
5
  "description": "Package to let you build storefronts with Moonbase.sh as payment and delivery provider",
6
6
  "author": "Tobias Lønnerød Madsen <m@dsen.tv>",
7
7
  "license": "MIT",