Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .changeset/foo-bar-baz.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
"@bigcommerce/catalyst-core": minor
---

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.

## Migration

### Step 1: Add environment variable

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.

### Step 2: Create proxy

Create a new file `core/proxies/with-graphql-proxy.ts`:

```ts
import { NextResponse, URLPattern } from 'next/server';
import { z } from 'zod';

import { auth } from '~/auth';
import { client } from '~/client';

import { type ProxyFactory } from './compose-proxies';

const ALLOWED_REQUESTERS = ['checkout-sdk-js'];
const graphqlPathPattern = new URLPattern({ pathname: '/graphql' });

const bodySchema = z.object({
query: z.unknown(),
variables: z.record(z.unknown()).default({}),
});

export const withGraphqlProxy: ProxyFactory = (next) => {
return async (request, event) => {
// Only handle /graphql path
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
return next(request, event);
}

const requester = request.headers.get('x-catalyst-graphql-proxy-requester');

// Validate required header
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
return next(request, event);
}

// Only handle POST requests
if (request.method !== 'POST') {
return new NextResponse('Method not allowed', { status: 405 });
}

// Wrap in auth to get customer access token for customer-specific data
return auth(async (req) => {
try {
// Parse incoming GraphQL request body
const body: unknown = await req.json();
const { query, variables } = bodySchema.parse(body);

if (!query) {
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
}

// Get customer access token if authenticated
const customerAccessToken = req.auth?.user?.customerAccessToken;

// Proxy the request using the existing client with an unauthenticated storefront token
const response = await client.fetch({
document: query,
variables,
customerAccessToken,
fetchOptions: {
headers: {
Authorization: `Bearer ${process.env.BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_TOKEN}`,
},
next: { revalidate: 0 },
},
});

return NextResponse.json(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);

return NextResponse.json(error, { status: 500 });
}
// @ts-expect-error auth() overload expects middleware return type, but we return NextResponse directly for the proxy
})(request, event);
};
};
```

### Step 3: Register proxy

Update `core/proxy.ts` to include the new proxy in the composition chain:

```diff
import { composeProxies } from './proxies/compose-proxies';
import { withAnalyticsCookies } from './proxies/with-analytics-cookies';
import { withAuth } from './proxies/with-auth';
import { withChannelId } from './proxies/with-channel-id';
+ import { withGraphqlProxy } from './proxies/with-graphql-proxy';
import { withIntl } from './proxies/with-intl';
import { withRoutes } from './proxies/with-routes';

export const proxy = composeProxies(
withAuth,
withAnalyticsCookies,
withIntl,
withChannelId,
+ withGraphqlProxy,
withRoutes,
);
```

The `withGraphqlProxy` proxy should be placed after `withChannelId` and before `withRoutes` in the chain.
73 changes: 73 additions & 0 deletions core/proxies/with-graphql-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { NextResponse, URLPattern } from 'next/server';
import { z } from 'zod';

import { auth } from '~/auth';
import { client } from '~/client';

import { type ProxyFactory } from './compose-proxies';

const ALLOWED_REQUESTERS = ['checkout-sdk-js'];
const graphqlPathPattern = new URLPattern({ pathname: '/graphql' });

const bodySchema = z.object({
query: z.unknown(),
variables: z.record(z.unknown()).default({}),
});

export const withGraphqlProxy: ProxyFactory = (next) => {
return async (request, event) => {
// Only handle /graphql path
if (!graphqlPathPattern.test(request.nextUrl.toString())) {
return next(request, event);
}

const requester = request.headers.get('x-catalyst-graphql-proxy-requester');

// Validate required header
if (!requester || !ALLOWED_REQUESTERS.includes(requester)) {
return next(request, event);
}

// Only handle POST requests
if (request.method !== 'POST') {
return new NextResponse('Method not allowed', { status: 405 });
}

// Wrap in auth to get customer access token for customer-specific data
return auth(async (req) => {
try {
// Parse incoming GraphQL request body
const body: unknown = await req.json();
const { query, variables } = bodySchema.parse(body);

if (!query) {
return NextResponse.json({ error: 'Missing query' }, { status: 400 });
}

// Get customer access token if authenticated
const customerAccessToken = req.auth?.user?.customerAccessToken;

// Proxy the request using the existing client with an unauthenticated storefront token
const response = await client.fetch({
document: query,
variables,
customerAccessToken,
fetchOptions: {
headers: {
Authorization: `Bearer ${process.env.BIGCOMMERCE_STOREFRONT_UNAUTHENTICATED_TOKEN}`,
},
next: { revalidate: 0 },
},
});

return NextResponse.json(response);
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);

return NextResponse.json(error, { status: 500 });
}
// @ts-expect-error auth() overload expects middleware return type, but we return NextResponse directly for the proxy
})(request, event);
};
};
2 changes: 2 additions & 0 deletions core/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { composeProxies } from './proxies/compose-proxies';
import { withAnalyticsCookies } from './proxies/with-analytics-cookies';
import { withAuth } from './proxies/with-auth';
import { withChannelId } from './proxies/with-channel-id';
import { withGraphqlProxy } from './proxies/with-graphql-proxy';
import { withIntl } from './proxies/with-intl';
import { withRoutes } from './proxies/with-routes';

Expand All @@ -10,6 +11,7 @@ export const proxy = composeProxies(
withAnalyticsCookies,
withIntl,
withChannelId,
withGraphqlProxy,
withRoutes,
);

Expand Down
Loading