Skip to content

Spec: one session for Elastic Path identity — what success is and how it is proven #553

Description

@field123

Spec for the ADR-0003 token architecture milestone. The 18 issues on the milestone hold the
mechanics; this holds what success is and how it is proven. Decisions live in ADR-0003 and
ADR-0004; vocabulary is CONTEXT.md's Identity & transport section.

Written because the milestone went map → decisions → issues with no spec step, so no artifact
defined done. One issue of eighteen carries acceptance criteria.

Problem Statement

A shopper who signs in to a business account does not see that organisation's prices.

Two token surfaces exist. The server holds the shopper's credential in the shopper
envelope
. Separately, the browser mints its own anonymous Elastic Path credential and calls
Elastic Path directly from thirteen call sites — catalog, search, stock, bundles, promo codes.
That browser credential is public by construction, so where it is kept protects nothing; the
problem is what it cannot do. An anonymous credential can never carry a selected account,
so every component that fetches in the browser is permanently unable to show account-scoped
pricing. For a B2B storefront that is the product.

It cannot be fixed component by component, because identity and transport are welded together:
server-originated implies the session credential, browser-originated implies the anonymous one.
A developer cannot opt one component into account scope without opting it into server rendering.

Three smaller problems ride on the same weld. A request header lets the browser assert which
shopper it is, ranked above the session cookie with no verification. The promo-code component
writes discounts to the basket from the browser using that public credential, so a shopper can
in principle set their own discount. And the envelope has no lifetime of its own, so it falls
back to roughly five minutes — after which the only handle to a live basket is gone, while
Elastic Path keeps that basket for seven days.

Separately and immediately: the package declares an InstantSearch version that refuses to
install alongside React 19, so it cannot be added to a current app at all.

Solution

One token surface: the shopper envelope. Every Elastic Path credential lives on the
storefront's server, and the browser holds none. Browser-originated calls reach Elastic Path by
asking the storefront's own origin to go and fetch — as a named operation, where the call
site names a server function and the server supplies the ids that constitute access control.

Because identity and transport are separate axes, this is not "no client-side fetching". It is
"browser-originated calls route through our origin", which is what makes account scope reachable
from any component without any component choosing it.

On top of that, the envelope learns to hold business-account identity: who signed in, which
organisation they are acting for, and enough to switch organisation without re-authenticating.
An account member belonging to several organisations picks one; one belonging to exactly one
is placed in it; one whose account access has expired is told so as a fact rather than being
quietly reverted to list prices.

The shopper sees: correct prices for their organisation, a basket that survives signing in and
survives the day, category pages whose products are already in the HTML, and no way for anything
in the page to claim to be someone else.

User Stories

Account identity and pricing

  1. As an account member, I want the prices I see to be the ones negotiated for my organisation, so that I order at my contracted rates rather than list prices.
  2. As an account member belonging to several organisations, I want to choose which one I am buying for, so that I never order against the wrong account.
  3. As an account member belonging to several organisations, I want to never be placed into one silently, so that every order is against an organisation I actively chose.
  4. As an account member belonging to exactly one organisation, I want to be placed into it automatically, so that I am not asked a question that has only one answer.
  5. As an account member, I want to switch organisation without typing my password again, so that buying for two parts of my business is not two sign-ins.
  6. As an account member whose account access has expired, I want to be told so plainly, so that I do not mistake list prices for my contracted ones.
  7. As an account member who belongs to no organisation yet, I want to sign in successfully anyway, so that an incomplete setup does not lock me out of the storefront.
  8. As a storefront developer, I want the account credential to be unreachable from the page, so that a hostile script cannot act as the organisation.
  9. As a storefront developer, I want to read the organisation's name and id in the page, so that I can show who the shopper is buying for.

Basket

  1. As a shopper who filled a basket before signing in, I want that basket still there afterwards, so that I do not lose what I just chose.
  2. As a shopper with both a guest basket and a saved account basket, I want the storefront to choose between them deliberately, so that quantities are not silently added together.
  3. As a storefront developer, I want to decide which basket wins when a shopper signs in, so that the choice matches my own business rules rather than a default I cannot change.
  4. As a storefront developer with no opinion on that, I want a sensible default, so that I do not have to write a rule to get reasonable behaviour.
  5. As a shopper, I want my basket still there when I come back hours later, so that I do not have to rebuild it.
  6. As a shopper, I want my basket to follow me when I switch organisation, so that switching is a change of prices and not a loss of work.

Browsing, search and discovery

  1. As a shopper, I want category and search pages to show products, so that I can find what I came for.
  2. As a shopper arriving from a search engine or a shared link, I want the page to already contain the products, so that I do not land on an empty shell.
  3. As a merchant, I want category pages to carry their products in the HTML, so that they can be indexed and can rank.
  4. As a shopper, I want to narrow a listing by brand, category and price, so that a large catalog is navigable.
  5. As a shopper, I want suggestions as I type, so that I find things without knowing the exact name.
  6. As a shopper on a long listing, I want to load more results in place, so that browsing is continuous rather than paged.
  7. As a shopper, I want a component that cannot load to tell me something useful, so that I retry rather than conclude the store is empty.

Security and correctness

  1. As a storefront developer, I want no Elastic Path credential anywhere in the browser, so that there is exactly one place to reason about identity.
  2. As a storefront developer, I want no request header able to assert who the shopper is, so that the shopper envelope is the only identity input.
  3. As a storefront developer, I want components never to name a route or a transport, so that I cannot get identity wrong by configuring it wrong.
  4. As a merchant, I want a shopper to be unable to set their own discount, so that money I control is not writable from the browser.
  5. As a storefront developer, I want a failed catalog search during server rendering to still return the page, so that a transient upstream blip is not a 500 or a cached indexing failure.

Designing in Studio

  1. As a designer, I want components to show my store's real products while I design, so that I am laying out real names, images and custom fields rather than invented ones.
  2. As a designer, I want design-time data to require no shopper sign-in, so that the editor is not blocked by an identity I do not have.
  3. As a designer, I want a component that cannot reach real data to show clearly labelled placeholders, so that I never mistake fixtures for my catalog.
  4. As a designer, I want a control that no longer does anything to say so in its own description, so that I do not build a page around a dead setting.
  5. As a designer, I want to be warned when I scope a category page in a way that will not survive server rendering, so that I do not ship a page that looks right in the editor and empty to a crawler.

Installing and upgrading

  1. As a storefront developer, I want the package to install into a React 19 application, so that adopting it does not hold me on an old React.
  2. As a storefront developer on an existing storefront, I want one release that breaks things rather than several, so that I plan one upgrade rather than a series.
  3. As a storefront developer, I want every release before that one to be safe to take, so that I can stay current while I plan the breaking upgrade.
  4. As a storefront developer facing the breaking release, I want to be told what to change and what replaces each removed thing, so that I can migrate without reading a diff.
  5. As a storefront developer, I want the documentation's security section to describe what the package actually does, so that I am not designing against a property that was reversed.

Implementation Decisions

One token surface. The shopper envelope is the sole input to Elastic Path identity on every
request. The browser holds no Elastic Path credential of any kind. This reverses a documented
earlier position, which is why it landed as an ADR rather than a change.

Identity and transport are separate axes. A browser-originated call carrying server identity
already exists, so a single token surface does not imply server-only rendering. It implies
browser-originated calls route through the storefront's own origin.

Named operations, not forwarded calls, wherever the resource is id-scoped or the request
needs server-side handling. A call site names a server function; it never names a URL, and no
component exposes transport as a registered prop. A forwarded call remains defined as the
boundary a future resource is measured against, with no live call site.

Six new named operations land before anything is deleted — stock, locations, three bundle
reads, and multi-search — because thirteen call sites currently fetch through the browser client
against eight existing dispatch entries. A registered function for cart adjustment exists but is
absent from dispatch, so the promo-code replacement is new code rather than a migration.

The multi-location inventory header moves to the server client builder before call sites
converge.
The browser client and the parallel cart routes both set it and the server builder
does not, so converging without this first would silently change inventory behaviour.

The envelope gains a grouped account shape — member id, the selected account as one group
carrying id, name, credential and expiry, an anchor token, and a lapsed account marker.
The flat account fields are replaced. Grouping is deliberate: the session-payload filter becomes
path-aware and names paths rather than whole fields, so a storefront can read the organisation's
name without the credential ever being in reach. Side by side, one forgotten line in the filter
would have released it.

Authentication means an account member is present, not that an account is selected. Those are
different facts and the current conflation makes a member with no organisation look signed out.

The server mints the account credential itself. The client-supplied-token shape of account
login, and the verification step that had to exist because the browser supplied it, both go — a
credential our own server just minted needs no proof it was not forged.

Roster and account switch are authentication-plugin endpoints, not named operations, so typed
client methods are generated for consumers rather than hand-written.

Selection rules. Auto-select only at exactly one organisation; a member transacting against
an organisation they never chose is the failure this prevents. Zero organisations is a successful
sign-in. The account credential rolls on use below an hour remaining, and a lapse is surfaced as
a positive fact rather than inferred from a timestamp.

An account switch re-mints, tears down any checkout session, clears the cart pointer, and
writes the new account last
— the fallible step first, so a failed switch cannot leave a
half-changed identity.

A session cart resolver hook chooses the session cart at an identity transition, configured
once rather than per call site. It receives credentials and runs inside the session context, so
it can act rather than only choose. With no resolver: keep the guest basket, since that is the
one the shopper is looking at when they sign in and the only choice that cannot silently destroy
or inflate what they just built; with no guest basket, adopt the most recently updated account
basket.

Design time gets its own session-free route in its own module, so "never reads the session"
is a property of the file rather than a branch someone can later invert. Allowed operations are
declared by name, never by subtraction, so the next session-scoped function added does not leak
into it by default. It runs in production because designers edit against a deployed host, with
no credentials and no origin gate — and the trusted-origin list stays shopper-only, so design
time never widens it.

Catalog search server-renders through the Next integration, swapped in unconditionally — no
prop and no separate component. No call site names a transport, a designer has no basis to reason
about server rendering, and a registered prop is permanent once shipped.

Server rendering delivers the unrefined default first page, and that is accepted. Scope that
rides the configure-filter prop flows into the server pass and renders correctly; URL-derived
refinements do not reach it at all, so a deep-linked refinement renders unrefined and corrects on
hydration. The degradation lands on search-results pages, which are conventionally not indexed
and were never the indexing case. A dev warning fires when a page looks like a category page
scoped by URL refinements rather than by the base filter — the heuristic cannot tell a category
page from a search page, so the copy must ask rather than assert.

A failed server-side search falls through to client rendering and must never return a 500.
A shopper always sees the hydrated render, so the error slot still reaches them; the alternative
is a crawler indexing an error message and a transient blip becoming a cached failure.

The InstantSearch family is loaded by require only, never mixed with import. Mixing yields
two copies of the search core, two contexts, and a guaranteed server-render failure on every
request. The package's existing require-everywhere style is accidentally correct and becomes a
load-bearing invariant.

Platform enablement is a loader protocol bump, not an edit. Two Next submodules must be
externalized behind a new loader-version gate, with a browser stub for the server-only one, plus
the equivalent in the canvas bundler. Ungated, a consumer on an older loader gets an unknown
module instead of a page.

Registered surfaces are append-only. Hostless publishing rejects a removed component or a
removed published prop and fails with zero consumers, so what cannot be removed is emptied
instead: body stripped, props hidden, and descriptions rewritten rather than merely marked —
copy that advertises a working feature is worse than a label saying it is dead.

Release shape is expand-then-contract. Every additive and internal change ships in ordinary
non-breaking releases while the old path still works; the breaking set lands together, once, last,
after the replacements are proven. Slicing the breaking work by feature area would put several
breaks in flight against a package where a bad break cannot be published at all and the repair
path exists only in the visual editor.

Each release is four steps, in order: pull request the package change, merge, publish, then
commit the re-resolved consumer lockfiles. The lockfiles pin the published tarball's own
dependency ranges, so they cannot move before the publish, and the hostless publishing pipeline
installs frozen — a manifest bump alone moves nothing.

Testing Decisions

What makes a good test here. Assert only what a shopper, a crawler or a consuming developer
can observe: what is in the returned HTML, which requests leave the browser and where they go,
what the session endpoint returns, what price renders, what the package exports. Never assert
that a particular module was called or that a request took a particular internal path — the whole
milestone is a change of internal path, so tests written against it would have to be rewritten by
the change they exist to protect.

Three seams, at two levels. The structural level proves the token surface is singular and stays
singular; the behavioural level proves the storefront works and that four things impossible today
now hold. Neither level can stand in for the other, which is why this exceeds one seam.

Seam 1 — component-tree request assertion, in the existing package test suite. Render a
component tree in the existing jsdom environment and assert every outbound request is
same-origin. This is the token-surface claim stated as behaviour, it needs no credentials and no
running store, and it runs on every pull request. Prior art: the package's existing suites,
particularly the catalog-search and cart-provider component tests, which already render trees and
assert on mocked transport.

Seam 2 — repository integrity, in the existing fork-integrity suite. Assert the deleted
modules, exports and route contracts stay deleted, and that nothing outside the server entry
reaches the Elastic Path client. Pure filesystem assertions from the repository root, no build,
no credentials, already running on every pull request in under three minutes. Prior art: the
dependency and file assertions already in that suite. Note the suite currently frames itself as
protecting customizations against upstream merges; this adds an absence-guard role, and its
header should say so.

Seam 3 — one shopper journey against the example storefront, new. The only place a real
browser, a real shopper envelope and a real store meet. One spec, one journey, whose spine is a
single assertion: zero requests from the browser to the Elastic Path host, across the whole
journey.
Thirteen call sites make exactly those requests today; the correct count afterwards is
zero, and the assertion fails loudly the moment one is reintroduced. Prior art for the Playwright
shape: the existing Studio end-to-end suite; for a Playwright configuration living outside the
main application, the loader test suite.

The journey, cold browser and no cookie:

  1. Land on a category page — products present in the server-rendered HTML, not only after hydration.
  2. Refine the listing — results change.
  3. Add to basket — the basket reflects it.
  4. Sign in as an account member belonging to two organisations — the account roster returns both, and nothing is auto-selected.
  5. Select an organisation — the basket survives.
  6. Switch organisation — the price changes.
  7. Continue to checkout through to payment — unchanged behaviour.
  8. Return after longer than the old five-minute window — the envelope and basket are still there.

Steps 1, 4, 6 and 8 assert things that are impossible today, not regressions. Step 6 is the
milestone's reason for existing.

The journey must land before the breaking release, not after it. The release plan's safety
argument is that the contraction happens once the replacements are proven; nothing currently does
the proving. Built afterwards, the old path is deleted and breakage is discovered in production.

Step 6 is blocked on store configuration, not on code. No catalog rule is bound to an account
on the integration store, so there is nothing for the account header to change and the effect is
untested rather than shown — ADR-0003 records this. Until a rule exists, that step is pending
rather than passing, and the milestone's central claim is unproven by any test.

Also tested, at the seam that already owns it: a regression test pinning that the Studio
canvas never mounts the catalog-search provider's inner component, since the server-rendering
work is survivable in the canvas only because of that fact.

Not tested here: checkout and payment internals, which this milestone does not touch;
authoring flows in the visual editor, already covered by the existing end-to-end suite.

Out of Scope

Checkout and payment. A separate subsystem with its own sealed cookie, its own stronger
credential, and its own re-read of the real total before charging. Untouched.

Whether the cart pointer stops being handed to the browser. A basket is addressed by its id
alone, so a copied id can be replayed for as long as the basket lives, and the session endpoint
still returns it. Closing that means letting the checkout routes read it from the envelope — a
decision about checkout rather than about identity, and it is open.

Route-wide rate limiting. Unaddressed, and this work widens the surface it would cover from
one route to two, the second being an unauthenticated design-time route on every consumer's
production origin.

The Orders authorization bypass found incidentally. A gateway-side Elastic Path platform
flaw that no storefront change closes.

Account-scoped design time. There are zero account-scoped components in the package, so there
is no parity to preserve. Whatever adds them inherits two facts: a route that can name an
organisation cannot be unauthenticated, and there is no impersonation grant.

Catalog search at design time stays fixtures-only, because the canvas never mounts the
provider's inner component. Documented as a limitation, not closed.

Upstream pull requests. Fork patches only, including for the platform enablement work.

Further Notes

Vocabulary throughout is the package glossary's Identity & transport section. Terms marked
there as not yet built — account roster, anchor token, session cart resolver, design-time catalog
route — name decided concepts this spec is the plan for.

This spec holds success and how it is proven. The eighteen milestone issues hold mechanics —
field shapes, resolver signatures, mount paths. ADR-0003 and ADR-0004 hold the decisions and the
platform facts no code expresses. The split exists because an earlier draft carried decisions and
mechanics together, which meant two copies that would disagree.

Critical path is the account identity thread, five deep, ending at the consumer update. Read
it from the tracker's native dependency graph rather than the prose blocked-by lines: the
breaking release declares six blockers natively and names four in its body, the only such
mismatch on the milestone.

The largest risk to proving any of this is store configuration, not engineering. Everything
else here can be demonstrated on the integration store as it stands.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentScoped tracer-bullet slice, ready for an agent to pick up

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions