Skip to content

Commit 7b38d98

Browse files
TRAC-525: feat - add graphql proxy in middleware (#2825)
Co-authored-by: Parth Shah <parth.shah@bigcommerce.com>
1 parent f4216a6 commit 7b38d98

6 files changed

Lines changed: 194 additions & 1 deletion

File tree

.changeset/foo-bar-baz.md

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Add GraphQL proxy to enable client-side GraphQL requests through the storefront. This proxy forwards requests from allowed clients (such as checkout-sdk-js) to the BigCommerce Storefront API using a dedicated unauthenticated storefront token for API authorization, while still passing through the customer access token for customer-specific data. No browser cookies are forwarded.
6+
7+
## Migration
8+
9+
### Step 1: Add environment variable
10+
11+
Add a `BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_TOKEN` to your `.env.local`. This should be a storefront API token scoped for client-side proxy use with minimal permissions.
12+
13+
### Step 2: Create proxy
14+
15+
Create a new file `core/proxies/with-graphql-proxy.ts`:
16+
17+
```ts
18+
import { NextResponse, URLPattern } from 'next/server';
19+
import { z } from 'zod';
20+
21+
import { auth } from '~/auth';
22+
import { client } from '~/client';
23+
24+
import { type ProxyFactory } from './compose-proxies';
25+
26+
const ALLOWED_REQUESTERS = ['checkout-sdk-js'];
27+
const graphqlPathPattern = new URLPattern({ pathname: '/graphql' });
28+
29+
const bodySchema = z.object({
30+
query: z.unknown(),
31+
variables: z.record(z.unknown()).default({}),
32+
});
33+
34+
export const withGraphqlProxy: ProxyFactory = (next) => {
35+
return async (request, event) => {
36+
// Only handle /graphql path
37+
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
38+
return next(request, event);
39+
}
40+
41+
const requester = request.headers.get('x-catalyst-graphql-proxy-requester');
42+
43+
// Validate required header
44+
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
45+
return next(request, event);
46+
}
47+
48+
// Only handle POST requests
49+
if (request.method !== 'POST') {
50+
return new NextResponse('Method not allowed', { status: 405 });
51+
}
52+
53+
// Wrap in auth to get customer access token for customer-specific data
54+
return auth(async (req) => {
55+
try {
56+
// Parse incoming GraphQL request body
57+
const body: unknown = await req.json();
58+
const { query, variables } = bodySchema.parse(body);
59+
60+
if (!query) {
61+
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
62+
}
63+
64+
// Get customer access token if authenticated
65+
const customerAccessToken = req.auth?.user?.customerAccessToken;
66+
67+
// Proxy the request using the existing client with an unauthenticated storefront token
68+
const response = await client.fetch({
69+
document: query,
70+
variables,
71+
customerAccessToken,
72+
fetchOptions: {
73+
headers: {
74+
Authorization: `Bearer ${process.env.BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_TOKEN}`,
75+
},
76+
next: { revalidate: 0 },
77+
},
78+
});
79+
80+
return NextResponse.json(response);
81+
} catch (error) {
82+
// eslint-disable-next-line no-console
83+
console.error(error);
84+
85+
return NextResponse.json(error, { status: 500 });
86+
}
87+
// @ts-expect-error auth() overload expects middleware return type, but we return NextResponse directly for the proxy
88+
})(request, event);
89+
};
90+
};
91+
```
92+
93+
### Step 3: Register proxy
94+
95+
Update `core/proxy.ts` to include the new proxy in the composition chain:
96+
97+
```diff
98+
import { composeProxies } from './proxies/compose-proxies';
99+
import { withAnalyticsCookies } from './proxies/with-analytics-cookies';
100+
import { withAuth } from './proxies/with-auth';
101+
import { withChannelId } from './proxies/with-channel-id';
102+
+ import { withGraphqlProxy } from './proxies/with-graphql-proxy';
103+
import { withIntl } from './proxies/with-intl';
104+
import { withRoutes } from './proxies/with-routes';
105+
106+
export const proxy = composeProxies(
107+
withAuth,
108+
withAnalyticsCookies,
109+
withIntl,
110+
withChannelId,
111+
+ withGraphqlProxy,
112+
withRoutes,
113+
);
114+
```
115+
116+
The `withGraphqlProxy` proxy should be placed after `withChannelId` and before `withRoutes` in the chain.

.changeset/fresh-steaks-remain.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@bigcommerce/catalyst-client": patch
33
---
44

5-
Fix `client.fetch` so that custom headers passed via `fetchOptions.headers` properly override the client's default headers (including `Authorization`). Previously the merge produced two distinct keys (e.g. `Authorization` and `authorization`) in a plain object, which `fetch` then `append`ed into a comma-joined value. Headers are now built via a `Headers` object using `.set()`, so case-insensitive overrides work as expected.
5+
Fix `client.fetch` so that custom headers passed via `fetchOptions.headers` properly override the client's default headers. Headers are now built via a `Headers` object using `.set()`, so case-insensitive overrides work as expected.

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ BIGCOMMERCE_STORE_HASH=
55
# A JWT Token for accessing the Storefront API. Enables server-to-server requests if allowed_cors_origins is omitted.
66
# See https://developer.bigcommerce.com/docs/rest-authentication/tokens#storefront-tokens
77
BIGCOMMERCE_STOREFRONT_TOKEN=
8+
BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_TOKEN=
89

910
# The Channel ID for the selling channel being serviced by this Catalyst storefront.
1011
# Channel ID 1 will allow you to load the same data being used on the default Stencil storefront on your store,

core/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ BIGCOMMERCE_STORE_HASH=
55
# A JWT Token for accessing the Storefront API. Enables server-to-server requests if allowed_cors_origins is omitted.
66
# See https://developer.bigcommerce.com/docs/rest-authentication/tokens#storefront-tokens
77
BIGCOMMERCE_STOREFRONT_TOKEN=
8+
BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_TOKEN=
89

910
# The Channel ID for the selling channel being serviced by this Catalyst storefront.
1011
# Channel ID 1 will allow you to load the same data being used on the default Stencil storefront on your store,

core/proxies/with-graphql-proxy.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { NextResponse, URLPattern } from 'next/server';
2+
import { z } from 'zod';
3+
4+
import { auth } from '~/auth';
5+
import { client } from '~/client';
6+
7+
import { type ProxyFactory } from './compose-proxies';
8+
9+
const ALLOWED_REQUESTERS = ['checkout-sdk-js'];
10+
const graphqlPathPattern = new URLPattern({ pathname: '/graphql' });
11+
12+
const bodySchema = z.object({
13+
query: z.unknown(),
14+
variables: z.record(z.unknown()).default({}),
15+
});
16+
17+
export const withGraphqlProxy: ProxyFactory = (next) => {
18+
return async (request, event) => {
19+
// Only handle /graphql path
20+
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
21+
return next(request, event);
22+
}
23+
24+
const requester = request.headers.get('x-catalyst-graphql-proxy-requester');
25+
26+
// Validate required header
27+
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
28+
return next(request, event);
29+
}
30+
31+
// Only handle POST requests
32+
if (request.method !== 'POST') {
33+
return new NextResponse('Method not allowed', { status: 405 });
34+
}
35+
36+
// Wrap in auth to get customer access token for customer-specific data
37+
return auth(async (req) => {
38+
try {
39+
// Parse incoming GraphQL request body
40+
const body: unknown = await req.json();
41+
const { query, variables } = bodySchema.parse(body);
42+
43+
if (!query) {
44+
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
45+
}
46+
47+
// Get customer access token if authenticated
48+
const customerAccessToken = req.auth?.user?.customerAccessToken;
49+
50+
// Proxy the request using the existing client with an unauthenticated storefront token
51+
const response = await client.fetch({
52+
document: query,
53+
variables,
54+
customerAccessToken,
55+
fetchOptions: {
56+
headers: {
57+
Authorization: `Bearer ${process.env.BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_TOKEN ?? ''}`,
58+
},
59+
next: { revalidate: 0 },
60+
},
61+
});
62+
63+
return NextResponse.json(response);
64+
} catch (error) {
65+
// eslint-disable-next-line no-console
66+
console.error(error);
67+
68+
return NextResponse.json(error, { status: 500 });
69+
}
70+
// @ts-expect-error auth() overload expects middleware return type, but we return NextResponse directly for the proxy
71+
})(request, event);
72+
};
73+
};

core/proxy.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { composeProxies } from './proxies/compose-proxies';
22
import { withAnalyticsCookies } from './proxies/with-analytics-cookies';
33
import { withAuth } from './proxies/with-auth';
44
import { withChannelId } from './proxies/with-channel-id';
5+
import { withGraphqlProxy } from './proxies/with-graphql-proxy';
56
import { withIntl } from './proxies/with-intl';
67
import { withRoutes } from './proxies/with-routes';
78

@@ -10,6 +11,7 @@ export const proxy = composeProxies(
1011
withAnalyticsCookies,
1112
withIntl,
1213
withChannelId,
14+
withGraphqlProxy,
1315
withRoutes,
1416
);
1517

0 commit comments

Comments
 (0)