@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.cjs.development.js +302 -12
- package/dist/api.cjs.development.js.map +1 -1
- package/dist/api.cjs.production.min.js +1 -1
- package/dist/api.cjs.production.min.js.map +1 -1
- package/dist/api.esm.js +302 -12
- package/dist/api.esm.js.map +1 -1
- package/dist/platforms/vtex/clients/commerce/index.d.ts +16 -2
- package/dist/platforms/vtex/clients/fetch.d.ts +6 -1
- package/dist/platforms/vtex/index.d.ts +5 -0
- package/dist/platforms/vtex/utils/cookies.d.ts +52 -0
- package/package.json +2 -2
- package/src/platforms/vtex/clients/commerce/index.ts +121 -7
- package/src/platforms/vtex/clients/fetch.ts +65 -2
- package/src/platforms/vtex/index.ts +6 -0
- package/src/platforms/vtex/resolvers/subscribeToNewsletter.ts +27 -1
- package/src/platforms/vtex/utils/cookies.ts +157 -0
|
@@ -14,6 +14,8 @@ import { MasterDataResponse } from './types/Newsletter';
|
|
|
14
14
|
import type { Address, AddressInput } from './types/Address';
|
|
15
15
|
import { ShippingDataBody } from './types/ShippingData';
|
|
16
16
|
declare type ValueOf<T> = T extends Record<string, infer K> ? K : never;
|
|
17
|
+
/** Test-only: forget which accounts were warned about the anonymous fallback. */
|
|
18
|
+
export declare const resetAnonymousNewsletterWarnings: () => void;
|
|
17
19
|
export declare const VtexCommerce: ({ account, environment }: Options, ctx: Context) => {
|
|
18
20
|
catalog: {
|
|
19
21
|
salesChannel: (sc: string) => Promise<SalesChannel>;
|
|
@@ -62,8 +64,20 @@ export declare const VtexCommerce: ({ account, environment }: Options, ctx: Cont
|
|
|
62
64
|
address: ({ postalCode, country, }: AddressInput) => Promise<Address>;
|
|
63
65
|
};
|
|
64
66
|
session: (search: string) => Promise<Session>;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Creates a newsletter subscription in the `NL` Master Data entity.
|
|
69
|
+
*
|
|
70
|
+
* With `FS_NEWSLETTER_APP_KEY`/`FS_NEWSLETTER_APP_TOKEN` set, the document is
|
|
71
|
+
* created with a `POST` authenticated by that dedicated key, so `NL` fields
|
|
72
|
+
* can stay private. The request is create-only: the body is built here from
|
|
73
|
+
* the validated fields and never carries a document id, so the caller can't
|
|
74
|
+
* reach an existing record.
|
|
75
|
+
*
|
|
76
|
+
* Without the credentials, it falls back to the legacy anonymous `PATCH`,
|
|
77
|
+
* which only works while `NL` fields are public.
|
|
78
|
+
*/
|
|
79
|
+
subscribeToNewsletter: ({ name, email, }: {
|
|
80
|
+
name?: string | undefined;
|
|
67
81
|
email: string;
|
|
68
82
|
}) => Promise<MasterDataResponse>;
|
|
69
83
|
};
|
|
@@ -1 +1,6 @@
|
|
|
1
|
-
export declare const
|
|
1
|
+
export declare const redactHeaders: (headers?: Headers | string[][] | Record<string, string> | undefined) => Record<string, string> | undefined;
|
|
2
|
+
interface FetchAPIOptions {
|
|
3
|
+
storeCookies?: (headers: Headers) => void;
|
|
4
|
+
}
|
|
5
|
+
export declare const fetchAPI: (info: RequestInfo, init?: RequestInit | undefined, options?: FetchAPIOptions | undefined) => Promise<any>;
|
|
6
|
+
export {};
|
|
@@ -28,6 +28,11 @@ export interface Context {
|
|
|
28
28
|
locale: string;
|
|
29
29
|
flags: FeatureFlags;
|
|
30
30
|
searchArgs?: Omit<SearchArgs, 'type'>;
|
|
31
|
+
/**
|
|
32
|
+
* Checkout cookies received while resolving this request, keyed by cookie
|
|
33
|
+
* name. The GraphQL handler forwards `setCookie` values to the browser.
|
|
34
|
+
*/
|
|
35
|
+
cookies: Map<string, Record<string, string>>;
|
|
31
36
|
};
|
|
32
37
|
headers: Record<string, string>;
|
|
33
38
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Context } from '../index';
|
|
2
|
+
export interface ContextForCookies {
|
|
3
|
+
headers: Context['headers'];
|
|
4
|
+
storage: Pick<Context['storage'], 'cookies'>;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Returns every `set-cookie` value of a fetch response.
|
|
8
|
+
*
|
|
9
|
+
* `Headers.getSetCookie()` only exists on Node >= 18.14 (undici). FastStore v1
|
|
10
|
+
* stores may still run on Node 16, where isomorphic-unfetch falls back to
|
|
11
|
+
* node-fetch v2, which exposes repeated headers through `raw()` instead.
|
|
12
|
+
*/
|
|
13
|
+
export declare const getSetCookies: (headers: Headers) => string[];
|
|
14
|
+
/**
|
|
15
|
+
* This function updates the ctx.storage.cookies, that is used in each request.
|
|
16
|
+
*
|
|
17
|
+
* ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
|
|
18
|
+
* [1] cookie key
|
|
19
|
+
* [2] {
|
|
20
|
+
* value: cookie value,
|
|
21
|
+
* setCookie: setCookie used in browser response
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
export declare const updatesContextStorageCookies: (ctx: Pick<Context, 'storage'>, setCookieValue: string) => void;
|
|
25
|
+
export declare const setCookie: (ctx: Pick<Context, 'storage'>, headers: Headers) => void;
|
|
26
|
+
export declare const getStoreCookie: (ctx: Pick<Context, 'storage'>) => (headers: Headers) => void;
|
|
27
|
+
/**
|
|
28
|
+
* This function returns a modified copy of the original cookie header (ctx.headers.cookie from the first request)
|
|
29
|
+
* with the cookie values that comes in each request (ctx.storage.cookies).
|
|
30
|
+
* If there is no cookies in storage, the ctx.headers?.cookie is used
|
|
31
|
+
*
|
|
32
|
+
* ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
|
|
33
|
+
* [1] cookie key
|
|
34
|
+
* [2] {
|
|
35
|
+
* value: cookie value,
|
|
36
|
+
* setCookie: setCookie used in browser response
|
|
37
|
+
* }
|
|
38
|
+
*/
|
|
39
|
+
export declare const getUpdatedCookie: (ctx: ContextForCookies) => string | null;
|
|
40
|
+
export declare const getWithCookie: (ctx: ContextForCookies) => <T extends Record<string, any>>(headers: T) => T | (T & {
|
|
41
|
+
cookie: string;
|
|
42
|
+
});
|
|
43
|
+
/**
|
|
44
|
+
* This function updates the cookie value based on its key
|
|
45
|
+
*
|
|
46
|
+
* const existingCookies = 'key=value1; key2=; key3=value3';
|
|
47
|
+
* const storageCookieKey = 'key2';
|
|
48
|
+
* const storageCookieValue = 'value2'
|
|
49
|
+
*
|
|
50
|
+
* updatesCookieValueByKey(existingCookies, storageCookieKey, storageCookieValue) returns 'key=value1; key2=value2; key3=value3';
|
|
51
|
+
*/
|
|
52
|
+
export declare const updatesCookieValueByKey: (existingCookies: string, storageCookieKey: string, storageCookieValue: string) => string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@faststore/api",
|
|
3
|
-
"version": "1.12.
|
|
3
|
+
"version": "1.12.52",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"typings": "dist/index.d.ts",
|
|
@@ -53,5 +53,5 @@
|
|
|
53
53
|
"@envelop/core": "^1 || ^2",
|
|
54
54
|
"graphql": "^15.6.0"
|
|
55
55
|
},
|
|
56
|
-
"gitHead": "
|
|
56
|
+
"gitHead": "f3dba5bfc0d3516fec52145565dae375263d511e"
|
|
57
57
|
}
|
|
@@ -19,6 +19,7 @@ import type { SalesChannel } from './types/SalesChannel'
|
|
|
19
19
|
import { MasterDataResponse } from './types/Newsletter'
|
|
20
20
|
import type { Address, AddressInput } from './types/Address'
|
|
21
21
|
import { ShippingDataBody } from './types/ShippingData'
|
|
22
|
+
import { getStoreCookie, getWithCookie } from '../../utils/cookies'
|
|
22
23
|
|
|
23
24
|
type ValueOf<T> = T extends Record<string, infer K> ? K : never
|
|
24
25
|
|
|
@@ -29,11 +30,56 @@ const BASE_INIT = {
|
|
|
29
30
|
},
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
// Accounts already warned that they rely on public `NL` fields. The commerce
|
|
34
|
+
// client is created per request, so the warning is kept once per account
|
|
35
|
+
// instead of per instance, to avoid flooding the logs. The account comes from
|
|
36
|
+
// the store's API options, not from the request, so the set holds at most one
|
|
37
|
+
// entry per account the process is configured for (usually one). It's still
|
|
38
|
+
// capped, so it can't grow without bound if that ever changes.
|
|
39
|
+
const MAX_WARNED_NEWSLETTER_ACCOUNTS = 100
|
|
40
|
+
const accountsWarnedAboutAnonymousNewsletter = new Set<string>()
|
|
41
|
+
|
|
42
|
+
/** Test-only: forget which accounts were warned about the anonymous fallback. */
|
|
43
|
+
export const resetAnonymousNewsletterWarnings = () =>
|
|
44
|
+
accountsWarnedAboutAnonymousNewsletter.clear()
|
|
45
|
+
|
|
46
|
+
const getHeader = (headers: Context['headers'] | undefined, name: string) => {
|
|
47
|
+
const value: string | string[] | undefined = (headers as any)?.[name]
|
|
48
|
+
|
|
49
|
+
return Array.isArray(value) ? value[0] : value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Host the Checkout API uses as the domain of the cookies it sets. Checkout
|
|
54
|
+
* only honors it for hosts registered in the account, so a forged value is
|
|
55
|
+
* ignored there.
|
|
56
|
+
*/
|
|
57
|
+
const getForwardedHost = (headers: Context['headers'] | undefined) => {
|
|
58
|
+
// Proxies may append values: "client, proxy". Only the first one is the store host.
|
|
59
|
+
const host =
|
|
60
|
+
getHeader(headers, 'x-forwarded-host')?.split(',')[0].trim() ||
|
|
61
|
+
getHeader(headers, 'host') ||
|
|
62
|
+
''
|
|
63
|
+
|
|
64
|
+
return host.trim().replace(/^www\./, '')
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
export const VtexCommerce = (
|
|
33
68
|
{ account, environment }: Options,
|
|
34
69
|
ctx: Context
|
|
35
70
|
) => {
|
|
36
71
|
const base = `https://${account}.${environment}.com.br`
|
|
72
|
+
const storeCookies = getStoreCookie(ctx)
|
|
73
|
+
const withCookie = getWithCookie(ctx)
|
|
74
|
+
const forwardedHost = getForwardedHost(ctx.headers)
|
|
75
|
+
|
|
76
|
+
// Checkout calls forward the shopper cookies and keep the cookies Checkout
|
|
77
|
+
// sets (e.g. CheckoutOrderFormOwnership) so they can reach the browser.
|
|
78
|
+
const checkoutHeaders = () =>
|
|
79
|
+
withCookie({
|
|
80
|
+
'content-type': 'application/json',
|
|
81
|
+
'X-FORWARDED-HOST': forwardedHost,
|
|
82
|
+
})
|
|
37
83
|
|
|
38
84
|
return {
|
|
39
85
|
catalog: {
|
|
@@ -107,8 +153,10 @@ export const VtexCommerce = (
|
|
|
107
153
|
`${base}/api/checkout/pub/orderForm/${id}/attachments/shippingData`,
|
|
108
154
|
{
|
|
109
155
|
...BASE_INIT,
|
|
156
|
+
headers: checkoutHeaders(),
|
|
110
157
|
body: JSON.stringify(body),
|
|
111
|
-
}
|
|
158
|
+
},
|
|
159
|
+
{ storeCookies }
|
|
112
160
|
)
|
|
113
161
|
},
|
|
114
162
|
orderForm: ({
|
|
@@ -128,7 +176,11 @@ export const VtexCommerce = (
|
|
|
128
176
|
|
|
129
177
|
return fetchAPI(
|
|
130
178
|
`${base}/api/checkout/pub/orderForm/${id}?${params.toString()}`,
|
|
131
|
-
|
|
179
|
+
{
|
|
180
|
+
...BASE_INIT,
|
|
181
|
+
headers: checkoutHeaders(),
|
|
182
|
+
},
|
|
183
|
+
{ storeCookies }
|
|
132
184
|
)
|
|
133
185
|
},
|
|
134
186
|
updateOrderFormItems: ({
|
|
@@ -153,12 +205,14 @@ export const VtexCommerce = (
|
|
|
153
205
|
`${base}/api/checkout/pub/orderForm/${id}/items?${params}`,
|
|
154
206
|
{
|
|
155
207
|
...BASE_INIT,
|
|
208
|
+
headers: checkoutHeaders(),
|
|
156
209
|
body: JSON.stringify({
|
|
157
210
|
orderItems,
|
|
158
211
|
noSplitItem: !shouldSplitItem,
|
|
159
212
|
}),
|
|
160
213
|
method: 'PATCH',
|
|
161
|
-
}
|
|
214
|
+
},
|
|
215
|
+
{ storeCookies }
|
|
162
216
|
)
|
|
163
217
|
},
|
|
164
218
|
setCustomData: ({
|
|
@@ -176,9 +230,11 @@ export const VtexCommerce = (
|
|
|
176
230
|
`${base}/api/checkout/pub/orderForm/${id}/customData/${appId}/${key}`,
|
|
177
231
|
{
|
|
178
232
|
...BASE_INIT,
|
|
233
|
+
headers: checkoutHeaders(),
|
|
179
234
|
body: JSON.stringify({ value }),
|
|
180
235
|
method: 'PUT',
|
|
181
|
-
}
|
|
236
|
+
},
|
|
237
|
+
{ storeCookies }
|
|
182
238
|
)
|
|
183
239
|
},
|
|
184
240
|
region: async ({
|
|
@@ -217,13 +273,71 @@ export const VtexCommerce = (
|
|
|
217
273
|
body: '{}',
|
|
218
274
|
})
|
|
219
275
|
},
|
|
220
|
-
|
|
221
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Creates a newsletter subscription in the `NL` Master Data entity.
|
|
278
|
+
*
|
|
279
|
+
* With `FS_NEWSLETTER_APP_KEY`/`FS_NEWSLETTER_APP_TOKEN` set, the document is
|
|
280
|
+
* created with a `POST` authenticated by that dedicated key, so `NL` fields
|
|
281
|
+
* can stay private. The request is create-only: the body is built here from
|
|
282
|
+
* the validated fields and never carries a document id, so the caller can't
|
|
283
|
+
* reach an existing record.
|
|
284
|
+
*
|
|
285
|
+
* Without the credentials, it falls back to the legacy anonymous `PATCH`,
|
|
286
|
+
* which only works while `NL` fields are public.
|
|
287
|
+
*/
|
|
288
|
+
subscribeToNewsletter: ({
|
|
289
|
+
name,
|
|
290
|
+
email,
|
|
291
|
+
}: {
|
|
292
|
+
name?: string
|
|
222
293
|
email: string
|
|
223
294
|
}): Promise<MasterDataResponse> => {
|
|
295
|
+
const body = JSON.stringify({
|
|
296
|
+
...(name ? { name } : {}),
|
|
297
|
+
email,
|
|
298
|
+
isNewsletterOptIn: true,
|
|
299
|
+
})
|
|
300
|
+
const appKey = process.env.FS_NEWSLETTER_APP_KEY
|
|
301
|
+
const appToken = process.env.FS_NEWSLETTER_APP_TOKEN
|
|
302
|
+
|
|
303
|
+
if (appKey && appToken) {
|
|
304
|
+
return fetchAPI(`${base}/api/dataentities/NL/documents`, {
|
|
305
|
+
method: 'POST',
|
|
306
|
+
headers: {
|
|
307
|
+
'content-type': 'application/json',
|
|
308
|
+
accept: 'application/json',
|
|
309
|
+
'X-VTEX-API-AppKey': appKey,
|
|
310
|
+
'X-VTEX-API-AppToken': appToken,
|
|
311
|
+
},
|
|
312
|
+
body,
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (!accountsWarnedAboutAnonymousNewsletter.has(account)) {
|
|
317
|
+
if (
|
|
318
|
+
accountsWarnedAboutAnonymousNewsletter.size >=
|
|
319
|
+
MAX_WARNED_NEWSLETTER_ACCOUNTS
|
|
320
|
+
) {
|
|
321
|
+
accountsWarnedAboutAnonymousNewsletter.clear()
|
|
322
|
+
}
|
|
323
|
+
accountsWarnedAboutAnonymousNewsletter.add(account)
|
|
324
|
+
|
|
325
|
+
// Name only the missing variables, so a partial setup is easy to spot.
|
|
326
|
+
const missing = [
|
|
327
|
+
!appKey && 'FS_NEWSLETTER_APP_KEY',
|
|
328
|
+
!appToken && 'FS_NEWSLETTER_APP_TOKEN',
|
|
329
|
+
].filter(Boolean)
|
|
330
|
+
|
|
331
|
+
console.warn(
|
|
332
|
+
`[subscribeToNewsletter] ${missing.join(' and ')} ${
|
|
333
|
+
missing.length > 1 ? 'are' : 'is'
|
|
334
|
+
} 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.`
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
224
338
|
return fetchAPI(`${base}/api/dataentities/NL/documents/`, {
|
|
225
339
|
...BASE_INIT,
|
|
226
|
-
body
|
|
340
|
+
body,
|
|
227
341
|
method: 'PATCH',
|
|
228
342
|
})
|
|
229
343
|
},
|
|
@@ -3,7 +3,50 @@ import packageJson from '../../../../package.json'
|
|
|
3
3
|
|
|
4
4
|
const USER_AGENT = `${packageJson.name}@${packageJson.version}`
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// Headers that may carry credentials: store API keys (`X-VTEX-API-AppKey`,
|
|
7
|
+
// `X-VTEX-API-AppToken`), shopper cookies (`Cookie`, `Set-Cookie`,
|
|
8
|
+
// `VtexIdclientAutCookie_*`), `Authorization`, and generic token, secret or
|
|
9
|
+
// API key headers. They are replaced before a failed request and its response
|
|
10
|
+
// are logged. Matched by substring so new credential headers are covered too.
|
|
11
|
+
const SENSITIVE_HEADER = /authorization|cookie|token|secret|api-?key|appkey/i
|
|
12
|
+
|
|
13
|
+
const REDACTED = '[redacted]'
|
|
14
|
+
|
|
15
|
+
export const redactHeaders = (
|
|
16
|
+
headers?: HeadersInit
|
|
17
|
+
): Record<string, string> | undefined => {
|
|
18
|
+
if (!headers) {
|
|
19
|
+
return undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const redacted: Record<string, string> = {}
|
|
23
|
+
const add = (name: string, value: string) => {
|
|
24
|
+
redacted[name] = SENSITIVE_HEADER.test(name) ? REDACTED : value
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(headers)) {
|
|
28
|
+
headers.forEach(([name, value]) => add(name, value))
|
|
29
|
+
} else if (typeof (headers as Headers).forEach === 'function') {
|
|
30
|
+
// Matches both the global `Headers` and the polyfill returned by
|
|
31
|
+
// `isomorphic-unfetch` on Node, which isn't an instance of the global one.
|
|
32
|
+
const headerList = headers as Headers
|
|
33
|
+
headerList.forEach((value, name) => add(name, value))
|
|
34
|
+
} else {
|
|
35
|
+
Object.entries(headers).forEach(([name, value]) => add(name, value))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return redacted
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FetchAPIOptions {
|
|
42
|
+
storeCookies?: (headers: Headers) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const fetchAPI = async (
|
|
46
|
+
info: RequestInfo,
|
|
47
|
+
init?: RequestInit,
|
|
48
|
+
options?: FetchAPIOptions
|
|
49
|
+
) => {
|
|
7
50
|
const response = await fetch(info, {
|
|
8
51
|
...init,
|
|
9
52
|
headers: {
|
|
@@ -13,10 +56,30 @@ export const fetchAPI = async (info: RequestInfo, init?: RequestInit) => {
|
|
|
13
56
|
})
|
|
14
57
|
|
|
15
58
|
if (response.ok) {
|
|
59
|
+
if (options?.storeCookies) {
|
|
60
|
+
options.storeCookies(response.headers)
|
|
61
|
+
}
|
|
62
|
+
|
|
16
63
|
return response.status !== 204 ? response.json() : undefined
|
|
17
64
|
}
|
|
18
65
|
|
|
19
|
-
|
|
66
|
+
// Log a summary instead of the raw Response, whose headers may include
|
|
67
|
+
// `Set-Cookie` with shopper credentials. The request body is omitted because
|
|
68
|
+
// it can carry personal data (e.g. the newsletter email and name).
|
|
69
|
+
console.error(
|
|
70
|
+
// Only the URL: a `Request` instance could carry unredacted headers.
|
|
71
|
+
typeof info === 'string' ? info : info.url,
|
|
72
|
+
init && {
|
|
73
|
+
...init,
|
|
74
|
+
headers: redactHeaders(init.headers),
|
|
75
|
+
body: init.body ? REDACTED : undefined,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
status: response.status,
|
|
79
|
+
statusText: response.statusText,
|
|
80
|
+
headers: redactHeaders(response.headers),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
20
83
|
const text = await response.text()
|
|
21
84
|
|
|
22
85
|
throw new Error(text)
|
|
@@ -56,6 +56,11 @@ export interface Context {
|
|
|
56
56
|
locale: string
|
|
57
57
|
flags: FeatureFlags
|
|
58
58
|
searchArgs?: Omit<SearchArgs, 'type'>
|
|
59
|
+
/**
|
|
60
|
+
* Checkout cookies received while resolving this request, keyed by cookie
|
|
61
|
+
* name. The GraphQL handler forwards `setCookie` values to the browser.
|
|
62
|
+
*/
|
|
63
|
+
cookies: Map<string, Record<string, string>>
|
|
59
64
|
}
|
|
60
65
|
headers: Record<string, string>
|
|
61
66
|
}
|
|
@@ -96,6 +101,7 @@ export const getContextFactory =
|
|
|
96
101
|
channel: ChannelMarshal.parse(options.channel),
|
|
97
102
|
flags: options.flags ?? {},
|
|
98
103
|
locale: options.locale,
|
|
104
|
+
cookies: new Map<string, Record<string, string>>(),
|
|
99
105
|
}
|
|
100
106
|
ctx.clients = getClients(options, ctx)
|
|
101
107
|
ctx.loaders = getLoaders(options, ctx)
|
|
@@ -3,12 +3,38 @@ import type {
|
|
|
3
3
|
MutationSubscribeToNewsletterArgs,
|
|
4
4
|
PersonNewsletter,
|
|
5
5
|
} from '../../../__generated__/schema'
|
|
6
|
+
import { BadRequestError } from '../../errors'
|
|
7
|
+
|
|
8
|
+
const EMAIL_MAX_LENGTH = 254
|
|
9
|
+
const NAME_MAX_LENGTH = 100
|
|
10
|
+
// Character classes exclude `.`, so each domain label is matched once and the
|
|
11
|
+
// pattern stays linear on long inputs.
|
|
12
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/
|
|
6
13
|
|
|
7
14
|
export const subscribeToNewsletter = async (
|
|
8
15
|
_: any,
|
|
9
16
|
{ data }: MutationSubscribeToNewsletterArgs,
|
|
10
17
|
{ clients: { commerce } }: Context
|
|
11
18
|
): Promise<PersonNewsletter | null> => {
|
|
12
|
-
|
|
19
|
+
// The subscription is written with a store credential, so only validated
|
|
20
|
+
// values reach Master Data.
|
|
21
|
+
const email = data.email?.trim().toLowerCase() ?? ''
|
|
22
|
+
const name = data.name?.trim() ?? ''
|
|
23
|
+
|
|
24
|
+
if (email.length > EMAIL_MAX_LENGTH || !EMAIL_PATTERN.test(email)) {
|
|
25
|
+
throw new BadRequestError('Invalid email')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (name.length > NAME_MAX_LENGTH) {
|
|
29
|
+
throw new BadRequestError(
|
|
30
|
+
`Invalid name: must have at most ${NAME_MAX_LENGTH} characters`
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const response = await commerce.subscribeToNewsletter({
|
|
35
|
+
email,
|
|
36
|
+
name: name || undefined,
|
|
37
|
+
})
|
|
38
|
+
|
|
13
39
|
return { id: response?.Id }
|
|
14
40
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { Context } from '../index'
|
|
2
|
+
|
|
3
|
+
export interface ContextForCookies {
|
|
4
|
+
headers: Context['headers']
|
|
5
|
+
storage: Pick<Context['storage'], 'cookies'>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const MATCH_FIRST_SET_COOKIE_KEY_VALUE = /^([^=]+)=([^;]*)/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Splits a combined `set-cookie` header value (cookies joined by `, `) into
|
|
12
|
+
* individual cookies. Commas inside `expires=...` dates are kept.
|
|
13
|
+
*/
|
|
14
|
+
const splitSetCookieHeader = (value: string) =>
|
|
15
|
+
value.split(/,(?=\s*[^;,=\s]+=)/).map((setCookie) => setCookie.trim())
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Returns every `set-cookie` value of a fetch response.
|
|
19
|
+
*
|
|
20
|
+
* `Headers.getSetCookie()` only exists on Node >= 18.14 (undici). FastStore v1
|
|
21
|
+
* stores may still run on Node 16, where isomorphic-unfetch falls back to
|
|
22
|
+
* node-fetch v2, which exposes repeated headers through `raw()` instead.
|
|
23
|
+
*/
|
|
24
|
+
export const getSetCookies = (headers: Headers): string[] => {
|
|
25
|
+
const maybeHeaders = headers as Headers & {
|
|
26
|
+
getSetCookie?: () => string[]
|
|
27
|
+
raw?: () => Record<string, string[]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof maybeHeaders.getSetCookie === 'function') {
|
|
31
|
+
return maybeHeaders.getSetCookie()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (typeof maybeHeaders.raw === 'function') {
|
|
35
|
+
return maybeHeaders.raw()['set-cookie'] ?? []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const setCookie = headers.get('set-cookie')
|
|
39
|
+
|
|
40
|
+
return setCookie ? splitSetCookieHeader(setCookie) : []
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* This function updates the ctx.storage.cookies, that is used in each request.
|
|
45
|
+
*
|
|
46
|
+
* ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
|
|
47
|
+
* [1] cookie key
|
|
48
|
+
* [2] {
|
|
49
|
+
* value: cookie value,
|
|
50
|
+
* setCookie: setCookie used in browser response
|
|
51
|
+
* }
|
|
52
|
+
*/
|
|
53
|
+
export const updatesContextStorageCookies = (
|
|
54
|
+
ctx: Pick<Context, 'storage'>,
|
|
55
|
+
setCookieValue: string
|
|
56
|
+
) => {
|
|
57
|
+
const matchCookie = setCookieValue.match(MATCH_FIRST_SET_COOKIE_KEY_VALUE)
|
|
58
|
+
|
|
59
|
+
if (matchCookie) {
|
|
60
|
+
const cookieKey = matchCookie[1]
|
|
61
|
+
const cookieValue = matchCookie[2]
|
|
62
|
+
|
|
63
|
+
ctx.storage.cookies.set(cookieKey, {
|
|
64
|
+
value: cookieValue,
|
|
65
|
+
setCookie: setCookieValue,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const setCookie = (ctx: Pick<Context, 'storage'>, headers: Headers) => {
|
|
71
|
+
getSetCookies(headers).forEach((setCookieValue) =>
|
|
72
|
+
updatesContextStorageCookies(ctx, setCookieValue)
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const getStoreCookie =
|
|
77
|
+
(ctx: Pick<Context, 'storage'>) => (headers: Headers) =>
|
|
78
|
+
setCookie(ctx, headers)
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* This function returns a modified copy of the original cookie header (ctx.headers.cookie from the first request)
|
|
82
|
+
* with the cookie values that comes in each request (ctx.storage.cookies).
|
|
83
|
+
* If there is no cookies in storage, the ctx.headers?.cookie is used
|
|
84
|
+
*
|
|
85
|
+
* ctx.storage.cookies is a Map<string[1], Record<string, string>[2]> where,
|
|
86
|
+
* [1] cookie key
|
|
87
|
+
* [2] {
|
|
88
|
+
* value: cookie value,
|
|
89
|
+
* setCookie: setCookie used in browser response
|
|
90
|
+
* }
|
|
91
|
+
*/
|
|
92
|
+
export const getUpdatedCookie = (ctx: ContextForCookies) => {
|
|
93
|
+
if (!ctx.headers?.cookie) {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const contextStorageCookies = Array.from(ctx.storage.cookies.entries())
|
|
98
|
+
|
|
99
|
+
if (contextStorageCookies.length === 0) {
|
|
100
|
+
return ctx.headers.cookie
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return contextStorageCookies.reduce(
|
|
104
|
+
(existingCookies, [storageCookieKey, { value: storageCookieValue }]) =>
|
|
105
|
+
updatesCookieValueByKey(
|
|
106
|
+
existingCookies,
|
|
107
|
+
storageCookieKey,
|
|
108
|
+
storageCookieValue
|
|
109
|
+
),
|
|
110
|
+
ctx.headers.cookie
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const getWithCookie = (ctx: ContextForCookies) =>
|
|
115
|
+
function withCookie<T extends Record<string, any>>(
|
|
116
|
+
headers: T
|
|
117
|
+
): T | (T & { cookie: string }) {
|
|
118
|
+
const updatedCookie = getUpdatedCookie(ctx)
|
|
119
|
+
|
|
120
|
+
if (!updatedCookie) {
|
|
121
|
+
return headers
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
...headers,
|
|
126
|
+
cookie: updatedCookie,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* This function updates the cookie value based on its key
|
|
132
|
+
*
|
|
133
|
+
* const existingCookies = 'key=value1; key2=; key3=value3';
|
|
134
|
+
* const storageCookieKey = 'key2';
|
|
135
|
+
* const storageCookieValue = 'value2'
|
|
136
|
+
*
|
|
137
|
+
* updatesCookieValueByKey(existingCookies, storageCookieKey, storageCookieValue) returns 'key=value1; key2=value2; key3=value3';
|
|
138
|
+
*/
|
|
139
|
+
export const updatesCookieValueByKey = (
|
|
140
|
+
existingCookies: string,
|
|
141
|
+
storageCookieKey: string,
|
|
142
|
+
storageCookieValue: string
|
|
143
|
+
) => {
|
|
144
|
+
const MATCH_COOKIE_KEY_VALUE = new RegExp(`(${storageCookieKey})=([^;]*)`)
|
|
145
|
+
const cookieParts = existingCookies.match(MATCH_COOKIE_KEY_VALUE)
|
|
146
|
+
|
|
147
|
+
// replaces original cookie with the one coming from storage
|
|
148
|
+
if (cookieParts) {
|
|
149
|
+
return existingCookies.replace(
|
|
150
|
+
cookieParts[0],
|
|
151
|
+
`${cookieParts[1]}=${storageCookieValue}`
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// add new storage cookie to the original list of cookies
|
|
156
|
+
return `${existingCookies};${storageCookieKey}=${storageCookieValue}`
|
|
157
|
+
}
|