Conversation
Implements PRD #273's stateless better-auth-backed session layer behind a feature-flag-style parallel surface. The legacy hand-rolled auth in auth/{create-ep-auth,session,handler,cookies,token}.ts continues to ship unchanged; the new code lives in auth/ep-plugin/ and is opted-in via either the new createBetterEpAuth() factory or by importing the raw plugin and constructing betterAuth() directly. Endpoints implemented (TDD-driven, 18 vitest tests, all green): - POST /ep/anonymous — implicit-grant mint, setSessionCookie writes epAccessToken/epClientId/epHost/epExpires into the JWE session_data cookie - POST /ep/refresh — rotates EP token, preserves better-auth session/user identity; falls back to anonymous when no session present - POST /ep/cart — writes epCartId onto the session, preserves epAccessToken; 401 without session - POST /ep/account/login — persists epAccountId/epAccountToken/ epAccountExpires from EP /v2/account-members/ tokens; preserves anonymous epAccessToken so catalog reads continue uninterrupted - POST /ep/account/logout — strips account fields, restores anonymous user record, preserves anonymous EP token Plus: - resolveConfig() option on epPlugin — per-request clientId/host resolution, replaces the legacy x-ep-client-id middleware-header escape hatch with a typed callback. Enables resolving config from the Plasmic loader bundle on each call. - createBetterEpAuth() — wraps betterAuth({plugins:[epPlugin(...)]}) with stateless cookieCache.strategy="jwe", returns the legacy EpSession public surface (session/user/cart/isAuthenticated/headers()/ providerProps()/commitCookies()). Auto-bootstraps anonymous via /ep/anonymous when getSession returns null. - Cross-instance stateless verification test — proves cookies written by instance A are decoded identically on instance B and C, the load-bearing property of the spike (see memory note project_better_auth_stateless_findings.md). Test-runner setup: better-auth and its transitive deps (@better-auth/core, jose, @noble/hashes) are ESM-only with dynamic import() calls. Jest's CJS-by-default transform cannot load them without a workspace-wide ESM-mode rewrite. Following the precedent set by plasmicpkgs/wordpress (and the per-package vitest configs in packages/plasmic-mcp, packages/plasmic-mcp-registry, packages/data-sources, plasmicpkgs-dev), the plugin tests run under vitest while the existing 91-test jest auth suite continues unchanged. Root jest.config.js excludes src/auth/ep-plugin/ from its testPathIgnorePatterns; the EP package's "test" script runs jest first then vitest. Demo route at app/api/ep-better/[[...all]]/route.ts mounts the new auth via toNextJsHandler from better-auth/next-js. Parallel to the legacy /api/ep/* — no consumer-facing breakage. Currently fails with 500 due to a node_modules resolution issue in the example app's stale .next cache; once that clears the route is reachable for end-to-end demonstration.
… auth (#273) The cutover. The example app's lib/ep-auth.ts now uses createBetterEpAuth (now the only createEpAuth). The /api/ep/[...path] handler is mounted via better-auth's toNextJsHandler. The catchall page drops the epProviderHeaders middleware-header dance — replaced by resolveConfig inside the auth factory. Deleted (~821 LoC of hand-rolled auth): - src/auth/create-ep-auth.ts - src/auth/session.ts - src/auth/handler.ts - src/auth/cookies.ts - src/auth/token.ts - corresponding __tests__/* (5 test files) Also dropped the parallel /api/ep-better/* demo route + lib/ep-better-auth.ts from the example app — the real /api/ep/* now runs the better-auth path directly, so the side-by-side proving ground is no longer needed. Verification: - Real /api/ep/ep/anonymous returns 200 against real EP — implicit-grant mint succeeds, real Set-Cookie headers come back with better-auth.session_token + session_data. - 89 jest suites / 1483 tests green (down from 94/1546 — math matches the 5 deleted suites + their 63 tests). - 20 vitest tests still green (epPlugin + adapter + cross-instance + endpoints). - createBetterEpAuth() now exposes `.handler` for toNextJsHandler mount; `resolveConfig` callback added to plumb Studio-bundle clientId/host per-request. Pending after this commit: - Catchall PDP render currently 500s with "Cannot read properties of null (reading 'useContext')" — the React-instance mismatch from earlier. Fixed by `rm -rf .next` + dev server restart; the runtime fix is in the registry (8c1c501 on the parent branch). Not a regression introduced here. - /api/ep handler basePath collision: better-auth's basePath is "/api/ep" AND the plugin's endpoint paths start with "/ep/", so URLs are /api/ep/ep/anonymous etc. Could be tidied via plugin path rewrite, but not blocking — the legacy code lived at /api/ep/ep_token and similar, so the collision is consistent.
…n-backed cart ID (#273) Three additions on top of cd0fd67's cutover, all per better-auth's canonical Next.js patterns: 1. epAuthMiddleware helper (`auth/ep-plugin/middleware.ts`) Drops into the consumer's `middleware.ts`. On any request without a better-auth session cookie, synthetically POSTs to /ep/anonymous via `epAuth.handler.handler` and forwards Set-Cookie headers on NextResponse.next(). RSC pages can't write cookies in Next 15; the middleware (Node runtime, runtime: "nodejs") is the canonical place to bootstrap. Exposed from /server; the example app's middleware.ts is now a 5-line drop-in. 2. nextCookies() plugin in createBetterEpAuth's auth config The standard better-auth way to flush Set-Cookie from server actions and route handlers into Next's cookies() writer. Must be the last plugin in the array per the docs; we honor that. 3. Cart-cookie refactor — `elasticpath_cart` deleted The legacy elasticpath_cart cookie (set + read by cart hooks via utils/cart-cookie.ts) is replaced by a session-backed cart ID. New helpers in `cart/cart-session.ts`: - getCartIdFromSession() → fetches /api/ep/get-session, returns session.epCartId - setCartIdInSession(id) → POSTs {cartId} to /api/ep/ep/cart, which calls setSessionCookie; nextCookies() persists. All 8 callsites updated: use-cart, use-add-item, use-remove-item, use-update-item (cart hooks); EPCheckoutProvider, EPPromoCodeInput (checkout composables). utils/cart-cookie.ts + its test deleted; `utils` re-export trimmed. Plus example-app fix: catchall page's globalContextsProps key now uses the `$dev`-suffixed component name (matching what registerWithDevMeta actually registers) so serverToken reaches the client CommerceProvider. This eliminates the EP SDK's localStorage `_store_ep_credentials` fallback — the SDK now uses serverTokenAdapter (in-memory, the real session token) instead of doing its own client-side implicit grant. End-state verified in browser: - cookies = [better-auth.session_token, better-auth.session_data] - localStorage = [] (no _store_ep_credentials) - sessionStorage = [] - elasticpath_cart = absent - PDP h1 = "Test Product 1" rendered SSR Test counts: - 88 jest suites / 1475 tests green - 23 vitest tests green - 1498 total Pending: - The shopper-context cart hooks (`shopper-context/use-cart.ts` etc.) call `${basePath}/cart` which expects a server route at /api/ep/cart. That route was bundled into the legacy auth/handler.ts and got deleted. Currently 404s. Tracked separately — restoring the cart-server-routes in a follow-up commit.
Replaces the cart-handling logic that was bundled into the deleted
legacy auth/handler.ts. shopper-context client hooks now reach a real
endpoint again instead of 404ing on /api/ep/cart.
New EP-package surface:
createCartRoutes(epAuth) → { handle: (req, ctx) => Response }
Single dispatch handler covering:
GET /api/ep/cart → cart contents
POST /api/ep/cart/items → add (creates cart if needed)
PUT /api/ep/cart/items/:id → update quantity
DELETE /api/ep/cart/items/:id → remove
Each request reads the session via epAuth.api.getSession({cookies, headers}),
calls EP's REST API server-side using session.accessToken. When a fresh
cart is created by a first POST /items, the handler invokes the
plugin's /ep/cart endpoint internally to persist the new cartId into
session.epCartId — better-auth's nextCookies() flushes the rotated
cookie alongside the response.
Consumer mounts in one file:
// app/api/ep/cart/[[...path]]/route.ts
import { createCartRoutes } from
"@elasticpath/plasmic-ep-commerce-elastic-path/server";
import { epAuth } from "@/lib/ep-auth";
const routes = createCartRoutes(epAuth);
export const GET = routes.handle;
export const POST = routes.handle;
export const PUT = routes.handle;
export const DELETE = routes.handle;
Next routing prefers `/api/ep/cart/[[...path]]` over the catch-all
`/api/ep/[...path]` for /api/ep/cart/*, so the better-auth handler at
the parent path keeps serving /ep/anonymous, /ep/refresh, etc.
Verified live:
curl http://127.0.0.1:3456/api/ep/cart → 200 {"items":[],"meta":null}
…273) The browser-side EP SDK was writing `_store_ep_credentials` to localStorage on every fresh visit, in parallel to better-auth's session cookie. Two contributing causes: 1. The SDK's `resolveStorage` falls back to `localStorageAdapter()` when `storage` is missing, "localStorage", or any falsy value. Our `client.ts` previously passed the literal string `"localStorage"` as the fallback when no `serverToken` was present — every render with an undefined `serverToken` (e.g., a pre-hydration first paint) seeded a localStorage adapter, triggering an implicit-grant mint that wrote the credentials key. 2. `client.ts` only configured the FRESH `createShopperClient` instance. Some package-internal hooks (`bundle/use-parent-products`, `bundle/use-bundle-option-products`) call SDK functions that resolve to the SDK's GLOBAL singleton instead of the fresh instance. The singleton was never configured, so it defaulted to localStorage. Fixes: - Always use an in-memory storage adapter, both when seeded with serverToken (preloaded with the better-auth session's accessToken) and when empty (SDK auto-mints into memory only). Replaces the `"localStorage"` string fallback. The adapter is a small closure defined inline; we avoid `memoryAdapter()` from `@epcc-sdk` because it isn't exported from the package's public API. - Call `configureClient(...)` alongside `createShopperClient(...)` so the SDK's singleton picks up the same in-memory storage. Now any hook that hits the singleton path is also localStorage-free. Verified live (browser): `localStorageContents = {}` after a fresh visit with cookies cleared. Cart browse + add still functional via the shopper-context server routes (#273) and the catchall page renders the real product via SSR with better-auth's session cookies. The remaining `__stripe_mid`/`__stripe_sid` cookies are set by Stripe's SDK; not our concern here.
) Two related issues seen in dev logs: ERROR [Better Auth]: Invalid origin: http://127.0.0.1:3456 POST /api/ep/ep/cart 403 in 65ms Root cause: better-auth defaults trustedOrigins to a strict match against `baseURL`. Our auth config sets `baseURL` to one host (`http://localhost:3456`), but Next.js dev serves equally well from `127.0.0.1:3456` — different host = different Origin = better-auth rejects. Compounding it: the internal Request the cart-routes handler synthesizes to call /ep/cart (to persist the new cartId via setSessionCookie) carries no Origin header at all. Even with trustedOrigins set, missing Origin trips the check. Fixes: 1. `createBetterEpAuth` accepts a `trustedOrigins?: string[]` option and infers a sensible dev default that includes both the localhost and 127.0.0.1 variants of `baseURL`. Consumers can override with their own production-host list. 2. The internal POST in `cart/server-routes.ts` (`persistCartId`) now forwards the incoming request's Origin header to the synthetic Request — so the better-auth handler sees a valid origin on server-to-server-style internal calls. 3. Same Origin-forwarding in `epAuthMiddleware`'s synthetic /ep/anonymous POST. Verified live (dev log): 403 Invalid origin disappears; cart routes return 200 throughout.
#273) Studio canvas evaluates server-query arg expressions with `\$ctx` in scope ONLY when the args use top-level keys matching the function's declared params. The pre-fix shape — single declared `input` param + binding `{input: "{id: \$ctx.params.slug}"}` — fails canvas-side because the embedded object-literal expression is evaluated through a different code path that does NOT inject `\$ctx`. Pre-#272 the binding was `{id: \$ctx.params.slug, auth: \$ctx.ep}` (flat unmatched keys), which Plasmic treated as whole-args-as-input and evaluated each expression with `\$ctx` available. That's the working eval path. Restore canvas compatibility by declaring the params flat (matching the old working shape) and bind args flat too. Each `ep.*` function gets its real input fields as separate Plasmic params: ep.getProduct → params: [{ name: "id", type: "string" }] ep.getCart → params: [] ep.getProductList → params: [{ limit }, { search }, { categoryId }, { sort }] ep.getRelatedProducts → params: [{ productId }, { relationshipSlug }, { limit }] The actual TS function signatures (`epGetProduct({ id })` etc.) stay as a single input object — ergonomic for direct callers. An adapter inside `registerEpCustomFunctions` reassembles flat positional args into the input object before forwarding. Bonus fix in EPProductProvider: when canvas passes a Promise as the `product` prop (because `\$q.product.data` is unresolved in canvas), treat it as not-prefetched and fall through to the SWR path. Previously the Promise was treated as a real product, leading to MOCK_PRODUCT fallback + a 400 on `/v2/inventories/mock-product`. After this commit, the consumer must update the Studio binding from `{ input: "{ id: \$ctx.params.slug }" }` to `{ id: "\$ctx.params.slug" }`. The new schema is required for the binding edit; refresh Studio to pick it up.
…273) Adds /api/ep/proxy/[fn] route handler factory + consumer mount. Browser- context ep* server functions (Studio canvas render, data-query preview) now POST to /api/ep/proxy/<fn> instead of returning null, then dispatch under withEpSession with the better-auth session — same path SSR uses. Canvas data-query preview resolves real data; child components receive the real product id (no more inventories/mock-product 400s). SSR is unchanged: catchall page calls ep* in-process with ALS-derived auth and never hits the proxy.
#280) The /ep/account/login endpoint previously trusted whatever {epAccountId, epAccountToken, epAccountExpires} the caller supplied and wrote them straight onto the session cookie. Any user with a session could claim arbitrary epAccountId, so any storefront authorizing on session.user.epAccountId would leak data. Verify the supplied token against EP via GET /v2/accounts/{id} with the shopper bearer + account-management header. Persist EP's canonical id from the response, not the body's claim. Reject with 401 (no Set-Cookie) on any non-2xx, network failure, or shape mismatch.
The plasmicpkgs Jest CLI invocation passes --testPathIgnorePatterns, which Jest treats as overriding (not merging with) the config-level ignore list. As a result the ep-plugin __tests__ — which import from "vitest" because better-auth's ESM-only build does not run under Jest's CommonJS transform — were being picked up by Jest and failing to load. Add the ep-plugin path to the CLI ignore list, and add a new "Run elastic-path Vitest tests" step that runs the package's own vitest config from its working directory.
7deb3ac to
11d206e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR re-targets the work from #276 (which was accidentally merged into
the stranded
feat/ep-als-session-contextbranch instead of master)plus two additions that didn't make the original PR:
cart server routes, localStorage elimination, trustedOrigins fixes,
flat function param schema for Studio canvas
ep*server functions (Studio canvas /data-query preview path) — was committed locally but never pushed
/ep/account/login(Security: verify EP account token in /ep/account/login (#279 HIGH-1) #280) — firstsecurity-hardening fix from PRD PRD: Security hardening for EP better-auth + proxy architecture (#273 follow-up) #279
Why two layers in one PR
#276 merged into
feat/ep-als-session-contextinstead of master. Thatbranch's HEAD is not an ancestor of master (the upstream PR #275 was
squash-merged), so opening a PR from the stranded branch directly
would diff cleanly but the source branch is now mid-air. Easier to
rebase the work onto current master and ship as a single integration
PR.
Test plan
cd plasmicpkgs/commerce-providers/elastic-path && yarn vitest run— 25/25 passing locally on rebased branchFollow-up
After merge, delete the stranded
feat/ep-als-session-contextbranchon origin — its content is fully contained in this PR.
Closes #280.
Re-targets #276.