From bff1b24ce9db15320b7023236b7cad5e5c03d61f Mon Sep 17 00:00:00 2001 From: Paqui Calabria Date: Thu, 13 Aug 2026 13:17:56 +0200 Subject: [PATCH] Remove Agent-specific proxy routes --- .changeset/bright-agents-proxy.md | 5 + .../skills/hydrogen-request-handlers/SKILL.md | 2 +- .../skills/hydrogen-setup/steps/2-scaffold.md | 2 +- packages/hydrogen/src/core/headers.ts | 4 - .../request-routing/handle-shopify-routes.ts | 2 - .../interceptors/agent-proxy.test.ts | 149 ------------------ .../interceptors/agent-proxy.ts | 14 -- .../interceptors/api-proxy.test.ts | 4 +- packages/hydrogen/src/core/url.ts | 2 - 9 files changed, 9 insertions(+), 175 deletions(-) create mode 100644 .changeset/bright-agents-proxy.md delete mode 100644 packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.test.ts delete mode 100644 packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.ts diff --git a/.changeset/bright-agents-proxy.md b/.changeset/bright-agents-proxy.md new file mode 100644 index 0000000000..4a1f01fec8 --- /dev/null +++ b/.changeset/bright-agents-proxy.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +Storefront Agent requests now route through the generic `/__shopify/*` Shopify API proxy. Unprefixed `/agent/buyer-claims` and `/agent/handoff` requests are no longer intercepted and fall through to app routing, where they may return a 404 or catch-all HTML response. diff --git a/packages/hydrogen/skills/hydrogen-request-handlers/SKILL.md b/packages/hydrogen/skills/hydrogen-request-handlers/SKILL.md index a4e22bce70..2c46a972b2 100644 --- a/packages/hydrogen/skills/hydrogen-request-handlers/SKILL.md +++ b/packages/hydrogen/skills/hydrogen-request-handlers/SKILL.md @@ -27,7 +27,7 @@ Request -> framework 404 page ``` -`handleShopifyRoutes` owns Hydrogen routes the framework should never see: SFAPI proxy URLs, `/checkout`, cart permalinks like `/cart/{variantId}:{quantity}`, AJAX cart URLs like `/cart.js` and `/cart/add.js`, `/api/mcp`, `/agent/*`, `/graphiql` in development, Liquid-style `?variant=` product URLs, and app-registered handler groups such as `createCartServerHandlers()` or `createCustomerAccountServerHandlers()`. +`handleShopifyRoutes` owns Hydrogen routes the framework should never see: SFAPI proxy URLs, the generic `/__shopify/*` API proxy, `/checkout`, cart permalinks like `/cart/{variantId}:{quantity}`, AJAX cart URLs like `/cart.js` and `/cart/add.js`, `/api/mcp`, `/graphiql` in development, Liquid-style `?variant=` product URLs, and app-registered handler groups such as `createCartServerHandlers()` or `createCustomerAccountServerHandlers()`. ## Variant Id Redirects diff --git a/packages/hydrogen/skills/hydrogen-setup/steps/2-scaffold.md b/packages/hydrogen/skills/hydrogen-setup/steps/2-scaffold.md index 752a07c4fd..2fc12d480e 100644 --- a/packages/hydrogen/skills/hydrogen-setup/steps/2-scaffold.md +++ b/packages/hydrogen/skills/hydrogen-setup/steps/2-scaffold.md @@ -10,7 +10,7 @@ Preserve the app's existing route shape when present. When there is no establish - `/products/{handle}` for product detail. Use plural `products`, not `/product`. - `/cart` for the full cart page. -Hydrogen-owned handlers are not page routes: `/api/cart`, `/api/{api-version}/graphql.json`, `/checkout`, cart permalinks like `/cart/{variantId}:{quantity}`, AJAX cart URLs like `/cart.js` and `/cart/add.js`, `/api/mcp`, `/agent/*`, `/graphiql` in development, Liquid-style `?variant=` product redirects, `/admin` redirects, and Storefront URL redirects belong in the `hydrogen-request-handlers` wiring. +Hydrogen-owned handlers are not page routes: `/api/cart`, `/api/{api-version}/graphql.json`, the generic `/__shopify/*` API proxy, `/checkout`, cart permalinks like `/cart/{variantId}:{quantity}`, AJAX cart URLs like `/cart.js` and `/cart/add.js`, `/api/mcp`, `/graphiql` in development, Liquid-style `?variant=` product redirects, `/admin` redirects, and Storefront URL redirects belong in the `hydrogen-request-handlers` wiring. Invoke the `hydrogen-routing` skill and create the shared route template manifest for Shopify resources such as products, collections, pages, blogs, or articles. diff --git a/packages/hydrogen/src/core/headers.ts b/packages/hydrogen/src/core/headers.ts index c9e51e9783..addb99bf5d 100644 --- a/packages/hydrogen/src/core/headers.ts +++ b/packages/hydrogen/src/core/headers.ts @@ -6,7 +6,6 @@ export const REQUEST_GROUP_ID_HEADER = "Custom-Storefront-Request-Group-ID"; export const SDK_VARIANT_HEADER = "X-SDK-Variant"; export const SDK_VARIANT_SOURCE_HEADER = "X-SDK-Variant-Source"; export const SDK_VERSION_HEADER = "X-SDK-Version"; -export const SHOPIFY_CHAT_FRAME_ORIGIN_HEADER = "Sec-Shopify-Chat-Frame-Origin"; export const SHOPIFY_STOREFRONT_ORIGIN_HEADER = "Sec-Shopify-Storefront-Origin"; export const STOREFRONT_ID_HEADER = "Shopify-Storefront-Id"; export const SHOPIFY_STOREFRONT_S_HEADER = "Shopify-Storefront-S"; @@ -51,7 +50,6 @@ export type ShopifyHeaderName = | typeof SDK_VARIANT_HEADER | typeof SDK_VARIANT_SOURCE_HEADER | typeof SDK_VERSION_HEADER - | typeof SHOPIFY_CHAT_FRAME_ORIGIN_HEADER | typeof SHOPIFY_STOREFRONT_ORIGIN_HEADER | typeof SHOPIFY_STOREFRONT_S_HEADER | typeof SHOPIFY_STOREFRONT_Y_HEADER @@ -118,8 +116,6 @@ export const SFAPI_REQUEST_HEADER_ALLOWLIST = defineHeaderList( export const MCP_REQUEST_HEADER_ALLOWLIST = defineHeaderList(...COMMON_PROXY_HEADER_ALLOWLIST); -export const AGENT_REQUEST_HEADER_ALLOWLIST = defineHeaderList(...COMMON_PROXY_HEADER_ALLOWLIST); - export const AJAX_API_REQUEST_HEADER_ALLOWLIST = defineHeaderList( ...COMMON_PROXY_HEADER_ALLOWLIST, "content-length", diff --git a/packages/hydrogen/src/core/request-routing/handle-shopify-routes.ts b/packages/hydrogen/src/core/request-routing/handle-shopify-routes.ts index 1c1630da64..df6ff0a9ea 100644 --- a/packages/hydrogen/src/core/request-routing/handle-shopify-routes.ts +++ b/packages/hydrogen/src/core/request-routing/handle-shopify-routes.ts @@ -1,5 +1,4 @@ import { handleProductVariantId } from "../product/accept-variant-id"; -import { handleAgentProxy } from "./interceptors/agent-proxy"; import { handleAjaxApi } from "./interceptors/ajax-api"; import { handleShopifyApiProxy } from "./interceptors/api-proxy"; import { handleCheckoutRedirect } from "./interceptors/checkout"; @@ -18,7 +17,6 @@ const SHOPIFY_ROUTE_INTERCEPTORS = [ handleCheckoutRedirect, handleWellKnownProxy, handleMcpProxy, - handleAgentProxy, handleAjaxApi, ] satisfies readonly HydrogenRouteInterceptor[]; diff --git a/packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.test.ts b/packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.test.ts deleted file mode 100644 index 43643c5d8f..0000000000 --- a/packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; - -import { SHOPIFY_CHAT_FRAME_ORIGIN_HEADER } from "../../headers"; -import { createShopifyRequestContext } from "../../request-context"; -import { assert } from "../../test-utils"; -import { handleAgentProxy as handleAgentProxyImpl } from "./agent-proxy"; - -const defaultStoreUrl = "https://test-store.myshopify.com"; - -function createRequest(path: string, origin = "https://headless.example"): Request { - return new Request(`${origin}${path}`); -} - -function handleAgentProxy(request: Request, storeUrl = defaultStoreUrl) { - const requestContext = createShopifyRequestContext({ - request, - i18n: { country: "US", language: "EN" }, - }); - return handleAgentProxyImpl(new URL(request.url), { - request, - requestContext, - sessionManager: createTestSessionManager(request), - storefrontClient: { - type: "private", - i18n: { country: "US", language: "EN", pathPrefix: "" }, - storeUrl, - apiUrl: `${storeUrl}/api/2026-04/graphql.json`, - requestContext, - graphql: vi.fn(), - }, - }); -} - -function createTestSessionManager(request: Request) { - const data = new Map(); - const origin = new URL(request.url).origin; - - return { - getSessionOrigin() { - return origin; - }, - getSessionItem(key: string) { - return data.get(key); - }, - setSessionItem(key: string, value: unknown) { - data.set(key, value); - }, - removeSessionItem(key: string) { - data.delete(key); - }, - commit() { - return new Headers({ "set-cookie": `session=abc; Path=/; Domain=${origin}` }); - }, - }; -} - -describe("handleAgentProxy", () => { - let mockFetch: ReturnType; - - beforeEach(() => { - mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 302 })); - vi.stubGlobal("fetch", mockFetch); - }); - - it("returns null for non-agent URLs", async () => { - const result = await handleAgentProxy(createRequest("/products")); - - expect(result).toBeNull(); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("does not intercept other /agent/ paths so app routes are not shadowed", async () => { - for (const path of [ - "/agent", - "/agent/", - "/agent/chat", - "/agent/handoff/extra", - "/agent/buyer-claims/extra", - "/agent/handoffs", - ]) { - const result = await handleAgentProxy(createRequest(path)); - expect(result, `expected ${path} not to be proxied`).toBeNull(); - } - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("forwards locale-prefixed and format-suffixed agent paths", async () => { - for (const path of [ - "/agent/handoff/", - "/agent/handoff.json", - "/en-us/agent/handoff", - "/agent/buyer-claims/", - "/agent/buyer-claims.json", - "/en-us/agent/buyer-claims", - ]) { - mockFetch.mockClear(); - await handleAgentProxy(createRequest(path)); - expect(mockFetch, `expected ${path} to be proxied`).toHaveBeenCalledTimes(1); - } - }); - - it("forwards agent requests to the Online Store origin", async () => { - for (const path of ["/agent/handoff?expanded=1", "/agent/buyer-claims?expanded=1"]) { - mockFetch.mockClear(); - await handleAgentProxy(createRequest(path)); - - const call = mockFetch.mock.calls[0]; - assert(call, "expected fetch to be called"); - const [url] = call; - expect(url.href).toBe(`https://test-store.myshopify.com${path}`); - } - }); - - it("forwards the embedding parent origin through a browser-forbidden Sec header", async () => { - await handleAgentProxy(createRequest("/agent/handoff", "https://headless.example")); - - const call = mockFetch.mock.calls[0]; - assert(call, "expected fetch to be called"); - const [, init] = call; - const headers = new Headers(init.headers); - expect(headers.get(SHOPIFY_CHAT_FRAME_ORIGIN_HEADER)).toBe("https://headless.example"); - }); - - it("forwards explicit-port loopback origins for local development", async () => { - await handleAgentProxy(createRequest("/agent/handoff", "http://localhost:3000")); - - const call = mockFetch.mock.calls[0]; - assert(call, "expected fetch to be called"); - const [, init] = call; - const headers = new Headers(init.headers); - expect(headers.get(SHOPIFY_CHAT_FRAME_ORIGIN_HEADER)).toBe("http://localhost:3000"); - }); - - it("does not allow incoming headers to override the request origin", async () => { - const request = new Request("https://headless.example/agent/handoff", { - headers: { - [SHOPIFY_CHAT_FRAME_ORIGIN_HEADER]: "https://attacker.example", - }, - }); - - await handleAgentProxy(request); - - const call = mockFetch.mock.calls[0]; - assert(call, "expected fetch to be called"); - const [, init] = call; - const headers = new Headers(init.headers); - expect(headers.get(SHOPIFY_CHAT_FRAME_ORIGIN_HEADER)).toBe("https://headless.example"); - }); -}); diff --git a/packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.ts b/packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.ts deleted file mode 100644 index b08af6dd8e..0000000000 --- a/packages/hydrogen/src/core/request-routing/interceptors/agent-proxy.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { AGENT_REQUEST_HEADER_ALLOWLIST, SHOPIFY_CHAT_FRAME_ORIGIN_HEADER } from "../../headers"; -import { AGENT_BUYER_CLAIMS_RE } from "../../url"; -import { createProxyInterceptor } from "./proxy"; - -export const handleAgentProxy = createProxyInterceptor({ - match: AGENT_BUYER_CLAIMS_RE, - headers: { - allow: AGENT_REQUEST_HEADER_ALLOWLIST, - prepare(headers, _options, url) { - headers.set(SHOPIFY_CHAT_FRAME_ORIGIN_HEADER, url.origin); - }, - }, - scope: "agent-proxy", -}); diff --git a/packages/hydrogen/src/core/request-routing/interceptors/api-proxy.test.ts b/packages/hydrogen/src/core/request-routing/interceptors/api-proxy.test.ts index bafa415296..03cb8029ca 100644 --- a/packages/hydrogen/src/core/request-routing/interceptors/api-proxy.test.ts +++ b/packages/hydrogen/src/core/request-routing/interceptors/api-proxy.test.ts @@ -55,12 +55,12 @@ describe("handleShopifyApiProxy", () => { it("removes the prefix and forwards the path and search params to SFR", async () => { await handleShopifyApiProxy( - new Request("https://my-app.com/__shopify/apps/inbox/config.json?locale=en"), + new Request("https://my-app.com/__shopify/agent/buyer-claims?locale=en"), ); const call = mockFetch.mock.calls[0]; assert(call, "expected fetch to be called"); - expect(call[0].href).toBe("https://test-store.myshopify.com/apps/inbox/config.json?locale=en"); + expect(call[0].href).toBe("https://test-store.myshopify.com/agent/buyer-claims?locale=en"); }); it("forwards the prefix root to the SFR root", async () => { diff --git a/packages/hydrogen/src/core/url.ts b/packages/hydrogen/src/core/url.ts index 175b9846b7..b22559df50 100644 --- a/packages/hydrogen/src/core/url.ts +++ b/packages/hydrogen/src/core/url.ts @@ -29,8 +29,6 @@ export function isHydrogenServerHandoffPath(pathname: string): boolean { ); } -export const AGENT_BUYER_CLAIMS_RE = - /^(?:\/[a-z]{2}(?:-[a-z]{2})?)?\/agent\/(?:handoff|buyer-claims)(?:\.[^/.]+)?\/?$/i; export const WELL_KNOWN_RE = /^\/\.well-known\/(?:apple-developer-merchantid-domain-association)$/; export const AJAX_CART_RE = /^(?:\/[a-z]{2}(?:-[a-z]{2})?)?\/cart(?:\.(?:js|json)|\/(?:add|update|change|clear)(?:\.(?:js|json))?)$/i;