diff --git a/examples/ep-commerce-app-router/.env.local.example b/examples/ep-commerce-app-router/.env.local.example index 11a14e410..86a4f3588 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 9dd2c2655..e6a3cf255 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 fba77b53e..311c47e86 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 a1cbda512..329b12698 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, - }; -} diff --git a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md index 7c9dd0c3e..1d506b5f1 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md +++ b/plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md @@ -2,6 +2,36 @@ ## 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-0006). Your +entries extend the defaults rather than replacing them. 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. `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` +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: @@ -126,6 +156,14 @@ does. See ADR-0005. EP Product Provider's product input is now displayed as **Product ID or slug**. No component, prop or function is added. +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/COMPONENTS.md b/plasmicpkgs/commerce-providers/elastic-path/COMPONENTS.md index eec1e20e0..217956fcb 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 aefbb536d..2ed2d3f1b 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 65904c459..fc1722cb3 100644 --- a/plasmicpkgs/commerce-providers/elastic-path/README.md +++ b/plasmicpkgs/commerce-providers/elastic-path/README.md @@ -240,11 +240,15 @@ 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` | 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 | -`hostAllowlist` is applied independently by `createEpAuth`, -`extractEpProviderConfig` and `buildEpCtx`, so pass the same list to all -three rather than only to the factory. +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-0006). ## Architecture @@ -651,7 +655,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 +669,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` @@ -715,7 +712,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 @@ -723,7 +720,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 f93862c71..213005568 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/0006-one-ep-host-allow-list.md b/plasmicpkgs/commerce-providers/elastic-path/docs/adr/0006-one-ep-host-allow-list.md new file mode 100644 index 000000000..eda8346ca --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/docs/adr/0006-one-ep-host-allow-list.md @@ -0,0 +1,68 @@ +# ADR-0006: 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 f4b18ca82..a39104f6d 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 ee48baff3..89d3a2c2a 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 5008293fe..bf128ef54 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 f04fa78da..1c060dc1a 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 aca29fdc1..6b4465beb 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"; @@ -52,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` @@ -83,6 +115,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 +157,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 +179,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 +234,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 c2a28317c..a7a793199 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 fbb3aeef6..f2d3d67b0 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 98bf6e124..bfd2fe24f 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 55da9d980..f33bb70c0 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 }, }, @@ -78,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 new file mode 100644 index 000000000..2aa427848 --- /dev/null +++ b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/host-allowlist-parity.test.ts @@ -0,0 +1,196 @@ +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("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/__tests__/identity-client.test.ts b/plasmicpkgs/commerce-providers/elastic-path/src/auth/ep-plugin/__tests__/identity-client.test.ts index 8ff712029..886788842 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 3842f2228..64f96152a 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 6720ab3d0..c86c6a3d1 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 aa7d38ba3..c463e3307 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 }; } @@ -400,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); @@ -492,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)); @@ -504,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) { 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 9e2fb702e..5e7ae7407 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 2c05a9eb1..1460ab3e1 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 da785969f..fc20c4a80 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 bd59be2ac..7c24b5fc3 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 a1c2b14b1..aaac7cac1 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 envelope = 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: envelope?.accessToken ?? "", + host: envelope?.host ?? "", + clientId: envelope?.clientId ?? "", + cartId: session.cart?.id, + 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/index.ts b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/index.ts index de3aa6b17..d81742b05 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/ep-server-functions/types.ts b/plasmicpkgs/commerce-providers/elastic-path/src/ep-server-functions/types.ts index c82a68495..f78c7b1e8 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). * diff --git a/plasmicpkgs/commerce-providers/elastic-path/src/server.ts b/plasmicpkgs/commerce-providers/elastic-path/src/server.ts index 42e235c7c..9bc5046fd 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,