From 0ca0385d8ba4bfd71505b6b2666d4ed576d95025 Mon Sep 17 00:00:00 2001 From: Robert Field Date: Fri, 25 Sep 2026 13:56:52 +0100 Subject: [PATCH 1/4] feat(commerce-elastic-path)!: one EP host allow-list, resolved by createEpAuth createEpAuth resolves the list once: the Elastic Path-operated defaults, plus the hostAllowlist option, plus EP_HOST_ALLOWLIST. Entries extend the defaults. The plugin admits a bundle-supplied host at mint and hands the list to resolveConfig, so the envelope carries the admitted host. BREAKING CHANGE: buildEpCtx takes the session returned by getSession and no longer reads the bundle or takes a list. extractEpProviderConfig and epPlugin require the list. Closes #586 --- .../elastic-path/CHANGELOG.md | 32 +++ .../elastic-path/COMPONENTS.md | 7 +- .../elastic-path/CONTEXT.md | 9 + .../commerce-providers/elastic-path/README.md | 34 +-- .../elastic-path/build-server.mjs | 4 +- .../docs/adr/0005-one-ep-host-allow-list.md | 68 ++++++ .../extract-ep-provider-config.test.ts | 218 +++++++++++------- .../__tests__/cross-instance.test.ts | 4 +- .../__tests__/ep-account-member.test.ts | 2 + .../ep-plugin/__tests__/ep-account.test.ts | 4 +- .../ep-plugin/__tests__/ep-anonymous.test.ts | 9 +- .../auth/ep-plugin/__tests__/ep-cart.test.ts | 4 +- .../__tests__/ep-envelope-lifetime.test.ts | 4 +- .../ep-plugin/__tests__/ep-plugin.test.ts | 3 + .../ep-plugin/__tests__/ep-refresh.test.ts | 4 +- .../__tests__/host-allowlist-parity.test.ts | 183 +++++++++++++++ .../__tests__/identity-client.test.ts | 7 +- .../ep-plugin/__tests__/session-cart.test.ts | 2 + .../auth/ep-plugin/create-ep-auth-better.ts | 42 +++- .../src/auth/ep-plugin/ep-plugin.ts | 36 ++- .../src/auth/extract-ep-provider-config.ts | 15 +- .../elastic-path/src/auth/host-allowlist.ts | 10 +- .../elastic-path/src/auth/index.ts | 2 +- .../__tests__/build-ep-ctx.test.ts | 132 ++++------- .../src/ep-server-functions/build-ep-ctx.ts | 60 ++--- .../src/ep-server-functions/index.ts | 2 - .../elastic-path/src/server.ts | 3 +- 27 files changed, 603 insertions(+), 297 deletions(-) create mode 100644 plasmicpkgs/commerce-providers/elastic-path/docs/adr/0005-one-ep-host-allow-list.md create mode 100644 plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts diff --git a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md index d6c70ce153..22c9403044 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md +++ b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md @@ -2,6 +2,38 @@ ## Unreleased +### Breaking + +There is one EP host allow-list, and `createEpAuth` resolves it: the Elastic +Path-operated defaults, plus the `hostAllowlist` option, plus the +comma-separated `EP_HOST_ALLOWLIST` environment variable (ADR-0005). Your +entries extend the defaults rather than replacing them. The list was applied +separately by three functions, each falling back to the defaults, so a list +passed to only some of them failed in a different silent way at each miss. +Pass it once, to `createEpAuth`, and delete your own `EP_HOST_ALLOWLIST` +parsing. + +| Was | Now | +| --- | --- | +| `buildEpCtx(prefetchedData, { session: { accessToken, cartId, account }, hostAllowlist })` | `buildEpCtx(session)`, where `session` is what `epAuth.api.getSession()` returned | +| `extractEpProviderConfig(prefetchedData)` | `extractEpProviderConfig(prefetchedData, { hostAllowlist })` — the list is required | +| `resolveConfig: async () => …` | `resolveConfig: async ({ hostAllowlist }) => …` — pass it to `extractEpProviderConfig` | +| `epPlugin({ clientId, host })` | `epPlugin({ clientId, host, hostAllowlist })` — the list is required | + +`buildEpCtx` reads the host and client id from the session, which carries the +ones admitted when it was minted, so the page and the auth routes use the same +Elastic Path host by construction. An empty session yields an empty context, +which the server functions refuse to run with, as before. Code outside +`resolveConfig` that calls `extractEpProviderConfig` passes +`epAuth.config.hostAllowlist`. `locale` and `currency` move to an optional +second argument, and the `BuildEpCtxSessionInput` and `BuildEpCtxAccountInput` +types are removed. + +A rejected host now names the `hostAllowlist` option and `EP_HOST_ALLOWLIST` +as the fix for a store whose Elastic Path API is served from a custom domain. +It no longer points at Elastic Path Self Managed Commerce, which never reaches +this package. + ### Added Six server functions reach data that previously only the browser client could: diff --git a/plasmicpkgs/commerce-providers/elastic-path/COMPONENTS.md b/plasmicpkgs/commerce-providers/elastic-path/COMPONENTS.md index b4536bf771..3210df1987 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/COMPONENTS.md +++ b/plasmicpkgs/commerce-providers/elastic-path/COMPONENTS.md @@ -768,7 +768,8 @@ For SSR'd product/cart/list data — where the initial HTML payload contains rea Browser Server (Next.js) Elastic Path ------- ---------------- ------------ catch-all page.tsx - buildEpCtx() ----- mints ---> /oauth/access_token + getSession() ----- mints ---> /oauth/access_token + buildEpCtx(session) /pcm/catalog/products withEpSession(epCtx, () => PLASMIC.unstable__getServerQueriesData @@ -838,7 +839,7 @@ Like Add to Cart, these values are shopper-facing copy derived from stable proxy 1. **`platformOptions: { nextjs: { appDir: true } }`** in `plasmic-init.ts`. Without this the loader fetches the Pages Router bundle which omits `serverQueriesExecFuncFileName` per-page metadata. 2. **Wrap `unstable__getServerQueriesData` in `withEpSession(epCtx, ...)`** in the catch-all page. Without it, the EP functions run outside any session scope and return `null` / `[]`. -3. **Resolve a real page path for the API route's `epProviderHeaders()`** — use `PLASMIC.fetchPages()` rather than hardcoding `/`. Projects without a homepage route otherwise return `null` from `maybeFetchComponentData("/")` and the credentials-extraction path silently fails. +3. **Resolve a real page path in `resolveConfig`** — use `PLASMIC.fetchPages()` rather than hardcoding `/`. Projects without a homepage route otherwise return `null` from `maybeFetchComponentData("/")` and the credentials-extraction path silently fails. ### Common gotchas @@ -846,7 +847,7 @@ Like Add to Cart, these values are shopper-facing copy derived from stable proxy |---|---| | Queries return `null` / `[]` despite valid arguments | Missing `withEpSession(epCtx, …)` wrap around `unstable__getServerQueriesData` | | `prefetchedQueryData: "$undefined"` in the SSR HTML | `appDir: true` missing from loader config | -| `EP OAuth failed (401)` in dev log | Override headers (`x-ep-client-id`/`x-ep-host`) returned empty — usually because `getEpProviderConfig` hardcoded `/` and the project has no homepage | +| `EP OAuth failed (401)` in dev log | `resolveConfig` found no EP Provider config — usually because `getEpProviderConfig` hardcoded `/` and the project has no homepage | | Auth works on the page but `/api/ep/cart` returns 500 | Pre-fix: `toNextJsHandler` was passing the native Next `Request` directly; resolved by the Request adapter committed in `a363aaf23` | | Studio binding still references `auth: $ctx.ep` | Project predates PRD #272 — drop `auth` from each Server Query argument | | A sort control over **EP Product List Provider** changes nothing | Expected — the catalog product endpoints cannot sort. Build the listing on `EPCatalogSearchProvider` + `EPSearchSortBy` instead | diff --git a/plasmicpkgs/commerce-providers/elastic-path/CONTEXT.md b/plasmicpkgs/commerce-providers/elastic-path/CONTEXT.md index 2f11df9c2a..cf4c6418d0 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/CONTEXT.md +++ b/plasmicpkgs/commerce-providers/elastic-path/CONTEXT.md @@ -108,6 +108,15 @@ origins when cross-site, and pass when no browser origin signal exists layered with `SameSite=Lax` cookies, not CSRF tokens. _Avoid_: CORS check (CORS is response readability; the gate is request rejection) +**EP host allow-list**: +The one list of hosts an EP API host read from the Plasmic bundle may name: +the Elastic Path-operated defaults plus whatever the operator adds. An +operator's entries extend the defaults, never replace them. Resolved once, +where the **trusted origin** list is, and read from there by every check. +Needed because the bundle is designer-edited input; the operator's own +`host` argument is theirs and is not checked against it. +_Avoid_: hostAllowlist as a per-function option + ### Identity & transport (ADR-0003) ADR-0003 decides this vocabulary. Entries marked *(not yet built)* name a diff --git a/plasmicpkgs/commerce-providers/elastic-path/README.md b/plasmicpkgs/commerce-providers/elastic-path/README.md index 252236bb41..eec0c04d7e 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/README.md +++ b/plasmicpkgs/commerce-providers/elastic-path/README.md @@ -240,11 +240,20 @@ Two options tighten the deployment further: | Option | Default | Use when | | --- | --- | --- | | `trustedOrigins` | the app's own origin | another origin must act as the shopper (e.g. Studio preview) | -| `hostAllowlist` | Elastic Path Composable Commerce regions, `*.epcloudops.com`, the integration host, and loopback outside production | the EP API lives elsewhere — Elastic Path Self Managed Commerce | - -`hostAllowlist` is applied independently by `createEpAuth`, -`extractEpProviderConfig` and `buildEpCtx`, so pass the same list to all -three rather than only to the factory. +| `hostAllowlist` | Elastic Path Composable Commerce regions, `*.epcloudops.com`, the integration host, and loopback outside production | this store's Elastic Path API is served from a custom domain | + +The EP API host comes from the EP Provider in the Plasmic bundle, which +designers edit, so it is checked against the **EP host allow-list**: +the defaults, plus `hostAllowlist`, plus the comma-separated +`EP_HOST_ALLOWLIST` environment variable. Your entries extend the defaults; +they never replace them. `createEpAuth` resolves the list once and exposes it +as `epAuth.config.hostAllowlist`. It hands the list to your `resolveConfig` +callback, and the host is admitted when the session is minted, so +`buildEpCtx` reads it from the session and needs no list. Code outside +`resolveConfig` that calls `extractEpProviderConfig` passes +`epAuth.config.hostAllowlist`. A host that is not on the list is logged and +ignored, and the session uses the `host` passed to `createEpAuth`, which is +your own configuration and is not checked. ## Architecture @@ -651,7 +660,7 @@ import { buildEpCtx, withEpSession, } from "@elasticpath/plasmic-ep-commerce-elastic-path/server"; -import { epAuth, epProviderHeaders } from "@/lib/ep-auth"; +import { epAuth } from "@/lib/ep-auth"; import { cookies } from "next/headers"; export default async function PlasmicLoaderPage({ params, searchParams }) { @@ -665,17 +674,10 @@ export default async function PlasmicLoaderPage({ params, searchParams }) { const cookieStore = await cookies(); const session = await epAuth.api.getSession({ cookies: Object.fromEntries(cookieStore.getAll().map((c) => [c.name, c.value])), - headers: await epProviderHeaders(prefetchedData), }); // Compose the EP session — auth + cart context for server-side EP calls. - const epCtx = buildEpCtx(prefetchedData, { - session: { - accessToken: session.session?.accessToken, - cartId: session.cart?.id ?? undefined, - account: session.session?.account ?? null, - }, - }); + const epCtx = buildEpCtx(session); // Run Studio Server Queries inside an EP session scope. Each `ep.*` // function reads the active session via AsyncLocalStorage — no `auth` @@ -713,7 +715,7 @@ Then bind the `EPProductProvider` component's advanced `product` prop to `$q.pro ### 5. Resolve EP credentials from Studio config -`buildEpCtx` reads `clientId` and `host` from the EP Provider global context (configured in Studio), not from `.env.local`. The helper that powers the lookup, `extractEpProviderConfig`, scans the loader bundle for the global-context module. For projects without a homepage route, `epProviderHeaders` resolves a real page path via `PLASMIC.fetchPages()` rather than hardcoding `/`. +`clientId` and `host` come from the EP Provider global context (configured in Studio), not from `.env.local`. `createEpAuth`'s `resolveConfig` callback reads them with `extractEpProviderConfig(prefetchedData, { hostAllowlist })`, which scans the loader bundle for the global-context module, and the session carries them from then on; `buildEpCtx` reads them from the session. For projects without a homepage route, resolve a real page path via `PLASMIC.fetchPages()` rather than hardcoding `/` — see `getEpProviderConfig` in the example's `lib/ep-auth.ts`. ### Common gotchas @@ -721,7 +723,7 @@ Then bind the `EPProductProvider` component's advanced `product` prop to `$q.pro |---|---|---| | `$q.product.data` always `null` / queries return `null` despite valid input | `withEpSession` not wrapped around `unstable__getServerQueriesData` | Wrap the query call per step 3; functions fail-soft to `null` outside an EP session scope | | `prefetchedQueryData: "$undefined"` in the SSR HTML | `appDir: true` not set in `plasmic-init.ts` | Add `platformOptions: { nextjs: { appDir: true } }` | -| `EP OAuth failed (401) Invalid credentials` | API route's `epProviderHeaders()` returned empty (project has no homepage at `/`) | Ensure the storefront resolves a real page path via `fetchPages()` (already done if you copied `lib/ep-auth.ts` from the example) | +| `EP OAuth failed (401) Invalid credentials` | `resolveConfig` found no EP Provider config (project has no homepage at `/`) | Ensure the storefront resolves a real page path via `fetchPages()` (already done if you copied `lib/ep-auth.ts` from the example) | | Studio binding still references `auth: $ctx.ep` | Project predates PRD #272 | Drop `auth` from each Server Query argument — the session now flows via ALS, not execParams | ## Components diff --git a/plasmicpkgs/commerce-providers/elastic-path/build-server.mjs b/plasmicpkgs/commerce-providers/elastic-path/build-server.mjs index f93862c71f..2130055683 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/build-server.mjs +++ b/plasmicpkgs/commerce-providers/elastic-path/build-server.mjs @@ -102,10 +102,10 @@ export type { EpCartCacheKey } from "./cart-provider/cache-keys"; export { seedCartFallback } from "./cart-provider/seed-cart-fallback"; export type { SessionRequest, SessionResponse, SessionHandlerContext, EPCredentials, AdapterRegistry, SessionStore, PaymentAdapter, CustomAttributeAllowList, CartPaymentIntentAdapter, LegacyPaymentAdapter, OrderFirstAdapter, PaymentSequence, PaymentSetupRequest } from "./checkout/session/types"; export { createEpAuth, createBetterEpAuth, extractEpProviderConfig, epPlugin, epAuthMiddleware, createEpAuthRoutes, createCartRoutes, createEpProxyRoutes, enforceOriginGate, isTrustedOrigin, passesOriginGate, assertProductionSecret, resolveAuthSecret, DEFAULT_HOST_ALLOWLIST, ENVELOPE_LIFETIME_SECONDS, EP_ACCOUNT_TOKEN_HEADER, isAllowedEpHost } from "./auth"; -export type { EpAccountCart, EpAccountSlot, EpAuth, EpAuthConfig, EpLapsedAccount, EpSession, EpSessionCartResolver, EpSessionCartResolverInput, EpSessionCartTrigger, EpSessionCartVerdict, EpSessionData, EpProviderBundleConfig, ExtractEpProviderConfigOptions, EpPluginOptions, EpProxyRoutes } from "./auth"; +export type { EpAccountCart, EpAccountSlot, EpAuth, EpAuthConfig, EpLapsedAccount, EpSession, EpSessionCartResolver, EpSessionCartResolverInput, EpSessionCartTrigger, EpSessionCartVerdict, EpSessionData, EpProviderBundleConfig, ExtractEpProviderConfigOptions, EpPluginOptions, EpResolveConfig, EpProxyRoutes } from "./auth"; export { epGetProduct, epGetCart, epGetProductList, epGetProductPage, epGetRelatedProducts, epGetStock, epGetLocations, epGetBundleOptionProducts, epGetBaseProducts, epConfigureBundle, epMultiSearch, epAddCartItem, epApplyCartAdjustment, epUpdateCartItem, epRemoveCartItem, epPlaceOrder, addCustomCartItem, CART_ADJUSTMENT_KINDS, registerEpCustomFunctions, buildEpCtx, withEpSession, getCurrentEpSession } from "./ep-server-functions"; export { getProduct, getCart, getProductList, getProductPage, getRelatedProducts, getStock, getLocations, getBundleOptionProducts, getBaseProducts, configureBundle, multiSearch, addCartItem, applyCartAdjustment, updateCartItem, removeCartItem } from "./ep-server-functions"; -export type { EpGetProductInput, EpGetProductListInput, EpGetProductPageInput, EpProductPage, EpGetRelatedProductsInput, EpGetStockInput, EpProductStock, EpLocationStock, EpGetLocationsInput, EpLocation, EpGetBundleOptionProductsInput, EpGetBaseProductsInput, EpConfigureBundleInput, EpConfiguredBundle, EpMultiSearchInput, EpMultiSearchQuery, EpMultiSearchResponse, EpAddCartItemInput, EpApplyCartAdjustmentInput, EpUpdateCartItemInput, EpRemoveCartItemInput, EpPlaceOrderInput, EpPlaceOrderAddress, EpPlaceOrderResult, AddCustomCartItemInput, CartAdjustmentKind, BuildEpCtxAccountInput, BuildEpCtxSessionInput, EpCtx, EpSessionContext, EpServerAuth } from "./ep-server-functions"; +export type { EpGetProductInput, EpGetProductListInput, EpGetProductPageInput, EpProductPage, EpGetRelatedProductsInput, EpGetStockInput, EpProductStock, EpLocationStock, EpGetLocationsInput, EpLocation, EpGetBundleOptionProductsInput, EpGetBaseProductsInput, EpConfigureBundleInput, EpConfiguredBundle, EpMultiSearchInput, EpMultiSearchQuery, EpMultiSearchResponse, EpAddCartItemInput, EpApplyCartAdjustmentInput, EpUpdateCartItemInput, EpRemoveCartItemInput, EpPlaceOrderInput, EpPlaceOrderAddress, EpPlaceOrderResult, AddCustomCartItemInput, CartAdjustmentKind, EpCtx, EpSessionContext, EpServerAuth } from "./ep-server-functions"; `; writeFileSync("dist/server.d.ts", dts); diff --git a/plasmicpkgs/commerce-providers/elastic-path/docs/adr/0005-one-ep-host-allow-list.md b/plasmicpkgs/commerce-providers/elastic-path/docs/adr/0005-one-ep-host-allow-list.md new file mode 100644 index 0000000000..d9fd4996ea --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/docs/adr/0005-one-ep-host-allow-list.md @@ -0,0 +1,68 @@ +# ADR-0005: One EP host allow-list, extended not replaced, admitted at mint + +## Status + +Accepted (2026-09-25) + +## Context + +The EP API host a storefront talks to is read from the EP Provider in the +Plasmic bundle. Designers edit the bundle, so the host is untrusted input and is +checked against an allow-list before any credential is sent to it. + +Three functions applied that check: `createEpAuth`, `extractEpProviderConfig` +and `buildEpCtx`. Each took its own `hostAllowlist` option and each fell back to +the defaults when it was omitted. A rejected host is logged and dropped, so an +operator who passed the list to only some of them got a different silent +partial failure at each miss: pages classified as not configured for commerce, +Server Queries ran with the host stripped, or the auth routes talked to a different host +from the pages. Nothing checked that the three agreed. Both consumers of the +package had written the same env-var parsing and the same three-way threading. + +This is the split-brain ADR-0001 removed for origins. + +## Decision + +There is one **EP host allow-list**, and `createEpAuth` resolves it, in the same +place and the same way as `trustedOrigins` (ADR-0001): the defaults, plus the +`hostAllowlist` option, plus the comma-separated `EP_HOST_ALLOWLIST` environment +variable. It is frozen on `config.hostAllowlist`, next to `trustedOrigins`. + +**Extend, not replace.** This is the one departure from ADR-0001, where an +explicit `trustedOrigins` replaces the defaults. The default hosts are all +operated by Elastic Path, so keeping them widens trust to no third party, and +"also allow my custom domain" is the only reason anyone sets the option. +Replacing has no safety net here: trusted origins fall back to the request's +own Host, and hosts have no equivalent, so a list naming only a custom domain +would lock out the real region host the moment the bundle pointed back at it. + +**Admitted at mint.** A bundle-supplied host is admitted once, by the plugin, +after `resolveConfig` returns and before the token is minted. The admitted host +and client id are written into the shopper envelope, which is already the sole +input to identity (ADR-0003). `buildEpCtx` reads them from the session and takes +no bundle and no list. + +**Two access paths, one value.** The plugin hands the resolved list to the +consumer's `resolveConfig` callback, so that closure never has to reference the +auth instance it is being passed into. Any other caller of +`extractEpProviderConfig`, such as page classification, reads +`epAuth.config.hostAllowlist`. The extractor's list is required, with no +default, so a missed list is a type error. The extractor keeps using the list to +tell the EP Provider apart from another commerce provider's global context. + +**`createEpAuth`'s own `host` is not checked.** It comes from the operator's +environment, not from the bundle, and it is where a rejected host falls back to. + +## Consequences + +- An operator sets a custom domain once, in code or by `EP_HOST_ALLOWLIST` + alone, and every check sees it. The shared app host image carries no + allow-list parsing. +- The pages and the auth routes use the same Elastic Path host by construction, + because both read it from the envelope. +- A rejected host still logs and falls back rather than failing the request. + How loud a rejection should be is not decided here. +- `buildEpCtx` and `extractEpProviderConfig` changed signature. Both are server + API, not registered surfaces, so ADR-0004 does not constrain the break. +- There is no way to narrow the defaults. A deployment that must not reach an + Elastic Path-operated host would need a second, explicit rationale to add one. diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/__tests__/extract-ep-provider-config.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/__tests__/extract-ep-provider-config.test.ts index f4b18ca822..a39104f6d3 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/__tests__/extract-ep-provider-config.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/__tests__/extract-ep-provider-config.test.ts @@ -1,4 +1,7 @@ import { extractEpProviderConfig } from "../extract-ep-provider-config"; +import { DEFAULT_HOST_ALLOWLIST } from "../host-allowlist"; + +const DEFAULTS = { hostAllowlist: DEFAULT_HOST_ALLOWLIST }; // Minified snippet taken from a real Plasmic loader response for a project // with the EP Provider configured: clientId set, host="custom", customHost set. @@ -36,29 +39,35 @@ function E(r){ describe("extractEpProviderConfig", () => { it("returns null for empty / missing prefetchedData", () => { - expect(extractEpProviderConfig(null)).toBeNull(); - expect(extractEpProviderConfig(undefined)).toBeNull(); - expect(extractEpProviderConfig({} as any)).toBeNull(); + expect(extractEpProviderConfig(null, DEFAULTS)).toBeNull(); + expect(extractEpProviderConfig(undefined, DEFAULTS)).toBeNull(); + expect(extractEpProviderConfig({} as any, DEFAULTS)).toBeNull(); expect( - extractEpProviderConfig({ bundle: { modules: { server: [] } } } as any) + extractEpProviderConfig( + { bundle: { modules: { server: [] } } } as any, + DEFAULTS + ) ).toBeNull(); }); it("extracts clientId + customHost when host === 'custom'", () => { - const config = extractEpProviderConfig({ - bundle: { - projects: [{ globalContextsProviderFileName: "global__proj.js" }], - modules: { - server: [ - { - type: "code", - fileName: "global__proj.js", - code: EP_PROVIDER_MODULE_CUSTOM_HOST, - }, - ], + const config = extractEpProviderConfig( + { + bundle: { + projects: [{ globalContextsProviderFileName: "global__proj.js" }], + modules: { + server: [ + { + type: "code", + fileName: "global__proj.js", + code: EP_PROVIDER_MODULE_CUSTOM_HOST, + }, + ], + }, }, }, - }); + DEFAULTS + ); expect(config).toEqual({ clientId: "oVC2dwzwVi0sCbov7voN63H8gami9do0TLm3GaVKAJ", host: "https://epcc-integration.global.ssl.fastly.net", @@ -66,20 +75,23 @@ describe("extractEpProviderConfig", () => { }); it("uses predefined host value directly when host !== 'custom'", () => { - const config = extractEpProviderConfig({ - bundle: { - projects: [{ globalContextsProviderFileName: "global__proj.js" }], - modules: { - server: [ - { - type: "code", - fileName: "global__proj.js", - code: EP_PROVIDER_MODULE_PREDEFINED_HOST, - }, - ], + const config = extractEpProviderConfig( + { + bundle: { + projects: [{ globalContextsProviderFileName: "global__proj.js" }], + modules: { + server: [ + { + type: "code", + fileName: "global__proj.js", + code: EP_PROVIDER_MODULE_PREDEFINED_HOST, + }, + ], + }, }, }, - }); + DEFAULTS + ); expect(config).toEqual({ clientId: "abc123", host: "https://useast.api.elasticpath.com", @@ -87,27 +99,30 @@ describe("extractEpProviderConfig", () => { }); it("falls back to non-project modules when no project globalContexts file matches", () => { - const config = extractEpProviderConfig({ - bundle: { - projects: [], - modules: { - server: [ - { - type: "code", - fileName: "some-other.js", - code: EP_PROVIDER_MODULE_CUSTOM_HOST, - }, - ], + const config = extractEpProviderConfig( + { + bundle: { + projects: [], + modules: { + server: [ + { + type: "code", + fileName: "some-other.js", + code: EP_PROVIDER_MODULE_CUSTOM_HOST, + }, + ], + }, }, }, - }); + DEFAULTS + ); expect(config?.clientId).toBe("oVC2dwzwVi0sCbov7voN63H8gami9do0TLm3GaVKAJ"); }); describe("host allowlist", () => { - const smcModule = EP_PROVIDER_MODULE_CUSTOM_HOST.replace( + const customDomainModule = EP_PROVIDER_MODULE_CUSTOM_HOST.replace( "https://epcc-integration.global.ssl.fastly.net", - "https://commerce.selfmanaged.example" + "https://commerce.acme.example" ); const bundleWith = (code: string) => ({ bundle: { @@ -120,51 +135,60 @@ describe("extractEpProviderConfig", () => { let errorSpy: jest.SpyInstance; beforeEach(() => { - errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + errorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); }); afterEach(() => errorSpy.mockRestore()); it("accepts the Elastic Path regions and the integration host by default", () => { expect( - extractEpProviderConfig(bundleWith(EP_PROVIDER_MODULE_PREDEFINED_HOST)) - ?.host + extractEpProviderConfig( + bundleWith(EP_PROVIDER_MODULE_PREDEFINED_HOST), + DEFAULTS + )?.host ).toBe("https://useast.api.elasticpath.com"); expect( - extractEpProviderConfig(bundleWith(EP_PROVIDER_MODULE_CUSTOM_HOST))?.host + extractEpProviderConfig( + bundleWith(EP_PROVIDER_MODULE_CUSTOM_HOST), + DEFAULTS + )?.host ).toBe("https://epcc-integration.global.ssl.fastly.net"); expect(errorSpy).not.toHaveBeenCalled(); }); it("rejects an unlisted host and says so, naming the host and the option", () => { - expect(extractEpProviderConfig(bundleWith(smcModule))).toBeNull(); + expect( + extractEpProviderConfig(bundleWith(customDomainModule), DEFAULTS) + ).toBeNull(); expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("commerce.selfmanaged.example") + expect.stringContaining("commerce.acme.example") ); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("hostAllowlist") ); }); - it("accepts a Self Managed Commerce host once it is allowlisted", () => { + it("accepts a custom-domain host once it is on the list", () => { expect( - extractEpProviderConfig(bundleWith(smcModule), { - hostAllowlist: ["commerce.selfmanaged.example"], + extractEpProviderConfig(bundleWith(customDomainModule), { + hostAllowlist: ["commerce.acme.example"], })?.host - ).toBe("https://commerce.selfmanaged.example"); + ).toBe("https://commerce.acme.example"); expect(errorSpy).not.toHaveBeenCalled(); }); it("honours wildcard entries", () => { expect( - extractEpProviderConfig(bundleWith(smcModule), { - hostAllowlist: ["*.selfmanaged.example"], + extractEpProviderConfig(bundleWith(customDomainModule), { + hostAllowlist: ["*.acme.example"], })?.host - ).toBe("https://commerce.selfmanaged.example"); + ).toBe("https://commerce.acme.example"); }); it("logs rather than silently returning null when nothing matches the regex", () => { expect( - extractEpProviderConfig(bundleWith("function noop(){}")) + extractEpProviderConfig(bundleWith("function noop(){}"), DEFAULTS) ).toBeNull(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("no usable EP Provider config") @@ -182,20 +206,23 @@ describe("extractEpProviderConfig", () => { } `; expect( - extractEpProviderConfig({ - bundle: { - projects: [{ globalContextsProviderFileName: "global__proj.js" }], - modules: { - server: [ - { - type: "code", - fileName: "global__proj.js", - code: unconfigured, - }, - ], + extractEpProviderConfig( + { + bundle: { + projects: [{ globalContextsProviderFileName: "global__proj.js" }], + modules: { + server: [ + { + type: "code", + fileName: "global__proj.js", + code: unconfigured, + }, + ], + }, }, }, - }) + DEFAULTS + ) ).toBeNull(); }); @@ -218,23 +245,28 @@ function E(r){ },s))); } `; - const errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + const errorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); try { expect( - extractEpProviderConfig({ - bundle: { - projects: [{ globalContextsProviderFileName: "global__proj.js" }], - modules: { - server: [ - { - type: "code", - fileName: "global__proj.js", - code: sharedGlobalContexts, - }, - ], + extractEpProviderConfig( + { + bundle: { + projects: [{ globalContextsProviderFileName: "global__proj.js" }], + modules: { + server: [ + { + type: "code", + fileName: "global__proj.js", + code: sharedGlobalContexts, + }, + ], + }, }, }, - }) + DEFAULTS + ) ).toEqual({ clientId: "b6ratpsgidekICtcXjPWPODbGHckKfXWNNXnTivqQR", host: "https://epcc-integration.global.ssl.fastly.net", @@ -257,7 +289,9 @@ function E(r){ let errorSpy: jest.SpyInstance; beforeEach(() => { - errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined); + errorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => undefined); }); afterEach(() => errorSpy.mockRestore()); @@ -276,7 +310,9 @@ function E(r){ },s)); } `; - expect(extractEpProviderConfig(bundleWith(blankCustomHost))).toBeNull(); + expect( + extractEpProviderConfig(bundleWith(blankCustomHost), DEFAULTS) + ).toBeNull(); expect(errorSpy).toHaveBeenCalledWith( expect.stringContaining("no usable EP Provider config") ); @@ -299,7 +335,9 @@ function E(r){ },s)); } `; - expect(extractEpProviderConfig(bundleWith(foreignCustom))).toEqual({ + expect( + extractEpProviderConfig(bundleWith(foreignCustom), DEFAULTS) + ).toEqual({ clientId: "EP_REAL_CLIENT_ID", host: "https://useast.api.elasticpath.com", }); @@ -321,7 +359,9 @@ function E(r){ },s)); } `; - expect(extractEpProviderConfig(bundleWith(foreignClientIdFirst))).toEqual({ + expect( + extractEpProviderConfig(bundleWith(foreignClientIdFirst), DEFAULTS) + ).toEqual({ clientId: "EP_REAL_CLIENT_ID", host: "https://epcc-integration.global.ssl.fastly.net", }); @@ -341,13 +381,15 @@ function E(r){ host:l&&"host"in l?l.host:"https://data.plasmic.app" },g.createElement(ep,{ clientId:o&&"clientId"in o?o.clientId:"EP_REAL_CLIENT_ID", - host:o&&"host"in o?o.host:"https://commerce.selfmanaged.example" + host:o&&"host"in o?o.host:"https://commerce.acme.example" },s)); } `; - expect(extractEpProviderConfig(bundleWith(cmsFirstUnlisted))).toBeNull(); + expect( + extractEpProviderConfig(bundleWith(cmsFirstUnlisted), DEFAULTS) + ).toBeNull(); expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("commerce.selfmanaged.example") + expect.stringContaining("commerce.acme.example") ); expect(errorSpy).not.toHaveBeenCalledWith( expect.stringContaining("data.plasmic.app") diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/cross-instance.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/cross-instance.test.ts index ee48baff34..89d3a2c2ad 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/cross-instance.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/cross-instance.test.ts @@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; const SECRET = "x".repeat(48); const EP_HOST = "https://api.test.elasticpath.com"; @@ -52,7 +53,8 @@ function buildAuth() { return betterAuth({ secret: SECRET, baseURL: "http://localhost:3000", - plugins: [epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST })], + plugins: [epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST })], session: { cookieCache: { enabled: true, strategy: "jwe", refreshCache: true }, }, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account-member.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account-member.test.ts index 5008293fea..bf128ef54e 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account-member.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account-member.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; import { createEpAuth } from "../create-ep-auth-better"; import { createEpAuthRoutes } from "../auth-routes"; @@ -145,6 +146,7 @@ function buildAuth(options: { passwordProfileId?: string } = {}) { baseURL: "http://localhost:3000", plugins: [ epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST, passwordProfileId: options.passwordProfileId, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account.test.ts index f04fa78da6..1c060dc1a9 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-account.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; import { createEpAuth } from "../create-ep-auth-better"; const SECRET = "x".repeat(48); @@ -50,7 +51,8 @@ function buildAuth() { return betterAuth({ secret: SECRET, baseURL: "http://localhost:3000", - plugins: [epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST })], + plugins: [epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST })], session: { cookieCache: { enabled: true, strategy: "jwe", refreshCache: true }, }, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts index aca29fdc10..5b9c72c503 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts @@ -16,6 +16,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; const SECRET = "x".repeat(48); const EP_HOST = "https://api.test.elasticpath.com"; @@ -83,6 +84,7 @@ describe("/ep/anonymous endpoint (PRD #273)", () => { baseURL: "http://localhost:3000", plugins: [ epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: "static-fallback", host: "https://static.example.com", resolveConfig: async () => ({ @@ -124,6 +126,7 @@ describe("/ep/anonymous endpoint (PRD #273)", () => { baseURL: "http://localhost:3000", plugins: [ epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST, resolveConfig: async () => null, @@ -145,7 +148,8 @@ describe("/ep/anonymous endpoint (PRD #273)", () => { const auth = betterAuth({ secret: SECRET, baseURL: "http://localhost:3000", - plugins: [epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST })], + plugins: [epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST })], session: { cookieCache: { enabled: true, strategy: "jwe", refreshCache: true }, }, @@ -199,7 +203,8 @@ describe("/ep/anonymous endpoint (PRD #273)", () => { secret: SECRET, baseURL: "http://localhost", basePath: "/api/ep", - plugins: [epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST })], + plugins: [epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST })], }); const result = await (auth.api as any).epAnonymous({ diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-cart.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-cart.test.ts index c2a28317cd..a7a793199d 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-cart.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-cart.test.ts @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; const SECRET = "x".repeat(48); const EP_HOST = "https://api.test.elasticpath.com"; @@ -49,7 +50,8 @@ function buildAuth() { return betterAuth({ secret: SECRET, baseURL: "http://localhost:3000", - plugins: [epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST })], + plugins: [epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST })], session: { cookieCache: { enabled: true, strategy: "jwe", refreshCache: true }, }, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-envelope-lifetime.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-envelope-lifetime.test.ts index fbb3aeef67..f2d3d67b07 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-envelope-lifetime.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-envelope-lifetime.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; import { ENVELOPE_LIFETIME_SECONDS } from "../envelope"; const SECRET = "x".repeat(48); @@ -37,7 +38,8 @@ function buildAuth(baseURL: string) { return betterAuth({ secret: SECRET, baseURL, - plugins: [epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST })], + plugins: [epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST })], session: { expiresIn: ENVELOPE_LIFETIME_SECONDS, cookieCache: { diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-plugin.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-plugin.test.ts index 98bf6e1243..bfd2fe24fb 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-plugin.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-plugin.test.ts @@ -18,6 +18,7 @@ import { describe, expect, it } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; const SECRET = "x".repeat(48); @@ -27,6 +28,7 @@ describe("epPlugin tracer (PRD #273)", () => { secret: SECRET, plugins: [ epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: "test-client", host: "https://api.test.elasticpath.com", }), @@ -51,6 +53,7 @@ describe("epPlugin tracer (PRD #273)", () => { secret: SECRET, plugins: [ epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: "test-client", host: "https://api.test.elasticpath.com", }), diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts index 55da9d9809..f7f7f96aa1 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts @@ -18,6 +18,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; const SECRET = "x".repeat(48); const EP_HOST = "https://api.test.elasticpath.com"; @@ -55,7 +56,8 @@ function buildAuth() { return betterAuth({ secret: SECRET, baseURL: "http://localhost:3000", - plugins: [epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST })], + plugins: [epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST })], session: { cookieCache: { enabled: true, strategy: "jwe", refreshCache: true }, }, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts new file mode 100644 index 0000000000..69c55b407e --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEpAuth } from "../create-ep-auth-better"; +import { extractEpProviderConfig } from "../../extract-ep-provider-config"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; +import { buildEpCtx } from "../../../ep-server-functions/build-ep-ctx"; +import { isUsableAuth } from "../../../ep-server-functions/ep-client"; + +const CONSTRUCTOR_HOST = "https://useast.api.elasticpath.com"; +const CUSTOM_HOST = "https://commerce.acme.test"; + +let originalFetch: typeof fetch; +let originalEnvHosts: string | undefined; + +beforeEach(() => { + originalFetch = globalThis.fetch; + originalEnvHosts = process.env.EP_HOST_ALLOWLIST; + delete process.env.EP_HOST_ALLOWLIST; + globalThis.fetch = vi.fn(async (url: any) => { + if (String(url).endsWith("/oauth/access_token")) { + return new Response( + JSON.stringify({ + access_token: "tok", + token_type: "Bearer", + expires: Math.floor(Date.now() / 1000) + 3600, + expires_in: 3600, + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + throw new Error(`Unexpected URL: ${url}`); + }) as any; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + if (originalEnvHosts === undefined) { + delete process.env.EP_HOST_ALLOWLIST; + } else { + process.env.EP_HOST_ALLOWLIST = originalEnvHosts; + } + vi.restoreAllMocks(); +}); + +function bundleNaming(host: string) { + return { + bundle: { + projects: [{ globalContextsProviderFileName: "global__p.js" }], + modules: { + server: [ + { + type: "code", + fileName: "global__p.js", + code: `g.createElement(e, { + clientId:o&&"clientId"in o?o.clientId:"bundle-cid", + customHost:o&&"customHost"in o?o.customHost:"${host}", + host:o&&"host"in o?o.host:"custom" + })`, + }, + ], + }, + }, + }; +} + +function authReadingBundle( + bundle: unknown, + hostAllowlist?: string[] +): { + auth: ReturnType; + received: Array; +} { + const received: Array = []; + const auth = createEpAuth({ + clientId: "constructor-cid", + host: CONSTRUCTOR_HOST, + secret: "z".repeat(48), + hostAllowlist, + resolveConfig: async ({ hostAllowlist }) => { + received.push(hostAllowlist); + return extractEpProviderConfig(bundle as any, { hostAllowlist }); + }, + }); + return { auth, received }; +} + +function mintedHosts(): string[] { + return (globalThis.fetch as any).mock.calls.map((c: any) => + String(c[0]).replace(/\/oauth\/access_token$/, "") + ); +} + +describe("EP host allow-list parity", () => { + it("hands the option to every check when it is passed to createEpAuth", async () => { + const { auth, received } = authReadingBundle(bundleNaming(CUSTOM_HOST), [ + "commerce.acme.test", + ]); + + const session = await auth.api.getSession({ cookies: {} }); + const ctx = buildEpCtx(session); + + expect(received).toEqual([ + [...DEFAULT_HOST_ALLOWLIST, "commerce.acme.test"], + ]); + expect(auth.config.hostAllowlist).toEqual(received[0]); + expect(mintedHosts()).toEqual([CUSTOM_HOST]); + expect(session.session?.host).toBe(CUSTOM_HOST); + expect(ctx).toEqual( + expect.objectContaining({ + host: CUSTOM_HOST, + clientId: "bundle-cid", + accessToken: "tok", + }) + ); + }); + + it("hands EP_HOST_ALLOWLIST to every check when no option is passed", async () => { + process.env.EP_HOST_ALLOWLIST = " commerce.acme.test , ,"; + const { auth, received } = authReadingBundle(bundleNaming(CUSTOM_HOST)); + + const session = await auth.api.getSession({ cookies: {} }); + + expect(received).toEqual([ + [...DEFAULT_HOST_ALLOWLIST, "commerce.acme.test"], + ]); + expect(mintedHosts()).toEqual([CUSTOM_HOST]); + expect(buildEpCtx(session).host).toBe(CUSTOM_HOST); + }); + + it("extends the defaults with the union of the option and the env var", () => { + process.env.EP_HOST_ALLOWLIST = "*.acme.test,commerce.acme.test"; + const { auth } = authReadingBundle(bundleNaming(CUSTOM_HOST), [ + "commerce.acme.test", + "elasticpath.com", + ]); + + expect(auth.config.hostAllowlist).toEqual([ + ...DEFAULT_HOST_ALLOWLIST, + "commerce.acme.test", + "*.acme.test", + ]); + expect(Object.isFrozen(auth.config.hostAllowlist)).toBe(true); + }); + + it("mints against the constructor host when the bundle names a host off the list", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const { auth } = authReadingBundle(bundleNaming(CUSTOM_HOST)); + + const session = await auth.api.getSession({ cookies: {} }); + + expect(mintedHosts()).toEqual([CONSTRUCTOR_HOST]); + expect(session.session?.host).toBe(CONSTRUCTOR_HOST); + expect(buildEpCtx(session).host).toBe(CONSTRUCTOR_HOST); + expect(errorSpy).toHaveBeenCalledTimes(1); + const message = String(errorSpy.mock.calls[0][0]); + expect(message).toContain("commerce.acme.test"); + expect(message).toContain("EP host allow-list"); + expect(message).toContain("hostAllowlist"); + expect(message).toContain("EP_HOST_ALLOWLIST"); + expect(message).toContain("custom domain"); + expect(message).not.toMatch(/Self Managed/); + }); + + it("rejects an off-list host the closure returns without the extractor", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const auth = createEpAuth({ + clientId: "constructor-cid", + host: CONSTRUCTOR_HOST, + secret: "z".repeat(48), + resolveConfig: async () => ({ clientId: "bundle-cid", host: CUSTOM_HOST }), + }); + + const session = await auth.api.getSession({ cookies: {} }); + + expect(mintedHosts()).toEqual([CONSTRUCTOR_HOST]); + expect(session.session?.clientId).toBe("constructor-cid"); + expect(errorSpy).toHaveBeenCalledTimes(1); + expect(String(errorSpy.mock.calls[0][0])).toContain("EP_HOST_ALLOWLIST"); + }); + + it("builds a context the server functions refuse from an empty session", () => { + expect(isUsableAuth(buildEpCtx({ session: null, cart: null }))).toBe(false); + }); +}); diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/identity-client.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/identity-client.test.ts index 8ff712029f..8867888424 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/identity-client.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/identity-client.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createEpAuth } from "../create-ep-auth-better"; import { createEpAuthRoutes } from "../auth-routes"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; import { createEpIdentityClient } from "../../../identity/client"; import { EP_IDENTITY_OPERATION_NAMES, @@ -247,7 +248,8 @@ describe("the identity client against the mounted handler", () => { describe("the client and the plugin agree on where every operation lives", () => { it("mounts every operation the client calls, at the path it calls", () => { const endpoints = ( - epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST }) as any + epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST }) as any ).endpoints as Record; const mounted = new Map(); @@ -268,7 +270,8 @@ describe("the client and the plugin agree on where every operation lives", () => it("leaves no endpoint on the plugin without a client method", () => { const endpoints = ( - epPlugin({ clientId: EP_CLIENT_ID, host: EP_HOST }) as any + epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST }) as any ).endpoints as Record; const called = new Set( diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/session-cart.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/session-cart.test.ts index 3842f2228c..64f96152a3 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/session-cart.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/session-cart.test.ts @@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { betterAuth } from "better-auth"; import { epPlugin } from "../ep-plugin"; +import { DEFAULT_HOST_ALLOWLIST } from "../../host-allowlist"; import { getCurrentEpSession } from "../../../ep-server-functions/session-context"; import type { EpSessionCartResolver } from "../session-cart"; @@ -184,6 +185,7 @@ function buildAuth(options: { sessionCartResolver?: EpSessionCartResolver } = {} baseURL: "http://localhost:3000", plugins: [ epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, clientId: EP_CLIENT_ID, host: EP_HOST, passwordProfileId: PROFILE, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/create-ep-auth-better.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/create-ep-auth-better.ts index 6720ab3d03..c86c6a3d13 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/create-ep-auth-better.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/create-ep-auth-better.ts @@ -25,6 +25,7 @@ import { betterAuth } from "better-auth"; import { nextCookies } from "better-auth/next-js"; import { epPlugin } from "./ep-plugin"; +import type { EpResolveConfig } from "./ep-plugin"; import { DEFAULT_HOST_ALLOWLIST } from "../host-allowlist"; import { assertNonSentinelSecret, @@ -55,7 +56,15 @@ export interface CreateEpAuthBetterInput { * both the localhost and 127.0.0.1 variants of `baseURL`. */ trustedOrigins?: string[]; - hostAllowlist?: string[]; + /** + * Hosts, beyond the Elastic Path-operated defaults, that an EP API host + * read from the Plasmic bundle may name — for a store whose Elastic Path + * API is served from a custom domain. Glob patterns such as + * `*.acme.example` are honoured. Entries extend the defaults, as do those + * in the comma-separated `EP_HOST_ALLOWLIST` environment variable. The + * resolved list is `config.hostAllowlist`. + */ + hostAllowlist?: readonly string[]; /** * @deprecated Read by no code. `sessionCartResolver` replaces it; this is * removed in the breaking release. @@ -80,13 +89,12 @@ export interface CreateEpAuthBetterInput { checkout?: { sessionSecret: string }; adapters?: { stripe?: { secretKey: string }; clover?: any }; /** - * Per-request resolver for clientId/host. Lets the consumer pull - * config from the Plasmic loader bundle on each call instead of - * pinning at factory construction. Forwarded directly to `epPlugin`. + * Per-mint resolver for clientId/host. Lets the consumer pull config from + * the Plasmic loader bundle instead of pinning it at factory construction. + * It is handed the resolved EP host allow-list to pass to + * `extractEpProviderConfig`. */ - resolveConfig?: () => Promise< - { clientId?: string; host?: string } | null | undefined - >; + resolveConfig?: EpResolveConfig; /** * The password profile account members sign in against. Discovered from the * store when omitted; required when the store's authentication realm carries @@ -129,6 +137,21 @@ function resolveTrustedOrigins( return [...out]; } +/** The defaults, extended (never replaced) by the option and the env var. */ +function resolveHostAllowlist( + configured: readonly string[] | undefined +): readonly string[] { + const out = new Set(DEFAULT_HOST_ALLOWLIST); + for (const host of [ + ...(configured ?? []), + ...(process.env.EP_HOST_ALLOWLIST ?? "").split(","), + ]) { + const trimmed = host.trim(); + if (trimmed) out.add(trimmed); + } + return Object.freeze([...out]); +} + export interface EpSessionData { accessToken: string; expires: number; @@ -244,6 +267,7 @@ export function createEpAuth(input: CreateEpAuthBetterInput): EpAuth { const basePath = input.basePath ?? "/api/ep"; const baseURL = input.baseURL ?? "http://localhost"; const trustedOrigins = resolveTrustedOrigins(input.trustedOrigins, baseURL); + const hostAllowlist = resolveHostAllowlist(input.hostAllowlist); const auth = betterAuth({ secret, @@ -254,7 +278,7 @@ export function createEpAuth(input: CreateEpAuthBetterInput): EpAuth { epPlugin({ clientId: input.clientId, host: input.host, - hostAllowlist: input.hostAllowlist, + hostAllowlist, resolveConfig: input.resolveConfig, passwordProfileId: input.passwordProfileId, sessionCartResolver: input.sessionCartResolver, @@ -280,7 +304,7 @@ export function createEpAuth(input: CreateEpAuthBetterInput): EpAuth { const config = Object.freeze({ basePath, trustedOrigins, - hostAllowlist: input.hostAllowlist ?? DEFAULT_HOST_ALLOWLIST, + hostAllowlist, cartMergeStrategy: input.cartMergeStrategy ?? "merge", checkout: input.checkout, adapters: input.adapters, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts index aa7d38ba38..8ce0a10ec0 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts @@ -21,11 +21,7 @@ import type { EpSelectAccountRequest, EpSetCartRequest, } from "../../identity/operations"; -import { - DEFAULT_HOST_ALLOWLIST, - isAllowedEpHost, - reportRejectedEpHost, -} from "../host-allowlist"; +import { isAllowedEpHost, reportRejectedEpHost } from "../host-allowlist"; import { EP_ACCOUNT_TOKEN_HEADER, accountNeedsRoll, @@ -65,17 +61,15 @@ export interface EpPluginOptions { /** Default host. Same fallback rules as `clientId`. */ host: string; /** - * Optional async resolver. Invoked on every endpoint call. Lets the - * consumer pull config from elsewhere — e.g. the Plasmic loader bundle - * via `extractEpProviderConfig(prefetchedData)` — instead of pinning - * static values at plugin construction. The legacy auth had this same - * shape via the `x-ep-client-id` / `x-ep-host` middleware-header - * escape hatch in createEpSession; here we just lift it to a function. + * Optional async resolver. Invoked on every mint. Lets the consumer pull + * config from elsewhere — e.g. the Plasmic loader bundle via + * `extractEpProviderConfig(prefetchedData, { hostAllowlist })` — instead + * of pinning static values at plugin construction. A host it returns is + * admitted only if it is on `hostAllowlist`. */ - resolveConfig?: () => Promise< - { clientId?: string; host?: string } | null | undefined - >; - hostAllowlist?: readonly string[]; + resolveConfig?: EpResolveConfig; + /** The resolved EP host allow-list; `createEpAuth` supplies it. */ + hostAllowlist: readonly string[]; /** * The password profile account members sign in against. Discovered from the * store when omitted; required when the store's realm carries more than one, @@ -90,6 +84,10 @@ export interface EpPluginOptions { sessionCartResolver?: EpSessionCartResolver; } +export type EpResolveConfig = (input: { + hostAllowlist: readonly string[]; +}) => Promise<{ clientId?: string; host?: string } | null | undefined>; + interface EpAnonymousTokenResponse { access_token: string; expires: number; @@ -133,12 +131,12 @@ function generateAnonymousId(): string { } async function resolveConfigFor(options: EpPluginOptions) { + const { hostAllowlist } = options; const resolved = options.resolveConfig - ? await options.resolveConfig().catch(() => null) + ? await options.resolveConfig({ hostAllowlist }).catch(() => null) : null; - const allowlist = options.hostAllowlist ?? DEFAULT_HOST_ALLOWLIST; - if (resolved?.host && !isAllowedEpHost(resolved.host, allowlist)) { - reportRejectedEpHost(resolved.host, "epPlugin.resolveConfig", allowlist); + if (resolved?.host && !isAllowedEpHost(resolved.host, hostAllowlist)) { + reportRejectedEpHost(resolved.host, "epPlugin.resolveConfig", hostAllowlist); // clientId is only valid against the host it was resolved with. return { clientId: options.clientId, host: options.host }; } diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/extract-ep-provider-config.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/extract-ep-provider-config.ts index 9e2fb702e7..5e7ae74072 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/extract-ep-provider-config.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/extract-ep-provider-config.ts @@ -19,11 +19,7 @@ * returns `null`, letting callers fall through to whatever defaults / env * vars they prefer. */ -import { - DEFAULT_HOST_ALLOWLIST, - isAllowedEpHost, - reportRejectedEpHost, -} from "./host-allowlist"; +import { isAllowedEpHost, reportRejectedEpHost } from "./host-allowlist"; export interface EpProviderBundleConfig { /** clientId configured on the EP Provider global context */ @@ -155,14 +151,17 @@ function resolveCandidateHost(c: ProviderCandidate): string | undefined { } export interface ExtractEpProviderConfigOptions { - hostAllowlist?: readonly string[]; + /** + * The EP host allow-list. Inside `resolveConfig` it is the argument the + * plugin hands in; anywhere else read it from `epAuth.config.hostAllowlist`. + */ + hostAllowlist: readonly string[]; } export function extractEpProviderConfig( prefetchedData: ServerBundleLike | null | undefined, - opts?: ExtractEpProviderConfigOptions + { hostAllowlist }: ExtractEpProviderConfigOptions ): EpProviderBundleConfig | null { - const hostAllowlist = opts?.hostAllowlist ?? DEFAULT_HOST_ALLOWLIST; const allMods: BundleModule[] = [ ...(prefetchedData?.bundle?.modules?.server ?? []), ...(prefetchedData?.bundle?.modules?.browser ?? []), diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/host-allowlist.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/host-allowlist.ts index 2c05a9eb18..1460ab3e15 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/host-allowlist.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/host-allowlist.ts @@ -46,10 +46,10 @@ export function reportRejectedEpHost( allowlist: readonly string[] ): void { console.error( - `[ep-commerce] ${source}: EP API host "${host}" is not in the host ` + - `allowlist (${allowlist.join(", ")}), so it was ignored. Elastic Path ` + - `Self Managed Commerce deployments must pass their host via the ` + - `\`hostAllowlist\` option on createEpAuth, extractEpProviderConfig, ` + - `and buildEpCtx.` + `[ep-commerce] ${source}: EP API host "${host}" is not on the EP host ` + + `allow-list (${allowlist.join(", ")}), so it was ignored. If this ` + + `store's Elastic Path API is served from a custom domain, add that ` + + `host with the \`hostAllowlist\` option on createEpAuth or the ` + + `EP_HOST_ALLOWLIST environment variable.` ); } diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/index.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/index.ts index da785969fd..fc20c4a80f 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/index.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/index.ts @@ -8,7 +8,7 @@ * `better-auth/next-js`. */ export { epPlugin } from "./ep-plugin/ep-plugin"; -export type { EpPluginOptions } from "./ep-plugin/ep-plugin"; +export type { EpPluginOptions, EpResolveConfig } from "./ep-plugin/ep-plugin"; export { createEpAuth, createEpAuth as createBetterEpAuth, diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/__tests__/build-ep-ctx.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/__tests__/build-ep-ctx.test.ts index bd59be2ac3..7c24b5fc3a 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/__tests__/build-ep-ctx.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/__tests__/build-ep-ctx.test.ts @@ -1,123 +1,73 @@ -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { buildEpCtx } = require("../build-ep-ctx"); +import { buildEpCtx } from "../build-ep-ctx"; +import { isUsableAuth } from "../ep-client"; -const EP_PROVIDER_MODULE = ` -function E(r){ - return g.createElement(e, { - clientId:o&&"clientId"in o?o.clientId:"cid-abc", - customHost:o&&"customHost"in o?o.customHost:"https://epcc-integration.global.ssl.fastly.net", - host:o&&"host"in o?o.host:"custom", - serverCartMode:o&&"serverCartMode"in o?o.serverCartMode:!0 - }); -} -`; +const HOST = "https://epcc-integration.global.ssl.fastly.net"; -const makePrefetchedData = () => ({ - bundle: { - projects: [{ globalContextsProviderFileName: "global__p.js" }], - modules: { - server: [ - { type: "code", fileName: "global__p.js", code: EP_PROVIDER_MODULE }, - ], +function sessionWith( + overrides: Record = {}, + cart: { id: string } | null = null +) { + return { + session: { + accessToken: "tok-abc", + expires: 1786630149, + clientId: "cid-abc", + host: HOST, + account: null, + lapsedAccount: null, + ...overrides, }, - }, -}); + cart, + }; +} describe("buildEpCtx", () => { - it("returns clientId and host resolved from the Plasmic bundle", () => { - const ctx = buildEpCtx(makePrefetchedData(), { - session: { accessToken: "tok-abc" }, + it("carries the host and client id the session was minted against", () => { + expect(buildEpCtx(sessionWith(), { locale: "en-GB" })).toEqual({ + accessToken: "tok-abc", + host: HOST, + clientId: "cid-abc", + cartId: undefined, + accountId: undefined, + accountToken: undefined, + locale: "en-GB", + currency: undefined, }); - - expect(ctx).toEqual( - expect.objectContaining({ - clientId: "cid-abc", - host: "https://epcc-integration.global.ssl.fastly.net", - accessToken: "tok-abc", - }) - ); }); - it("produces an anonymous ctx (empty accessToken, no cartId/accountId) when the session is empty", () => { - const ctx = buildEpCtx(makePrefetchedData(), { session: {} }); - - expect(ctx.accessToken).toBe(""); - expect(ctx.cartId).toBeUndefined(); - expect(ctx.accountId).toBeUndefined(); - expect(ctx.clientId).toBe("cid-abc"); - expect(ctx.host).toBe("https://epcc-integration.global.ssl.fastly.net"); + it("carries the session cart", () => { + expect(buildEpCtx(sessionWith({}, { id: "cart-1" })).cartId).toBe("cart-1"); }); it("carries the selected organisation's id and credential", () => { - const ctx = buildEpCtx(makePrefetchedData(), { - session: { - accessToken: "tok-abc", + const ctx = buildEpCtx( + sessionWith({ account: { id: "acct-1", name: "Acme Industrial", token: "account-management-token", expires: 1786630149, }, - }, - }); + }) + ); expect(ctx.accountId).toBe("acct-1"); expect(ctx.accountToken).toBe("account-management-token"); }); it("carries no account credential when no organisation is selected", () => { - const ctx = buildEpCtx(makePrefetchedData(), { - session: { accessToken: "tok-abc", account: null }, - }); + const ctx = buildEpCtx(sessionWith()); expect(ctx.accountId).toBeUndefined(); expect(ctx.accountToken).toBeUndefined(); }); - it("rejects a host outside the allowlist, and accepts it once the deployment opts in", () => { - const smcData = { - bundle: { - projects: [{ globalContextsProviderFileName: "global__p.js" }], - modules: { - server: [ - { - type: "code", - fileName: "global__p.js", - code: EP_PROVIDER_MODULE.replace( - "https://epcc-integration.global.ssl.fastly.net", - "https://commerce.selfmanaged.example" - ), - }, - ], - }, - }, - }; - const errorSpy = jest - .spyOn(console, "error") - .mockImplementation(() => undefined); + it("yields a context the server functions refuse when the session is empty", () => { + const ctx = buildEpCtx({ session: null, cart: null }); - expect(() => - buildEpCtx(smcData, { session: { accessToken: "tok" } }) - ).toThrow(/EP Provider config not found/); - expect(errorSpy).toHaveBeenCalledWith( - expect.stringContaining("commerce.selfmanaged.example") + expect(ctx).toEqual( + expect.objectContaining({ accessToken: "", host: "", clientId: "" }) ); - - const ctx = buildEpCtx(smcData, { - session: { accessToken: "tok" }, - hostAllowlist: ["commerce.selfmanaged.example"], - }); - expect(ctx.host).toBe("https://commerce.selfmanaged.example"); - - errorSpy.mockRestore(); - }); - - it("throws a clear error when prefetchedData has no EP Provider config", () => { - expect(() => - buildEpCtx( - { bundle: { projects: [], modules: { server: [] } } }, - { session: { accessToken: "tok" } } - ) - ).toThrow(/EP Provider config not found/); + expect(isUsableAuth(ctx)).toBe(false); }); }); diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts index a1c2b14b12..d77e33f4f0 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts @@ -1,34 +1,21 @@ /** * Composes the EP session payload that drives every `ep.*` server function - * (per PRD #262 / #272). Takes the Plasmic loader's prefetchedData (source - * of connection/config — clientId, host) and the resolved EP session - * (source of per-shopper auth — accessToken, cartId, selected account). + * from the shopper session `epAuth.api.getSession()` returns. The envelope + * already carries the host and client id admitted when it was minted. * * Consumers call this in their RSC catchall page, then run Server Queries * inside a `withEpSession` scope so each function reads the session via * AsyncLocalStorage instead of a per-call `auth` argument: - * const epCtx = buildEpCtx(prefetchedData, { session }); + * const epCtx = buildEpCtx(session); * const prefetchedQueryData = await withEpSession(epCtx, () => * PLASMIC.unstable__getServerQueriesData(prefetchedData, queryCtx) * ); + * + * An empty session, from a failed mint, yields an empty context, which the + * server functions refuse to run with. */ -import { extractEpProviderConfig } from "../auth/extract-ep-provider-config"; - -export interface BuildEpCtxAccountInput { - id: string; - name?: string; - token: string; - expires?: number; -} - -export interface BuildEpCtxSessionInput { - accessToken?: string; - cartId?: string; - account?: BuildEpCtxAccountInput | null; - locale?: string; - currency?: string; -} +import type { EpSession } from "../auth/ep-plugin/create-ep-auth-better"; export interface EpCtx { accessToken: string; @@ -42,29 +29,18 @@ export interface EpCtx { } export function buildEpCtx( - prefetchedData: unknown, - opts: { - session: BuildEpCtxSessionInput; - hostAllowlist?: readonly string[]; - } + session: Pick, + opts: { locale?: string; currency?: string } = {} ): EpCtx { - const config = extractEpProviderConfig(prefetchedData as any, { - hostAllowlist: opts.hostAllowlist, - }); - if (!config) { - throw new Error( - "buildEpCtx: EP Provider config not found in prefetchedData. " + - "Ensure the project has an EP Commerce Provider global context configured in Studio." - ); - } + const data = session.session; return { - accessToken: opts.session.accessToken ?? "", - host: config.host, - clientId: config.clientId, - cartId: opts.session.cartId, - accountId: opts.session.account?.id, - accountToken: opts.session.account?.token, - locale: opts.session.locale, - currency: opts.session.currency, + accessToken: data?.accessToken ?? "", + host: data?.host ?? "", + clientId: data?.clientId ?? "", + cartId: session.cart?.id, + accountId: data?.account?.id, + accountToken: data?.account?.token, + locale: opts.locale, + currency: opts.currency, }; } diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/index.ts b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/index.ts index de3aa6b178..d81742b05e 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/index.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/index.ts @@ -61,8 +61,6 @@ export type { // `register-custom-functions.ts` for why these are the adapted forms. export { buildEpCtx } from "./build-ep-ctx"; export type { - BuildEpCtxAccountInput, - BuildEpCtxSessionInput, EpCtx, } from "./build-ep-ctx"; export { diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/server.ts b/plasmicpkgs/commerce-providers/elastic-path/src/server.ts index 42e235c7c0..9bc5046fdf 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/server.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/server.ts @@ -96,6 +96,7 @@ export type { EpPluginOptions, EpProviderBundleConfig, EpProxyRoutes, + EpResolveConfig, EpSession, EpSessionCartResolver, EpSessionCartResolverInput, @@ -153,8 +154,6 @@ export { } from "./ep-server-functions"; export type { AddCustomCartItemInput, - BuildEpCtxAccountInput, - BuildEpCtxSessionInput, CartAdjustmentKind, EpAddCartItemInput, EpApplyCartAdjustmentInput, From 0fa5c353153a1d07a76ee741e3a60a762924a775 Mon Sep 17 00:00:00 2001 From: Robert Field Date: Fri, 25 Sep 2026 13:56:59 +0100 Subject: [PATCH 2/4] refactor(ep-commerce-app-router): pass the session to buildEpCtx and drop allow-list parsing createEpAuth now reads EP_HOST_ALLOWLIST and hands the list to resolveConfig. The deprecated epProviderHeaders helper is deleted. --- .../ep-commerce-app-router/.env.local.example | 10 ++--- .../app/[[...catchall]]/page.tsx | 12 +----- .../lib/checkout-context.ts | 2 +- .../ep-commerce-app-router/lib/ep-auth.ts | 38 +++---------------- 4 files changed, 14 insertions(+), 48 deletions(-) diff --git a/examples/ep-commerce-app-router/.env.local.example b/examples/ep-commerce-app-router/.env.local.example index 11a14e410e..86a4f3588c 100644 --- a/examples/ep-commerce-app-router/.env.local.example +++ b/examples/ep-commerce-app-router/.env.local.example @@ -42,11 +42,11 @@ NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key # cross-origin preview drive the cart. # BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3003 -# Comma-separated host patterns the EP API may be reached at. Defaults to -# Elastic Path Composable Commerce regions plus the integration host, with -# loopback allowed outside production. Elastic Path Self Managed Commerce -# deployments must list their own host here. -# EP_HOST_ALLOWLIST=commerce.internal.example +# Comma-separated host patterns the EP Provider in Studio may name, on top of +# the Elastic Path-operated defaults (loopback is also allowed outside +# production). Set it when this store's Elastic Path API is served from a +# custom domain. +# EP_HOST_ALLOWLIST=commerce.acme.example # Plasmic project and codegen origin. Both default to the "Elastic Path # Storefront Starter" project on Elastic Path's integration instance. Point diff --git a/examples/ep-commerce-app-router/app/[[...catchall]]/page.tsx b/examples/ep-commerce-app-router/app/[[...catchall]]/page.tsx index 9dd2c26558..e6a3cf255a 100644 --- a/examples/ep-commerce-app-router/app/[[...catchall]]/page.tsx +++ b/examples/ep-commerce-app-router/app/[[...catchall]]/page.tsx @@ -7,7 +7,7 @@ import { } from "@elasticpath/plasmic-ep-commerce-elastic-path/server"; import { notFound } from "next/navigation"; import { cookies } from "next/headers"; -import { epAuth, EP_HOST_ALLOWLIST } from "@/lib/ep-auth"; +import { epAuth } from "@/lib/ep-auth"; export const revalidate = 60; @@ -45,18 +45,10 @@ export default async function PlasmicLoaderPage({ ), }); - // --------------------------------------------------------------------------- // Build EP session context + run Studio Server Queries (PRD #262 / #272) // --------------------------------------------------------------------------- - const epCtx = buildEpCtx(prefetchedData, { - session: { - accessToken: session.session?.accessToken, - cartId: session.cart?.id ?? undefined, - account: session.session?.account ?? null, - }, - hostAllowlist: EP_HOST_ALLOWLIST, - }); + const epCtx = buildEpCtx(session); const queryCtx = { pageRoute: pageMeta.path, diff --git a/examples/ep-commerce-app-router/lib/checkout-context.ts b/examples/ep-commerce-app-router/lib/checkout-context.ts index fba77b53e3..311c47e861 100644 --- a/examples/ep-commerce-app-router/lib/checkout-context.ts +++ b/examples/ep-commerce-app-router/lib/checkout-context.ts @@ -40,7 +40,7 @@ export interface RequestCheckoutContext { export async function buildCheckoutContext( request: Request ): Promise { - const config = await getEpProviderConfig(); + const config = await getEpProviderConfig(epAuth.config.hostAllowlist); const clientId = config?.clientId ?? process.env.EP_CLIENT_ID ?? diff --git a/examples/ep-commerce-app-router/lib/ep-auth.ts b/examples/ep-commerce-app-router/lib/ep-auth.ts index a1cbda5125..329b126984 100644 --- a/examples/ep-commerce-app-router/lib/ep-auth.ts +++ b/examples/ep-commerce-app-router/lib/ep-auth.ts @@ -27,20 +27,15 @@ import { PLASMIC } from "@/plasmic-init"; */ const SECRET = process.env.CHECKOUT_SESSION_SECRET; -export const EP_HOST_ALLOWLIST = process.env.EP_HOST_ALLOWLIST?.split(",") - .map((h) => h.trim()) - .filter(Boolean); - export const epAuth = createBetterEpAuth({ clientId: "bootstrap-placeholder", host: "https://useast.api.elasticpath.com", secret: SECRET, baseURL: process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3456", basePath: "/api/ep", - hostAllowlist: EP_HOST_ALLOWLIST, passwordProfileId: process.env.EP_PASSWORD_PROFILE_ID, - resolveConfig: async () => { - const config = await getEpProviderConfig(); + resolveConfig: async ({ hostAllowlist }) => { + const config = await getEpProviderConfig(hostAllowlist); if (!config) return null; return { clientId: config.clientId, host: config.host }; }, @@ -55,7 +50,9 @@ export const epAuth = createBetterEpAuth({ * bundle which gets refetched when the project version bumps. */ let _configPromise: Promise | null = null; -export function getEpProviderConfig(): Promise { +export function getEpProviderConfig( + hostAllowlist: readonly string[] +): Promise { if (!_configPromise) { _configPromise = (async () => { // The EP Provider globalContext config is part of the project bundle — @@ -66,31 +63,8 @@ export function getEpProviderConfig(): Promise { const pages = await PLASMIC.fetchPages(); if (pages.length === 0) return null; const data = await PLASMIC.maybeFetchComponentData(pages[0].path); - return extractEpProviderConfig(data, { - hostAllowlist: EP_HOST_ALLOWLIST, - }); + return extractEpProviderConfig(data, { hostAllowlist }); })(); } return _configPromise; } - -/** - * @deprecated PRD #273 — `resolveConfig` on `createBetterEpAuth` makes this - * redundant. Kept temporarily for any caller still passing - * `epProviderHeaders()` to `epAuth.api.getSession({headers: ...})`. - * The new auth ignores the headers; remove call sites and delete this - * helper after the next release. - */ -export async function epProviderHeaders( - prefetchedData?: unknown -): Promise> { - const fromData = prefetchedData - ? extractEpProviderConfig(prefetchedData as any) - : null; - const config = fromData ?? (await getEpProviderConfig()); - if (!config) return {}; - return { - "x-ep-client-id": config.clientId, - "x-ep-host": config.host, - }; -} From fa3e0850e0454da22528f22a708fa82e1891e1e3 Mon Sep 17 00:00:00 2001 From: Robert Field Date: Fri, 25 Sep 2026 13:59:42 +0100 Subject: [PATCH 3/4] docs(commerce-elastic-path): tighten the allow-list docs and drop a stale reference --- .../elastic-path/CHANGELOG.md | 10 ++++------ .../commerce-providers/elastic-path/README.md | 19 +++++++------------ .../src/ep-server-functions/build-ep-ctx.ts | 12 ++++++------ .../src/ep-server-functions/types.ts | 4 ++-- 4 files changed, 19 insertions(+), 26 deletions(-) diff --git a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md index 22c9403044..695fed1140 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md +++ b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md @@ -7,11 +7,8 @@ There is one EP host allow-list, and `createEpAuth` resolves it: the Elastic Path-operated defaults, plus the `hostAllowlist` option, plus the comma-separated `EP_HOST_ALLOWLIST` environment variable (ADR-0005). Your -entries extend the defaults rather than replacing them. The list was applied -separately by three functions, each falling back to the defaults, so a list -passed to only some of them failed in a different silent way at each miss. -Pass it once, to `createEpAuth`, and delete your own `EP_HOST_ALLOWLIST` -parsing. +entries extend the defaults rather than replacing them. Pass it once, to +`createEpAuth`, and delete your own `EP_HOST_ALLOWLIST` parsing. | Was | Now | | --- | --- | @@ -23,7 +20,8 @@ parsing. `buildEpCtx` reads the host and client id from the session, which carries the ones admitted when it was minted, so the page and the auth routes use the same Elastic Path host by construction. An empty session yields an empty context, -which the server functions refuse to run with, as before. Code outside +which the server functions refuse to run with, as before. `buildEpCtx` no +longer throws when the page's bundle has no EP Provider. Code outside `resolveConfig` that calls `extractEpProviderConfig` passes `epAuth.config.hostAllowlist`. `locale` and `currency` move to an optional second argument, and the `BuildEpCtxSessionInput` and `BuildEpCtxAccountInput` diff --git a/plasmicpkgs/commerce-providers/elastic-path/README.md b/plasmicpkgs/commerce-providers/elastic-path/README.md index eec0c04d7e..ebec32b66e 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/README.md +++ b/plasmicpkgs/commerce-providers/elastic-path/README.md @@ -242,18 +242,13 @@ Two options tighten the deployment further: | `trustedOrigins` | the app's own origin | another origin must act as the shopper (e.g. Studio preview) | | `hostAllowlist` | Elastic Path Composable Commerce regions, `*.epcloudops.com`, the integration host, and loopback outside production | this store's Elastic Path API is served from a custom domain | -The EP API host comes from the EP Provider in the Plasmic bundle, which -designers edit, so it is checked against the **EP host allow-list**: -the defaults, plus `hostAllowlist`, plus the comma-separated -`EP_HOST_ALLOWLIST` environment variable. Your entries extend the defaults; -they never replace them. `createEpAuth` resolves the list once and exposes it -as `epAuth.config.hostAllowlist`. It hands the list to your `resolveConfig` -callback, and the host is admitted when the session is minted, so -`buildEpCtx` reads it from the session and needs no list. Code outside -`resolveConfig` that calls `extractEpProviderConfig` passes -`epAuth.config.hostAllowlist`. A host that is not on the list is logged and -ignored, and the session uses the `host` passed to `createEpAuth`, which is -your own configuration and is not checked. +The EP API host named in the Plasmic bundle is checked against the **EP host +allow-list**: the defaults, plus `hostAllowlist`, plus the comma-separated +`EP_HOST_ALLOWLIST` environment variable. Your entries extend the defaults. +`createEpAuth` resolves the list once, hands it to `resolveConfig`, and +exposes it as `epAuth.config.hostAllowlist` for any other caller of +`extractEpProviderConfig`. A host off the list is logged and ignored, and the +session falls back to the `host` passed to `createEpAuth` (ADR-0005). ## Architecture diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts index d77e33f4f0..aaac7cac17 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/build-ep-ctx.ts @@ -32,14 +32,14 @@ export function buildEpCtx( session: Pick, opts: { locale?: string; currency?: string } = {} ): EpCtx { - const data = session.session; + const envelope = session.session; return { - accessToken: data?.accessToken ?? "", - host: data?.host ?? "", - clientId: data?.clientId ?? "", + accessToken: envelope?.accessToken ?? "", + host: envelope?.host ?? "", + clientId: envelope?.clientId ?? "", cartId: session.cart?.id, - accountId: data?.account?.id, - accountToken: data?.account?.token, + accountId: envelope?.account?.id, + accountToken: envelope?.account?.token, locale: opts.locale, currency: opts.currency, }; diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/types.ts b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/types.ts index c82a684953..f78c7b1e89 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/types.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/types.ts @@ -1,7 +1,7 @@ /** * Resolved EP connection + auth context. Built by the consumer's RSC - * catchall page from `extractEpProviderConfig(prefetchedData)` (connection) - * and `epAuth.api.getSession()` (per-shopper auth), then handed to + * catchall page with `buildEpCtx(await epAuth.api.getSession(...))`, then + * handed to * `withEpSession()` so every `ep.*` server function can read it via * AsyncLocalStorage (per PRD #272). * From a3d7a2464465e3e106b205b4aded4b37c12b7a4b Mon Sep 17 00:00:00 2001 From: Robert Field Date: Fri, 25 Sep 2026 15:01:18 +0100 Subject: [PATCH 4/4] fix(commerce-elastic-path): fail soft when the shopper token mint is refused The mint's error escaped /ep/anonymous and /ep/refresh, so getSession threw instead of returning its empty session and every page returned 500. Both endpoints now answer 502 shopper_token_mint_failed and log the cause. --- .../elastic-path/CHANGELOG.md | 8 +++++ .../ep-plugin/__tests__/ep-anonymous.test.ts | 31 +++++++++++++++++++ .../ep-plugin/__tests__/ep-refresh.test.ts | 16 ++++++++++ .../__tests__/host-allowlist-parity.test.ts | 13 ++++++++ .../src/auth/ep-plugin/ep-plugin.ts | 23 ++++++++++++-- 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md index 695fed1140..d1952af1ff 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md +++ b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md @@ -128,6 +128,14 @@ else, so no member profile field (name, email) is in the contract. ### Fixed +A page whose shopper token could not be minted renders without commerce data +instead of failing with a 500. `getSession` was meant to return an empty +session when the anonymous mint failed, but the mint's error escaped the +endpoint, so the empty session was never reached. `/ep/anonymous` and +`/ep/refresh` now answer 502 with `shopper_token_mint_failed` and log the +cause. An Elastic Path outage, or a Studio host that is not on the EP host +allow-list with no working fallback, reaches this path. + `ep.applyCartAdjustment` is dispatchable from the browser. It has been registered as a Studio mutation since it landed, but had no entry in the proxy route's dispatch table, so an adjustment a designer wired to an onClick — a diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts index 5b9c72c503..6b4465beb2 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-anonymous.test.ts @@ -53,6 +53,37 @@ afterEach(() => { }); describe("/ep/anonymous endpoint (PRD #273)", () => { + it("answers 502 with a code, rather than throwing, when Elastic Path refuses the mint", async () => { + (globalThis.fetch as any).mockImplementation( + async () => new Response("down", { status: 503 }) + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const auth = betterAuth({ + secret: SECRET, + baseURL: "http://localhost:3000", + plugins: [ + epPlugin({ + hostAllowlist: DEFAULT_HOST_ALLOWLIST, + clientId: EP_CLIENT_ID, + host: EP_HOST, + }), + ], + }); + + const result = await (auth.api as any).epAnonymous({ + body: {}, + headers: new Headers(), + asResponse: true, + }); + + expect(result.status).toBe(502); + expect((await result.json()).code).toBe("shopper_token_mint_failed"); + expect(result.headers.get("set-cookie")).toBeNull(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("EP OAuth failed (503)") + ); + }); + it("uses resolveConfig() over static options when provided", async () => { // The legacy auth's middleware-header escape hatch (`x-ep-client-id` / // `x-ep-host`) is preserved here as a per-request `resolveConfig` diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts index f7f7f96aa1..f33bb70c08 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/ep-refresh.test.ts @@ -80,6 +80,22 @@ function cookiesToHeaderValue(setCookies: string[]): string { } describe("/ep/refresh endpoint (PRD #273)", () => { + it("answers 502 with a code, rather than throwing, when Elastic Path refuses the mint", async () => { + (globalThis.fetch as any).mockImplementation( + async () => new Response("down", { status: 503 }) + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + + const result = await (buildAuth().api as any).epRefresh({ + body: {}, + headers: new Headers(), + asResponse: true, + }); + + expect(result.status).toBe(502); + expect((await result.json()).code).toBe("shopper_token_mint_failed"); + }); + it("rotates epAccessToken while preserving session identity", async () => { const auth = buildAuth(); diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts index 69c55b407e..2aa4278489 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts @@ -177,6 +177,19 @@ describe("EP host allow-list parity", () => { expect(String(errorSpy.mock.calls[0][0])).toContain("EP_HOST_ALLOWLIST"); }); + it("fails soft when the fallback mint is refused", async () => { + (globalThis.fetch as any).mockImplementation( + async () => new Response("unauthorized", { status: 401 }) + ); + vi.spyOn(console, "error").mockImplementation(() => {}); + const { auth } = authReadingBundle(bundleNaming(CUSTOM_HOST)); + + const session = await auth.api.getSession({ cookies: {} }); + + expect(session.session).toBeNull(); + expect(isUsableAuth(buildEpCtx(session))).toBe(false); + }); + it("builds a context the server functions refuse from an empty session", () => { expect(isUsableAuth(buildEpCtx({ session: null, cart: null }))).toBe(false); }); diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts index 8ce0a10ec0..c463e3307a 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/ep-plugin.ts @@ -398,6 +398,15 @@ function accountLapsedError(): Response { ); } +function shopperTokenFailure(err: unknown): Response { + console.error(`[ep-commerce] epPlugin: ${(err as Error)?.message ?? err}`); + return jsonError( + "shopper_token_mint_failed", + 502, + "Elastic Path refused the shopper token request." + ); +} + function accountTokenFailure(err: unknown): Response { if (err instanceof EpAccountTokenError) { return jsonError(err.code, err.status, err.message); @@ -490,7 +499,12 @@ export function epPlugin(options: EpPluginOptions): BetterAuthPlugin { { method: "POST" }, async (ctx) => { const { clientId, host } = await resolveConfigFor(options); - const tokenData = await mintAnonymousEpToken(clientId, host); + let tokenData: EpAnonymousTokenResponse; + try { + tokenData = await mintAnonymousEpToken(clientId, host); + } catch (err) { + return shopperTokenFailure(err); + } const snap = buildAnonymousSnapshot(tokenData, clientId, host); await setSessionCookie(ctx, snap as any); return ctx.json(epIdentityPayload("signInAnonymously", snap)); @@ -502,7 +516,12 @@ export function epPlugin(options: EpPluginOptions): BetterAuthPlugin { { method: "POST" }, async (ctx) => { const { clientId, host } = await resolveConfigFor(options); - const tokenData = await mintAnonymousEpToken(clientId, host); + let tokenData: EpAnonymousTokenResponse; + try { + tokenData = await mintAnonymousEpToken(clientId, host); + } catch (err) { + return shopperTokenFailure(err); + } const existing = await readExistingSession(ctx); if (!existing || !existing.user || !existing.session) {