@moonbase.sh/storefront-api 3.6.0 → 3.8.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
@@ -24,7 +24,7 @@ Configuration notes:
24
24
 
25
25
  - `endpoint`: your Moonbase storefront URL.
26
26
  - `includeManifests`: include downloadable file manifests when available.
27
- - `persistUtm`: store detected UTM values in localStorage instead of sessionStorage.
27
+ - `persistUtm`: store detected UTM values and ad click IDs in localStorage instead of sessionStorage.
28
28
 
29
29
  ## Common usage
30
30
 
@@ -172,6 +172,38 @@ refused with `CaptchaRequiredError` (a `MoonbaseError`). A site with a
172
172
  Content-Security-Policy has to allow the store's `https://<account-id>.moonbase.sh`
173
173
  in `frame-src`.
174
174
 
175
+ ### Where a customer came from
176
+
177
+ Pass the UTM tags, ad click IDs and referrer the visitor arrived with, the same
178
+ shape orders take, so the merchant can see how the customer was acquired:
179
+
180
+ ```ts
181
+ import { parseOffSiteReferrer } from '@moonbase.sh/storefront-api'
182
+
183
+ const params = new URLSearchParams(window.location.search)
184
+
185
+ await client.identity.signUp('Jane', 'jane@example.com', 'password', null, true, true, true, {
186
+ utm: {
187
+ source: params.get('utm_source') || undefined,
188
+ campaign: params.get('utm_campaign') || undefined,
189
+ // Click IDs ad platforms add to the link, often with no UTM tags beside them.
190
+ gclid: params.get('gclid') || undefined,
191
+ fbclid: params.get('fbclid') || undefined,
192
+ msclkid: params.get('msclkid') || undefined,
193
+ ttclid: params.get('ttclid') || undefined,
194
+ // The referring site's host only, and nothing when the visitor came from this site.
195
+ referrer: parseOffSiteReferrer(document.referrer, window.location.origin),
196
+ },
197
+ })
198
+ ```
199
+
200
+ Fields left out are not sent. The click IDs (`gclid`, `gbraid` and `wbraid` from
201
+ Google Ads, `fbclid` from Meta, `msclkid` from Microsoft Advertising and `ttclid`
202
+ from TikTok) go to the API under those names, and the rest as `utm_*`.
203
+ `useAuth().signUp` in `@moonbase.sh/vue`, and so the storefront widget, fills this
204
+ in from the tags and click IDs it has already picked up, unless you pass your own
205
+ `utm`.
206
+
175
207
  ## Inventory flow
176
208
 
177
209
  Most inventory endpoints require an authenticated user:
@@ -191,6 +223,14 @@ while (page.hasMore && page.next) {
191
223
  }
192
224
  ```
193
225
 
226
+ A product's release history needs no sign-in. `getReleases` lists its published releases, highest
227
+ version first, each with its version, release notes, publish date and files, and whether the caller
228
+ may download it (`downloadsAllowed`). It pages the same way:
229
+
230
+ ```ts
231
+ const releases = await client.inventory.products.getReleases('my-product')
232
+ ```
233
+
194
234
  ## Endpoint groups
195
235
 
196
236
  `client.storefront`, `client.identity`, `client.orders`, `client.vouchers`, `client.inventory`, `client.activationRequests`, `client.vendor`, `client.communications`, `client.groups`.
package/dist/index.cjs CHANGED
@@ -51,6 +51,7 @@ __export(index_exports, {
51
51
  SubscriptionCancellationReason: () => SubscriptionCancellationReason,
52
52
  SubscriptionStatus: () => SubscriptionStatus,
53
53
  TokenStore: () => TokenStore,
54
+ accessTokenLifetime: () => accessTokenLifetime,
54
55
  imageWidths: () => imageWidths,
55
56
  isLocalStorageAvailable: () => isLocalStorageAvailable,
56
57
  isPromotionDisplay: () => isPromotionDisplay,
@@ -119,7 +120,13 @@ function utmToObject(utm) {
119
120
  utm_campaign: utm == null ? void 0 : utm.campaign,
120
121
  utm_term: utm == null ? void 0 : utm.term,
121
122
  utm_content: utm == null ? void 0 : utm.content,
122
- utm_referrer: utm == null ? void 0 : utm.referrer
123
+ utm_referrer: utm == null ? void 0 : utm.referrer,
124
+ gclid: utm == null ? void 0 : utm.gclid,
125
+ gbraid: utm == null ? void 0 : utm.gbraid,
126
+ wbraid: utm == null ? void 0 : utm.wbraid,
127
+ fbclid: utm == null ? void 0 : utm.fbclid,
128
+ msclkid: utm == null ? void 0 : utm.msclkid,
129
+ ttclid: utm == null ? void 0 : utm.ttclid
123
130
  }).filter(([_, value]) => value !== void 0).reduce((obj, [key, value]) => ({
124
131
  ...obj,
125
132
  [key]: value
@@ -211,6 +218,7 @@ var schemas_exports = {};
211
218
  __export(schemas_exports, {
212
219
  downloadSchema: () => downloadSchema,
213
220
  manifestSchema: () => manifestSchema,
221
+ productReleaseSchema: () => productReleaseSchema,
214
222
  productSummarySchema: () => productSummarySchema
215
223
  });
216
224
  var import_zod2 = require("zod");
@@ -273,10 +281,20 @@ var productSummarySchema = import_zod2.z.object({
273
281
  downloadsNeedsOwnership: import_zod2.z.boolean(),
274
282
  downloadsNeedsGroupMembership: import_zod2.z.boolean(),
275
283
  // Whether this caller clears every requirement above, including the group membership the client
276
- // can't see for itself.
284
+ // can't see for itself. False with none of them set means the merchant closed the release to everyone.
277
285
  downloadsAllowed: import_zod2.z.boolean(),
278
286
  downloads: import_zod2.z.array(downloadSchema).optional()
279
287
  });
288
+ var productReleaseSchema = import_zod2.z.object({
289
+ version: import_zod2.z.string(),
290
+ description: import_zod2.z.string().nullish(),
291
+ publishedAt: import_zod2.z.coerce.date(),
292
+ // Whether this caller may download the release, resolved against its own access control: a
293
+ // merchant can keep past releases to owners while the current one is open to anyone.
294
+ downloadsAllowed: import_zod2.z.boolean(),
295
+ downloads: import_zod2.z.array(downloadSchema),
296
+ properties: import_zod2.z.record(rawPropertyValueSchema).nullish()
297
+ });
280
298
 
281
299
  // src/storefront/schemas.ts
282
300
  var storefrontProductSchema = import_zod3.z.object({
@@ -460,7 +478,10 @@ var activationRequestSchema = import_zod4.z.object({
460
478
  requiresAccount: import_zod4.z.boolean(),
461
479
  requiresNewsletterOptIn: import_zod4.z.boolean(),
462
480
  numberOfDaysPerProduct: import_zod4.z.number(),
463
- numberOfDaysRemaining: import_zod4.z.number()
481
+ numberOfDaysRemaining: import_zod4.z.number(),
482
+ // The trial never expires, so the day counts are not worth showing. Absent from a backend that
483
+ // predates endless trials.
484
+ endless: import_zod4.z.boolean().default(false)
464
485
  }).optional(),
465
486
  licenseEligibility: import_zod4.z.object({
466
487
  eligible: import_zod4.z.boolean()
@@ -837,8 +858,12 @@ var IdentityEndpoints = class {
837
858
  return userSchema.parse(identity);
838
859
  }
839
860
  async signUp(name, email, password, address, acceptedPrivacyPolicy, acceptedTermsAndConditions, communicationOptIn, options) {
840
- const path = `/api/customer/identity/sign-up?scheme=JWT&communicationOptIn=${communicationOptIn ? "true" : "false"}`;
841
- const response = await fetch(`${this.api.baseUrl}${path}`, {
861
+ const query = new URLSearchParams({
862
+ scheme: "JWT",
863
+ communicationOptIn: communicationOptIn ? "true" : "false",
864
+ ...utmToObject(options == null ? void 0 : options.utm)
865
+ });
866
+ const response = await fetch(`${this.api.baseUrl}/api/customer/identity/sign-up?${query.toString()}`, {
842
867
  method: "POST",
843
868
  headers: {
844
869
  "Accept": "application/json",
@@ -874,8 +899,13 @@ var IdentityEndpoints = class {
874
899
  this.tokenStore.setUser(identity);
875
900
  return { status: "signed-in", user: userSchema.parse(identity) };
876
901
  }
902
+ /**
903
+ * Signs the buyer out here at once, and ends the session on the server so its refresh
904
+ * token stops working too. Resolves once the server has answered, and never rejects:
905
+ * the buyer is signed out in this browser whatever happens to that request.
906
+ */
877
907
  async signOut() {
878
- this.tokenStore.clear();
908
+ await this.tokenStore.signOut();
879
909
  }
880
910
  async update(name, email, emailConfirmationToken, communicationPreferences) {
881
911
  const response = await this.api.authenticatedFetch("/api/customer/identity", userSchema, {
@@ -1082,6 +1112,15 @@ var ProductEndpoints = class {
1082
1112
  const response = await this.api.fetch(`/api/customer/inventory/products/${productId}${version2 ? `?version=${version2}${this.configuration.includeManifests ? "&includeManifests=true" : ""}` : this.configuration.includeManifests ? "?includeManifests=true" : ""}`, productSummarySchema);
1083
1113
  return response.data;
1084
1114
  }
1115
+ /**
1116
+ * The product's published releases, highest version first, with their release notes and files,
1117
+ * and whether the caller may download each. Open to anyone who can see the product, signed in or
1118
+ * not. A page can hold fewer releases than its size, so keep following `next` while `hasMore` is set.
1119
+ */
1120
+ async getReleases(productId, nextUrl) {
1121
+ const response = await this.api.fetch(nextUrl || `/api/customer/inventory/products/${productId}/releases${this.configuration.includeManifests ? "?includeManifests=true" : ""}`, paged(productReleaseSchema));
1122
+ return response.data;
1123
+ }
1085
1124
  async getOwned(nextUrl) {
1086
1125
  const response = await this.api.authenticatedFetch(nextUrl || `/api/customer/inventory/products${this.configuration.includeManifests ? "?includeManifests=true" : ""}`, paged(productSummarySchema));
1087
1126
  return response.data;
@@ -1108,7 +1147,7 @@ var ProductEndpoints = class {
1108
1147
  var import_zod14 = require("zod");
1109
1148
 
1110
1149
  // package.json
1111
- var version = "3.6.0";
1150
+ var version = "3.8.0";
1112
1151
 
1113
1152
  // src/utils/client.ts
1114
1153
  function buildClientHeader(sdk) {
@@ -1787,24 +1826,36 @@ var InMemoryStore = class {
1787
1826
  };
1788
1827
 
1789
1828
  // src/utils/tokenStore.ts
1829
+ var defaultAccessTokenLifetime = 15 * 60 * 1e3;
1830
+ var renewBeforeExpiry = 5 * 60 * 1e3;
1831
+ var expiryMargin = 30 * 1e3;
1832
+ var refreshLockName = "moonbase_auth_refresh";
1833
+ function accessTokenLifetime(accessToken) {
1834
+ try {
1835
+ const payload = accessToken.split(".")[1].replaceAll("-", "+").replaceAll("_", "/");
1836
+ const claims = JSON.parse(atob(payload.padEnd(Math.ceil(payload.length / 4) * 4, "=")));
1837
+ const lifetime = (Number(claims.exp) - Number(claims.iat)) * 1e3;
1838
+ if (Number.isFinite(lifetime) && lifetime > 0 && lifetime <= 24 * 60 * 60 * 1e3)
1839
+ return lifetime;
1840
+ } catch (e) {
1841
+ }
1842
+ return defaultAccessTokenLifetime;
1843
+ }
1790
1844
  var _TokenStore = class _TokenStore {
1791
1845
  constructor(configuration) {
1792
1846
  this.configuration = configuration;
1793
1847
  this.tokens = null;
1794
1848
  this.refreshTimeoutId = null;
1795
1849
  this.refreshPromise = null;
1850
+ this.waitingForVisibility = false;
1851
+ this.sessionEndedListeners = [];
1796
1852
  var _a, _b;
1797
1853
  this.logger = (_a = configuration.logger) != null ? _a : new ConsoleLogger();
1798
1854
  this.store = (_b = configuration.store) != null ? _b : isLocalStorageAvailable() ? new LocalStorageStore((err) => this.warnAboutStorage(err)) : new InMemoryStore();
1799
1855
  if (typeof window !== "undefined" && !configuration.store && !(this.store instanceof LocalStorageStore))
1800
1856
  this.warnAboutStorage();
1801
- const storedToken = this.store.get(_TokenStore.storageKey);
1802
- if (storedToken) {
1803
- this.tokens = {
1804
- ...storedToken,
1805
- expiresAt: new Date(storedToken.expiresAt)
1806
- };
1807
- }
1857
+ this.tokens = this.readStoredTokens();
1858
+ this.scheduleRefresh();
1808
1859
  this.store.listen(_TokenStore.storageKey, (updatedTokens) => this.handleStorageUpdate(updatedTokens));
1809
1860
  }
1810
1861
  get user() {
@@ -1838,29 +1889,91 @@ var _TokenStore = class _TokenStore {
1838
1889
  } else {
1839
1890
  this.tokens = {
1840
1891
  ...identity,
1841
- // Hardcoded 15 minutes now, might want to check the JWT tho
1842
- expiresAt: new Date((/* @__PURE__ */ new Date()).getTime() + 15 * 60 * 1e3)
1892
+ expiresAt: new Date(Date.now() + accessTokenLifetime(identity.accessToken))
1843
1893
  };
1844
1894
  }
1845
1895
  this.store.set(_TokenStore.storageKey, this.tokens);
1846
- if (typeof window !== "undefined") {
1847
- if (this.refreshTimeoutId != null)
1848
- window.clearTimeout(this.refreshTimeoutId);
1849
- this.refreshTimeoutId = window.setTimeout(() => {
1850
- this.startRefresh().catch((err) => this.logger.warn("Could not refresh access token", err));
1851
- }, 10 * 60 * 1e3);
1852
- }
1896
+ this.scheduleRefresh();
1853
1897
  return this.tokens;
1854
1898
  }
1855
1899
  clear() {
1856
1900
  this.tokens = null;
1857
1901
  this.store.remove(_TokenStore.storageKey);
1858
- if (this.refreshTimeoutId != null && typeof window !== "undefined")
1859
- window.clearTimeout(this.refreshTimeoutId);
1902
+ this.scheduleRefresh();
1860
1903
  return null;
1861
1904
  }
1905
+ async signOut() {
1906
+ const tokens = this.tokens;
1907
+ this.clear();
1908
+ if (!tokens)
1909
+ return;
1910
+ try {
1911
+ await fetch(`${this.configuration.endpoint}/api/customer/identity/revoke?token=${encodeURIComponent(tokens.refreshToken)}`, {
1912
+ method: "POST",
1913
+ keepalive: true,
1914
+ headers: {
1915
+ "Accept": "application/json",
1916
+ "Content-Type": "text/plain",
1917
+ "x-mb-cors": "1",
1918
+ "x-mb-client": buildClientHeader(this.configuration.sdk)
1919
+ },
1920
+ body: tokens.accessToken
1921
+ });
1922
+ } catch (err) {
1923
+ this.logger.warn("Could not end the session on the server", err);
1924
+ }
1925
+ }
1926
+ onSessionEnded(listener) {
1927
+ this.sessionEndedListeners.push(listener);
1928
+ }
1862
1929
  get isExpired() {
1863
- return this.tokens != null && this.tokens.expiresAt < /* @__PURE__ */ new Date();
1930
+ return this.tokens != null && this.tokens.expiresAt.getTime() - expiryMargin < Date.now();
1931
+ }
1932
+ readStoredTokens() {
1933
+ const stored = this.store.get(_TokenStore.storageKey);
1934
+ return stored ? { ...stored, expiresAt: new Date(stored.expiresAt) } : null;
1935
+ }
1936
+ /**
1937
+ * (Re)arms the timer that renews the access token shortly before it runs out, or disarms
1938
+ * it when there is nothing to renew. Every tab arms one, and `refreshTokens` makes sure
1939
+ * only one of them actually renews.
1940
+ */
1941
+ scheduleRefresh() {
1942
+ if (typeof window === "undefined")
1943
+ return;
1944
+ if (this.refreshTimeoutId != null)
1945
+ window.clearTimeout(this.refreshTimeoutId);
1946
+ this.refreshTimeoutId = null;
1947
+ if (!this.tokens)
1948
+ return;
1949
+ const delay = Math.max(0, this.renewalDueAt(this.tokens) - Date.now());
1950
+ this.refreshTimeoutId = window.setTimeout(() => this.renewInBackground(), delay);
1951
+ }
1952
+ renewalDueAt(tokens) {
1953
+ const lead = Math.min(renewBeforeExpiry, accessTokenLifetime(tokens.accessToken) / 3);
1954
+ return tokens.expiresAt.getTime() - lead;
1955
+ }
1956
+ renewInBackground() {
1957
+ var _a;
1958
+ this.refreshTimeoutId = null;
1959
+ if (!this.tokens)
1960
+ return;
1961
+ if (this.renewalDueAt(this.tokens) > Date.now()) {
1962
+ this.scheduleRefresh();
1963
+ return;
1964
+ }
1965
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1966
+ if (!this.waitingForVisibility) {
1967
+ this.waitingForVisibility = true;
1968
+ document.addEventListener("visibilitychange", () => {
1969
+ this.waitingForVisibility = false;
1970
+ this.renewInBackground();
1971
+ }, { once: true });
1972
+ }
1973
+ return;
1974
+ }
1975
+ const pending = (_a = this.refreshPromise) != null ? _a : this.startRefresh();
1976
+ pending.catch((err) => this.logger.warn("Could not refresh access token", err));
1864
1977
  }
1865
1978
  /**
1866
1979
  * Start a refresh and hold it so concurrent callers join it rather than firing
@@ -1877,7 +1990,45 @@ var _TokenStore = class _TokenStore {
1877
1990
  this.refreshPromise = pending;
1878
1991
  return pending;
1879
1992
  }
1993
+ /**
1994
+ * A refresh token can be used once: the server answers a second use by revoking the
1995
+ * session, taking it for a stolen token. So refreshes are serialised across every tab of
1996
+ * the origin, and whoever gets the lock second takes up what the first one stored rather
1997
+ * than spending the same refresh token again. The stored copy has to be read, not waited
1998
+ * for: storage events never reach the document that wrote them, so a second client on
1999
+ * the same page would not hear about it.
2000
+ */
1880
2001
  async refreshTokens() {
2002
+ var _a;
2003
+ const spending = (_a = this.tokens) == null ? void 0 : _a.refreshToken;
2004
+ const locks = typeof navigator !== "undefined" ? navigator.locks : void 0;
2005
+ if (locks == null ? void 0 : locks.request) {
2006
+ let held = false;
2007
+ try {
2008
+ return await locks.request(refreshLockName, () => {
2009
+ held = true;
2010
+ return this.refreshHoldingLock(spending);
2011
+ });
2012
+ } catch (err) {
2013
+ if (held)
2014
+ throw err;
2015
+ }
2016
+ }
2017
+ return await this.refreshHoldingLock(spending);
2018
+ }
2019
+ /**
2020
+ * @param spending The refresh token this refresh set out to use. If the session has moved
2021
+ * on from it while we waited for the lock, someone else has renewed already.
2022
+ */
2023
+ async refreshHoldingLock(spending) {
2024
+ var _a;
2025
+ const stored = this.readStoredTokens();
2026
+ if (stored && stored.refreshToken !== ((_a = this.tokens) == null ? void 0 : _a.refreshToken))
2027
+ this.tokens = stored;
2028
+ if (this.tokens && this.tokens.refreshToken !== spending && !this.isExpired) {
2029
+ this.scheduleRefresh();
2030
+ return this.tokens;
2031
+ }
1881
2032
  if (!this.tokens)
1882
2033
  throw new Error("No tokens found to refresh");
1883
2034
  const response = await fetch(`${this.configuration.endpoint}/api/customer/identity/refresh?token=${encodeURIComponent(this.tokens.refreshToken)}`, {
@@ -1893,6 +2044,13 @@ var _TokenStore = class _TokenStore {
1893
2044
  if (response.status !== 200) {
1894
2045
  if (response.status === 403 || response.status === 404) {
1895
2046
  this.clear();
2047
+ for (const listener of this.sessionEndedListeners) {
2048
+ try {
2049
+ listener();
2050
+ } catch (err) {
2051
+ this.logger.warn("A listener for the end of the session failed", err);
2052
+ }
2053
+ }
1896
2054
  return null;
1897
2055
  }
1898
2056
  throw new MoonbaseError("Unexpected result", `Could not refresh access token, status code ${response.status}`, response.status);
@@ -1903,11 +2061,10 @@ var _TokenStore = class _TokenStore {
1903
2061
  }
1904
2062
  handleStorageUpdate(updatedTokens) {
1905
2063
  this.tokens = updatedTokens;
1906
- if (this.refreshTimeoutId != null && typeof window !== "undefined")
1907
- window.clearTimeout(this.refreshTimeoutId);
1908
2064
  if (this.tokens) {
1909
2065
  this.tokens.expiresAt = new Date(this.tokens.expiresAt);
1910
2066
  }
2067
+ this.scheduleRefresh();
1911
2068
  }
1912
2069
  warnAboutStorage(err) {
1913
2070
  this.logger.warn(
@@ -2604,7 +2761,8 @@ function parseOffSiteReferrer(rawReferrer, currentOrigin) {
2604
2761
  }
2605
2762
  function resolveUtm(cached, fresh) {
2606
2763
  const freshHasUtm = !!(fresh.source || fresh.medium || fresh.campaign || fresh.term || fresh.content);
2607
- const freshIsExternalArrival = freshHasUtm || !!fresh.referrer;
2764
+ const freshHasClickId = !!(fresh.gclid || fresh.gbraid || fresh.wbraid || fresh.fbclid || fresh.msclkid || fresh.ttclid);
2765
+ const freshIsExternalArrival = freshHasUtm || freshHasClickId || !!fresh.referrer;
2608
2766
  if (freshIsExternalArrival)
2609
2767
  return fresh;
2610
2768
  return cached != null ? cached : fresh;
@@ -2663,6 +2821,7 @@ var MoonbaseClient = class {
2663
2821
  SubscriptionCancellationReason,
2664
2822
  SubscriptionStatus,
2665
2823
  TokenStore,
2824
+ accessTokenLifetime,
2666
2825
  imageWidths,
2667
2826
  isLocalStorageAvailable,
2668
2827
  isPromotionDisplay,