Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/commerce-catalog-channel-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@godaddy/gd-commerce-server": patch
---

Filter storefront product lists and product details by the server-configured Commerce channel. Active products unavailable on that channel no longer appear in lists or resolve through direct product links. Search, category filters, and explicit product IDs retain the channel filter; browser query parameters cannot override it.

Document stale-channel empty results and upstream channel lookup costs. Product browsing filters do not check channel eligibility for SKU lookup or cart writes; hosts must enforce any required restrictions on those operations.
10 changes: 10 additions & 0 deletions packages/commerce-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ Hosts with their own configuration service can implement `CommerceConfiguration`

The HTTP checkout route accepts an existing `draftOrderId` or a direct `skuId`; Commerce resolves catalog prices. It rejects `lineItemData`. For non-catalog charges such as fixed-price deposits, calculate the amount on the server and call `createCheckoutSession(params, configuration)` directly. Never forward browser-supplied prices to this trusted helper. Server callers can also use `getOrderStatus(orderId, configuration)` without an HTTP loopback.

### Product channel visibility

`GET /products` and `GET /products/:id` use the channel from the server configuration and request only `ACTIVE` products. Search, category filters, and explicit product IDs retain that channel filter. Product-detail requests for products unavailable on that channel return 404. Browser query parameters cannot select a different channel. Products configured for all channels remain available when the configured channel is active and registered to the store.

If a configured `GODADDY_CHANNEL_ID` is inactive or is not registered to the configured store, the Catalog API returns an empty product list and product-detail requests return 404. These are successful empty results from the Catalog API, not configuration errors. When an expected catalog is empty, verify that the channel is active and registered to `GODADDY_STORE_ID`, then check product status and channel assignments. A missing `GODADDY_CHANNEL_ID` is different: the default configuration reader rejects it with 503 `not_configured`.

The Catalog API resolves active store channels through the paginated `/v1/commerce/channels` endpoint when evaluating a channel filter. Those lookups are not currently cached by the Catalog API, so channel-filtered catalog requests can add upstream calls and latency.

These checks control product browsing. `GET /skus/:id`, cart creation (`POST /cart`), and item addition (`POST /cart/:id/items`) forward SKU IDs without this package checking whether their products are available on the configured channel. A 404 from product details does not establish that a SKU lookup or cart write will be rejected. Hosts that require channel-based restrictions on these operations must enforce them before forwarding the SKU IDs; browsing filters are not an authorization boundary.

## Checkout return destinations

Configure `checkoutReturnUrls` on the router using trusted deployment settings. Both lists contain complete absolute HTTPS URLs; scheme, origin, port, path, and configured query parameters must match. Success URLs may add a single `orderId` query parameter. Additional query parameters, fragments, credentials, relative URLs, and unlisted destinations are rejected. Configure separate cancel and success destinations as shown above; do not derive the allowlist from request headers or request bodies.
Expand Down
99 changes: 93 additions & 6 deletions packages/commerce-server/src/configuration-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,30 @@ afterEach((): void => {
vi.restoreAllMocks();
});

it('serves variant product details without querying SKUGroup.status', async (): Promise<void> => {
it('serves variant product details only on the configured channel without querying SKUGroup.status', async (): Promise<void> => {
const upstream = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const { query, variables } = JSON.parse(String(init?.body)) as {
query: string;
variables: { id: string; attributeValues: string[] };
variables: { id: string; attributeValues: string[]; channelId?: string };
};
if (/^\s+status\s*$/m.test(query)) {
return Response.json({ errors: [{ message: 'Cannot query field "status" on type "SKUGroup".' }] });
}
expect(query).toContain(
'activeSkuGroups: skuGroups(id: { in: [$id] }, status: { eq: "ACTIVE" }, first: 1)',
'activeSkuGroups: skuGroups(id: { in: [$id] }, channelId: { eq: $channelId }, status: { eq: "ACTIVE" }, first: 1)',
);
expect(['shirt', 'archived-shirt']).toContain(variables.id);
expect(['shirt', 'other-shirt', 'archived-shirt']).toContain(variables.id);
const appliesChannelFilter = /channelId:\s*\{\s*eq:\s*\$channelId\s*\}/.test(query);
const productChannel = variables.id === 'other-shirt' ? 'channel-2' : 'channel-1';
const visible =
variables.id !== 'archived-shirt' && (!appliesChannelFilter || variables.channelId === productChannel);
const skus = [
{ id: 'shirt-red', label: 'Red shirt' },
{ id: 'shirt-blue', label: 'Blue shirt' },
];
return Response.json({
data: {
activeSkuGroups: { edges: variables.id === 'shirt' ? [{ node: { id: 'shirt' } }] : [] },
activeSkuGroups: { edges: visible ? [{ node: { id: variables.id } }] : [] },
skuGroup: {
id: variables.id,
label: 'Shirt',
Expand Down Expand Up @@ -101,7 +105,90 @@ it('serves variant product details without querying SKUGroup.status', async ():
code: 'not_found',
requestId: expect.any(String),
});
expect(upstream).toHaveBeenCalledTimes(3);
const otherChannel = await clientFetch(`${url.replace('/shirt', '/other-shirt')}?channelId=channel-2`);
expect(otherChannel.status).toBe(404);
expect(await otherChannel.json()).toEqual({
error: 'Product not found',
code: 'not_found',
requestId: expect.any(String),
});
expect(upstream).toHaveBeenCalledTimes(4);
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
});

it('filters product lists by the current server channel with every catalog filter', async (): Promise<void> => {
const environment = {
GODADDY_OAUTH_CLIENT_ID: 'client-1',
GODADDY_OAUTH_CLIENT_SECRET: 'secret-1',
GODADDY_STORE_ID: 'store-1',
GODADDY_CHANNEL_ID: 'channel-1',
GODADDY_CURRENCY_CODE: 'USD',
};
const products = [
{ id: 'shirt', channelId: 'channel-1' },
{ id: 'other-shirt', channelId: 'channel-2' },
{ id: 'all-channels-shirt', channelId: null },
];
const upstream = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const { query, variables } = JSON.parse(String(init?.body)) as {
query: string;
variables: { channelId?: string };
};
// Catalog products without custom channel assignments remain available on all channels.
const appliesChannelFilter = /channelId:\s*\{\s*eq:\s*\$channelId\s*\}/.test(query);
const visible = products.filter(
(product) =>
!appliesChannelFilter || product.channelId === null || product.channelId === variables.channelId,
);
return Response.json({ data: { skuGroups: { edges: visible.map(({ id }) => ({ node: { id } })) } } });
});
vi.stubGlobal('fetch', upstream);
const app = express();
app.use(
'/api/commerce',
createCommerceRouter({ configuration: createRuntimeCommerceConfiguration({ environment }) }),
);
const server = app.listen(0, '127.0.0.1');
await once(server, 'listening');
try {
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Expected a listening TCP server');
const url = `http://127.0.0.1:${address.port}/api/commerce/products`;
const cases = [
['', { first: 24 }],
['?first=12&after=page-2', { first: 12, after: 'page-2' }],
['?searchQuery=shirt', { first: 24, label: { contains: 'shirt' } }],
[
'?productIds=shirt&productIds=other-shirt&productIds=all-channels-shirt&searchQuery=ignored',
{ first: 24, id: { in: ['shirt', 'other-shirt', 'all-channels-shirt'] } },
],
[
'?categoryIds=clothing&categoryIds=sale&searchQuery=ignored',
{ first: 24, listId: { in: ['clothing', 'sale'] } },
],
['?channelId=channel-2', { first: 24 }],
] as const;
for (const [search, filters] of cases) {
const response = await clientFetch(`${url}${search}`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
skuGroups: { edges: [{ node: { id: 'shirt' } }, { node: { id: 'all-channels-shirt' } }] },
});
const request = JSON.parse(String(upstream.mock.lastCall?.[1]?.body));
expect(request.variables).toEqual({ ...filters, channelId: 'channel-1' });
expect(request.query).toContain('$channelId: String!');
}
environment.GODADDY_CHANNEL_ID = 'channel-2';
const rebound = await clientFetch(url);
expect(rebound.status).toBe(200);
expect(await rebound.json()).toEqual({
skuGroups: { edges: [{ node: { id: 'other-shirt' } }, { node: { id: 'all-channels-shirt' } }] },
});
expect(upstream).toHaveBeenCalledTimes(cases.length + 1);
} finally {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export interface LabelFilter {
}

export interface SkuGroupsVariables {
channelId: string;
first?: number | null;
after?: string | null;
id?: StringInFilter;
Expand All @@ -135,6 +136,7 @@ export interface SkuGroupsVariables {
}

export interface SkuGroupVariables {
channelId: string;
id: string;
first?: number | null;
/** Attribute value `name` fields — the catalog API's `has` filter matches by `name`, not `id`. */
Expand All @@ -158,6 +160,7 @@ export interface SkuResult {
}

export interface ProductGridVariablesInput {
channelId: string;
first?: number | null;
after?: string | null;
productIds?: readonly string[];
Expand All @@ -166,6 +169,7 @@ export interface ProductGridVariablesInput {
}

export interface ProductDetailsVariablesInput {
channelId: string;
productId: string;
/** Attribute value `name` fields, not `id`. */
selectedAttributeValues?: readonly string[];
Expand Down Expand Up @@ -204,6 +208,7 @@ export function buildSkuGroupsVariables(input: ProductGridVariablesInput): SkuGr
const hasExplicitFilters = productIds.length > 0 || categoryIds.length > 0;

return {
channelId: input.channelId,
...(input.first !== undefined && { first: input.first }),
...(input.after && { after: input.after }),
...(productIds.length > 0 && { id: { in: [...productIds] } }),
Expand All @@ -213,13 +218,15 @@ export function buildSkuGroupsVariables(input: ProductGridVariablesInput): SkuGr
}

export function buildSkuGroupVariables({
channelId,
productId,
selectedAttributeValues,
skuGroupFirst,
}: ProductDetailsVariablesInput): SkuGroupVariables {
const attributeValues = selectedAttributeValues ? [...selectedAttributeValues] : [];

return {
channelId,
id: productId,
attributeValues,
...(!attributeValues.length && { first: skuGroupFirst ?? 50 }),
Expand Down
6 changes: 4 additions & 2 deletions packages/commerce-server/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,15 +106,17 @@ describe('Commerce scoped routes', () => {
res as unknown as Response,
);
expect(gqlRequest).toHaveBeenCalledWith(
expect.objectContaining({ variables: { id: 'product', attributeValues: ['red', 'large'] } }),
expect.objectContaining({
variables: { channelId: 'channel-1', id: 'product', attributeValues: ['red', 'large'] },
}),
);
const query: string = vi.mocked(gqlRequest).mock.calls[0]?.[0].query ?? '';
expect(query).toContain('prices(first: 10)');
expect(query).toContain('inventoryCounts');
expect(query).toContain('attributeValues(first: 50) {');
expect(query).toContain('pageInfo { hasNextPage }');
expect(query).toContain(
'activeSkuGroups: skuGroups(id: { in: [$id] }, status: { eq: "ACTIVE" }, first: 1)',
'activeSkuGroups: skuGroups(id: { in: [$id] }, channelId: { eq: $channelId }, status: { eq: "ACTIVE" }, first: 1)',
);
expect(query).toMatch(/skuGroup\(id: \$id\) \{\s+id\s+name/);
expect(query).not.toMatch(/^\s+status\s*$/m);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* the catalog subgraph directly so the browser never sees `X-Store-ID` /
* `X-Client-ID` and the server can layer caching, abuse protection, or
* per-tenant filtering later without touching the client.
* Only ACTIVE products available on the server-configured channel are returned.
*
* Query params (all optional):
* first - page size (number)
Expand All @@ -32,8 +33,9 @@ import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql';
// Cards use group pricing/media and SKU identity/inventory for quick-add.
// Nested SKU price money fields would exceed the catalog API's depth limit of 10.
const skuGroupsQuery = `
query SkuGroups($first: Int, $after: String, $id: SKUGroupIdsFilter, $listId: ListIdFilter, $label: LabelFilter) {
query SkuGroups($channelId: String!, $first: Int, $after: String, $id: SKUGroupIdsFilter, $listId: ListIdFilter, $label: LabelFilter) {
skuGroups(
channelId: { eq: $channelId }
first: $first
after: $after
id: $id
Expand Down Expand Up @@ -134,9 +136,10 @@ function asNumber(value: unknown): number | undefined {
async function listProducts(req: Request, res: Response): Promise<void> {
const config: CommerceConfig = readCommerceConfigForResponse(res);
assertCommerceCartScope(req, config);
const { storeId, clientId, apiBaseUrl } = config;
const { storeId, channelId, clientId, apiBaseUrl } = config;

const variables = buildSkuGroupsVariables({
channelId,
first: asNumber(req.query.first) ?? 24,
after: typeof req.query.after === 'string' ? req.query.after : undefined,
searchQuery: typeof req.query.searchQuery === 'string' ? req.query.searchQuery : undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* GET /api/commerce/products/:id
*
* Proxy for the catalog `skuGroup` query — single-product (PDP) detail.
* Only ACTIVE groups appear on a product page, matching the catalog list.
* Only ACTIVE groups available on the configured channel appear on a product
* page, matching the catalog list.
* Re-call this route after the user picks attribute values to narrow the
* SKU set (pass `?attributeValues=red&attributeValues=large`). Use the
* `getSingleMatchedSku` helper returns the selected SKU, including price,
Expand Down Expand Up @@ -33,8 +34,8 @@ import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql';
type ProductDetailsResult = SkuGroupResult & { activeSkuGroups?: SkuGroupsResult['skuGroups'] };

const skuGroupQuery = `
query SkuGroup($id: String!, $first: Int, $attributeValues: [String!] = []) {
activeSkuGroups: skuGroups(id: { in: [$id] }, status: { eq: "ACTIVE" }, first: 1) {
query SkuGroup($channelId: String!, $id: String!, $first: Int, $attributeValues: [String!] = []) {
activeSkuGroups: skuGroups(id: { in: [$id] }, channelId: { eq: $channelId }, status: { eq: "ACTIVE" }, first: 1) {
edges { node { id } }
}
skuGroup(id: $id) {
Expand Down Expand Up @@ -139,9 +140,10 @@ async function readProduct(req: Request, res: Response): Promise<void> {

const config: CommerceConfig = readCommerceConfigForResponse(res);
assertCommerceCartScope(req, config);
const { storeId, clientId, apiBaseUrl } = config;
const { storeId, channelId, clientId, apiBaseUrl } = config;

const variables = buildSkuGroupVariables({
channelId,
productId,
selectedAttributeValues: asStringArray(req.query.attributeValues),
skuGroupFirst: asNumber(req.query.skuGroupFirst),
Expand Down
4 changes: 3 additions & 1 deletion packages/commerce-storefront/docs/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ Server implementations can use GoDaddy Commerce APIs or their existing integrati

`/checkout` is required only when checkout is enabled. `/discounts` is needed if the host uses `applyDiscount`. The standard catalog/detail/cart flow uses the other routes. All product/cart IDs in request paths are encoded.

The companion `@godaddy/gd-commerce-server` returns 404 from product details when the group is missing or excluded by the catalog's ACTIVE filter, including direct links to inactive products.
The companion `@godaddy/gd-commerce-server` filters `GET /products` to ACTIVE groups available on the server-configured channel. It returns 404 from `GET /products/:id` when the group is missing, inactive, or unavailable on that channel, including direct product links. Browser callers cannot override the configured channel. This browsing filter does not check product-channel eligibility for `GET /skus/:id`, cart creation, or item addition by SKU ID; hosts must enforce any required restrictions on those operations. See the [server package's channel visibility notes][server-channel-visibility] for stale-channel troubleshooting and upstream lookup costs.

[server-channel-visibility]: ../../commerce-server/README.md#product-channel-visibility

## Configuration

Expand Down
Loading