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