diff --git a/.changeset/clear-order-status-errors.md b/.changeset/clear-order-status-errors.md new file mode 100644 index 00000000..bcd7f745 --- /dev/null +++ b/.changeset/clear-order-status-errors.md @@ -0,0 +1,5 @@ +--- +'@godaddy/gd-commerce-server': minor +--- + +Return 400 from the order-status route for invalid order IDs, including IDs the Orders API rejects as malformed, and 404 when the Orders API reports the order missing or it belongs to another store or channel, instead of 500. A 404 without the Orders API's `NOT_FOUND` code, such as from a misconfigured base URL, stays a logged 500. The 500 response no longer includes the internal error message; it is logged server-side instead. Export `InvalidOrderIdError`, `OrderNotFoundError`, and `ORDER_STATUS_UNKNOWN` for in-process `getOrderStatus()` callers; check `error.name` rather than `instanceof` so the check holds when a host has more than one copy of this package. diff --git a/.changeset/quiet-route-failures.md b/.changeset/quiet-route-failures.md new file mode 100644 index 00000000..7af5840b --- /dev/null +++ b/.changeset/quiet-route-failures.md @@ -0,0 +1,16 @@ +--- +'@godaddy/gd-commerce-server': minor +--- + +Standardize route error handling. Every failure now responds with `{ error, code, requestId }` and no longer includes internal error text in a `message` field. + +Status codes now reflect the cause: + +- 502 for Commerce failures, including rejected OAuth credentials (`upstream_unauthorized`); previously 500. This includes order-status upstream failures, which were 500. An OAuth 400 is `upstream_unauthorized` only for `invalid_client`, `invalid_grant`, `unauthorized_client`, or `invalid_scope`. +- 503 for missing or unreadable configuration on every route; previously 500 everywhere except `/config`. +- 404 for writes to an existing cart (`/cart/:id/...`) that is missing, expired, or completed; previously 500. Creating a cart and checkout are not classified this way and return 502. +- 500 only for unexpected errors. + +Hosts can pass `logger` and `getRequestId` to the router factories to receive failure detail and reuse their own request ids; a throwing `getRequestId` falls back to a generated id. In-process helpers, including `getOrderStatus()` and `createCheckoutSession()`, throw exported `CommerceError` subclasses, also when a host configuration throws while being read. Check `error.code` rather than `instanceof` or `error.name`; it holds when a host has more than one copy of this package. `validateCommerceCartScope` was internal and is replaced by a throwing `assertCommerceCartScope`. + +Hosts that read `message` or check for status 500 should switch to `code` and server-side logs. diff --git a/packages/commerce-server/README.md b/packages/commerce-server/README.md index eb867c1f..68f737ce 100644 --- a/packages/commerce-server/README.md +++ b/packages/commerce-server/README.md @@ -56,4 +56,31 @@ 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. +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`; the exported `ORDER_STATUS_UNKNOWN`, `'unknown'`, if absent). Show it only as display enrichment and never present an unconfirmed payment as paid. 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. The route returns 400 for a missing, blank, padded, `.`, or `..` order ID (padded IDs are rejected, not trimmed) or one the Orders API rejects as malformed (422 `VALIDATION_FAILED`), and 404 when the Orders API reports the order missing (404 `NOT_FOUND`) or the order belongs to another store or channel. A 404 without that code, such as from a misconfigured API base URL, is an upstream failure. Token and other Commerce failures, including a response for a different order ID, return 502 and unreadable configuration returns 503 (see [Errors](#errors)). Hosts must authenticate callers and authorize access to each requested order before exposing this route. + +## Errors + +Every route reports failures the same way. The body is `{ "error": "", "code": "", "requestId": "" }` (order-status also includes `success: false`). The same id is passed to the logger. Bodies never contain upstream messages, configuration details, or credentials. + +| Status | `code` | Meaning | +| --- | --- | --- | +| 400 | `invalid_request` | Missing or invalid input. | +| 404 | `not_found` | Missing order or product, or a write to an existing cart (`/cart/:id/...`) that is missing, expired, or completed. Cart reads keep returning `200 { "cart": null }`. Checkout with a stale or completed `draftOrderId` is not yet distinguished and returns 502. | +| 409 | `scope_mismatch` | `X-Commerce-Scope` no longer matches the configured store binding. | +| 502 | `upstream_unauthorized` | Commerce rejected this server's OAuth client or requested scope (HTTP 401/403, or an OAuth 400 with `invalid_client`, `invalid_grant`, `unauthorized_client`, or `invalid_scope`). It concerns server credentials, not the caller, so it is never reported as 401/403. | +| 502 | `upstream_error` | Commerce failed, returned an unexpected response, or could not be reached. | +| 503 | `not_configured` | Configuration is missing or unreadable, or checkout return URLs are not configured. | +| 500 | `internal_error` | An unexpected error in this package. | + +Upstream errors without a specific mapping stay 502; business errors such as invalid discount codes are not yet distinguished. 5xx detail, including upstream status and GraphQL error codes, goes to the logger: + +```ts +createCommerceRouter({ + configuration, + logger: { error: (message, context) => log.error(context, message) }, + // Use the host's request id when its edge sets one; unsafe or missing ids fall back to a UUID. + getRequestId: (req) => req.get('x-request-id'), +}); +``` + +`createCommerceCatalogRouter` and `createGoDaddyPaymentsRouter` accept the same `{ logger, getRequestId }` as their last argument. The logger defaults to `console.error`. In-process helpers throw the exported `CommerceError` subclasses (`InvalidRequestError`, `NotFoundError`, `CommerceNotConfiguredError`, `UpstreamError`), including when a host configuration throws while being read. Check `error.code` (for example `'not_found'`) rather than `instanceof`, which fails when a host has more than one copy of this package. diff --git a/packages/commerce-server/src/configuration-integration.test.ts b/packages/commerce-server/src/configuration-integration.test.ts index c6a0a421..335d893f 100644 --- a/packages/commerce-server/src/configuration-integration.test.ts +++ b/packages/commerce-server/src/configuration-integration.test.ts @@ -7,6 +7,7 @@ import { createCommerceRouter } from './router'; const clientFetch = globalThis.fetch; afterEach((): void => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); it('serves variant product details without querying SKUGroup.status', async (): Promise => { @@ -95,7 +96,11 @@ it('serves variant product details without querying SKUGroup.status', async (): ]); const archived = await clientFetch(`${url.replace('/shirt', '/archived-shirt')}?attributeValues=blue`); expect(archived.status).toBe(404); - expect(await archived.json()).toEqual({ error: 'Product not found' }); + expect(await archived.json()).toEqual({ + error: 'Product not found', + code: 'not_found', + requestId: expect.any(String), + }); expect(upstream).toHaveBeenCalledTimes(3); } finally { await new Promise((resolve, reject) => @@ -237,15 +242,12 @@ it.each([undefined, 'https://api.example.com', 'https://api.example.com:8443'])( it.each([ ['Order not found', 200, { cart: null }], - [ - 'Authentication token expired', - 500, - { error: 'Failed to load cart', message: 'Authentication token expired' }, - ], - ['Database unavailable', 500, { error: 'Failed to load cart', message: 'Database unavailable' }], + ['Authentication token expired', 502, { error: 'Failed to load cart', code: 'upstream_error' }], + ['Database unavailable', 502, { error: 'Failed to load cart', code: 'upstream_error' }], ] as const)( 'handles the actual Apollo error envelope for %s', async (message, status, body): Promise => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); vi.stubGlobal( 'fetch', vi.fn( @@ -278,7 +280,67 @@ it.each([ if (!address || typeof address === 'string') throw new Error('Expected a listening TCP server'); const response = await clientFetch(`http://127.0.0.1:${address.port}/api/commerce/cart/completed-cart`); expect(response.status).toBe(status); - expect(await response.json()).toEqual(body); + const json = (await response.json()) as { requestId?: string }; + const requestId = json.requestId; + expect(json).toEqual(status === 200 ? body : { ...body, requestId: expect.any(String) }); + if (status === 502) { + expect(consoleError).toHaveBeenCalledWith( + 'commerce-server: Failed to load cart', + expect.objectContaining({ + requestId, + error: expect.objectContaining({ message: expect.stringContaining(message) }), + }), + ); + } + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }, +); + +it.each([ + ['extensions.http.status', { code: 'UNAUTHENTICATED', http: { status: 401 } }], + ['extensions.status', { code: 'FORBIDDEN', status: 403 }], +])( + 'reports an auth failure in an HTTP 200 GraphQL body (%s) as upstream_unauthorized', + async (_case, extensions): Promise => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.stubGlobal( + 'fetch', + vi.fn( + async (): Promise => + Response.json({ data: { orderById: null }, errors: [{ message: 'Unauthorized', extensions }] }), + ), + ); + const app = express(); + app.use( + '/api/commerce', + createCommerceRouter({ + configuration: createRuntimeCommerceConfiguration({ + 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 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 response = await clientFetch(`http://127.0.0.1:${address.port}/api/commerce/cart/cart-1`); + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + error: 'Failed to load cart', + code: 'upstream_unauthorized', + requestId: expect.any(String), + }); } finally { await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), diff --git a/packages/commerce-server/src/create-checkout-session.test.ts b/packages/commerce-server/src/create-checkout-session.test.ts index 1e615dd7..a5508c94 100644 --- a/packages/commerce-server/src/create-checkout-session.test.ts +++ b/packages/commerce-server/src/create-checkout-session.test.ts @@ -255,13 +255,13 @@ describe('createCheckoutSession', () => { ]); }); - it('returns host configuration errors immediately without OAuth or checkout requests', async (): Promise => { + it('reports host configuration errors as not configured without OAuth or checkout requests', async (): Promise => { + const cause = new Error('Host configuration unavailable'); vi.mocked(configuration.read).mockImplementation(() => { - throw new Error('Host configuration unavailable'); + throw cause; }); - await expect(createCheckoutSession(cart, configuration)).rejects.toThrow( - 'Host configuration unavailable', - ); + const error = await createCheckoutSession(cart, configuration).catch((thrown: unknown) => thrown); + expect(error).toMatchObject({ code: 'not_configured', httpStatus: 503, cause }); expect(configuration.read).toHaveBeenCalledTimes(1); expect(mockGetOAuthAccessToken).not.toHaveBeenCalled(); expect(mockGqlRequest).not.toHaveBeenCalled(); diff --git a/packages/commerce-server/src/get-order-status.test.ts b/packages/commerce-server/src/get-order-status.test.ts index ca89fc3f..9d8d96d8 100644 --- a/packages/commerce-server/src/get-order-status.test.ts +++ b/packages/commerce-server/src/get-order-status.test.ts @@ -2,7 +2,7 @@ import { once } from 'node:events'; import express from 'express'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRuntimeCommerceConfiguration } from './lib/commerce/config'; -import { getOrderStatus } from './lib/commerce/get-order-status'; +import { getOrderStatus, InvalidOrderIdError, OrderNotFoundError } from './lib/commerce/get-order-status'; import { createGoDaddyPaymentsRouter } from './router'; const clientFetch = globalThis.fetch; @@ -34,6 +34,17 @@ const summary = { updatedAt: order.updatedAt, lineItems: [{ id: 'line-1', name: 'Mug', quantity: 1 }], }; +// Bodies the Orders API's REST error handler sends for a missing order and an undecodable ID. +const ordersApiNotFound = (): Response => + Response.json( + { code: 'NOT_FOUND', message: 'Order not found. Possible reasons: invalid orderId, storeID mismatch.' }, + { status: 404 }, + ); +const ordersApiInvalidId = (): Response => + Response.json( + { code: 'VALIDATION_FAILED', message: 'Invalid global ID: completed-order' }, + { status: 422 }, + ); let upstream: ReturnType>; beforeEach((): void => { @@ -52,6 +63,7 @@ beforeEach((): void => { }); afterEach((): void => { vi.unstubAllGlobals(); + vi.restoreAllMocks(); }); describe('authorized order lookup', () => { @@ -128,26 +140,76 @@ describe('authorized order lookup', () => { it.each([ undefined, + { ...order, context: undefined }, + { ...order, context: {} }, + { ...order, context: { channelId: order.context.channelId } }, + { ...order, context: { storeId: order.context.storeId } }, + { ...order, context: { ...order.context, storeId: '' } }, + { ...order, id: undefined }, + { ...order, id: 42 }, { ...order, id: 'another-order' }, + ])('rejects an incomplete or mismatched order response', async (result): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(Response.json({ order: result })); + const lookup = getOrderStatus(order.id, configuration); + await expect(lookup).rejects.toThrow('Order lookup did not return the requested order'); + await expect(lookup).rejects.not.toBeInstanceOf(OrderNotFoundError); + }); + + it.each([ { ...order, context: { ...order.context, storeId: 'another-store' } }, { ...order, context: { ...order.context, channelId: 'another-channel' } }, - { ...order, context: undefined }, - ])('rejects missing or mismatched order bindings', async (result): Promise => { + ])('reports an order bound to another store or channel as not found', async (result): Promise => { upstream .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) .mockResolvedValueOnce(Response.json({ order: result })); - await expect(getOrderStatus(order.id, configuration)).rejects.toThrow('Order lookup'); + await expect(getOrderStatus(order.id, configuration)).rejects.toBeInstanceOf(OrderNotFoundError); }); - it.each([401, 403, 404, 500])( + it('reports an Orders API NOT_FOUND as not found without exposing its body', async (): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(ordersApiNotFound()); + const lookup = getOrderStatus(order.id, configuration); + await expect(lookup).rejects.toBeInstanceOf(OrderNotFoundError); + await expect(lookup).rejects.toThrow(/^Order not found$/); + }); + + it('reports an Orders API VALIDATION_FAILED for the order ID as an invalid order ID', async (): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(ordersApiInvalidId()); + await expect(getOrderStatus(order.id, configuration)).rejects.toBeInstanceOf(InvalidOrderIdError); + }); + + it.each([ + ['an HTML 404 from an unrouted path', new Response('
Cannot GET /v1/x
', { status: 404 })], + ['a JSON 404 without a code', Response.json({ message: 'Not Found' }, { status: 404 })], + ['a 404 with another code', Response.json({ code: 'ROUTE_NOT_FOUND' }, { status: 404 })], + ['a 422 with another code', Response.json({ code: 'CONFLICT' }, { status: 422 })], + ['a non-JSON 422', new Response('Unprocessable', { status: 422 })], + ])('treats %s as an upstream failure', async (_case, failure): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(failure); + const lookup = getOrderStatus(order.id, configuration); + await expect(lookup).rejects.toThrow(`Failed to load order: upstream returned ${failure.status}`); + await expect(lookup).rejects.not.toBeInstanceOf(OrderNotFoundError); + await expect(lookup).rejects.not.toBeInstanceOf(InvalidOrderIdError); + }); + + it.each([401, 403, 500])( 'preserves an upstream order lookup failure (%i) without exposing its body', async (status): Promise => { + const cancel = vi.spyOn(ReadableStream.prototype, 'cancel'); upstream .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) .mockResolvedValueOnce(new Response('Private upstream details', { status })); await expect(getOrderStatus(order.id, configuration)).rejects.toThrow( `Failed to load order: upstream returned ${status}`, ); + expect(cancel).toHaveBeenCalledTimes(1); }, ); @@ -157,11 +219,203 @@ describe('authorized order lookup', () => { expect(upstream).toHaveBeenCalledTimes(1); }); - it.each(['', ' ', '.', '..'])( + it('reports a host configuration that throws as not configured', async (): Promise => { + const cause = new Error('secrets file missing'); + const failing = { + read: (): never => { + throw cause; + }, + readCheckout: configuration.readCheckout, + }; + const error = await getOrderStatus(order.id, failing).catch((thrown: unknown) => thrown); + expect(error).toMatchObject({ code: 'not_configured', httpStatus: 503, cause }); + expect(upstream).not.toHaveBeenCalled(); + }); + + it.each(['', ' ', '.', '..', ' completed-order', 'completed-order\n'])( 'rejects invalid order ID %j before requesting credentials', async (id): Promise => { - await expect(getOrderStatus(id, configuration)).rejects.toThrow('a valid orderId is required'); + const lookup = getOrderStatus(id, configuration); + await expect(lookup).rejects.toBeInstanceOf(InvalidOrderIdError); + await expect(lookup).rejects.toThrow('a valid orderId is required'); expect(upstream).not.toHaveBeenCalled(); }, ); }); + +describe('order-status route', () => { + async function requestOrderStatus(query: string): Promise<{ status: number; body: unknown }> { + const app = express(); + app.use('/api/commerce', createGoDaddyPaymentsRouter(configuration)); + 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 response = await clientFetch( + `http://127.0.0.1:${address.port}/api/commerce/order-status${query}`, + ); + const body = (await response.json()) as Record; + expect(body.requestId).toEqual(expect.any(String)); + const { requestId: _requestId, ...stable } = body; + return { status: response.status, body: stable }; + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + } + + it.each([ + '', + '?orderId=', + '?orderId=%20', + '?orderId=.', + '?orderId=..', + '?orderId=%20cart-1', + '?orderId=a&orderId=b', + ])( + 'returns 400 for invalid order ID query %j without an upstream request', + async (query): Promise => { + await expect(requestOrderStatus(query)).resolves.toEqual({ + status: 400, + body: { + success: false, + error: 'missing or invalid orderId query parameter', + code: 'invalid_request', + }, + }); + expect(upstream).not.toHaveBeenCalled(); + }, + ); + + it('returns 400 when the Orders API rejects the order ID format', async (): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(ordersApiInvalidId()); + await expect(requestOrderStatus(`?orderId=${order.id}`)).resolves.toEqual({ + status: 400, + body: { success: false, error: 'missing or invalid orderId query parameter', code: 'invalid_request' }, + }); + }); + + it.each([ + ['an Orders API NOT_FOUND', ordersApiNotFound()], + [ + 'another store', + Response.json({ order: { ...order, context: { ...order.context, storeId: 'another-store' } } }), + ], + ])('returns 404 for %s', async (_case, orderResponse): Promise => { + upstream + .mockResolvedValueOnce(Response.json({ access_token: 'order-token' })) + .mockResolvedValueOnce(orderResponse); + await expect(requestOrderStatus(`?orderId=${order.id}`)).resolves.toEqual({ + status: 404, + body: { success: false, error: 'Order not found', code: 'not_found' }, + }); + }); + + it.each([ + [ + 'a token denied the order-read scope', + 'upstream_unauthorized', + [new Response('Invalid scope', { status: 403 })], + ], + [ + 'an OAuth invalid_scope rejection', + 'upstream_unauthorized', + [Response.json({ error: 'invalid_scope' }, { status: 400 })], + ], + [ + 'an OAuth invalid_client rejection', + 'upstream_unauthorized', + [Response.json({ error: 'invalid_client' }, { status: 400 })], + ], + [ + 'a malformed OAuth request', + 'upstream_error', + [Response.json({ error: 'invalid_request' }, { status: 400 })], + ], + ['a non-JSON OAuth 400', 'upstream_error', [new Response('Bad Request', { status: 400 })]], + [ + 'an upstream 500', + 'upstream_error', + [ + Response.json({ access_token: 'order-token' }), + new Response('Private upstream details', { status: 500 }), + ], + ], + [ + 'an incomplete order', + 'upstream_error', + [Response.json({ access_token: 'order-token' }), Response.json({})], + ], + [ + 'an order missing its store binding', + 'upstream_error', + [ + Response.json({ access_token: 'order-token' }), + Response.json({ order: { ...order, context: { channelId: order.context.channelId } } }), + ], + ], + [ + 'a 404 from an unrouted path', + 'upstream_error', + [ + Response.json({ access_token: 'order-token' }), + new Response('
Cannot GET /v1/x
', { status: 404 }), + ], + ], + [ + 'a different order ID', + 'upstream_error', + [ + Response.json({ access_token: 'order-token' }), + Response.json({ order: { ...order, id: 'another-order' } }), + ], + ], + ])( + 'returns a generic 502 for %s and logs the detail server-side', + async (_case, code, responses): Promise => { + const log = vi.spyOn(console, 'error').mockImplementation((): void => {}); + for (const response of responses) upstream.mockResolvedValueOnce(response); + const result = await requestOrderStatus(`?orderId=${order.id}`); + expect(result).toEqual({ + status: 502, + body: { success: false, error: 'Failed to get order status', code }, + }); + expect(log).toHaveBeenCalledWith( + 'commerce-server: Failed to get order status', + expect.objectContaining({ httpStatus: 502, code, error: expect.any(Error) }), + ); + }, + ); + + it('returns 503 when Commerce is not configured', async (): Promise => { + const log = vi.spyOn(console, 'error').mockImplementation((): void => {}); + vi.stubEnv('GODADDY_STORE_ID', ''); + const app = express(); + app.use( + '/api/commerce', + createGoDaddyPaymentsRouter(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 response = await clientFetch( + `http://127.0.0.1:${address.port}/api/commerce/order-status?orderId=o-1`, + ); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ success: false, code: 'not_configured' }); + expect(upstream).not.toHaveBeenCalled(); + } finally { + vi.unstubAllEnvs(); + log.mockRestore(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }); +}); diff --git a/packages/commerce-server/src/index.ts b/packages/commerce-server/src/index.ts index fd69b46b..3681e108 100644 --- a/packages/commerce-server/src/index.ts +++ b/packages/commerce-server/src/index.ts @@ -4,6 +4,11 @@ export { parseCommerceCheckoutConfiguration, } from './lib/commerce/checkout-config'; export type { CheckoutReturnUrls } from './lib/commerce/checkout-return-urls'; +export type { + CommerceErrorLogContext, + CommerceLogger, + CommerceRequestIdResolver, +} from './lib/commerce/commerce-route'; export { type CommerceConfig, type CommerceConfiguration, @@ -16,12 +21,25 @@ export { type CreateCheckoutSessionParams, createCheckoutSession, } from './lib/commerce/create-checkout-session'; +export { + CommerceError, + type CommerceErrorCode, + CommerceNotConfiguredError, + InvalidRequestError, + NotFoundError, + ScopeMismatchError, + UpstreamError, +} from './lib/commerce/errors'; export { type CommerceOrderStatus, getOrderStatus, + InvalidOrderIdError, + ORDER_STATUS_UNKNOWN, + OrderNotFoundError, } from './lib/commerce/get-order-status'; export { type CommerceRouterFeatures, + type CommerceRouterObservabilityOptions, type CreateCommerceRouterOptions, createCommerceCatalogRouter, createCommerceRouter, diff --git a/packages/commerce-server/src/lib/commerce/cart-scope.ts b/packages/commerce-server/src/lib/commerce/cart-scope.ts index 46c5382a..8fe07896 100644 --- a/packages/commerce-server/src/lib/commerce/cart-scope.ts +++ b/packages/commerce-server/src/lib/commerce/cart-scope.ts @@ -1,7 +1,8 @@ // SERVER ONLY: uses node:crypto. Browser code receives cartScope from GET /api/commerce/config. import { createHash } from 'node:crypto'; -import type { Request, Response } from 'express'; +import type { Request } from 'express'; import type { CommerceConfig } from './config'; +import { ScopeMismatchError } from './errors'; type CartBinding = Pick; @@ -14,9 +15,8 @@ export function getCommerceCartScope(config: CartBinding): string { } /** Existing custom clients may omit the header; managed components always send it. */ -export function validateCommerceCartScope(req: Request, res: Response, config: CartBinding): boolean { +export function assertCommerceCartScope(req: Request, config: CartBinding): void { const suppliedScope: string | string[] | undefined = req.headers?.['x-commerce-scope']; - if (suppliedScope === undefined || suppliedScope === getCommerceCartScope(config)) return true; - res.status(409).json({ error: 'The connected store changed. Reload the page before continuing.' }); - return false; + if (suppliedScope === undefined || suppliedScope === getCommerceCartScope(config)) return; + throw new ScopeMismatchError(); } diff --git a/packages/commerce-server/src/lib/commerce/checkout-subgraph.ts b/packages/commerce-server/src/lib/commerce/checkout-subgraph.ts index 38056d6b..5d0b22a6 100644 --- a/packages/commerce-server/src/lib/commerce/checkout-subgraph.ts +++ b/packages/commerce-server/src/lib/commerce/checkout-subgraph.ts @@ -12,6 +12,7 @@ * - Existing custom cart: convert your cart into lineItems, then post to the route. */ +import { CommerceNotConfiguredError, UpstreamError } from './errors'; import type { Money } from './gql'; /** @@ -335,6 +336,26 @@ export function authorizationHeaders({ accessToken }: AuthorizationHeadersInput) }; } +// RFC 6749 §5.2 errors that mean this server's client or its granted scope was rejected. Other 400s +// (e.g. `invalid_request`, `unsupported_grant_type`) are a malformed request, not bad credentials. +const UNAUTHORIZED_OAUTH_ERRORS: ReadonlySet = new Set([ + 'invalid_client', + 'invalid_grant', + 'unauthorized_client', + 'invalid_scope', +]); + +async function readOAuthError(response: Response): Promise { + try { + const body: unknown = await response.json(); + return body && typeof body === 'object' && 'error' in body && typeof body.error === 'string' + ? body.error + : undefined; + } catch { + return undefined; + } +} + export async function getOAuthAccessToken({ clientId, clientSecret, @@ -343,7 +364,7 @@ export async function getOAuthAccessToken({ fetch: fetchImplementation, }: OAuthTokenInput): Promise { if (!clientId || !clientSecret) { - throw new Error('clientId and clientSecret are required'); + throw new CommerceNotConfiguredError('clientId and clientSecret are required'); } const requestFetch = fetchImplementation ?? fetch; @@ -353,20 +374,36 @@ export async function getOAuthAccessToken({ body.append('client_secret', clientSecret); body.append('scope', scope); - const response = await requestFetch(new URL('/v2/oauth2/token', apiBaseUrl).toString(), { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: body.toString(), - cache: 'no-store', - }); + let response: Response; + try { + response = await requestFetch(new URL('/v2/oauth2/token', apiBaseUrl).toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + cache: 'no-store', + }); + } catch (cause) { + throw new UpstreamError('Access token request could not reach Commerce', { cause, details: { scope } }); + } if (!response.ok) { - throw new Error(`Failed to get access token: ${response.status} ${response.statusText}`); + const oauthError = response.status === 400 ? await readOAuthError(response) : undefined; + throw new UpstreamError(`Failed to get access token: ${response.status} ${response.statusText}`, { + unauthorized: + response.status === 401 || + response.status === 403 || + (oauthError !== undefined && UNAUTHORIZED_OAUTH_ERRORS.has(oauthError)), + details: { upstreamStatus: response.status, scope, oauthError }, + }); } - return (await response.json()) as OAuthTokenResponse; + try { + return (await response.json()) as OAuthTokenResponse; + } catch (cause) { + throw new UpstreamError('Access token response was not JSON', { cause, details: { scope } }); + } } export function buildBuyNowCheckoutInput( diff --git a/packages/commerce-server/src/lib/commerce/commerce-route.ts b/packages/commerce-server/src/lib/commerce/commerce-route.ts new file mode 100644 index 00000000..d7983d54 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/commerce-route.ts @@ -0,0 +1,130 @@ +// SERVER ONLY: uses node:crypto. +import { randomUUID } from 'node:crypto'; +import type { Request, Response } from 'express'; +import { CommerceError, type CommerceErrorCode, UpstreamError } from './errors'; + +export interface CommerceErrorLogContext { + requestId: string; + method: string; + path: string; + httpStatus: number; + code: CommerceErrorCode; + details?: Record; + error: unknown; +} + +/** Receives server-side failure detail that is never sent to the browser. */ +export interface CommerceLogger { + error(message: string, context: CommerceErrorLogContext): void; +} + +export const consoleCommerceLogger: CommerceLogger = { + error: (message, context): void => console.error(message, context), +}; + +/** Returns the host's id for this request (for example one set by its load balancer), if any. */ +export type CommerceRequestIdResolver = (req: Request) => string | undefined; + +// Ids are echoed into logs and response bodies, so reject anything that could forge either. +const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; + +/** What `createCommerceRouter` stores in `res.locals` for its routes. */ +export interface CommerceObservabilityLocals { + commerceLogger?: CommerceLogger; + commerceRequestIdResolver?: CommerceRequestIdResolver; + commerceRequestId?: string; +} + +function observabilityLocals(res: Response): CommerceObservabilityLocals { + return res.locals as CommerceObservabilityLocals; +} + +export function resolveRequestId(req: Request, resolver?: CommerceRequestIdResolver): string { + let supplied: string | undefined; + // A failing host resolver must not prevent the failure response. + try { + supplied = resolver?.(req); + } catch { + supplied = undefined; + } + return typeof supplied === 'string' && REQUEST_ID_PATTERN.test(supplied) ? supplied : randomUUID(); +} + +// Resolved only when a failure body needs it, and at most once per request. +function requestIdFor(req: Request, res: Response): string { + const locals = observabilityLocals(res); + locals.commerceRequestId ??= resolveRequestId(req, locals.commerceRequestIdResolver); + return locals.commerceRequestId; +} + +/** + * The single place Commerce failures become HTTP responses. Typed errors keep their status and + * public message; unmapped upstream failures are 502 and anything else is 500. 5xx detail goes + * to the configured logger, never into the body. + */ +export function sendCommerceError( + req: Request, + res: Response, + label: string, + error: unknown, + { failureFields = {}, classifyUpstreamError }: CommerceRouteOptions = {}, +): void { + const resolved: CommerceError | undefined = + error instanceof UpstreamError + ? (classifyUpstreamError?.(error) ?? error) + : error instanceof CommerceError + ? error + : undefined; + const httpStatus = resolved?.httpStatus ?? 500; + const code: CommerceErrorCode = resolved?.code ?? 'internal_error'; + const requestId = requestIdFor(req, res); + + if (httpStatus >= 500) { + const context: CommerceErrorLogContext = { + requestId, + method: req.method, + path: req.path, + httpStatus, + code, + ...(resolved?.details ? { details: resolved.details } : {}), + error, + }; + // A failing or invalid host logger must not prevent the failure response. + try { + (observabilityLocals(res).commerceLogger ?? consoleCommerceLogger).error( + `commerce-server: ${label}`, + context, + ); + } catch (loggerError) { + console.error(`commerce-server: ${label} (host logger failed)`, { ...context, loggerError }); + } + } + + if (res.headersSent) return; + res.status(httpStatus).json({ ...failureFields, error: resolved?.publicMessage ?? label, code, requestId }); +} + +export interface CommerceRouteOptions { + /** Fields every failure body carries in addition to `error`, `code`, and `requestId`. */ + failureFields?: Record; + /** + * Turns a specific upstream signal into a more precise error. Unclassified upstream failures + * are 502. Only routes whose evidence supports a rule should pass one. + */ + classifyUpstreamError?: (error: UpstreamError) => CommerceError; +} + +/** Wraps a route so thrown errors are answered by `sendCommerceError` with the route's label. */ +export function commerceRoute( + label: string, + handler: (req: Request, res: Response) => Promise, + options: CommerceRouteOptions = {}, +): (req: Request, res: Response) => Promise { + return async (req, res): Promise => { + try { + await handler(req, res); + } catch (error) { + sendCommerceError(req, res, label, error, options); + } + }; +} diff --git a/packages/commerce-server/src/lib/commerce/config.ts b/packages/commerce-server/src/lib/commerce/config.ts index 19381526..d59b0d41 100644 --- a/packages/commerce-server/src/lib/commerce/config.ts +++ b/packages/commerce-server/src/lib/commerce/config.ts @@ -1,6 +1,7 @@ /** Server-only Commerce configuration. Hosts own secrets and deployment-specific loading. */ import type { Response } from 'express'; import { type CommerceCheckoutConfiguration, parseCommerceCheckoutConfiguration } from './checkout-config'; +import { CommerceError, CommerceNotConfiguredError } from './errors'; const DEFAULT_API_BASE_URL = 'https://api.godaddy.com'; @@ -41,7 +42,7 @@ function normalizeApiBaseUrl(raw: string): string { try { url = new URL(raw); } catch { - throw new Error('Commerce config: apiBaseUrl must be a valid HTTPS origin.'); + throw new CommerceNotConfiguredError('Commerce config: apiBaseUrl must be a valid HTTPS origin.'); } if ( url.protocol !== 'https:' || @@ -51,7 +52,7 @@ function normalizeApiBaseUrl(raw: string): string { url.search || url.hash ) { - throw new Error( + throw new CommerceNotConfiguredError( 'Commerce config: apiBaseUrl must be an HTTPS origin without credentials, path, query, or fragment.', ); } @@ -60,7 +61,11 @@ function normalizeApiBaseUrl(raw: string): string { function requireValue(environment: NodeJS.ProcessEnv, key: string): string { const value = environment[key]?.trim(); - if (!value) throw new Error(`Commerce config: ${key} is missing. Configure it in the server environment.`); + if (!value) { + throw new CommerceNotConfiguredError( + `Commerce config: ${key} is missing. Configure it in the server environment.`, + ); + } return value; } @@ -89,19 +94,38 @@ export function createRuntimeCommerceConfiguration( }; } -export function commerceConfigurationForResponse(res: Response): CommerceConfiguration { - const configuration: unknown = res.locals.commerceConfiguration; - if ( - configuration && - typeof configuration === 'object' && - 'read' in configuration && - typeof configuration.read === 'function' && - 'readCheckout' in configuration && - typeof configuration.readCheckout === 'function' - ) { - return configuration as CommerceConfiguration; +// Host-supplied configurations may throw plain errors (for example a missing secrets file); +// any failure to read configuration is reported as "not configured", never as a server bug. +function readOrNotConfigured(read: () => T): T { + try { + return read(); + } catch (cause) { + if (cause instanceof CommerceError) throw cause; + throw new CommerceNotConfiguredError('Commerce configuration could not be read', { cause }); } - return createRuntimeCommerceConfiguration(); +} + +/** Wraps a configuration so routes and in-process helpers report read failures the same way. */ +export function guardCommerceConfiguration(configuration: CommerceConfiguration): CommerceConfiguration { + return { + read: (): CommerceConfig => readOrNotConfigured(() => configuration.read()), + readCheckout: (): CommerceCheckoutConfiguration => + readOrNotConfigured(() => configuration.readCheckout()), + }; +} + +export function commerceConfigurationForResponse(res: Response): CommerceConfiguration { + const supplied: unknown = res.locals.commerceConfiguration; + return guardCommerceConfiguration( + supplied && + typeof supplied === 'object' && + 'read' in supplied && + typeof supplied.read === 'function' && + 'readCheckout' in supplied && + typeof supplied.readCheckout === 'function' + ? (supplied as CommerceConfiguration) + : createRuntimeCommerceConfiguration(), + ); } export function readCommerceConfigForResponse(res: Response): CommerceConfig { diff --git a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts index a57e8bbc..d9b778d4 100644 --- a/packages/commerce-server/src/lib/commerce/create-checkout-session.ts +++ b/packages/commerce-server/src/lib/commerce/create-checkout-session.ts @@ -25,7 +25,12 @@ import { DEFAULT_CHECKOUT_PAYMENT_METHODS, getOAuthAccessToken, } from './checkout-subgraph'; -import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import { + type CommerceConfiguration, + createRuntimeCommerceConfiguration, + guardCommerceConfiguration, +} from './config'; +import { InvalidRequestError, UpstreamError } from './errors'; import { gqlRequest } from './gql'; export interface CreateCheckoutSessionParams { @@ -167,14 +172,17 @@ export async function createCheckoutSession( const { draftOrderId, skuId, quantity, lineItemData, returnUrl, successUrl } = params; if (!returnUrl || !successUrl) { - throw new Error('createCheckoutSession: returnUrl and successUrl are required'); + throw new InvalidRequestError('createCheckoutSession: returnUrl and successUrl are required'); } const checkoutSourceCount = [draftOrderId, skuId, lineItemData].filter(Boolean).length; if (checkoutSourceCount !== 1) { - throw new Error('createCheckoutSession: exactly one of draftOrderId, skuId, or lineItemData is required'); + throw new InvalidRequestError( + 'createCheckoutSession: exactly one of draftOrderId, skuId, or lineItemData is required', + ); } + const guardedConfiguration = guardCommerceConfiguration(configuration); const { storeId, channelId, @@ -184,8 +192,8 @@ export async function createCheckoutSession( sourceApp, owner, currencyCode: configCurrencyCode, - } = configuration.read(); - const checkoutConfiguration = configuration.readCheckout(); + } = guardedConfiguration.read(); + const checkoutConfiguration = guardedConfiguration.readCheckout(); const enablePromotionCodes: boolean = promotionCodesEnabled(checkoutConfiguration); const catalogShippingEnabled: boolean = lineItemData === undefined && checkoutConfiguration.enableShipping; const checkoutOAuthScope: string = 'commerce.product:read'; @@ -266,16 +274,21 @@ export async function createCheckoutSession( headers: authorizationHeaders({ accessToken: token.access_token }), }); } catch (error) { - throw new Error('Commerce checkout session could not be created', { cause: error }); + // Not classified further: the cart-not-found rule is evidenced for the order storefront only. + throw new UpstreamError('Commerce checkout session could not be created', { + cause: error, + unauthorized: error instanceof UpstreamError && error.code === 'upstream_unauthorized', + details: error instanceof UpstreamError ? error.details : undefined, + }); } const session = result.createCheckoutSession; if (!session?.url || !session.id) { - throw new Error('Checkout session was not created'); + throw new UpstreamError('Checkout session was not created'); } if (session.storeId !== storeId || session.channelId !== channelId) { - throw new Error( + throw new UpstreamError( `Checkout session binding mismatch: expected store ${storeId} and channel ${channelId}, received store ${session.storeId ?? 'missing'} and channel ${session.channelId ?? 'missing'}`, ); } @@ -283,20 +296,20 @@ export async function createCheckoutSession( const expectedShipping = lineItemData === undefined && checkoutConfiguration.enableShipping; const expectedPromotionCodes: boolean = lineItemData === undefined && enablePromotionCodes; if (expectedPromotionCodes && session.enablePromotionCodes !== true) { - throw new Error('Checkout session did not enable configured promotion codes'); + throw new UpstreamError('Checkout session did not enable configured promotion codes'); } if (checkoutConfiguration.enableTaxCollection && session.enableTaxCollection !== true) { - throw new Error('Checkout session did not enable configured tax collection'); + throw new UpstreamError('Checkout session did not enable configured tax collection'); } if ( expectedShipping && (session.enableShipping !== true || session.enableShippingAddressCollection !== true) ) { - throw new Error('Checkout session did not enable configured shipping and address collection'); + throw new UpstreamError('Checkout session did not enable configured shipping and address collection'); } if (!session.paymentMethods?.card?.processor) { - throw new Error('Checkout session did not configure payment methods.'); + throw new UpstreamError('Checkout session did not configure payment methods.'); } return { diff --git a/packages/commerce-server/src/lib/commerce/errors.ts b/packages/commerce-server/src/lib/commerce/errors.ts new file mode 100644 index 00000000..e7c1fc36 --- /dev/null +++ b/packages/commerce-server/src/lib/commerce/errors.ts @@ -0,0 +1,101 @@ +/** + * Typed failures for the Commerce routes and helpers. + * + * Domain code throws these; `commerceRoute` is the only place that turns them into HTTP + * responses. `message` is internal and only logged. `publicMessage` is safe to show a shopper; + * when it is absent the route's failure label is used instead. + */ + +export type CommerceErrorCode = + | 'invalid_request' + | 'scope_mismatch' + | 'not_found' + | 'not_configured' + | 'upstream_unauthorized' + | 'upstream_error' + | 'internal_error'; + +export interface CommerceErrorOptions { + cause?: unknown; + publicMessage?: string; + /** Internal diagnostics for the server log. Never sent to the browser. */ + details?: Record; +} + +export class CommerceError extends Error { + readonly httpStatus: number; + readonly code: CommerceErrorCode; + readonly publicMessage?: string; + readonly details?: Record; + + constructor( + httpStatus: number, + code: CommerceErrorCode, + message: string, + options: CommerceErrorOptions = {}, + ) { + super(message, options.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'CommerceError'; + this.httpStatus = httpStatus; + this.code = code; + this.publicMessage = options.publicMessage; + this.details = options.details; + } +} + +export interface ClientErrorOptions extends Omit { + /** Internal message for in-process callers; defaults to the public message. */ + message?: string; +} + +export class InvalidRequestError extends CommerceError { + constructor(publicMessage: string, { message = publicMessage, ...options }: ClientErrorOptions = {}) { + super(400, 'invalid_request', message, { ...options, publicMessage }); + this.name = 'InvalidRequestError'; + } +} + +export class ScopeMismatchError extends CommerceError { + constructor() { + const publicMessage = 'The connected store changed. Reload the page before continuing.'; + super(409, 'scope_mismatch', publicMessage, { publicMessage }); + this.name = 'ScopeMismatchError'; + } +} + +export class NotFoundError extends CommerceError { + constructor(publicMessage: string, { message = publicMessage, ...options }: ClientErrorOptions = {}) { + super(404, 'not_found', message, { ...options, publicMessage }); + this.name = 'NotFoundError'; + } +} + +export class CommerceNotConfiguredError extends CommerceError { + constructor( + message: string, + { + publicMessage = 'Commerce configuration is unavailable. Complete the store connection before continuing.', + ...options + }: CommerceErrorOptions = {}, + ) { + super(503, 'not_configured', message, { ...options, publicMessage }); + this.name = 'CommerceNotConfiguredError'; + } +} + +export interface UpstreamErrorOptions extends Omit { + /** The upstream rejected this server's own credentials (OAuth client or scope). */ + unauthorized?: boolean; +} + +/** + * Commerce returned a failure, an unexpected response, or could not be reached. Always 502: + * even an upstream 401/403 concerns this server's client credentials, not the caller, and + * answering 401 would collide with the host's own authentication handling. + */ +export class UpstreamError extends CommerceError { + constructor(message: string, { unauthorized = false, ...options }: UpstreamErrorOptions = {}) { + super(502, unauthorized ? 'upstream_unauthorized' : 'upstream_error', message, options); + this.name = 'UpstreamError'; + } +} diff --git a/packages/commerce-server/src/lib/commerce/get-order-status.ts b/packages/commerce-server/src/lib/commerce/get-order-status.ts index a01c66a3..9c7575c8 100644 --- a/packages/commerce-server/src/lib/commerce/get-order-status.ts +++ b/packages/commerce-server/src/lib/commerce/get-order-status.ts @@ -3,13 +3,21 @@ * Unlike the storefront cart API, this endpoint includes completed orders. */ import { authorizationHeaders, getOAuthAccessToken } from './checkout-subgraph'; -import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './config'; +import { + type CommerceConfiguration, + createRuntimeCommerceConfiguration, + guardCommerceConfiguration, +} from './config'; +import { InvalidRequestError, NotFoundError, UpstreamError } from './errors'; import type { Money } from './gql'; +/** `CommerceOrderStatus.status` when the Orders API reports no payment status. */ +export const ORDER_STATUS_UNKNOWN = 'unknown'; + export interface CommerceOrderStatus { /** GoDaddy order id. */ id: string; - /** Payment status reported by Commerce, or 'unknown' when it is unavailable. */ + /** Payment status reported by Commerce (e.g. `PAID`, `PENDING`), or `ORDER_STATUS_UNKNOWN`. */ status: string; /** Total amount in the currency's smallest unit (cents for USD). */ amount: number; @@ -23,6 +31,22 @@ export interface CommerceOrderStatus { lineItems?: unknown[]; } +export class InvalidOrderIdError extends InvalidRequestError { + constructor() { + super('missing or invalid orderId query parameter', { + message: 'getOrderStatus: a valid orderId is required', + }); + this.name = 'InvalidOrderIdError'; + } +} + +export class OrderNotFoundError extends NotFoundError { + constructor() { + super('Order not found'); + this.name = 'OrderNotFoundError'; + } +} + interface OrderResponse { order?: { id: string; @@ -35,45 +59,108 @@ interface OrderResponse { }; } +function isBindingId(value: unknown): value is string { + return typeof value === 'string' && value !== ''; +} + +async function readErrorCode(response: Response): Promise { + try { + const body: unknown = await response.json(); + return body && typeof body === 'object' && 'code' in body && typeof body.code === 'string' + ? body.code + : null; + } catch { + return null; + } +} + export async function getOrderStatus( orderId: string, configuration: CommerceConfiguration = createRuntimeCommerceConfiguration(), ): Promise { - if (typeof orderId !== 'string' || !orderId.trim() || orderId === '.' || orderId === '..') { - throw new Error('getOrderStatus: a valid orderId is required'); + // `.` and `..` survive encodeURIComponent and would resolve the URL to the store resource. + if ( + typeof orderId !== 'string' || + !orderId || + orderId !== orderId.trim() || + orderId === '.' || + orderId === '..' + ) { + throw new InvalidOrderIdError(); } - const { storeId, channelId, clientId, clientSecret, apiBaseUrl, currencyCode } = configuration.read(); + const { storeId, channelId, clientId, clientSecret, apiBaseUrl, currencyCode } = + guardCommerceConfiguration(configuration).read(); const token = await getOAuthAccessToken({ clientId, clientSecret, apiBaseUrl, scope: 'commerce.order:read', }); - const response = await fetch( - new URL( - `/v1/commerce/stores/${encodeURIComponent(storeId)}/orders/${encodeURIComponent(orderId)}`, - apiBaseUrl, - ), - { + const endpoint = new URL( + `/v1/commerce/stores/${encodeURIComponent(storeId)}/orders/${encodeURIComponent(orderId)}`, + apiBaseUrl, + ); + let response: Response; + try { + response = await fetch(endpoint, { method: 'GET', headers: { ...authorizationHeaders({ accessToken: token.access_token }), Accept: 'application/json' }, cache: 'no-store', - }, - ); - if (!response.ok) throw new Error(`Failed to load order: upstream returned ${response.status}`); + }); + } catch (cause) { + throw new UpstreamError('Order lookup could not reach Commerce', { + cause, + details: { endpoint: endpoint.href }, + }); + } + if (!response.ok) { + // The Orders API reports a missing order as 404 NOT_FOUND and an ID it can't decode as 422 + // VALIDATION_FAILED. A 404 without that code comes from an unrouted path or base URL. + const code = + response.status === 404 || response.status === 422 ? await readErrorCode(response) : undefined; + // Unread bodies can hold pooled connections while callers poll. + if (code === undefined) await response.body?.cancel(); + if (response.status === 404 && code === 'NOT_FOUND') throw new OrderNotFoundError(); + if (response.status === 422 && code === 'VALIDATION_FAILED') throw new InvalidOrderIdError(); + throw new UpstreamError(`Failed to load order: upstream returned ${response.status}`, { + unauthorized: response.status === 401 || response.status === 403, + details: { endpoint: endpoint.href, upstreamStatus: response.status, upstreamCode: code ?? undefined }, + }); + } - const data = (await response.json()) as OrderResponse; + let data: OrderResponse; + try { + data = (await response.json()) as OrderResponse; + } catch (cause) { + throw new UpstreamError('Order lookup returned a non-JSON response', { + cause, + details: { endpoint: endpoint.href }, + }); + } const order = data?.order; - if (!order?.id || order.id !== orderId) throw new Error('Order lookup did not return the requested order'); - if (order.context?.storeId !== storeId || order.context?.channelId !== channelId) { - throw new Error('Order lookup returned a different store or channel'); + const context = order?.context; + // A binding missing either field is a malformed upstream response, not proof of another store or channel. + // So is a different order id: the lookup was by id, so it means upstream returned the wrong record. + if ( + !isBindingId(order?.id) || + order.id !== orderId || + !isBindingId(context?.storeId) || + !isBindingId(context?.channelId) + ) { + throw new UpstreamError('Order lookup did not return the requested order', { + details: { endpoint: endpoint.href }, + }); + } + // Report an order bound to another store or channel as missing so the route doesn't reveal it exists. + if (context.storeId !== storeId || context.channelId !== channelId) { + throw new OrderNotFoundError(); } const total = order.totals?.total; return { id: order.id, - status: order.statuses?.paymentStatus ?? 'unknown', + status: order.statuses?.paymentStatus ?? ORDER_STATUS_UNKNOWN, amount: total?.value ?? 0, currency: total?.currencyCode ?? currencyCode, createdAt: order.createdAt, diff --git a/packages/commerce-server/src/lib/commerce/gql.ts b/packages/commerce-server/src/lib/commerce/gql.ts index 25c36d7e..ac5dc578 100644 --- a/packages/commerce-server/src/lib/commerce/gql.ts +++ b/packages/commerce-server/src/lib/commerce/gql.ts @@ -6,6 +6,8 @@ * isomorphic and safe to import from anywhere. */ +import { UpstreamError } from './errors'; + export type GraphQLVariables = Record; export interface GraphQLResponseError { @@ -32,13 +34,14 @@ export interface GqlRequestOptions fetch?: (input: RequestInfo | URL, init?: RequestInit) => Promise; } +/** `status` is the upstream HTTP status; the route responds with `httpStatus` (502) unless mapped. */ export class GraphQLErrorWithCodes< T extends { message?: string; code?: string; status?: number } = { message?: string; code?: string; status?: number; }, -> extends Error { +> extends UpstreamError { constructor( public errors: T[], public status?: number, @@ -51,7 +54,17 @@ export class GraphQLErrorWithCodes< .filter(Boolean) .join('; '); - super(errorMessage); + // Commerce can report auth failures inside an HTTP 200 body via `extensions.(http.)status`. + super(errorMessage, { + unauthorized: [status, ...errors.map((error) => error.status)].some( + (value) => value === 401 || value === 403, + ), + details: { + upstreamStatus: status, + upstreamCodes: errors.map((error) => error.code).filter(Boolean), + upstreamStatuses: errors.map((error) => error.status).filter((value) => typeof value === 'number'), + }, + }); this.name = 'GraphQLErrorWithCodes'; } @@ -99,12 +112,17 @@ export async function gqlRequest; @@ -149,7 +167,7 @@ export async function gqlRequest + status !== undefined && status >= 400 && status !== 404 && status !== 410; + if (isFailure(error.status) || error.errors.length === 0) return false; + + return error.errors.every(({ code, message, status }): boolean => { + if (isFailure(status)) return false; + // Orders throws this exact message for absent or non-draft orders; Apollo + // supplies its generic code rather than a domain-specific not-found code. + if (code === 'INTERNAL_SERVER_ERROR' && message === 'Order not found') return true; + if (code && /^(?:DRAFT[_-]?)?(?:ORDER|CART)[_-]?(?:NOT[_-]?FOUND|EXPIRED)$/i.test(code)) return true; + if (code && !/^(?:NOT[_-]?FOUND|EXPIRED)$/i.test(code)) return false; + return /\b(?:cart|(?:draft[ -])?order)\s+(?:(?:is|was|has)\s+)?(?:not found|expired)\b/i.test( + message ?? '', + ); + }); +} + +interface UpstreamErrorRule { + matches(error: UpstreamError): boolean; + toError(error: UpstreamError): CommerceError; +} + +const CART_UPSTREAM_ERROR_RULES: readonly UpstreamErrorRule[] = [ + { + // A completed (paid) draft is reported the same way, so this is "no longer a usable cart". + matches: isCartNotFoundError, + toError: (error) => new NotFoundError('Cart not found', { cause: error }), + }, +]; + +/** For routes acting on an existing saved cart (`/cart/:id`); creating a cart has none to miss. */ +export function classifyCartUpstreamError(error: UpstreamError): CommerceError { + return CART_UPSTREAM_ERROR_RULES.find((rule) => rule.matches(error))?.toError(error) ?? error; +} diff --git a/packages/commerce-server/src/router.test.ts b/packages/commerce-server/src/router.test.ts index bcd07e95..35c0f43b 100644 --- a/packages/commerce-server/src/router.test.ts +++ b/packages/commerce-server/src/router.test.ts @@ -1,7 +1,7 @@ import type { AddressInfo } from 'node:net'; import type { Request, Response } from 'express'; import express from 'express'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { getCommerceCartScope } from './lib/commerce/cart-scope'; import type { CommerceConfiguration } from './lib/commerce/config'; import { createCheckoutSession } from './lib/commerce/create-checkout-session'; @@ -62,6 +62,10 @@ describe('Commerce scoped routes', () => { vi.clearAllMocks(); }); + afterEach((): void => { + vi.restoreAllMocks(); + }); + it('does not query unsupported status fields on the storefront cart API', (): void => { expect(getCartOrderQuery).not.toMatch(/\bstatuses\s*\{/); }); @@ -173,12 +177,16 @@ describe('Commerce scoped routes', () => { throw new Error('Missing channel'); }, }; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); await configHandler({} as Request, res as unknown as Response); expect(res.status).toHaveBeenCalledWith(503); expect(res.json).toHaveBeenCalledWith({ - error: expect.any(String), - message: 'Missing channel', + error: 'Commerce configuration is unavailable. Complete the store connection before continuing.', + code: 'not_configured', + requestId: expect.any(String), }); + const [, context] = consoleError.mock.calls[0] ?? []; + expect((context as { error: Error }).error.cause).toEqual(new Error('Missing channel')); }); it.each([ @@ -285,14 +293,23 @@ describe('Commerce scoped routes', () => { { code: 'FORBIDDEN', message: 'Access denied', status: 403 }, ]), ])('preserves the cart on unrelated upstream failures: %s', async (error): Promise => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const res = response(); vi.mocked(gqlRequest).mockRejectedValueOnce(error); await readCart( { headers: {}, params: { id: 'cart-1' } } as unknown as Request, res as unknown as Response, ); - expect(res.status).toHaveBeenCalledWith(500); - expect(res.json).toHaveBeenCalledWith({ error: 'Failed to load cart', message: error.message }); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith({ + error: 'Failed to load cart', + code: expect.stringMatching(/^upstream_(?:error|unauthorized)$/), + requestId: expect.any(String), + }); + expect(consoleError).toHaveBeenCalledWith( + 'commerce-server: Failed to load cart', + expect.objectContaining({ httpStatus: 502, error }), + ); }); it.each([ @@ -337,6 +354,184 @@ describe('Commerce scoped routes', () => { }); }, ); + + const scopedRequest = { + headers: {}, + query: {}, + params: { id: 'id-1', itemId: 'item-1' }, + body: { + skuId: 'sku-1', + name: 'Product', + quantity: 1, + discountCodes: ['PROMO'], + lineItems: [{ skuId: 'sku-1', name: 'Product', quantity: 1 }], + }, + } as unknown as Request; + + it.each([ + ['products', readProducts, 'Failed to load products'], + ['product', readProduct, 'Failed to load product'], + ['sku', readSku, 'Failed to load sku'], + ['create cart', createCart, 'Failed to create cart'], + ['read cart', readCart, 'Failed to load cart'], + ['add item', addItem, 'Failed to add line item'], + ['update item', updateItem, 'Failed to update line item'], + ['delete item', deleteItem, 'Failed to delete line item'], + ['discounts', applyDiscount, 'Failed to apply discount codes'], + ['checkout', checkout, 'Failed to create checkout session'], + ] as const)( + 'returns a generic 500 and logs the detail of an unexpected error for %s', + async (_name, handler, label): Promise => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new Error('secret internal detail'); + vi.mocked(gqlRequest).mockRejectedValue(failure); + vi.mocked(createCheckoutSession).mockRejectedValue(failure); + const res = response(); + (res.locals as Record).commerceCheckoutReturnUrlValidator = ( + returnUrl?: string, + successUrl?: string, + ) => ({ returnUrl, successUrl }); + const req = { + ...scopedRequest, + body: { + ...(scopedRequest.body as object), + ...(handler === checkout ? { lineItems: undefined, skuId: 'sku-1' } : {}), + }, + } as unknown as Request; + await handler(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledTimes(1); + expect(res.json).toHaveBeenCalledWith({ + error: label, + code: 'internal_error', + requestId: expect.any(String), + }); + expect(consoleError).toHaveBeenCalledWith( + `commerce-server: ${label}`, + expect.objectContaining({ httpStatus: 500, code: 'internal_error', error: failure }), + ); + }, + ); + + it.each([ + ['products', readProducts, 'Failed to load products'], + ['product', readProduct, 'Failed to load product'], + ['sku', readSku, 'Failed to load sku'], + ['create cart', createCart, 'Failed to create cart'], + ['read cart', readCart, 'Failed to load cart'], + ['add item', addItem, 'Failed to add line item'], + ['update item', updateItem, 'Failed to update line item'], + ['delete item', deleteItem, 'Failed to delete line item'], + ['discounts', applyDiscount, 'Failed to apply discount codes'], + ['checkout', checkout, 'Failed to create checkout session'], + ] as const)( + 'returns 502 without upstream detail for an upstream failure in %s', + async (_name, handler, label): Promise => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const failure = new GraphQLErrorWithCodes( + [{ message: 'secret-upstream-detail', code: 'INTERNAL_SERVER_ERROR' }], + 500, + ); + vi.mocked(gqlRequest).mockRejectedValue(failure); + vi.mocked(createCheckoutSession).mockRejectedValue(failure); + const res = response(); + (res.locals as Record).commerceCheckoutReturnUrlValidator = ( + returnUrl?: string, + successUrl?: string, + ) => ({ returnUrl, successUrl }); + const req = { + ...scopedRequest, + body: { + ...(scopedRequest.body as object), + ...(handler === checkout ? { lineItems: undefined, skuId: 'sku-1' } : {}), + }, + } as unknown as Request; + await handler(req, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith({ + error: label, + code: 'upstream_error', + requestId: expect.any(String), + }); + expect(JSON.stringify(vi.mocked(res.json).mock.calls)).not.toContain('secret-upstream-detail'); + expect(consoleError).toHaveBeenCalledWith( + `commerce-server: ${label}`, + expect.objectContaining({ + httpStatus: 502, + details: expect.objectContaining({ upstreamStatus: 500, upstreamCodes: ['INTERNAL_SERVER_ERROR'] }), + }), + ); + }, + ); + + it.each([ + ['add item', addItem], + ['update item', updateItem], + ['delete item', deleteItem], + ['discounts', applyDiscount], + ] as const)( + 'returns 404 when %s targets a missing or completed cart', + async (_name, handler): Promise => { + vi.mocked(gqlRequest).mockRejectedValueOnce( + new GraphQLErrorWithCodes([{ code: 'INTERNAL_SERVER_ERROR', message: 'Order not found' }]), + ); + const res = response(); + await handler(scopedRequest, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: 'Cart not found', + code: 'not_found', + requestId: expect.any(String), + }); + }, + ); + + // The cart-not-found rule only applies to routes acting on an existing saved cart. + it.each([ + ['products', readProducts, []], + ['create cart (adding the first item)', createCart, [{ addDraftOrder: { id: 'cart-new' } }]], + ] as const)( + 'returns 502, not cart not found, when %s receives "Order not found"', + async (_name, handler, before): Promise => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + for (const result of before) vi.mocked(gqlRequest).mockResolvedValueOnce(result); + vi.mocked(gqlRequest).mockRejectedValueOnce( + new GraphQLErrorWithCodes([{ code: 'INTERNAL_SERVER_ERROR', message: 'Order not found' }]), + ); + const res = response(); + await handler(scopedRequest, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'upstream_error' })); + }, + ); + + it('reports a mixed not-found and embedded 403 as upstream_unauthorized without clearing the cart', async (): Promise => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(gqlRequest).mockRejectedValueOnce( + new GraphQLErrorWithCodes([ + { code: 'ORDER_NOT_FOUND', message: 'Order not found' }, + { code: 'FORBIDDEN', message: 'Access denied', status: 403 }, + ]), + ); + const res = response(); + await readCart( + { headers: {}, params: { id: 'cart-1' } } as unknown as Request, + res as unknown as Response, + ); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'upstream_unauthorized' })); + }); + + it('reports an upstream 401 as upstream_unauthorized, not a caller authentication failure', async (): Promise => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(gqlRequest).mockRejectedValueOnce( + new GraphQLErrorWithCodes([{ code: 'UNAUTHENTICATED', message: 'Bad client' }], 401), + ); + const res = response(); + await readProducts(scopedRequest, res as unknown as Response); + expect(res.status).toHaveBeenCalledWith(502); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'upstream_unauthorized' })); + }); }); describe('Commerce router mounting', (): void => { @@ -379,4 +574,111 @@ describe('Commerce router mounting', (): void => { }); } }); + + async function requestThroughRouter( + router: express.Router, + path: string, + headers: Record = {}, + ): Promise { + const app = express(); + app.use('/api/commerce', router); + const server = app.listen(0); + try { + const address = server.address() as AddressInfo; + return await fetch(`http://127.0.0.1:${address.port}/api/commerce${path}`, { headers }); + } finally { + await new Promise((resolve, reject): void => { + server.close((error?: Error): void => { + if (error) reject(error); + else resolve(); + }); + }); + } + } + + const failingConfiguration: CommerceConfiguration = { + ...configuration, + read: (): never => { + throw new Error('secret config detail'); + }, + }; + + it('logs failures through the host logger with the response request id', async (): Promise => { + const logger = { error: vi.fn() }; + const result = await requestThroughRouter( + createCommerceCatalogRouter(failingConfiguration, { logger }), + '/products', + ); + const body = (await result.json()) as { requestId: string }; + expect(result.status).toBe(503); + expect(JSON.stringify(body)).not.toContain('secret config detail'); + expect(logger.error).toHaveBeenCalledWith( + 'commerce-server: Failed to load products', + expect.objectContaining({ requestId: body.requestId, httpStatus: 503, code: 'not_configured' }), + ); + }); + + it('still sends the standard failure body when the host logger throws', async (): Promise => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const loggerError = new Error('logger down'); + const logger = { + error: (): never => { + throw loggerError; + }, + }; + const result = await requestThroughRouter( + createCommerceCatalogRouter(failingConfiguration, { logger }), + '/products', + ); + expect(result.status).toBe(503); + await expect(result.json()).resolves.toEqual({ + error: 'Commerce configuration is unavailable. Complete the store connection before continuing.', + code: 'not_configured', + requestId: expect.any(String), + }); + expect(consoleError).toHaveBeenCalledWith( + 'commerce-server: Failed to load products (host logger failed)', + expect.objectContaining({ httpStatus: 503, loggerError }), + ); + }); + + it('falls back to a generated id when getRequestId throws', async (): Promise => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = await requestThroughRouter( + createCommerceCatalogRouter(failingConfiguration, { + getRequestId: (req): string => (req.get('x-request-id') as string).trim(), + }), + '/config', + ); + expect(result.status).toBe(503); + const { requestId } = (await result.json()) as { requestId: string }; + expect(requestId).toMatch(/^[0-9a-f-]{36}$/); + }); + + it('does not resolve a request id for a successful request', async (): Promise => { + const getRequestId = vi.fn((): string => 'edge-123'); + const result = await requestThroughRouter( + createCommerceCatalogRouter(configuration, { getRequestId }), + '/config', + ); + expect(result.status).toBe(200); + expect(getRequestId).not.toHaveBeenCalled(); + }); + + it.each([ + ['a valid host id', 'edge-123', 'edge-123'], + ['an id with unsafe characters', 'id with spaces; level=error', undefined], + ])('uses %s from getRequestId when it is safe', async (_case, supplied, expected): Promise => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = await requestThroughRouter( + createCommerceCatalogRouter(failingConfiguration, { + getRequestId: (req) => req.get('x-request-id'), + }), + '/config', + { 'x-request-id': supplied }, + ); + const { requestId } = (await result.json()) as { requestId: string }; + if (expected) expect(requestId).toBe(expected); + else expect(requestId).toMatch(/^[0-9a-f-]{36}$/); + }); }); diff --git a/packages/commerce-server/src/router.ts b/packages/commerce-server/src/router.ts index ede55f5a..93508768 100644 --- a/packages/commerce-server/src/router.ts +++ b/packages/commerce-server/src/router.ts @@ -3,6 +3,12 @@ import { type CheckoutReturnUrls, createCheckoutReturnUrlValidator, } from './lib/commerce/checkout-return-urls'; +import { + type CommerceLogger, + type CommerceObservabilityLocals, + type CommerceRequestIdResolver, + consoleCommerceLogger, +} from './lib/commerce/commerce-route'; import { type CommerceConfiguration, createRuntimeCommerceConfiguration } from './lib/commerce/config'; import cartDiscountPost from './server/api/commerce/cart/[id]/discounts/POST'; import cartGet from './server/api/commerce/cart/[id]/GET'; @@ -22,7 +28,18 @@ export interface CommerceRouterFeatures { payments?: boolean; } -export interface CreateCommerceRouterOptions { +/** Where failure detail is logged and how each request's id is chosen. */ +export interface CommerceRouterObservabilityOptions { + /** Receives 5xx failure detail. Defaults to `console.error`. */ + logger?: CommerceLogger; + /** + * Returns the host's id for the request, for example `(req) => req.get('x-request-id')` when + * the host's edge sets that header. Invalid or missing ids fall back to a random UUID. + */ + getRequestId?: CommerceRequestIdResolver; +} + +export interface CreateCommerceRouterOptions extends CommerceRouterObservabilityOptions { configuration?: CommerceConfiguration; features?: CommerceRouterFeatures; /** Required to enable browser checkout; never derived from request headers. */ @@ -35,11 +52,18 @@ export function createCommerceRouter(options: CreateCommerceRouterOptions = {}): const catalogEnabled: boolean = options.features?.catalog ?? true; const paymentsEnabled: boolean = options.features?.payments ?? true; + const logger: CommerceLogger = options.logger ?? consoleCommerceLogger; + const validateCheckoutReturnUrls = options.checkoutReturnUrls ? createCheckoutReturnUrlValidator(options.checkoutReturnUrls) : undefined; router.use((_req, res, next): void => { + const observability: CommerceObservabilityLocals = { + commerceLogger: logger, + commerceRequestIdResolver: options.getRequestId, + }; + Object.assign(res.locals, observability); res.locals.commerceConfiguration = configuration; res.locals.commerceCheckoutReturnUrlValidator = validateCheckoutReturnUrls; next(); @@ -66,15 +90,24 @@ export function createCommerceRouter(options: CreateCommerceRouterOptions = {}): return router; } -export function createCommerceCatalogRouter(configuration?: CommerceConfiguration): Router { - return createCommerceRouter({ configuration, features: { catalog: true, payments: false } }); +export function createCommerceCatalogRouter( + configuration?: CommerceConfiguration, + observability: CommerceRouterObservabilityOptions = {}, +): Router { + return createCommerceRouter({ + ...observability, + configuration, + features: { catalog: true, payments: false }, + }); } export function createGoDaddyPaymentsRouter( configuration?: CommerceConfiguration, checkoutReturnUrls?: CheckoutReturnUrls, + observability: CommerceRouterObservabilityOptions = {}, ): Router { return createCommerceRouter({ + ...observability, configuration, checkoutReturnUrls, features: { catalog: false, payments: true }, diff --git a/packages/commerce-server/src/server/api/commerce/cart/POST.ts b/packages/commerce-server/src/server/api/commerce/cart/POST.ts index c249a905..0d94b4fb 100644 --- a/packages/commerce-server/src/server/api/commerce/cart/POST.ts +++ b/packages/commerce-server/src/server/api/commerce/cart/POST.ts @@ -25,8 +25,10 @@ * GraphQL field names like `addDraftOrder` or `orderById`. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { InvalidRequestError, UpstreamError } from '@/lib/commerce/errors'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; import { type AddCartOrderResult, @@ -63,81 +65,74 @@ interface CreateCartBody { lineItems?: AddToCartItemInput[]; } -export default async function handler(req: Request, res: Response): Promise { - try { - const body = (req.body ?? {}) as CreateCartBody; - const initialItems = Array.isArray(body.lineItems) ? body.lineItems : []; +async function createCart(req: Request, res: Response): Promise { + const body = (req.body ?? {}) as CreateCartBody; + const initialItems = Array.isArray(body.lineItems) ? body.lineItems : []; - // Validate all items BEFORE creating the cart so a bad payload can't - // produce an orphaned cart (cart created, item add fails, client never - // gets the id). Mirrors the validation in POST /api/commerce/cart/:id/items. - for (const item of initialItems) { - if (!item.skuId || !item.name || typeof item.quantity !== 'number') { - res.status(400).json({ - error: 'Each lineItem must have skuId, a non-empty name, and a numeric quantity', - }); - return; - } + // Validate all items BEFORE creating the cart so a bad payload can't + // produce an orphaned cart (cart created, item add fails, client never + // gets the id). Mirrors the validation in POST /api/commerce/cart/:id/items. + for (const item of initialItems) { + if (!item.skuId || !item.name || typeof item.quantity !== 'number') { + throw new InvalidRequestError( + 'Each lineItem must have skuId, a non-empty name, and a numeric quantity', + ); } + } - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, channelId, clientId, apiBaseUrl, currencyCode } = config; + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, channelId, clientId, apiBaseUrl, currencyCode } = config; - const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); - const headers = storefrontHeaders({ storeId, clientId }); + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); - // Step 1: create an EMPTY cart. Do not pass `lineItems` here — the - // `addDraftOrder` mutation expects `CreateDraftLineItemInput` (with - // server-priced `totals` + `unitAmount`), which the client cannot - // produce. SKU-based adds must go through `addLineItemBySkuId`. - const created = await gqlRequest({ - endpoint, - query: addCartOrderMutation, - variables: { - input: buildEmptyCartOrderInput({ - storeId, - channelId, - owner: config.owner, - currencyCode: body.currencyCode ?? currencyCode, - }), - }, - headers, - }); + // Step 1: create an EMPTY cart. Do not pass `lineItems` here — the + // `addDraftOrder` mutation expects `CreateDraftLineItemInput` (with + // server-priced `totals` + `unitAmount`), which the client cannot + // produce. SKU-based adds must go through `addLineItemBySkuId`. + const created = await gqlRequest({ + endpoint, + query: addCartOrderMutation, + variables: { + input: buildEmptyCartOrderInput({ + storeId, + channelId, + owner: config.owner, + currencyCode: body.currencyCode ?? currencyCode, + }), + }, + headers, + }); - const newCartId = created.addDraftOrder?.id; - if (!newCartId) { - res.status(500).json({ error: 'Failed to create cart: missing id in mutation response' }); - return; - } - - // Step 2: append each initial SKU one-by-one via `addLineItemBySkuId`. - // Sequential, not parallel — same-cart line item adds are not safe to - // race on the server side. - for (const item of initialItems) { - await gqlRequest({ - endpoint, - query: addLineItemBySkuIdMutation, - variables: { input: buildAddLineItemBySkuIdInput(newCartId, item) }, - headers, - }); - } + const newCartId = created.addDraftOrder?.id; + if (!newCartId) { + throw new UpstreamError('addDraftOrder response did not include a cart id'); + } - // Step 3: re-hydrate the cart and return it under the canonical `cart` - // key. Clients always read `data.cart` regardless of which cart route - // they called. - const hydrated = await gqlRequest({ + // Step 2: append each initial SKU one-by-one via `addLineItemBySkuId`. + // Sequential, not parallel — same-cart line item adds are not safe to + // race on the server side. + for (const item of initialItems) { + await gqlRequest({ endpoint, - query: getCartOrderQuery, - variables: { id: newCartId }, + query: addLineItemBySkuIdMutation, + variables: { input: buildAddLineItemBySkuIdInput(newCartId, item) }, headers, }); - - res.status(201).json({ cart: hydrated.orderById ?? null }); - } catch (error) { - res.status(500).json({ - error: 'Failed to create cart', - message: error instanceof Error ? error.message : String(error), - }); } + + // Step 3: re-hydrate the cart and return it under the canonical `cart` + // key. Clients always read `data.cart` regardless of which cart route + // they called. + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: newCartId }, + headers, + }); + + res.status(201).json({ cart: hydrated.orderById ?? null }); } + +export default commerceRoute('Failed to create cart', createCart); diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/GET.ts index 436a2900..78200f4f 100644 --- a/packages/commerce-server/src/server/api/commerce/cart/[id]/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/GET.ts @@ -16,72 +16,51 @@ * and clear the persisted `draftOrderId` when that is the case. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; -import { GraphQLErrorWithCodes, gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; +import { InvalidRequestError } from '@/lib/commerce/errors'; +import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; import { type GetCartOrderResult, type GetCartOrderVariables, getCartOrderQuery, orderStorefrontEndpoint, } from '@/lib/commerce/order-subgraph'; +import { classifyCartUpstreamError, isCartNotFoundError } from '@/lib/commerce/upstream-errors'; -function isCartNotFoundError(error: unknown): boolean { - if (!(error instanceof GraphQLErrorWithCodes)) { - return false; +async function readCart(req: Request, res: Response): Promise { + const cartId: unknown = req.params.id; + if (typeof cartId !== 'string' || !cartId) { + throw new InvalidRequestError('Missing cart id'); } - // A transport failure or an unrelated GraphQL error must not erase a saved cart. - // A bare HTTP 404 can also mean the upstream endpoint itself is unavailable. - const isFailure = (status: number | undefined): boolean => - status !== undefined && status >= 400 && status !== 404 && status !== 410; - if (isFailure(error.status) || error.errors.length === 0) return false; + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; - return error.errors.every(({ code, message, status }): boolean => { - if (isFailure(status)) return false; - // Orders throws this exact message for absent or non-draft orders; Apollo - // supplies its generic code rather than a domain-specific not-found code. - if (code === 'INTERNAL_SERVER_ERROR' && message === 'Order not found') return true; - if (code && /^(?:DRAFT[_-]?)?(?:ORDER|CART)[_-]?(?:NOT[_-]?FOUND|EXPIRED)$/i.test(code)) return true; - if (code && !/^(?:NOT[_-]?FOUND|EXPIRED)$/i.test(code)) return false; - return /\b(?:cart|(?:draft[ -])?order)\s+(?:(?:is|was|has)\s+)?(?:not found|expired)\b/i.test( - message ?? '', - ); - }); -} - -export default async function handler(req: Request, res: Response): Promise { + let data: GetCartOrderResult; try { - const cartId: unknown = req.params.id; - if (typeof cartId !== 'string' || !cartId) { - res.status(400).json({ error: 'Missing cart id' }); - return; - } - - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; - - const data = await gqlRequest({ + data = await gqlRequest({ endpoint: orderStorefrontEndpoint({ apiBaseUrl }), query: getCartOrderQuery, variables: { id: cartId }, headers: storefrontHeaders({ storeId, clientId }), }); - - // Normalise to a stable `cart` key so client code never has to know that - // the underlying GraphQL field is `orderById`. See docblock at the top - // of this file. - res.json({ cart: data.orderById ?? null }); } catch (error) { if (isCartNotFoundError(error)) { res.status(200).json({ cart: null }); return; } - - res.status(500).json({ - error: 'Failed to load cart', - message: error instanceof Error ? error.message : String(error), - }); + throw error; } + + // Normalise to a stable `cart` key so client code never has to know that + // the underlying GraphQL field is `orderById`. See docblock at the top + // of this file. + res.json({ cart: data.orderById ?? null }); } + +export default commerceRoute('Failed to load cart', readCart, { + classifyUpstreamError: classifyCartUpstreamError, +}); diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/discounts/POST.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/discounts/POST.ts index d995e73b..c0ae58e4 100644 --- a/packages/commerce-server/src/server/api/commerce/cart/[id]/discounts/POST.ts +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/discounts/POST.ts @@ -14,8 +14,10 @@ * read one shape. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { InvalidRequestError } from '@/lib/commerce/errors'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; import { type ApplyDiscountCodesResult, @@ -25,6 +27,7 @@ import { getCartOrderQuery, orderStorefrontEndpoint, } from '@/lib/commerce/order-subgraph'; +import { classifyCartUpstreamError } from '@/lib/commerce/upstream-errors'; const applyDiscountCodesMutation = ` mutation ApplyDiscountCodes($input: ApplyDiscountCodesInput!) { @@ -38,49 +41,44 @@ interface ApplyDiscountsBody { discountCodes?: unknown; } -export default async function handler(req: Request, res: Response): Promise { - try { - const cartId: unknown = req.params.id; - if (typeof cartId !== 'string' || !cartId) { - res.status(400).json({ error: 'Missing cart id' }); - return; - } +async function applyDiscountCodes(req: Request, res: Response): Promise { + const cartId: unknown = req.params.id; + if (typeof cartId !== 'string' || !cartId) { + throw new InvalidRequestError('Missing cart id'); + } - const body = (req.body ?? {}) as ApplyDiscountsBody; - const codes = Array.isArray(body.discountCodes) - ? body.discountCodes.filter((code): code is string => typeof code === 'string' && code.length > 0) - : []; + const body = (req.body ?? {}) as ApplyDiscountsBody; + const codes = Array.isArray(body.discountCodes) + ? body.discountCodes.filter((code): code is string => typeof code === 'string' && code.length > 0) + : []; - if (codes.length === 0) { - res.status(400).json({ error: 'discountCodes must be a non-empty string array' }); - return; - } + if (codes.length === 0) { + throw new InvalidRequestError('discountCodes must be a non-empty string array'); + } - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; - const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); - const headers = storefrontHeaders({ storeId, clientId }); + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); - await gqlRequest({ - endpoint, - query: applyDiscountCodesMutation, - variables: { input: { orderId: cartId, discountCodes: codes } }, - headers, - }); + await gqlRequest({ + endpoint, + query: applyDiscountCodesMutation, + variables: { input: { orderId: cartId, discountCodes: codes } }, + headers, + }); - const hydrated = await gqlRequest({ - endpoint, - query: getCartOrderQuery, - variables: { id: cartId }, - headers, - }); + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); - res.json({ cart: hydrated.orderById ?? null }); - } catch (error) { - res.status(500).json({ - error: 'Failed to apply discount codes', - message: error instanceof Error ? error.message : String(error), - }); - } + res.json({ cart: hydrated.orderById ?? null }); } + +export default commerceRoute('Failed to apply discount codes', applyDiscountCodes, { + classifyUpstreamError: classifyCartUpstreamError, +}); diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/POST.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/POST.ts index cc09744d..0731bff9 100644 --- a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/POST.ts +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/POST.ts @@ -15,8 +15,10 @@ * GET /api/commerce/cart/:id so clients only ever read one shape. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { InvalidRequestError } from '@/lib/commerce/errors'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; import { type AddLineItemBySkuIdResult, @@ -28,6 +30,7 @@ import { getCartOrderQuery, orderStorefrontEndpoint, } from '@/lib/commerce/order-subgraph'; +import { classifyCartUpstreamError } from '@/lib/commerce/upstream-errors'; const addLineItemBySkuIdMutation = ` mutation AddLineItemBySkuId($input: AddLineItemInput!) { @@ -37,54 +40,49 @@ const addLineItemBySkuIdMutation = ` } `; -export default async function handler(req: Request, res: Response): Promise { - try { - const cartId: unknown = req.params.id; - if (typeof cartId !== 'string' || !cartId) { - res.status(400).json({ error: 'Missing cart id' }); - return; - } +async function addLineItem(req: Request, res: Response): Promise { + const cartId: unknown = req.params.id; + if (typeof cartId !== 'string' || !cartId) { + throw new InvalidRequestError('Missing cart id'); + } - const body = (req.body ?? {}) as Partial; - if (!body.skuId || !body.name || typeof body.quantity !== 'number') { - res.status(400).json({ error: 'Missing required fields: skuId, name, quantity' }); - return; - } + const body = (req.body ?? {}) as Partial; + if (!body.skuId || !body.name || typeof body.quantity !== 'number') { + throw new InvalidRequestError('Missing required fields: skuId, name, quantity'); + } - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; - const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); - const headers = storefrontHeaders({ storeId, clientId }); + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); - // Mutation only returns the new CartLineItem (no order totals). Discard - // it and re-fetch the full cart so the response matches the shape of - // GET /api/commerce/cart/:id. - await gqlRequest({ - endpoint, - query: addLineItemBySkuIdMutation, - variables: { - input: buildAddLineItemBySkuIdInput(cartId, { - skuId: body.skuId, - name: body.name, - quantity: body.quantity, - }), - }, - headers, - }); + // Mutation only returns the new CartLineItem (no order totals). Discard + // it and re-fetch the full cart so the response matches the shape of + // GET /api/commerce/cart/:id. + await gqlRequest({ + endpoint, + query: addLineItemBySkuIdMutation, + variables: { + input: buildAddLineItemBySkuIdInput(cartId, { + skuId: body.skuId, + name: body.name, + quantity: body.quantity, + }), + }, + headers, + }); - const hydrated = await gqlRequest({ - endpoint, - query: getCartOrderQuery, - variables: { id: cartId }, - headers, - }); + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); - res.status(201).json({ cart: hydrated.orderById ?? null }); - } catch (error) { - res.status(500).json({ - error: 'Failed to add line item', - message: error instanceof Error ? error.message : String(error), - }); - } + res.status(201).json({ cart: hydrated.orderById ?? null }); } + +export default commerceRoute('Failed to add line item', addLineItem, { + classifyUpstreamError: classifyCartUpstreamError, +}); diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/DELETE.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/DELETE.ts index 8ea0adb6..ac48726d 100644 --- a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/DELETE.ts +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/DELETE.ts @@ -11,8 +11,10 @@ * GET /api/commerce/cart/:id so clients only ever read one shape. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { InvalidRequestError } from '@/lib/commerce/errors'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; import { type DeleteLineItemByIdResult, @@ -22,6 +24,7 @@ import { getCartOrderQuery, orderStorefrontEndpoint, } from '@/lib/commerce/order-subgraph'; +import { classifyCartUpstreamError } from '@/lib/commerce/upstream-errors'; const deleteLineItemByIdMutation = ` mutation DeleteLineItemById($id: ID!, $orderId: ID!) { @@ -29,40 +32,36 @@ const deleteLineItemByIdMutation = ` } `; -export default async function handler(req: Request, res: Response): Promise { - try { - const cartId: unknown = req.params.id; - const itemId: unknown = req.params.itemId; - if (typeof cartId !== 'string' || !cartId || typeof itemId !== 'string' || !itemId) { - res.status(400).json({ error: 'Missing cart id or item id' }); - return; - } +async function deleteLineItem(req: Request, res: Response): Promise { + const cartId: unknown = req.params.id; + const itemId: unknown = req.params.itemId; + if (typeof cartId !== 'string' || !cartId || typeof itemId !== 'string' || !itemId) { + throw new InvalidRequestError('Missing cart id or item id'); + } - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; - const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); - const headers = storefrontHeaders({ storeId, clientId }); + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); - await gqlRequest({ - endpoint, - query: deleteLineItemByIdMutation, - variables: { id: itemId, orderId: cartId }, - headers, - }); + await gqlRequest({ + endpoint, + query: deleteLineItemByIdMutation, + variables: { id: itemId, orderId: cartId }, + headers, + }); - const hydrated = await gqlRequest({ - endpoint, - query: getCartOrderQuery, - variables: { id: cartId }, - headers, - }); + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); - res.json({ cart: hydrated.orderById ?? null }); - } catch (error) { - res.status(500).json({ - error: 'Failed to delete line item', - message: error instanceof Error ? error.message : String(error), - }); - } + res.json({ cart: hydrated.orderById ?? null }); } + +export default commerceRoute('Failed to delete line item', deleteLineItem, { + classifyUpstreamError: classifyCartUpstreamError, +}); diff --git a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/PATCH.ts b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/PATCH.ts index e77360b6..38ed496f 100644 --- a/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/PATCH.ts +++ b/packages/commerce-server/src/server/api/commerce/cart/[id]/items/[itemId]/PATCH.ts @@ -14,8 +14,10 @@ * clients only ever read one shape. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { InvalidRequestError } from '@/lib/commerce/errors'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; import { type GetCartOrderResult, @@ -26,6 +28,7 @@ import { type UpdateLineItemByIdResult, type UpdateLineItemByIdVariables, } from '@/lib/commerce/order-subgraph'; +import { classifyCartUpstreamError } from '@/lib/commerce/upstream-errors'; const updateLineItemByIdMutation = ` mutation UpdateLineItemById($input: UpdateLineItemByIdInput!) { @@ -37,52 +40,48 @@ const updateLineItemByIdMutation = ` type UpdateLineItemBody = Omit; -export default async function handler(req: Request, res: Response): Promise { - try { - const cartId: unknown = req.params.id; - const itemId: unknown = req.params.itemId; - if (typeof cartId !== 'string' || !cartId || typeof itemId !== 'string' || !itemId) { - res.status(400).json({ error: 'Missing cart id or item id' }); - return; - } +async function updateLineItem(req: Request, res: Response): Promise { + const cartId: unknown = req.params.id; + const itemId: unknown = req.params.itemId; + if (typeof cartId !== 'string' || !cartId || typeof itemId !== 'string' || !itemId) { + throw new InvalidRequestError('Missing cart id or item id'); + } - const body = (req.body ?? {}) as UpdateLineItemBody; - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; - const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); - const headers = storefrontHeaders({ storeId, clientId }); + const body = (req.body ?? {}) as UpdateLineItemBody; + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; + const endpoint = orderStorefrontEndpoint({ apiBaseUrl }); + const headers = storefrontHeaders({ storeId, clientId }); - await gqlRequest({ - endpoint, - query: updateLineItemByIdMutation, - variables: { - input: { - id: itemId, - orderId: cartId, - name: body.name, - quantity: body.quantity, - fulfillmentMode: body.fulfillmentMode, - status: body.status, - type: body.type, - details: body.details, - }, + await gqlRequest({ + endpoint, + query: updateLineItemByIdMutation, + variables: { + input: { + id: itemId, + orderId: cartId, + name: body.name, + quantity: body.quantity, + fulfillmentMode: body.fulfillmentMode, + status: body.status, + type: body.type, + details: body.details, }, - headers, - }); + }, + headers, + }); - const hydrated = await gqlRequest({ - endpoint, - query: getCartOrderQuery, - variables: { id: cartId }, - headers, - }); + const hydrated = await gqlRequest({ + endpoint, + query: getCartOrderQuery, + variables: { id: cartId }, + headers, + }); - res.json({ cart: hydrated.orderById ?? null }); - } catch (error) { - res.status(500).json({ - error: 'Failed to update line item', - message: error instanceof Error ? error.message : String(error), - }); - } + res.json({ cart: hydrated.orderById ?? null }); } + +export default commerceRoute('Failed to update line item', updateLineItem, { + classifyUpstreamError: classifyCartUpstreamError, +}); diff --git a/packages/commerce-server/src/server/api/commerce/checkout/POST.ts b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts index 42dd56c0..4ea8448e 100644 --- a/packages/commerce-server/src/server/api/commerce/checkout/POST.ts +++ b/packages/commerce-server/src/server/api/commerce/checkout/POST.ts @@ -32,81 +32,73 @@ * * Response: { url, id, draftOrderId, storeId, channelId, businessId, * storeName, sourceApp } from the created checkout session. The route returns - * 500 if checkout-api does not preserve the configured store/channel binding. + * 502 (`upstream_error`) if checkout-api does not preserve the configured + * store/channel binding or the requested checkout settings. * Browser callers should redirect to `response.url`; this route does not * return a `redirectUrl` field. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; import type { CheckoutReturnUrlValidator } from '@/lib/commerce/checkout-return-urls'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; - import { type CreateCheckoutSessionParams, createCheckoutSession, } from '@/lib/commerce/create-checkout-session'; +import { CommerceNotConfiguredError, InvalidRequestError } from '@/lib/commerce/errors'; type CheckoutBody = Partial; -export default async function handler(req: Request, res: Response): Promise { - try { - const configuration = commerceConfigurationForResponse(res); - const body = (req.body ?? {}) as CheckoutBody; - const { draftOrderId, skuId, quantity, lineItemData, returnUrl, successUrl } = body; - - if (lineItemData !== undefined) { - res.status(400).json({ error: 'Non-catalog checkout must be created by the server.' }); - return; - } - - const checkoutSourceCount = [draftOrderId, skuId].filter(Boolean).length; - if ( - checkoutSourceCount !== 1 || - [draftOrderId, skuId].some( - (value) => value !== undefined && (typeof value !== 'string' || !value.trim()), - ) - ) { - res.status(400).json({ error: 'exactly one non-empty draftOrderId or skuId is required' }); - return; - } +async function createCheckout(req: Request, res: Response): Promise { + const configuration = commerceConfigurationForResponse(res); + const body = (req.body ?? {}) as CheckoutBody; + const { draftOrderId, skuId, quantity, lineItemData, returnUrl, successUrl } = body; - if (quantity !== undefined && (!Number.isSafeInteger(quantity) || quantity < 1)) { - res.status(400).json({ error: 'quantity must be a positive whole number' }); - return; - } + if (lineItemData !== undefined) { + throw new InvalidRequestError('Non-catalog checkout must be created by the server.'); + } - if (req.headers?.['x-commerce-scope'] !== undefined) { - const config: CommerceConfig = configuration.read(); - if (!validateCommerceCartScope(req, res, config)) return; - } + const checkoutSourceCount = [draftOrderId, skuId].filter(Boolean).length; + if ( + checkoutSourceCount !== 1 || + [draftOrderId, skuId].some((value) => value !== undefined && (typeof value !== 'string' || !value.trim())) + ) { + throw new InvalidRequestError('exactly one non-empty draftOrderId or skuId is required'); + } - const validateReturnUrls: CheckoutReturnUrlValidator | undefined = - res.locals.commerceCheckoutReturnUrlValidator; - if (!validateReturnUrls) { - res.status(503).json({ error: 'Checkout return destinations are not configured.' }); - return; - } - const destinations = validateReturnUrls(returnUrl, successUrl); - if (!destinations) { - res.status(400).json({ error: 'Checkout return destinations are not allowed.' }); - return; - } + if (quantity !== undefined && (!Number.isSafeInteger(quantity) || quantity < 1)) { + throw new InvalidRequestError('quantity must be a positive whole number'); + } - const session = await createCheckoutSession( - { - draftOrderId, - skuId, - quantity, - ...destinations, - }, - configuration, - ); + if (req.headers?.['x-commerce-scope'] !== undefined) { + const config: CommerceConfig = configuration.read(); + assertCommerceCartScope(req, config); + } - res.status(200).json(session); - } catch (error) { - res.status(500).json({ - error: 'Failed to create checkout session', - message: error instanceof Error ? error.message : String(error), + const validateReturnUrls: CheckoutReturnUrlValidator | undefined = + res.locals.commerceCheckoutReturnUrlValidator; + if (!validateReturnUrls) { + throw new CommerceNotConfiguredError('Checkout return URLs are not configured on the router.', { + publicMessage: 'Checkout return destinations are not configured.', }); } + const destinations = validateReturnUrls(returnUrl, successUrl); + if (!destinations) { + throw new InvalidRequestError('Checkout return destinations are not allowed.'); + } + + const session = await createCheckoutSession( + { + draftOrderId, + skuId, + quantity, + ...destinations, + }, + configuration, + ); + + res.status(200).json(session); } + +export default commerceRoute('Failed to create checkout session', createCheckout); diff --git a/packages/commerce-server/src/server/api/commerce/config/GET.ts b/packages/commerce-server/src/server/api/commerce/config/GET.ts index 3169634f..8c5f9df7 100644 --- a/packages/commerce-server/src/server/api/commerce/config/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/config/GET.ts @@ -5,21 +5,16 @@ */ import type { Request, Response } from 'express'; import { getCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, commerceConfigurationForResponse } from '@/lib/commerce/config'; -export default async function handler(_req: Request, res: Response): Promise { +async function readPublicConfig(_req: Request, res: Response): Promise { res.setHeader('Cache-Control', 'no-store'); - try { - const configuration = commerceConfigurationForResponse(res); - const config: CommerceConfig = configuration.read(); - res.json({ - cartScope: getCommerceCartScope(config), - currencyCode: config.currencyCode, - }); - } catch (cause: unknown) { - res.status(503).json({ - error: 'Commerce configuration is unavailable. Complete the store connection before continuing.', - message: cause instanceof Error ? cause.message : 'Invalid Commerce configuration.', - }); - } + const config: CommerceConfig = commerceConfigurationForResponse(res).read(); + res.json({ + cartScope: getCommerceCartScope(config), + currencyCode: config.currencyCode, + }); } + +export default commerceRoute('Failed to load Commerce configuration', readPublicConfig); diff --git a/packages/commerce-server/src/server/api/commerce/order-status/GET.ts b/packages/commerce-server/src/server/api/commerce/order-status/GET.ts index 797f5404..8841b0e6 100644 --- a/packages/commerce-server/src/server/api/commerce/order-status/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/order-status/GET.ts @@ -11,30 +11,33 @@ * Query: * orderId - GoDaddy order id (required) * - * Response: { success: true, order: CommerceOrderStatus } - * order.status is the payment status returned by the authorized Orders API. + * Responses: + * 200 { success: true, order: CommerceOrderStatus } + * order.status is the payment status returned by the authorized Orders API. + * 400 missing, blank, padded, `.` or `..` orderId, or one the Orders API rejects as malformed + * 404 no order with that id in the configured store and channel + * 502 Commerce or token failure (`code: upstream_error | upstream_unauthorized`) + * 503 Commerce is not configured + * 500 unexpected server failure + * Failure bodies are { success: false, error, code, requestId }. * The host must authorize the caller's access to the requested order. */ import type { Request, Response } from 'express'; - +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { commerceConfigurationForResponse } from '@/lib/commerce/config'; -import { getOrderStatus } from '@/lib/commerce/get-order-status'; - -export default async function handler(req: Request, res: Response): Promise { - try { - const { orderId } = req.query; - if (!orderId || typeof orderId !== 'string') { - res.status(400).json({ success: false, error: 'missing or invalid orderId query parameter' }); - return; - } +import { getOrderStatus, InvalidOrderIdError } from '@/lib/commerce/get-order-status'; - const order = await getOrderStatus(orderId, commerceConfigurationForResponse(res)); - res.status(200).json({ success: true, order }); - } catch (error) { - res.status(500).json({ - success: false, - error: 'Failed to get order status', - message: error instanceof Error ? error.message : String(error), - }); +async function readOrderStatus(req: Request, res: Response): Promise { + const { orderId } = req.query; + // getOrderStatus validates string IDs; only repeated or nested query values need rejecting here. + if (typeof orderId !== 'string') { + throw new InvalidOrderIdError(); } + + const order = await getOrderStatus(orderId, commerceConfigurationForResponse(res)); + res.status(200).json({ success: true, order }); } + +export default commerceRoute('Failed to get order status', readOrderStatus, { + failureFields: { success: false }, +}); 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 e2ccfb7b..34e27e86 100644 --- a/packages/commerce-server/src/server/api/commerce/products/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/products/GET.ts @@ -18,13 +18,14 @@ * `data` field. Use the helpers in lib/commerce/catalog-subgraph.ts to extract view-model fields. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; import { buildSkuGroupsVariables, catalogStorefrontEndpoint, type SkuGroupsResult, type SkuGroupsVariables, } from '@/lib/commerce/catalog-subgraph'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; @@ -130,32 +131,27 @@ function asNumber(value: unknown): number | undefined { return Number.isFinite(n) ? n : undefined; } -export default async function handler(req: Request, res: Response): Promise { - try { - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; +async function listProducts(req: Request, res: Response): Promise { + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; - const variables = buildSkuGroupsVariables({ - 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, - productIds: asStringArray(req.query.productIds), - categoryIds: asStringArray(req.query.categoryIds), - }); + const variables = buildSkuGroupsVariables({ + 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, + productIds: asStringArray(req.query.productIds), + categoryIds: asStringArray(req.query.categoryIds), + }); - const data = await gqlRequest({ - endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), - query: skuGroupsQuery, - variables, - headers: storefrontHeaders({ storeId, clientId }), - }); + const data = await gqlRequest({ + endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), + query: skuGroupsQuery, + variables, + headers: storefrontHeaders({ storeId, clientId }), + }); - res.json(data); - } catch (error) { - res.status(500).json({ - error: 'Failed to load products', - message: error instanceof Error ? error.message : String(error), - }); - } + res.json(data); } + +export default commerceRoute('Failed to load products', listProducts); 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 3b9f8ce4..7abf1d7b 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 @@ -16,7 +16,7 @@ * Response: { skuGroup: SKUGroup } for an active product, otherwise 404. */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; import { buildSkuGroupVariables, catalogStorefrontEndpoint, @@ -24,7 +24,9 @@ import { type SkuGroupsResult, type SkuGroupVariables, } from '@/lib/commerce/catalog-subgraph'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { InvalidRequestError, NotFoundError } from '@/lib/commerce/errors'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; type ProductDetailsResult = SkuGroupResult & { activeSkuGroups?: SkuGroupsResult['skuGroups'] }; @@ -125,41 +127,34 @@ function asNumber(value: unknown): number | undefined { return Number.isFinite(n) ? n : undefined; } -export default async function handler(req: Request, res: Response): Promise { - try { - const productId: unknown = req.params.id; - if (typeof productId !== 'string' || !productId) { - res.status(400).json({ error: 'Missing product id' }); - return; - } - - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; +async function readProduct(req: Request, res: Response): Promise { + const productId: unknown = req.params.id; + if (typeof productId !== 'string' || !productId) { + throw new InvalidRequestError('Missing product id'); + } - const variables = buildSkuGroupVariables({ - productId, - selectedAttributeValues: asStringArray(req.query.attributeValues), - skuGroupFirst: asNumber(req.query.skuGroupFirst), - }); + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; - const data = await gqlRequest({ - endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), - query: skuGroupQuery, - variables, - headers: storefrontHeaders({ storeId, clientId }), - }); + const variables = buildSkuGroupVariables({ + productId, + selectedAttributeValues: asStringArray(req.query.attributeValues), + skuGroupFirst: asNumber(req.query.skuGroupFirst), + }); - if (!data.skuGroup || !data.activeSkuGroups?.edges?.some((edge) => edge?.node?.id === productId)) { - res.status(404).json({ error: 'Product not found' }); - return; - } + const data = await gqlRequest({ + endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), + query: skuGroupQuery, + variables, + headers: storefrontHeaders({ storeId, clientId }), + }); - res.json({ skuGroup: data.skuGroup }); - } catch (error) { - res.status(500).json({ - error: 'Failed to load product', - message: error instanceof Error ? error.message : String(error), - }); + if (!data.skuGroup || !data.activeSkuGroups?.edges?.some((edge) => edge?.node?.id === productId)) { + throw new NotFoundError('Product not found'); } + + res.json({ skuGroup: data.skuGroup }); } + +export default commerceRoute('Failed to load product', readProduct); diff --git a/packages/commerce-server/src/server/api/commerce/skus/[id]/GET.ts b/packages/commerce-server/src/server/api/commerce/skus/[id]/GET.ts index f5373276..e19a187a 100644 --- a/packages/commerce-server/src/server/api/commerce/skus/[id]/GET.ts +++ b/packages/commerce-server/src/server/api/commerce/skus/[id]/GET.ts @@ -8,13 +8,15 @@ * Response: { sku: SKU | null } */ import type { Request, Response } from 'express'; -import { validateCommerceCartScope } from '@/lib/commerce/cart-scope'; +import { assertCommerceCartScope } from '@/lib/commerce/cart-scope'; import { catalogStorefrontEndpoint, type SkuResult, type SkuVariables, } from '@/lib/commerce/catalog-subgraph'; +import { commerceRoute } from '@/lib/commerce/commerce-route'; import { type CommerceConfig, readCommerceConfigForResponse } from '@/lib/commerce/config'; +import { InvalidRequestError } from '@/lib/commerce/errors'; import { gqlRequest, storefrontHeaders } from '@/lib/commerce/gql'; const skuQuery = ` @@ -74,30 +76,24 @@ const skuQuery = ` } `; -export default async function handler(req: Request, res: Response): Promise { - try { - const skuId: unknown = req.params.id; - if (typeof skuId !== 'string' || !skuId) { - res.status(400).json({ error: 'Missing sku id' }); - return; - } +async function readSku(req: Request, res: Response): Promise { + const skuId: unknown = req.params.id; + if (typeof skuId !== 'string' || !skuId) { + throw new InvalidRequestError('Missing sku id'); + } - const config: CommerceConfig = readCommerceConfigForResponse(res); - if (!validateCommerceCartScope(req, res, config)) return; - const { storeId, clientId, apiBaseUrl } = config; + const config: CommerceConfig = readCommerceConfigForResponse(res); + assertCommerceCartScope(req, config); + const { storeId, clientId, apiBaseUrl } = config; - const data = await gqlRequest({ - endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), - query: skuQuery, - variables: { id: skuId }, - headers: storefrontHeaders({ storeId, clientId }), - }); + const data = await gqlRequest({ + endpoint: catalogStorefrontEndpoint({ storeId, apiBaseUrl }), + query: skuQuery, + variables: { id: skuId }, + headers: storefrontHeaders({ storeId, clientId }), + }); - res.json(data); - } catch (error) { - res.status(500).json({ - error: 'Failed to load sku', - message: error instanceof Error ? error.message : String(error), - }); - } + res.json(data); } + +export default commerceRoute('Failed to load sku', readSku);