@faststore/api 1.12.50 → 1.12.52

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/api.esm.js CHANGED
@@ -18,7 +18,7 @@ import { trace, context, SpanKind } from '@opentelemetry/api';
18
18
  import { SeverityNumber } from '@opentelemetry/api-logs';
19
19
 
20
20
  var name = "@faststore/api";
21
- var version = "1.12.49";
21
+ var version = "1.12.51";
22
22
  var license = "MIT";
23
23
  var main = "dist/index.js";
24
24
  var typings = "dist/index.d.ts";
@@ -87,8 +87,39 @@ var packageJson = {
87
87
  peerDependencies: peerDependencies
88
88
  };
89
89
 
90
- const USER_AGENT = `${packageJson.name}@${packageJson.version}`;
91
- const fetchAPI = async (info, init) => {
90
+ const USER_AGENT = `${packageJson.name}@${packageJson.version}`; // Headers that may carry credentials: store API keys (`X-VTEX-API-AppKey`,
91
+ // `X-VTEX-API-AppToken`), shopper cookies (`Cookie`, `Set-Cookie`,
92
+ // `VtexIdclientAutCookie_*`), `Authorization`, and generic token, secret or
93
+ // API key headers. They are replaced before a failed request and its response
94
+ // are logged. Matched by substring so new credential headers are covered too.
95
+
96
+ const SENSITIVE_HEADER = /authorization|cookie|token|secret|api-?key|appkey/i;
97
+ const REDACTED = '[redacted]';
98
+ const redactHeaders = headers => {
99
+ if (!headers) {
100
+ return undefined;
101
+ }
102
+
103
+ const redacted = {};
104
+
105
+ const add = (name, value) => {
106
+ redacted[name] = SENSITIVE_HEADER.test(name) ? REDACTED : value;
107
+ };
108
+
109
+ if (Array.isArray(headers)) {
110
+ headers.forEach(([name, value]) => add(name, value));
111
+ } else if (typeof headers.forEach === 'function') {
112
+ // Matches both the global `Headers` and the polyfill returned by
113
+ // `isomorphic-unfetch` on Node, which isn't an instance of the global one.
114
+ const headerList = headers;
115
+ headerList.forEach((value, name) => add(name, value));
116
+ } else {
117
+ Object.entries(headers).forEach(([name, value]) => add(name, value));
118
+ }
119
+
120
+ return redacted;
121
+ };
122
+ const fetchAPI = async (info, init, options) => {
92
123
  const response = await fetch(info, { ...init,
93
124
  headers: { ...(init == null ? void 0 : init.headers),
94
125
  'User-Agent': USER_AGENT
@@ -96,25 +127,200 @@ const fetchAPI = async (info, init) => {
96
127
  });
97
128
 
98
129
  if (response.ok) {
130
+ if (options != null && options.storeCookies) {
131
+ options.storeCookies(response.headers);
132
+ }
133
+
99
134
  return response.status !== 204 ? response.json() : undefined;
100
- }
135
+ } // Log a summary instead of the raw Response, whose headers may include
136
+ // `Set-Cookie` with shopper credentials. The request body is omitted because
137
+ // it can carry personal data (e.g. the newsletter email and name).
138
+
101
139
 
102
- console.error(info, init, response);
140
+ console.error( // Only the URL: a `Request` instance could carry unredacted headers.
141
+ typeof info === 'string' ? info : info.url, init && { ...init,
142
+ headers: redactHeaders(init.headers),
143
+ body: init.body ? REDACTED : undefined
144
+ }, {
145
+ status: response.status,
146
+ statusText: response.statusText,
147
+ headers: redactHeaders(response.headers)
148
+ });
103
149
  const text = await response.text();
104
150
  throw new Error(text);
105
151
  };
106
152
 
153
+ const MATCH_FIRST_SET_COOKIE_KEY_VALUE = /^([^=]+)=([^;]*)/;
154
+ /**
155
+ * Splits a combined `set-cookie` header value (cookies joined by `, `) into
156
+ * individual cookies. Commas inside `expires=...` dates are kept.
157
+ */
158
+
159
+ const splitSetCookieHeader = value => value.split(/,(?=\s*[^;,=\s]+=)/).map(setCookie => setCookie.trim());
160
+ /**
161
+ * Returns every `set-cookie` value of a fetch response.
162
+ *
163
+ * `Headers.getSetCookie()` only exists on Node >= 18.14 (undici). FastStore v1
164
+ * stores may still run on Node 16, where isomorphic-unfetch falls back to
165
+ * node-fetch v2, which exposes repeated headers through `raw()` instead.
166
+ */
167
+
168
+
169
+ const getSetCookies = headers => {
170
+ const maybeHeaders = headers;
171
+
172
+ if (typeof maybeHeaders.getSetCookie === 'function') {
173
+ return maybeHeaders.getSetCookie();
174
+ }
175
+
176
+ if (typeof maybeHeaders.raw === 'function') {
177
+ var _maybeHeaders$raw$set;
178
+
179
+ return (_maybeHeaders$raw$set = maybeHeaders.raw()['set-cookie']) != null ? _maybeHeaders$raw$set : [];
180
+ }
181
+
182
+ const setCookie = headers.get('set-cookie');
183
+ return setCookie ? splitSetCookieHeader(setCookie) : [];
184
+ };
185
+ /**
186
+ * This function updates the ctx.storage.cookies, that is used in each request.
187
+ *
188
+ * ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
189
+ * [1] cookie key
190
+ * [2] {
191
+ * value: cookie value,
192
+ * setCookie: setCookie used in browser response
193
+ * }
194
+ */
195
+
196
+ const updatesContextStorageCookies = (ctx, setCookieValue) => {
197
+ const matchCookie = setCookieValue.match(MATCH_FIRST_SET_COOKIE_KEY_VALUE);
198
+
199
+ if (matchCookie) {
200
+ const cookieKey = matchCookie[1];
201
+ const cookieValue = matchCookie[2];
202
+ ctx.storage.cookies.set(cookieKey, {
203
+ value: cookieValue,
204
+ setCookie: setCookieValue
205
+ });
206
+ }
207
+ };
208
+ const setCookie = (ctx, headers) => {
209
+ getSetCookies(headers).forEach(setCookieValue => updatesContextStorageCookies(ctx, setCookieValue));
210
+ };
211
+ const getStoreCookie = ctx => headers => setCookie(ctx, headers);
212
+ /**
213
+ * This function returns a modified copy of the original cookie header (ctx.headers.cookie from the first request)
214
+ * with the cookie values that comes in each request (ctx.storage.cookies).
215
+ * If there is no cookies in storage, the ctx.headers?.cookie is used
216
+ *
217
+ * ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
218
+ * [1] cookie key
219
+ * [2] {
220
+ * value: cookie value,
221
+ * setCookie: setCookie used in browser response
222
+ * }
223
+ */
224
+
225
+ const getUpdatedCookie = ctx => {
226
+ var _ctx$headers;
227
+
228
+ if (!((_ctx$headers = ctx.headers) != null && _ctx$headers.cookie)) {
229
+ return null;
230
+ }
231
+
232
+ const contextStorageCookies = Array.from(ctx.storage.cookies.entries());
233
+
234
+ if (contextStorageCookies.length === 0) {
235
+ return ctx.headers.cookie;
236
+ }
237
+
238
+ return contextStorageCookies.reduce((existingCookies, [storageCookieKey, {
239
+ value: storageCookieValue
240
+ }]) => updatesCookieValueByKey(existingCookies, storageCookieKey, storageCookieValue), ctx.headers.cookie);
241
+ };
242
+ const getWithCookie = ctx => function withCookie(headers) {
243
+ const updatedCookie = getUpdatedCookie(ctx);
244
+
245
+ if (!updatedCookie) {
246
+ return headers;
247
+ }
248
+
249
+ return { ...headers,
250
+ cookie: updatedCookie
251
+ };
252
+ };
253
+ /**
254
+ * This function updates the cookie value based on its key
255
+ *
256
+ * const existingCookies = 'key=value1; key2=; key3=value3';
257
+ * const storageCookieKey = 'key2';
258
+ * const storageCookieValue = 'value2'
259
+ *
260
+ * updatesCookieValueByKey(existingCookies, storageCookieKey, storageCookieValue) returns 'key=value1; key2=value2; key3=value3';
261
+ */
262
+
263
+ const updatesCookieValueByKey = (existingCookies, storageCookieKey, storageCookieValue) => {
264
+ const MATCH_COOKIE_KEY_VALUE = new RegExp(`(${storageCookieKey})=([^;]*)`);
265
+ const cookieParts = existingCookies.match(MATCH_COOKIE_KEY_VALUE); // replaces original cookie with the one coming from storage
266
+
267
+ if (cookieParts) {
268
+ return existingCookies.replace(cookieParts[0], `${cookieParts[1]}=${storageCookieValue}`);
269
+ } // add new storage cookie to the original list of cookies
270
+
271
+
272
+ return `${existingCookies};${storageCookieKey}=${storageCookieValue}`;
273
+ };
274
+
107
275
  const BASE_INIT = {
108
276
  method: 'POST',
109
277
  headers: {
110
278
  'content-type': 'application/json'
111
279
  }
280
+ }; // Accounts already warned that they rely on public `NL` fields. The commerce
281
+ // client is created per request, so the warning is kept once per account
282
+ // instead of per instance, to avoid flooding the logs. The account comes from
283
+ // the store's API options, not from the request, so the set holds at most one
284
+ // entry per account the process is configured for (usually one). It's still
285
+ // capped, so it can't grow without bound if that ever changes.
286
+
287
+ const MAX_WARNED_NEWSLETTER_ACCOUNTS = 100;
288
+ const accountsWarnedAboutAnonymousNewsletter = /*#__PURE__*/new Set();
289
+
290
+ const getHeader = (headers, name) => {
291
+ const value = headers == null ? void 0 : headers[name];
292
+ return Array.isArray(value) ? value[0] : value;
112
293
  };
294
+ /**
295
+ * Host the Checkout API uses as the domain of the cookies it sets. Checkout
296
+ * only honors it for hosts registered in the account, so a forged value is
297
+ * ignored there.
298
+ */
299
+
300
+
301
+ const getForwardedHost = headers => {
302
+ var _getHeader;
303
+
304
+ // Proxies may append values: "client, proxy". Only the first one is the store host.
305
+ const host = ((_getHeader = getHeader(headers, 'x-forwarded-host')) == null ? void 0 : _getHeader.split(',')[0].trim()) || getHeader(headers, 'host') || '';
306
+ return host.trim().replace(/^www\./, '');
307
+ };
308
+
113
309
  const VtexCommerce = ({
114
310
  account,
115
311
  environment
116
312
  }, ctx) => {
117
313
  const base = `https://${account}.${environment}.com.br`;
314
+ const storeCookies = getStoreCookie(ctx);
315
+ const withCookie = getWithCookie(ctx);
316
+ const forwardedHost = getForwardedHost(ctx.headers); // Checkout calls forward the shopper cookies and keep the cookies Checkout
317
+ // sets (e.g. CheckoutOrderFormOwnership) so they can reach the browser.
318
+
319
+ const checkoutHeaders = () => withCookie({
320
+ 'content-type': 'application/json',
321
+ 'X-FORWARDED-HOST': forwardedHost
322
+ });
323
+
118
324
  return {
119
325
  catalog: {
120
326
  salesChannel: sc => fetchAPI(`${base}/api/catalog_system/pub/saleschannel/${sc}`),
@@ -165,7 +371,10 @@ const VtexCommerce = ({
165
371
  }
166
372
 
167
373
  return fetchAPI(`${base}/api/checkout/pub/orderForm/${id}/attachments/shippingData`, { ...BASE_INIT,
374
+ headers: checkoutHeaders(),
168
375
  body: JSON.stringify(body)
376
+ }, {
377
+ storeCookies
169
378
  });
170
379
  },
171
380
  orderForm: ({
@@ -180,7 +389,11 @@ const VtexCommerce = ({
180
389
  refreshOutdatedData: refreshOutdatedData.toString(),
181
390
  sc: salesChannel
182
391
  });
183
- return fetchAPI(`${base}/api/checkout/pub/orderForm/${id}?${params.toString()}`, BASE_INIT);
392
+ return fetchAPI(`${base}/api/checkout/pub/orderForm/${id}?${params.toString()}`, { ...BASE_INIT,
393
+ headers: checkoutHeaders()
394
+ }, {
395
+ storeCookies
396
+ });
184
397
  },
185
398
  updateOrderFormItems: ({
186
399
  id,
@@ -194,11 +407,14 @@ const VtexCommerce = ({
194
407
  sc: salesChannel
195
408
  });
196
409
  return fetchAPI(`${base}/api/checkout/pub/orderForm/${id}/items?${params}`, { ...BASE_INIT,
410
+ headers: checkoutHeaders(),
197
411
  body: JSON.stringify({
198
412
  orderItems,
199
413
  noSplitItem: !shouldSplitItem
200
414
  }),
201
415
  method: 'PATCH'
416
+ }, {
417
+ storeCookies
202
418
  });
203
419
  },
204
420
  setCustomData: ({
@@ -208,10 +424,13 @@ const VtexCommerce = ({
208
424
  value
209
425
  }) => {
210
426
  return fetchAPI(`${base}/api/checkout/pub/orderForm/${id}/customData/${appId}/${key}`, { ...BASE_INIT,
427
+ headers: checkoutHeaders(),
211
428
  body: JSON.stringify({
212
429
  value
213
430
  }),
214
431
  method: 'PUT'
432
+ }, {
433
+ storeCookies
215
434
  });
216
435
  },
217
436
  region: async ({
@@ -240,11 +459,58 @@ const VtexCommerce = ({
240
459
  body: '{}'
241
460
  });
242
461
  },
243
- subscribeToNewsletter: data => {
462
+
463
+ /**
464
+ * Creates a newsletter subscription in the `NL` Master Data entity.
465
+ *
466
+ * With `FS_NEWSLETTER_APP_KEY`/`FS_NEWSLETTER_APP_TOKEN` set, the document is
467
+ * created with a `POST` authenticated by that dedicated key, so `NL` fields
468
+ * can stay private. The request is create-only: the body is built here from
469
+ * the validated fields and never carries a document id, so the caller can't
470
+ * reach an existing record.
471
+ *
472
+ * Without the credentials, it falls back to the legacy anonymous `PATCH`,
473
+ * which only works while `NL` fields are public.
474
+ */
475
+ subscribeToNewsletter: ({
476
+ name,
477
+ email
478
+ }) => {
479
+ const body = JSON.stringify({ ...(name ? {
480
+ name
481
+ } : {}),
482
+ email,
483
+ isNewsletterOptIn: true
484
+ });
485
+ const appKey = process.env.FS_NEWSLETTER_APP_KEY;
486
+ const appToken = process.env.FS_NEWSLETTER_APP_TOKEN;
487
+
488
+ if (appKey && appToken) {
489
+ return fetchAPI(`${base}/api/dataentities/NL/documents`, {
490
+ method: 'POST',
491
+ headers: {
492
+ 'content-type': 'application/json',
493
+ accept: 'application/json',
494
+ 'X-VTEX-API-AppKey': appKey,
495
+ 'X-VTEX-API-AppToken': appToken
496
+ },
497
+ body
498
+ });
499
+ }
500
+
501
+ if (!accountsWarnedAboutAnonymousNewsletter.has(account)) {
502
+ if (accountsWarnedAboutAnonymousNewsletter.size >= MAX_WARNED_NEWSLETTER_ACCOUNTS) {
503
+ accountsWarnedAboutAnonymousNewsletter.clear();
504
+ }
505
+
506
+ accountsWarnedAboutAnonymousNewsletter.add(account); // Name only the missing variables, so a partial setup is easy to spot.
507
+
508
+ const missing = [!appKey && 'FS_NEWSLETTER_APP_KEY', !appToken && 'FS_NEWSLETTER_APP_TOKEN'].filter(Boolean);
509
+ console.warn(`[subscribeToNewsletter] ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set. Falling back to an anonymous Master Data request, which requires public fields in the NL entity. Configure both credentials and make the NL fields private.`);
510
+ }
511
+
244
512
  return fetchAPI(`${base}/api/dataentities/NL/documents/`, { ...BASE_INIT,
245
- body: JSON.stringify({ ...data,
246
- isNewsletterOptIn: true
247
- }),
513
+ body,
248
514
  method: 'PATCH'
249
515
  });
250
516
  }
@@ -1036,6 +1302,11 @@ const StoreFacetValueBoolean = {
1036
1302
  }) => quantity
1037
1303
  };
1038
1304
 
1305
+ const EMAIL_MAX_LENGTH = 254;
1306
+ const NAME_MAX_LENGTH = 100; // Character classes exclude `.`, so each domain label is matched once and the
1307
+ // pattern stays linear on long inputs.
1308
+
1309
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/;
1039
1310
  const subscribeToNewsletter = async (_, {
1040
1311
  data
1041
1312
  }, {
@@ -1043,7 +1314,25 @@ const subscribeToNewsletter = async (_, {
1043
1314
  commerce
1044
1315
  }
1045
1316
  }) => {
1046
- const response = await commerce.subscribeToNewsletter(data);
1317
+ var _data$email$trim$toLo, _data$email, _data$name$trim, _data$name;
1318
+
1319
+ // The subscription is written with a store credential, so only validated
1320
+ // values reach Master Data.
1321
+ const email = (_data$email$trim$toLo = (_data$email = data.email) == null ? void 0 : _data$email.trim().toLowerCase()) != null ? _data$email$trim$toLo : '';
1322
+ const name = (_data$name$trim = (_data$name = data.name) == null ? void 0 : _data$name.trim()) != null ? _data$name$trim : '';
1323
+
1324
+ if (email.length > EMAIL_MAX_LENGTH || !EMAIL_PATTERN.test(email)) {
1325
+ throw new BadRequestError('Invalid email');
1326
+ }
1327
+
1328
+ if (name.length > NAME_MAX_LENGTH) {
1329
+ throw new BadRequestError(`Invalid name: must have at most ${NAME_MAX_LENGTH} characters`);
1330
+ }
1331
+
1332
+ const response = await commerce.subscribeToNewsletter({
1333
+ email,
1334
+ name: name || undefined
1335
+ });
1047
1336
  return {
1048
1337
  id: response == null ? void 0 : response.Id
1049
1338
  };
@@ -2464,7 +2753,8 @@ const getContextFactory = options => ctx => {
2464
2753
  ctx.storage = {
2465
2754
  channel: ChannelMarshal.parse(options.channel),
2466
2755
  flags: (_options$flags = options.flags) != null ? _options$flags : {},
2467
- locale: options.locale
2756
+ locale: options.locale,
2757
+ cookies: new Map()
2468
2758
  };
2469
2759
  ctx.clients = getClients(options, ctx);
2470
2760
  ctx.loaders = getLoaders(options, ctx);