From 26e35c7cb0c03a1bb4240d9ce02c4677e7e0680f Mon Sep 17 00:00:00 2001 From: gbashimam-godaddy Date: Thu, 5 Nov 2026 12:06:58 -0700 Subject: [PATCH 1/2] fix(commerce-server): respect product listing channels in storefronts --- .changeset/commerce-catalog-channel-filter.md | 5 + packages/commerce-server/README.md | 2 + .../src/configuration-integration.test.ts | 99 +++++++++++++++++-- .../src/lib/commerce/catalog-subgraph.ts | 7 ++ packages/commerce-server/src/router.test.ts | 6 +- .../src/server/api/commerce/products/GET.ts | 7 +- .../server/api/commerce/products/[id]/GET.ts | 10 +- .../commerce-storefront/docs/server-api.md | 2 +- 8 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 .changeset/commerce-catalog-channel-filter.md diff --git a/.changeset/commerce-catalog-channel-filter.md b/.changeset/commerce-catalog-channel-filter.md new file mode 100644 index 00000000..ddbf75f7 --- /dev/null +++ b/.changeset/commerce-catalog-channel-filter.md @@ -0,0 +1,5 @@ +--- +"@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. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index 77736f77..20e390f9 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -48,6 +48,8 @@ Hosts with their own configuration service can implement `CommerceConfiguration` `createCommerceCatalogRouter(configuration)` installs catalog, public configuration, and cart routes. `createGoDaddyPaymentsRouter(configuration, checkoutReturnUrls)` installs checkout and read-only order-status routes without requiring catalog UI. `createCommerceRouter({ configuration })` installs both by default or accepts explicit feature flags. +Product lists and product details use the channel from the server configuration and request only `ACTIVE` products. Search, category filters, and explicit product IDs retain that channel filter. Direct links to products unavailable on that channel return 404. Browser query parameters cannot select a different channel. + 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. ## Checkout return destinations diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts index 335d893f..c5c06b05 100644 --- a/packages/commerce-server/src/configuration-integration.test.ts +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -10,26 +10,30 @@ afterEach((): void => { vi.restoreAllMocks(); }); -it('serves variant product details without querying SKUGroup.status', async (): Promise => { +it('serves variant product details only on the configured channel without querying SKUGroup.status', async (): Promise => { const upstream = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit): Promise => { 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', @@ -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((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}); + +it('filters product lists by the current server channel with every catalog filter', async (): Promise => { + 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 => { + 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((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), diff --git a/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts index 47d1d070..dd4335d8 100644 --- a/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts +++ b/packages/commerce-server/src/lib/commerce/catalog-subgraph.ts @@ -127,6 +127,7 @@ export interface LabelFilter { } export interface SkuGroupsVariables { + channelId: string; first?: number | null; after?: string | null; id?: StringInFilter; @@ -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`. */ @@ -158,6 +160,7 @@ export interface SkuResult { } export interface ProductGridVariablesInput { + channelId: string; first?: number | null; after?: string | null; productIds?: readonly string[]; @@ -166,6 +169,7 @@ export interface ProductGridVariablesInput { } export interface ProductDetailsVariablesInput { + channelId: string; productId: string; /** Attribute value `name` fields, not `id`. */ selectedAttributeValues?: readonly string[]; @@ -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] } }), @@ -213,6 +218,7 @@ export function buildSkuGroupsVariables(input: ProductGridVariablesInput): SkuGr } export function buildSkuGroupVariables({ + channelId, productId, selectedAttributeValues, skuGroupFirst, @@ -220,6 +226,7 @@ export function buildSkuGroupVariables({ const attributeValues = selectedAttributeValues ? [...selectedAttributeValues] : []; return { + channelId, id: productId, attributeValues, ...(!attributeValues.length && { first: skuGroupFirst ?? 50 }), diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index 30409009..24773dac 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -106,7 +106,9 @@ 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)'); @@ -114,7 +116,7 @@ describe('Commerce scoped routes', () => { 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); diff --git a/packages/commerce-server/src/server/api/commerce/products/GET.ts b/packages/commerce-server/src/server/api/commerce/products/GET.ts index 34e27e86..03560a4c 100644 --- a/packages/commerce-server/src/server/api/commerce/products/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/GET.ts @@ -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) @@ -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 @@ -134,9 +136,10 @@ function asNumber(value: unknown): number | undefined { async function listProducts(req: Request, res: Response): Promise { 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, diff --git a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts index 258c60b2..74c3905a 100644 --- a/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/[id]/GET.ts @@ -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, @@ -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) { @@ -139,9 +140,10 @@ async function readProduct(req: Request, res: Response): Promise { 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), diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index d0ee6088..f8278107 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -31,7 +31,7 @@ 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 product lists to ACTIVE groups available on the server-configured channel. It returns 404 from product details when the group is missing, inactive, or unavailable on that channel, including direct links. Browser callers cannot override the configured channel. ## Configuration From 959bd32dafe7fafa07c833bede33f8794b474081 Mon Sep 17 00:00:00 2001 From: gbashimam-godaddy Date: Thu, 5 Nov 2026 14:25:34 -0700 Subject: [PATCH 2/2] update --- .changeset/commerce-catalog-channel-filter.md | 2 ++ packages/commerce-server/README.md | 12 ++++++++++-- packages/commerce-storefront/docs/server-api.md | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.changeset/commerce-catalog-channel-filter.md b/.changeset/commerce-catalog-channel-filter.md index ddbf75f7..42651882 100644 --- a/.changeset/commerce-catalog-channel-filter.md +++ b/.changeset/commerce-catalog-channel-filter.md @@ -3,3 +3,5 @@ --- 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. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index 20e390f9..0f4a860b 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -48,10 +48,18 @@ Hosts with their own configuration service can implement `CommerceConfiguration` `createCommerceCatalogRouter(configuration)` installs catalog, public configuration, and cart routes. `createGoDaddyPaymentsRouter(configuration, checkoutReturnUrls)` installs checkout and read-only order-status routes without requiring catalog UI. `createCommerceRouter({ configuration })` installs both by default or accepts explicit feature flags. -Product lists and product details use the channel from the server configuration and request only `ACTIVE` products. Search, category filters, and explicit product IDs retain that channel filter. Direct links to products unavailable on that channel return 404. Browser query parameters cannot select a different channel. - 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. diff --git a/packages/commerce-storefront/docs/server-api.md b/packages/commerce-storefront/docs/server-api.md index f8278107..d02cab5a 100644 --- a/packages/commerce-storefront/docs/server-api.md +++ b/packages/commerce-storefront/docs/server-api.md @@ -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` filters product lists to ACTIVE groups available on the server-configured channel. It returns 404 from product details when the group is missing, inactive, or unavailable on that channel, including direct links. Browser callers cannot override the configured channel. +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