Skip to content

feat(ep-commerce): better-auth cutover + proxy route + account-token verification (re-target of #276)#289

Merged
field123 merged 10 commits intomasterfrom
feat/ep-better-auth-rebased
Apr 28, 2026
Merged

feat(ep-commerce): better-auth cutover + proxy route + account-token verification (re-target of #276)#289
field123 merged 10 commits intomasterfrom
feat/ep-better-auth-rebased

Conversation

@field123
Copy link
Copy Markdown
Collaborator

Summary

This PR re-targets the work from #276 (which was accidentally merged into
the stranded feat/ep-als-session-context branch instead of master)
plus two additions that didn't make the original PR:

Why two layers in one PR

#276 merged into feat/ep-als-session-context instead of master. That
branch'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 branch
  • CI green on PR
  • Manual smoke: example app starts, anonymous bootstrap works, cart add/remove succeeds

Follow-up

After merge, delete the stranded feat/ep-als-session-context branch
on origin — its content is fully contained in this PR.

Closes #280.
Re-targets #276.

field123 added 10 commits April 28, 2026 14:20
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.
@field123 field123 force-pushed the feat/ep-better-auth-rebased branch from 7deb3ac to 11d206e Compare April 28, 2026 13:44
@field123 field123 merged commit ab514fa into master Apr 28, 2026
9 checks passed
@field123 field123 deleted the feat/ep-better-auth-rebased 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.

Security: verify EP account token in /ep/account/login (#279 HIGH-1)

1 participant