Skip to content

feat(ep-commerce): Studio extension API MVP — ep.applyCartAdjustment#373

Open
field123 wants to merge 2 commits into
masterfrom
feat/ep-apply-cart-adjustment
Open

feat(ep-commerce): Studio extension API MVP — ep.applyCartAdjustment#373
field123 wants to merge 2 commits into
masterfrom
feat/ep-apply-cart-adjustment

Conversation

@field123

@field123 field123 commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

What

Implements the MVP of the Studio extension API (PRD #371): one typed, server-side injection function, ep.applyCartAdjustment, that lets a tenant designer's Plasmic server query write a bounded, labelled money line into the authoritative EP cart. Checkout then charges the new server-computed total — a shopper cannot forge or remove it.

Three layers, smallest-surface-first:

  • custom-cart-item.tsaddCustomCartItem(client, …) deep-module write primitive. Writes a single EP custom_item (name + amount, not a catalog product) and re-reads/normalizes the cart. Pure over an injected EP client (mockable, request-scope-free), enforces sanity bounds (amountMinor ≥ 0 integer, label required, kind ∈ {fee,handling,shipping}, quantity ≥ 1). The existing epAddCartItem only supported type: "cart_item"; this is the new custom_item capability. Parameterised by client so a future negative-amount member (a discount) can hand it a client-credentials client without a redesign.
  • epApplyCartAdjustment — thin adapter (in cart-mutations.ts). Resolves cartId + auth from the request-scoped session (getCurrentEpSession), calls the primitive, returns the normalized cart. Uses the shopper-auth path: a positive fee is harmless to replay (it only adds cost to the shopper's own cart).
  • Registration. ep.applyCartAdjustment registered with isMutation: true and a flat Studio param schema (label, amountMinor, kind, quantity). Added isMutation support to the registry spec.

Trust contract

The EP cart is the sole authority for the charged total; handlePay reads the cart's server-computed meta.display_price. Injected money lands in the EP cart via a server-side credentialed write, never trusted from the session and never accepted on a shopper-facing route. The bounds here are a guard against a buggy tenant function, not the trust boundary (the cart's pricing is). This is the page-initiated Studio extension API direction; the inverted hook/SPI direction (every-add invariants, post-order side effects) is an explicit non-goal.

Testing

  • Deep-module unit tests (custom-cart-item.test.ts, 12) over a mocked EP client: payload shape (custom_item with label→name, amountMinor→price, kind tag, default qty 1), locale/currency re-read headers, bounds enforcement (reject negative/non-integer amount, blank label, out-of-enum kind, missing cartId, non-positive quantity — all before any network call), and SDK error propagation.
  • Adapter tests (cart-mutations.test.ts, +4): session resolution, no-session / no-cart guards, bound rejection propagated without a write.
  • Registration tests (register-custom-functions.test.ts, +2): registered as isMutation with the flat param schema; read functions stay non-mutation.
  • Security-regression (security-regression.test.ts, +4): handlePay charges the EP-cart total including the adjustment (order built from the fee-bearing cart, paid path not free settlement); a client-forged body amount is ignored (cart total governs free-vs-paid); post-session cart-line tampering is rejected 409 (the line set is pinned at session creation, so an adjustment can't be slipped in or stripped behind the session's back); the public update-session route cannot inject a money line.
  • Full elastic-path suite: 1905 passing.

Scope

MVP ships exactly one family member. setShippingLine / setTax / applyDiscount are designed-for (same pattern, primitive reused) but not built. No generic mutateCart. Multi-tenant sandboxing and the inverted SPI direction are out of scope.

Dependencies / risks

  • Requires the upstream arbitrary-code-in-server-queries capability (PLA-12776 / #2565) in the deployed Studio image, not just source.
  • EP custom_item pricing (includes_tax: true keeps the charged amount exactly amountMinor; tax interaction) should be confirmed against a live store.

Update — browser invocation (mutation path)

applyCartAdjustment is a write, so it's invoked on demand (an element interaction), not as an auto-running page server query. Mirrors epGetCart's browser fallback: when called client-side with no request-scoped session, it routes through the consumer's EP proxy (createEpProxyRoutes / the proxy route), which re-establishes the session + credentials server-side and runs the same credentialed cart write. A real SSR session still writes directly. New unit tests cover the browser proxy path, quantity omission, and "never proxy when a session is present". Verified end-to-end against a live store via the proxy route (custom_item added, cart re-priced, line removed for cleanup).

field123 added 2 commits June 12, 2026 13:24
Adds one typed, server-side injection function that lets a tenant
designer's Plasmic server query write a bounded, labelled money line
into the authoritative EP cart. Checkout charges the new server-computed
total; a shopper cannot forge or remove it.

- custom-cart-item.ts: addCustomCartItem deep-module write primitive —
  writes an EP custom_item (name + amount), enforces sanity bounds
  (amountMinor >= 0 integer, label required, kind in {fee,handling,
  shipping}, quantity >= 1), re-reads/normalizes the cart. Pure over an
  injected EP client (mockable, request-scope-free) and parameterised by
  client so a future negative-amount member can escalate auth.
- epApplyCartAdjustment thin adapter: resolves cartId + auth from the
  request-scoped session, calls the primitive, returns the cart. Shopper
  auth — a positive fee is harmless to replay.
- Registered ep.applyCartAdjustment with isMutation: true and a flat
  Studio param schema; added isMutation support to the registry.

Trust contract: the EP cart is the sole authority for the charged total
(handlePay reads meta.display_price); injected money lands in the cart
via a server-side credentialed write, never the session or a shopper
route. Bounds guard against a buggy tenant function, not the boundary.

Tests: deep-module unit (bounds, payload, re-read headers, error
propagation), adapter (session resolution, guards), registration
(isMutation + flat schema), and security-regression (adjustment is
charged, forged body amount ignored, post-session cart-line tampering
409, public update-session can't inject money). Full suite: 1905 pass.
Mirror epGetCart's browser path: when invoked client-side (a designer's
element interaction / Studio canvas) there is no AsyncLocalStorage
session, so route through the consumer EP proxy, which re-establishes
withEpSession server-side (credentials + cartId from cookies) and runs
the same credentialed cart write. A real ALS session (SSR) still wins
and writes directly.

This makes applyCartAdjustment invokable as an on-demand mutation from a
UI action — not just an SSR server query — keeping the money write
server-only. Unit tests cover: proxies on the browser path with the flat
input, omits quantity when absent, and never proxies when a session is
present.
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