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
6 changes: 6 additions & 0 deletions .changeset/sample-products-before-connection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@godaddy/gd-commerce-storefront": minor
"@godaddy/gd-commerce-server": minor
---

Support storefronts that are not connected to a store yet. `Catalog sampleProducts` shows built-in sample products and a local sample cart only while the server reports `unbound`, and the components switch to the connected store automatically, without source changes. Unknown state, empty live catalogs, and failures never show samples. Add optional `CommerceConfiguration.readConnectionState()` to the server config contract, and reject live operations until the state is `ready`. Existing successful config responses and live cart behavior are unchanged.
21 changes: 21 additions & 0 deletions packages/commerce-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,24 @@ Configure `checkoutReturnUrls` on the router using trusted deployment settings.
Without this policy, HTTP checkout returns 503 before creating a session. Invalid request destinations return 400. This applies to both router presets that expose checkout. Trusted in-process callers of `createCheckoutSession()` own their return URLs and must construct or validate them server-side.

A return from hosted checkout is not proof of payment. The order-status route uses the authorized Orders REST API, which supports completed orders, and returns its payment status (for example `PAID` or `PENDING`; `unknown` if absent). The server OAuth client must be granted `commerce.order:read`. The helper verifies the returned order ID, store, and channel and returns a limited summary without customer contact data. Hosts must authenticate callers and authorize access to each requested order before exposing this route.

### Connection state

Hosts can implement the optional synchronous `CommerceConfiguration.readConnectionState()`
method. Its return type is exported as `CommerceConnectionState` (`unbound | connecting | ready`).
Returning `undefined` keeps the existing behavior. Throw when the state is unknown or cannot be
read. The default environment configuration does not implement this method, so missing
credentials never count as `unbound`.

`GET /config` returns `{ state: 'unbound' }` or `{ state: 'connecting' }` without reading store
credentials. `ready` includes the usual opaque `cartScope` and `currencyCode` after `read()`
succeeds. Without this method, the successful response shape is unchanged. All responses are
uncached; a malformed state or unavailable configuration returns 503. Data and checkout routes,
and the in-process checkout and order helpers, reject any state other than `ready` before
upstream calls.

The host owns the connection lifecycle. Report `ready` only after the store connection is
complete. Lost configuration is an error, never evidence of `unbound`. After a store connects,
report `unbound` again only after it is deliberately disconnected. The storefront package owns
the generic sample products and local sample cart; this server never creates sample Commerce
records or simulates checkout.
1 change: 1 addition & 0 deletions packages/commerce-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type { CheckoutReturnUrls } from './lib/commerce/checkout-return-urls';
export {
type CommerceConfig,
type CommerceConfiguration,
type CommerceConnectionState,
createRuntimeCommerceConfiguration,
type RuntimeCommerceConfigurationOptions,
readCommerceConfig,
Expand Down
18 changes: 17 additions & 1 deletion packages/commerce-server/src/lib/commerce/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export interface CommerceConfig {
owner?: string;
}

export type CommerceConnectionState = 'unbound' | 'connecting' | 'ready';

export interface CommerceConfiguration {
/** Authoritative host state. Missing support preserves legacy live/error behavior. */
readConnectionState?(): CommerceConnectionState | undefined;
read(): CommerceConfig;
readCheckout(): CommerceCheckoutConfiguration;
}
Expand Down Expand Up @@ -105,5 +109,17 @@ export function commerceConfigurationForResponse(res: Response): CommerceConfigu
}

export function readCommerceConfigForResponse(res: Response): CommerceConfig {
return commerceConfigurationForResponse(res).read();
const configuration = commerceConfigurationForResponse(res);
assertCommerceConnectionReady(configuration);
return configuration.read();
}

/** Nonready and invalid connection states must never use leftover credentials. */
export function assertCommerceConnectionReady(configuration: CommerceConfiguration): void {
const state = configuration.readConnectionState?.();
if (state !== undefined && state !== 'ready') {
throw new Error(
'Commerce store connection is not ready. Complete the store connection before continuing.',
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ import {
DEFAULT_CHECKOUT_PAYMENT_METHODS,
getOAuthAccessToken,
} from './checkout-subgraph';
import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config';
import {
assertCommerceConnectionReady,
type CommerceConfiguration,
createRuntimeCommerceConfiguration,
} from './config';
import { gqlRequest } from './gql';

export interface CreateCheckoutSessionParams {
Expand Down Expand Up @@ -175,6 +179,7 @@ export async function createCheckoutSession(
throw new Error('createCheckoutSession: exactly one of draftOrderId, skuId, or lineItemData is required');
}

assertCommerceConnectionReady(configuration);
const {
storeId,
channelId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
* Unlike the storefront cart API, this endpoint includes completed orders.
*/
import { authorizationHeaders, getOAuthAccessToken } from './checkout-subgraph';
import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config';
import {
assertCommerceConnectionReady,
type CommerceConfiguration,
createRuntimeCommerceConfiguration,
} from './config';
import type { Money } from './gql';

export interface CommerceOrderStatus {
Expand Down Expand Up @@ -43,6 +47,7 @@ export async function getOrderStatus(
throw new Error('getOrderStatus: a valid orderId is required');
}

assertCommerceConnectionReady(configuration);
const { storeId, channelId, clientId, clientSecret, apiBaseUrl, currencyCode } = configuration.read();
const token = await getOAuthAccessToken({
clientId,
Expand Down
82 changes: 82 additions & 0 deletions packages/commerce-server/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,3 +380,85 @@ describe('Commerce router mounting', (): void => {
}
});
});

describe('host-reported connection state', () => {
it.each(['unbound', 'connecting'] as const)(
'reports %s without credentials or upstream calls',
async (state) => {
const res = response();
const read = vi.fn(() => {
throw new Error('No credentials');
});
res.locals.commerceConfiguration = { ...configuration, read, readConnectionState: () => state };
await configHandler({} as Request, res as unknown as Response);
expect(res.json).toHaveBeenCalledWith({ state });
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(read).not.toHaveBeenCalled();
},
);

it('retains legacy config and validates an explicitly ready connection', async () => {
for (const state of [undefined, 'ready'] as const) {
const res = response();
res.locals.commerceConfiguration = { ...configuration, readConnectionState: () => state };
await configHandler({} as Request, res as unknown as Response);
expect(res.json).toHaveBeenCalledWith({
...(state ? { state } : {}),
cartScope: getCommerceCartScope(binding),
currencyCode: 'USD',
});
}
const res = response();
res.locals.commerceConfiguration = {
...configuration,
readConnectionState: () => 'ready',
read: () => {
throw new Error('Lost configuration');
},
};
await configHandler({} as Request, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(503);
});

it.each(['invalid', 'throws'])('rejects %s host state rather than granting samples', async (state) => {
const res = response();
res.locals.commerceConfiguration = {
...configuration,
readConnectionState: () => {
if (state === 'throws') throw new Error('Host read failed');
return state as 'ready';
},
};
await configHandler({} as Request, res as unknown as Response);
expect(res.status).toHaveBeenCalledWith(503);
});

it.each([
createCart,
readCart,
addItem,
updateItem,
deleteItem,
applyDiscount,
checkout,
readProducts,
readProduct,
readSku,
])('blocks nonready handlers even with leftover credentials', async (handler) => {
vi.clearAllMocks();
const res = response();
res.locals.commerceConfiguration = { ...configuration, readConnectionState: () => 'connecting' };
await handler(
{
headers: {},
query: {},
params: { id: 'cart-1', itemId: 'item-1' },
body: { skuId: 'sku-1', name: 'Product', quantity: 1 },
} as unknown as Request,
res as unknown as Response,
);
expect(res.status).toHaveBeenCalled();
expect(gqlRequest).not.toHaveBeenCalled();
expect(createCheckoutSession).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@
import type { Request, Response } from 'express';
import { validateCommerceCartScope } from '@/lib/commerce/cart-scope';
import type { CheckoutReturnUrlValidator } from '@/lib/commerce/checkout-return-urls';
import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config';
import {
assertCommerceConnectionReady,
type CommerceConfig,
commerceConfigurationForResponse,
} from '@/lib/commerce/config';

import {
type CreateCheckoutSessionParams,
Expand Down Expand Up @@ -76,6 +80,7 @@ export default async function handler(req: Request, res: Response): Promise<void
}

if (req.headers?.['x-commerce-scope'] !== undefined) {
assertCommerceConnectionReady(configuration);
const config: CommerceConfig = configuration.read();
if (!validateCommerceCartScope(req, res, config)) return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,15 @@ export default async function handler(_req: Request, res: Response): Promise<voi
res.setHeader('Cache-Control', 'no-store');
try {
const configuration = commerceConfigurationForResponse(res);
const state = configuration.readConnectionState?.();
if (state === 'unbound' || state === 'connecting') {
res.json({ state });
return;
}
if (state !== undefined && state !== 'ready') throw new Error('Invalid Commerce connection state.');
const config: CommerceConfig = configuration.read();
res.json({
...(state === 'ready' ? { state } : {}),
cartScope: getCommerceCartScope(config),
currencyCode: config.currencyCode,
});
Expand Down
40 changes: 40 additions & 0 deletions packages/commerce-storefront/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,43 @@ Build before testing: artifact tests check the compiled JavaScript and shipped C
## License

[MIT](LICENSE.md).

### Before a store is connected

A site can mount the same components before it has a connected store:

```tsx
<CommerceStorefront theme={theme}>
<Header><CartButton /></Header>
<Catalog sampleProducts />
</CommerceStorefront>
```

`Catalog.sampleProducts` defaults to `false`. It allows six built-in sample products
(Product 1–6, $10–$60 USD) **only** when `/api/commerce/config` reports `state: 'unbound'`.
When a store is connected, the server reports a different state and the components show real
products. You do not need to change props or the component tree. The prop has no effect while
a store is connected. Real empty catalogs and errors never fall back to samples.

The sample cart supports adding items, changing quantities (1–999), removing items, and an
example subtotal. It uses the existing drawer and theme, keeps its items during navigation under
the shared provider, and resets on reload, unmount, or any change away from `unbound`. It uses no
live cart storage and makes no product, cart, checkout, or upstream Commerce requests.
Configuration reads continue. Sample entries are not SKUs or draft orders and never move into a
real cart. Checkout is unavailable, including direct `useCommerce().checkout()` calls.

`useCommerce().connection` now includes `unbound` and `connecting` in addition to `loading`,
`ready`, and `error`. Only `ready` means a usable store connection. The public `cart` and live
mutation methods apply only to the connected store; sample cart data is internal. A catalog
without `sampleProducts`, and direct product-detail visits, show the unconnected state.
Sample grids and drawers have the attribute `data-commerce-source="sample"`.

The provider reads configuration on mount and focus, and every five seconds while the page is
visible and the state is `unbound` or `connecting`. The first failed refresh hides samples and
clears the sample cart; a later `unbound` response starts an empty sample cart. Polling stops
when the state is `ready`; focus and explicit retry still refresh configuration.

Use a server that reports connection state before you enable `sampleProducts`. Successful
config responses without `state` still mean ready. A server that does not report `unbound`
never shows samples. See the [server contract](docs/server-api.md) for the host's
responsibilities.
32 changes: 32 additions & 0 deletions packages/commerce-storefront/docs/server-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,35 @@ The client refreshes the cart before checkout and sends its ID, an absolute cata
When using `@godaddy/gd-commerce-server`, configure the router's `checkoutReturnUrls.returnUrls` with the absolute catalog return URL and `checkoutReturnUrls.successUrls` with the absolute success-page URL. The package matches these exact destinations and permits an additional `orderId` parameter on success URLs. Missing policy disables HTTP checkout. Public HTTP requests accept only cart or SKU checkout; non-catalog amounts belong in a trusted server handler.

Do not treat `checkoutSuccessPath`, a client-provided URL, the redirect itself, or a draft-order response as proof that payment succeeded. Verify payment using the checkout/payment service or trusted webhook state. Expire or reject paid/closed drafts on subsequent cart reads so returning customers cannot reuse a completed cart.

## Connection state

A host may return one of these uncached config responses:

```json
{ "state": "unbound" }
{ "state": "connecting" }
{ "state": "ready", "cartScope": "opaque-cart-scope", "currencyCode": "USD" }
```

Only an explicit `unbound` response allows `Catalog sampleProducts`. `connecting` suspends all
catalog and cart actions. Loading, malformed state, HTTP errors, and missing routes never allow
samples. A successful `{ cartScope, currencyCode }` response without `state` still means ready.

`@godaddy/gd-commerce-server` accepts an optional synchronous
`CommerceConfiguration.readConnectionState(): 'unbound' | 'connecting' | 'ready' | undefined`.
Return `undefined` to keep the existing config behavior. Return `unbound` only when the host has
confirmed that no store is connected. Throw when the state is unknown or cannot be read.
`unbound` and `connecting` responses need no credentials. `ready` still requires `read()` to
succeed. Data and checkout handlers reject any state other than `ready`, even if credentials
remain. Do not infer `unbound` from missing credentials or configuration.

The host owns the connection lifecycle. Report `ready` only when the store connection is
complete, and keep failures in a state other than `unbound`. After a store connects, missing
configuration must never bring samples back. Report `unbound` again only after the store is
deliberately disconnected. State changes must be visible to the next request.

The storefront polls every five seconds while the page is visible and the state is `unbound` or
`connecting`, and refreshes on mount and focus. A failed refresh immediately hides samples, without
automatic request retries. Aborted configuration requests cannot overwrite newer responses.
Release server and client support before using the `sampleProducts` prop. No new endpoint is required.
Loading
Loading