Skip to content

feat(ep-commerce): better-auth-backed EP session — full cutover (#273)#276

Merged
field123 merged 7 commits intofeat/ep-als-session-contextfrom
feat/ep-better-auth
Apr 28, 2026
Merged

feat(ep-commerce): better-auth-backed EP session — full cutover (#273)#276
field123 merged 7 commits intofeat/ep-als-session-contextfrom
feat/ep-better-auth

Conversation

@field123
Copy link
Copy Markdown
Collaborator

@field123 field123 commented Apr 27, 2026

Closes #273. Stacked on #275.

Replaces the hand-rolled EP auth (created in #180) with a stateless better-auth integration. The legacy auth/{create-ep-auth,session,handler,cookies,token}.ts (~821 LoC) is deleted; everything that touched the cart cookie or the /api/ep/* routes has been ported. The example app cuts over end-to-end with no consumer-facing API breakage on the public surface.

Architecture (better-auth-canonical)

Browser
  │
  │ (1) middleware bootstrap on first visit
  ▼
middleware.ts (Node runtime)
  │ epAuthMiddleware(epAuth) — synthesizes POST /ep/anonymous, attaches
  │ Set-Cookie via NextResponse.next()
  ▼
app/[[...catchall]]/page.tsx (RSC)
  │ epAuth.api.getSession({cookies}) — reads better-auth.session_data
  │ withEpSession(epCtx, () => ...)  — PRD #272 ALS
  │ globalContextsProps[..."$dev"] = { serverToken }
  ▼
PlasmicClientRootProvider (client)
  │ EPCommerceProvider receives serverToken → SDK uses serverTokenAdapter
  ▼
Cart hooks (shopper-context/use-*.ts)
  │ shopperFetch → /api/ep/cart, /cart/items, /cart/items/:id
  ▼
app/api/ep/cart/[[...path]]/route.ts
  │ createCartRoutes(epAuth) — reads session, calls EP API server-side,
  │ persists fresh cartId via /ep/cart endpoint

Auth handler at app/api/ep/[...path]/route.ts mounts toNextJsHandler(epAuth.handler) from better-auth/next-js. nextCookies() plugin registered last in the auth config (per better-auth docs) so server-action / route-handler Set-Cookie persistence is automatic.

What's in this PR (commits on feat/ep-better-auth)

SHA Summary
cad0b9c07 Plugin foundation — endpoints /ep/anonymous, /ep/refresh, /ep/cart, /ep/account/login, /ep/account/logout + adapter + cross-instance verification (18 tests).
cd0fd6718 Cutover: example app lib/ep-auth.ts switches to createBetterEpAuth; legacy auth files deleted (~821 LoC).
0ed68eef6 Middleware bootstrap + nextCookies + cart-cookie consolidation: epAuthMiddleware helper, nextCookies() plugin, /api/ep/cart/* cart hooks now read/write session.epCartId instead of legacy elasticpath_cart cookie.
da9b547ee Cart server routes restored: createCartRoutes(epAuth) factory, mounted at /api/ep/cart/[[...path]].

Verified end-to-end

Browser-tested via Playwright on localhost:3456:

  • /product/3281... renders SSR'd "Test Product 1" with real EP product data.
  • better-auth.session_token + better-auth.session_data cookies present (HttpOnly, JWE).
  • /api/ep/ep/anonymous POST → real EP /oauth/access_token mint, real Set-Cookie back.
  • ✅ Subsequent /api/ep/get-session GET reads back all EP fields from the JWE cookie.
  • ✅ Cross-process restart preserves session — pure stateless.
  • /api/ep/cart GET returns {items, meta} from EP, sourced via session's accessToken + cartId.
  • elasticpath_cart JS-readable cookie eliminated; cart ID lives in session.epCartId only.

Test counts

  • 88 jest suites / 1475 tests (5 legacy auth-test files removed; 63 tests culled correspondingly)
  • 23 vitest tests (better-auth integration, can't run under jest because better-auth is ESM-only)
  • All green.

Known follow-ups (NOT in this PR)

Test plan

  • CI green
  • Reviewer pulls, runs yarn test:vitest in plasmicpkgs/commerce-providers/elastic-path/ → 23 pass
  • Reviewer runs example app, hits /product/<uuid>, verifies cookies via DevTools (HttpOnly better-auth.session_token + session_data only)
  • Reviewer verifies getCookie("elasticpath_cart") === undefined in DevTools console

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}
@field123 field123 marked this pull request as ready for review April 27, 2026 14:53
@field123 field123 changed the title feat(ep-commerce): WIP — better-auth plugin foundation for EP session (#273) feat(ep-commerce): better-auth-backed EP session — full cutover (#273) Apr 27, 2026
…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.
@field123 field123 merged commit 847ee12 into feat/ep-als-session-context Apr 28, 2026
1 check passed
field123 added a commit that referenced this pull request Apr 28, 2026
feat(ep-commerce): better-auth cutover + proxy route + account-token verification (re-target of #276)
@field123 field123 deleted the feat/ep-better-auth branch April 28, 2026 15:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant