Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions examples/ep-commerce-app-router/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 2 additions & 10 deletions examples/ep-commerce-app-router/app/[[...catchall]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion examples/ep-commerce-app-router/lib/checkout-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface RequestCheckoutContext {
export async function buildCheckoutContext(
request: Request
): Promise<RequestCheckoutContext> {
const config = await getEpProviderConfig();
const config = await getEpProviderConfig(epAuth.config.hostAllowlist);
const clientId =
config?.clientId ??
process.env.EP_CLIENT_ID ??
Expand Down
38 changes: 6 additions & 32 deletions examples/ep-commerce-app-router/lib/ep-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
Expand All @@ -55,7 +50,9 @@ export const epAuth = createBetterEpAuth({
* bundle which gets refetched when the project version bumps.
*/
let _configPromise: Promise<EpProviderBundleConfig | null> | null = null;
export function getEpProviderConfig(): Promise<EpProviderBundleConfig | null> {
export function getEpProviderConfig(
hostAllowlist: readonly string[]
): Promise<EpProviderBundleConfig | null> {
if (!_configPromise) {
_configPromise = (async () => {
// The EP Provider globalContext config is part of the project bundle —
Expand All @@ -66,31 +63,8 @@ export function getEpProviderConfig(): Promise<EpProviderBundleConfig | null> {
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<Record<string, string>> {
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,
};
}
38 changes: 38 additions & 0 deletions plasmicpkgs/commerce-providers/elastic-path/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions plasmicpkgs/commerce-providers/elastic-path/COMPONENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -838,15 +839,15 @@ 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

| Symptom | Likely cause |
|---|---|
| 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 |
Expand Down
9 changes: 9 additions & 0 deletions plasmicpkgs/commerce-providers/elastic-path/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 12 additions & 15 deletions plasmicpkgs/commerce-providers/elastic-path/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 }) {
Expand All @@ -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`
Expand Down Expand Up @@ -715,15 +712,15 @@ 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

| Symptom | Cause | Fix |
|---|---|---|
| `$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
Expand Down
4 changes: 2 additions & 2 deletions plasmicpkgs/commerce-providers/elastic-path/build-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading