Spark 2.0 Overall Architecture
Infrastructure · Edge · Session · Environments · Roadmap — Kuben · Oct 2026 · 15 min + Q&A
Why Spark 2.0 — one unified UI framework
2 minPandora's storefront UI is converging on one framework, one design system, one way of working — Spark 2.0 — replacing today's mix of UI frameworks: PWA Kit, Spark on Modern.js, and Spark on Next.js.
One stack instead of many
Three parallel stacks today (PWA Kit, Spark/Modern.js, Spark/Next.js) → one. No more fixing the same bug three times, duplicated components, or divergent behaviour between markets.
Independent team velocity
Vertical apps (home, PLP, PDP, account, checkout) deploy independently — a PLP release never waits on checkout. Legacy was one monolithic app, one release train.
Performance by architecture
Identity-free documents + edge caching give shared cache HITs for every shopper — impossible in the token-in-browser PWA Kit model.
Security by design
Tokens move out of the browser into Redis. One opaque cookie; nothing worth stealing client-side.
Shared code, versioned
One design system and shared packages (@pandora/*) consumed by all verticals — fix once, every app picks it up via a version bump.
Lower cost of change
Adding a market is config (Terraform + env repo), not a new codebase. One skill set to hire for and onboard into.
Infrastructure
2 minVertical Next.js apps — home · PLP · PDP · account · checkout — deployed independently, composed at runtime via Module Federation, fronted by Cloudflare.
One repo per vertical
Own pipeline, own deployment, independent release cadence.
Module Federation
Host app loads other verticals as federated remotes. Shared singletons: react, next-intl, LaunchDarkly, shared-components.
Predictable origins
<env>-ecom-<app>.spark.ui.pandoradigital.io — e.g. test-ecom-plp… for stage.
Versioned shared code
@pandora/shared-components from Azure Artifacts, released with changesets.
Same topology everywhere
dev / stage / prod differ only in Cloudflare zones and origin prefixes.
Repos & CI/CD — how a team works day-to-day
2 minEach vertical team interacts with four repos: its own app, the shared packages, and two config repos that hold environment variables per market.
| Repo | What lives there | When you touch it |
|---|---|---|
Own appSpark/ecom-home (etc.) |
The vertical Next.js app — pages, BFF routes, its pipeline definition | Daily feature work; deploys independently |
Shared packagesSpark/pandora-platform-packages |
@pandora/auth, @pandora/shared-components, app-shell… — versioned with changesets, published packages consumed by all apps |
Cross-cutting changes; then bump the version in each app |
Non-prod configSpark/nonprod-config |
Environment variables for dev / test / E2E / PT / hotfix, per market | New env var, new market, SLAS client / API endpoints for test envs |
Prod configSpark/prod-config |
Environment variables for production, per market — separate repo = separate review gate | Prod rollout of a var already proven in non-prod |
Root-level structure — what you see when you clone
Own app — ecom-home
ecom-home/ ├─ src/ the Next.js app — pages, components, BFF API routes ├─ tests/ Playwright E2E suite (see Quality · SDET) ├─ public/ static assets ├─ azure-pipelines/ this app's CI/CD pipeline definitions ├─ docker/ · Dockerfile the runtime image that gets deployed ├─ scripts/ · docs/ tooling + app docs ├─ module-federation.config.js what this app exposes/consumes as MF remotes ├─ next.config.ts · postcss.config.mjs ├─ playwright.config.ts └─ package.json · pnpm-lock.yaml · lefthook.yml
Shared packages — pandora-platform-packages
pandora-platform-packages/ (pnpm + turbo monorepo) ├─ packages/ │ ├─ design-system/ ui · icons · tokens · utils — the shared design system │ ├─ ecom/ app-shell · shared-components · cms-modules │ │ · store-context · ecom-utils │ └─ shared/ auth · analytics · core · dol-api · spark-env │ · mf-build · eslint-config · typescript-config ├─ apps/docs/ Storybook — deployed, see the Storybook tab ├─ azure-pipelines/ · azure-pipelines.yml publish & CI ├─ scripts/ · docs/ └─ turbo.json · pnpm-workspace.yaml (.changeset drives versioning)
Non-prod config — nonprod-config
nonprod-config/ one folder per app, one subfolder per env ├─ ecom-home/ │ ├─ dev/app.properties env vars for dev │ └─ test/app.properties env vars for test/stage ├─ ecom-plp/ · ecom-pdp/ · ecom-account/ same shape ├─ fusion/ · engraving/ · pandora-group/ · …other Spark apps └─ azure-pipelines.yml applies config changes on merge
Prod config — prod-config
prod-config/ same shape, prod only — separate review gate ├─ fusion/ │ └─ prod-eu/app.properties env vars per prod region ├─ engraving/ · pandora-group/ · studio-spa/ · … └─ azure-pipelines.yml ecom-* apps are not onboarded here yet — they land with the same <app>/prod-<region>/app.properties shape at go-live.
CI/CD & configuration — market and region level
Configuration is layered: region picks the artifact's deployment target, environment picks the variable set (ConfigMap + Key Vault), and market is pure data.
Market & region level — realm configs in shared code
// pandora-platform-packages/packages/ecom/ecom-utils/src/config/
├─ realms/ nam · emea · apac · latam · lite
│ per-realm site definitions (markets, siteIds,
│ locales, aliases) — typed + unit-tested
├─ resolve.ts picks the active site at runtime:
│ 1. REALM + ENVIRONMENT from process.env
│ 2. load that realm's config
│ 3. match hostname against site aliases
│ dev-{alias}.pandora.net → development
│ stg-{alias}.pandora.net → staging
│ {alias}.pandora.net → production
├─ env.ts · shared.ts env plumbing + cross-realm shared values
└─ types.ts Realm / Environment / SiteDefinition
The runtime source of truth for markets: one deployment serves every market in its realm — the hostname decides the site, REALM+ENVIRONMENT decide which config it reads. A new market is a reviewed code change in realms/ (Playwright has a mirror in tests/config/market-configs.json).
Env level — ConfigMap vars, Key Vault secrets
# <app>/.env.local — local mirror of the Azure ConfigMap (nonprod/dev) # every var is app-prefixed; NEXT_PUBLIC_* are inlined at build time ecom-plp-NEXT_PUBLIC_APP_URL=… ecom-plp-NEXT_PUBLIC_ENVIRONMENT=… ecom-plp-REALM=… # region slice this deploy serves ecom-plp-DOL_PROXY_PATH=… ecom-plp-NEXT_PUBLIC_EINSTEIN_ID_emea=… # realm-suffixed per region ecom-plp-NEXT_PUBLIC_EINSTEIN_ID_nam=… # secrets (SLAS client secret, API keys…) are NOT here — # ConfigManager pulls them from Azure Key Vault at boot
The config repos + ConfigMap hold non-secret vars per environment; the app's ConfigManager scans Azure Key Vault for the secret ones. Locally, .env.local mirrors the ConfigMap (never committed).
Region level
One artifact, deployed per regional cluster (NAM / EMEA / APAC — see Envs & connections). REALM (fed into resolve.ts above) + realm-suffixed vars (…_emea, …_nam) tell the same build which region slice it serves.
One pipeline shape, all apps
App pipelines share a common template (cicd-pipeline-code): build once, deploy the same artifact per environment — only the ConfigMap/Key Vault contents differ.
Config is reviewed like code
Both config repos are PR-gated. Prod config living in its own repo keeps the prod change audit trail clean.
Secrets never live in the repos
Secrets, passwords and API keys live in Azure Key Vault (per environment) and are injected at deploy time — nothing sensitive in git, ConfigMaps hold only non-secret vars.
Storybook — the shared components, live
1 minEverything in pandora-platform-packages is browsable as a deployed Storybook (apps/docs) — the single source of truth for what the design system and shared components look like and how to use them.
🔗 dev-pandora-platform-packages.spark.ui.pandoradigital.io ↗
Design system
ui components, icons, design tokens and utils — rendered with every variant and state, no local setup needed.
Shared ecom components
The building blocks the vertical apps consume (shared-components, cms-modules) — what you get when you bump a package version.
One reference for everyone
Designers, engineers and QE review the same deployed artifact — "does it match Storybook?" replaces screenshot ping-pong.
Deployed like an app
Built from the monorepo and served on the same predictable origin pattern (<env>-pandora-platform-packages.spark.ui…) as the verticals.
Environments — strategy & connections
2 minThree regional stacks, each a straight vertical slice: markets → production swim lane → regional DOL → regional Spark origin → SFCC realm. A request never crosses a region.
| NAM | LATAM | EMEA | Lite & Starter | APAC | |
|---|---|---|---|---|---|
| Markets | US, CA | PA, PE, CL, CO, AR, BR | UK, IT, PL, NL, DK, SE, FR, ES, DE, AT |
CH, IE, HU, BE, PT, GR, TR, NO, RO, SK AE, TW, ZA |
AU, NZ, JP, HK, SG |
| Clusters | 2 Cluster | 2 Cluster | 2 Cluster | ||
| Swim lane (realm) | USESTORE Production | NA01 Production | EU03 Production | EU04 Production | AP07 Production |
| DOL | nam.dol.api.pandoradigital.io |
emea.dol.api.pandoradigital.io |
apac.dol.api.pandoradigital.io |
||
| Spark origin | nam-ecom-plp.spark.ui.pandoradigital.io |
emea-ecom-plp.spark.ui.pandoradigital.io |
apac-ecom-plp.spark.ui.pandoradigital.io |
||
| NAM | LATAM | EMEA | Lite & Starter | APAC | |
|---|---|---|---|---|---|
| SFCC realms | USESTORE StagingUSESTORE Development | NA01 StagingNA01 Development | EU03 StagingEU03 Development | EU04 StagingEU04 Development | AP07 StagingAP07 Development |
| DOL | — | e2e.dol.api.pandoradigital.io |
e2e.dol.api.pandoradigital.io |
||
| Environments (= Spark origins) |
— |
E2E · e2e-ecom-*.spark.ui… → StagingPT · pt-ecom-*.spark.ui… → Production1 Cluster |
Dev · dev-ecom-*.spark.ui… → DevelopmentTest · test-ecom-*.spark.ui… → Staging1 Cluster |
||
How to read the connections
- One vertical slice per region: a shopper in a market is served by that region's Spark origin, which calls that region's DOL, which talks to the SFCC realm in the same column. Latency stays regional, and a realm incident is contained to its own markets.
- Spark-live markets (AE, TW, ZA, EU04 realm) are where the Spark migration is running today — the hybrid cookie model from the roadmap applies there first.
- Why the env is baked into the Redis key: non-prod environments (Dev and Test on APAC, E2E/PT/Hotfix on EMEA) share infrastructure — one Redis can serve two environments with different SLAS orgs/clients. That is exactly the collision the
app:slas_token:{org}:{client}:{siteId}key shape prevents (see Roadmap). - Non-prod funnels through one DOL (
e2e.dol.api…) against the Staging/Development realms — cheap, but it means a non-prod outage can block several test environments at once. - Naming is predictable:
<region>-ecom-<app>.spark.ui.pandoradigital.iofor Spark origins,<region>.dol.api.pandoradigital.iofor DOL — adding an app or region does not invent new conventions.
CDN — Cloudflare as code
3 minAll edge routing is Terraform (projects/cdn): one workspace per market, one reusable module.
Origin Rules
Rewrite only the Host header. The public URL never changes.
Opt-in gate
Traffic reaches Spark 2.0 only with the x-spark-enable cookie or spark2_0 param → safe, reversible migration.
App stamping
x-pandora-app cookie remembers which app served the page, so RSC calls hit the same origin.
New market = 1 line
Add to a locals map, then terraform workspace select <market> && apply.
Cache Management
4 min · screenshotDocuments are identity-free and shared-cacheable; everything personal is excluded by rule and by header.
Exclusion list
/account-* /checkout* /my-wishlist /shopping-bag /logout… — personal routes never enter shared cache, even if an origin header slipped.
Defense in depth
Session, wishlist and basket APIs also send cache-control: no-store from the BFF. Cached pages, uncached identity.
?_rsc=…) carry per-build hashes + wide Vary → permanent MISS. That's why client-side navigation is slower than first load. Fix = custom cache key, not a TTL tweak.⏱ Who maintains which cache — and for how long
Every lifetime is decided by the app or the auth server — the edge only respects what the origin says.
max-age=0, must-revalidate forces revalidation at the edge on every view.s-maxage decides. After the 15 min a stale copy may be served while revalidating — and up to a day if the origin errors.cache-control: no-store, plus the edge exclusion list (/checkout*, /account-*…).pnd_session cookie unlocks: the shopper's SLAS tokens, usid, dwsid, customer id, basket id. The Redis record is disposable — if it's gone, the next session/customer call rebuilds it and the hybrid bridge re-establishes the same shopper from the SLAS cookies. (Roadmap slims this to a tokens-only vault — see 🗺.)Session Management
4 min · identity docPages stay anonymous; identity is minted lazily on one no-store endpoint. Redis is only the vault — it stores the tokens, it never decides anything.
One identity call
GET /api/<app>/session/customer — the only place identity flows. Documents never embed customer data.
Redis custody
pnd_session cookie → Redis holds the SLAS tokens + expiries (and dwsid while SFRA lives). Identity is decoded from the token — never stored twice.
Guests are first-class
SLAS mints every visitor a customer id — wishlist works without login.
Hybrid bridge
Reconciles with SFRA on every customer-state call → Spark and legacy share one shopper.
One call per page load
session/customer fires once; its payload carries everything personal (login state, basket count, customer id) — minicart, account links etc. reuse it, no extra calls. Pages without personal elements make zero identity calls.
Cheap reconciliation
The bridge compares the stored dwsid first (≈0 ms) and asks SLAS only when it changed — i.e. exactly at SFRA login/logout. Asking SLAS on every call would mean millions of calls/day and +100–200 ms per page.
▸ Deep dive — call counts per page load & the dwsid trade-off
What actually fires on a page load
Guest/registered shopper, warm session — e.g. a PLP visit:
1. GET /plp document ............... CDN shared cache — 0 identity calls
2. GET /api/<app>/session/customer . exactly 1 call (no-store)
fast path: stored dwsid matches + access token fresh
→ read Redis, decode identity from the JWT, return
→ 0 SLAS calls, 0 SCAPI calls
3. minicart · account links · hearts 0 extra calls — all reuse payload of (2)
Page with no personal elements ..... 0 identity calls at all
SLAS is asked only when something actually changed:
· dwsid rotated (SFRA login/logout) → re-reconcile identity, 1 SLAS call
· access token expired (~30 min) → refresh, 1 SLAS call
Why compare dwsid first — vs "always ask SLAS"
"Always ask SLAS on every session/customer" is 100% correct too — it can never serve a stale identity, and it's simpler. What it costs is the reason the comparison exists:
| Always ask SLAS | Compare dwsid first (chosen) | |
|---|---|---|
| Correctness | ✅ always fresh | ✅ equally fresh — a mismatch forces the same SLAS call |
| SLAS calls | one per page landing, per shopper — millions/day | only when something actually changed — a tiny fraction |
Speed of session/customer | + one SLAS round-trip (~100–200 ms) every time | one string comparison (~0 ms) in the normal case |
| SLAS rate limits | real risk — SLAS throttles bursts (which is why the single-flight guard exists around token minting in our code) | safe |
Cookies
2 minFew cookies, each with one job. Nothing personal is readable client-side.
Sequences
speak over theseInfra + CDN + Cache — same shape as PWA Kit step 1 (S5): only the pieces in bold change: CloudFront→Cloudflare, MRT→Spark app server, plus the opt-in gate and the x-pandora-app routing cookie.
sequenceDiagram
autonumber
participant B as Browser - Spark app
participant CDN as Cloudflare CDN
participant APP as Spark app server
Note over B,APP: 1. Guest opens a product listing page
B->>CDN: GET pandora.net/en/charms/ with x-spark-enable cookie
Note over CDN: SPARK CHANGE: opt-in gate matches the cookie →
Origin Rule rewrites Host only, path untouched
alt edge cache HIT (TTL from Cache-Control set by the Spark render)
CDN-->>B: cached page served from edge — app server never invoked
else cache MISS or excluded route (/checkout, /my-wishlist…)
CDN->>APP: forward to SSR (x-forwarded-host preserved)
APP-->>CDN: rendered page + Cache-Control headers
(s-maxage for documents, no-store for personal)
CDN-->>B: page (now cached at edge for next shopper)
+ Set-Cookie: x-pandora-app=ecom-plp
end
Note over B: cached HTML is anonymous and shared — hearts empty,
no personal data in it. ALL personalization is layered on
client-side afterwards, which is why page caching is safe.
SAME model as PWA Kit — this part carries over 1:1
Note over B,CDN: SPARK CHANGE: later RSC calls carry x-pandora-app →
routed to the same app's origin
Guest identity — same shape as PWA Kit step 2 (S5): same SLAS endpoint, same grant, same token lifetimes. The Spark change: the browser makes ONE session/customer call and the server sorts out identity against Redis — instead of commerce-sdk-react checking the cookie jar client-side.
sequenceDiagram
autonumber
participant B as Browser - Spark app
participant CDN as Cloudflare CDN
participant APP as Spark app server
participant R as Redis
participant SLAS as SLAS
participant SFRA as Legacy SFRA
rect rgba(103,183,247,0.07)
Note over B,SLAS: 🆕 FIRST VISIT — 2. The SERVER sorts out identity via session/customer
B->>CDN: GET pandora.net/en/charms/ (first time ever)
CDN-->>B: anonymous cached HTML — page paints, no identity needed
Note over B: SPARK CHANGE: the browser does NOT inspect tokens itself —
it makes ONE call and trusts the answer
(PWA Kit: commerce-sdk-react checks the cookie jar,
then calls /oauth2/token through the MRT proxy)
B->>APP: GET /api/ecom-plp/session/customer (no pnd_session yet)
APP->>SLAS: POST /oauth2/token — grant_type=client_credentials,
channel_id=en-ZA — secret from server config,
no proxy placeholder trick needed, browser never involved
SLAS-->>APP: access_token JWT, 30 min + refresh_token, 30 days
+ guest customer_id + usid
+ Set-Cookie dwsid and dwanonymous via Hybrid Auth
APP->>SFRA: hybrid bridge — reconcile shopper with SFRA
dwsid captured → later injected as sfdc_dwsid on every SCAPI call
APP->>R: SPARK CHANGE: tokens stored in Redis, not browser cookies —
tokens, usid, dwsid, customerId
APP-->>B: CustomerState · Set-Cookie: pnd_session + relayed SLAS cookies
Note over B: PWA Kit: SDK writes cc-at_x / cc-nx-g_x cookies —
the cookie jar IS the session.
Spark: one opaque pnd_session cookie — Redis IS the session
end
rect rgba(95,214,139,0.07)
Note over B,SFRA: 🔁 SECOND VISIT — pnd_session cookie returns
B->>CDN: GET pandora.net/en/charms/ (cookie: pnd_session)
CDN-->>B: same anonymous cached HTML — cookie never varies the document
B->>APP: GET session/customer (cookie: pnd_session)
APP->>R: look up session → same shopper, same wishlist — NO SLAS call
Note over APP,SLAS: access token expired? refreshed server-side —
guest refresh token lives 30 days (same TTLs as PWA Kit)
APP-->>B: customer state (no re-mint, no new cookie)
end
Registered — current state: Spark has no login/logout of its own — the shopper signs in (and out) on the SFRA pages, exactly as today. Spark finds out on its next session/customer call: the rotated dwsid is the tripwire, the hybrid bridge re-reconciles with SLAS, and the same pnd_session upgrades to the registered shopper — basket and wishlist merge happen on the Salesforce side. Spark-owned login arrives only in 🗺 Phase 2 when SFRA retires.
sequenceDiagram
autonumber
participant B as Browser - Spark app
participant APP as Spark app server
participant R as Redis
participant SLAS as SLAS
participant SFRA as Legacy SFRA
rect rgba(238,123,171,0.07)
Note over B,SFRA: 🔐 LOGIN — happens on SFRA ONLY (current state, same as today)
B->>SFRA: shopper signs in on an SFRA page (email + password)
SFRA->>SLAS: SFRA + Hybrid Auth authenticate the shopper
Note over SLAS: guest basket + wishlist
merge into the account (Salesforce side)
SFRA-->>B: logged in · dwsid ROTATES · registered SLAS cookies set
Note over B: Spark knows nothing yet — it finds out on its next call
end
rect rgba(103,183,247,0.07)
Note over B,SFRA: 🔄 SHOPPER NAVIGATES BACK TO A SPARK PAGE
B->>APP: GET session/customer (cookie: pnd_session + rotated dwsid)
APP->>R: compare dwsid — rotation detected (the tripwire)
APP->>SLAS: hybrid bridge re-reconciles → registered tokens
APP->>R: SAME pnd_session record upgrades to the registered shopper
APP-->>B: logged in — bag & wishlist intact, no new cookie
end
Note over B,SFRA: LOGOUT works the same way: SFRA logout rotates dwsid →
next session/customer detects it → Redis record back to guest
Note over B,SFRA: Spark-owned login/logout is 🗺 Phase 2 —
only when SFRA retires per market
All layers in one picture (current state): edge cache → server-side identity → hearts → add to bag → rehydration. Stored session fields shown are today's — the roadmap slims them to the lean vault.
sequenceDiagram
autonumber
participant B as Browser - Spark app
participant CDN as Cloudflare CDN
participant APP as Spark app server
participant R as Redis
participant SLAS as SLAS
participant SFCC as SFCC SCAPI
participant DOL as DOL API
Note over B,APP: 1. Guest opens a product listing page
B->>CDN: GET pandora.net/en/charms/ with x-spark-enable cookie
alt edge cache HIT (TTL from Cache-Control set by the Spark render)
CDN-->>B: cached page served from Cloudflare edge — app server never invoked
else cache MISS or non-cacheable request
CDN->>APP: forward to SSR
APP-->>CDN: rendered page + Cache-Control headers
CDN-->>B: page (now cached at edge for next shopper)
end
Note over B: cached HTML is anonymous and shared — hearts empty,
no personal data in it. ALL personalization is layered on
client-side in steps 2-6, which is why page caching is safe.
SAME model as PWA Kit — this part carries over 1:1
Note over B,SLAS: 2. The SERVER sorts out identity via session/customer
Note over B: the browser does NOT inspect tokens itself —
it makes ONE call and trusts the answer
B->>APP: GET /api/ecom-plp/session/customer
SEND: pnd_session cookie + dwsid + cc-at/cc-nx cookies if any
APP->>R: look up session hash by pnd_session value
alt no session, token expired, or dwsid rotated
APP->>SLAS: POST /oauth2/token
SEND: grant_type=client_credentials, channel_id=en-ZA,
usid if one exists — secret from server config,
no proxy placeholder trick needed, browser never involved
SLAS-->>APP: GET BACK: access_token JWT, 30 min + refresh_token, 30 days
+ guest customer_id + usid
+ Set-Cookie dwsid and dwanonymous via Hybrid Auth
APP->>R: store customerType, customerId,
slasAccessToken per siteId, slasExpiresAt per siteId —
token fields are SITE-SCOPED, one token per market,
so a market switch never reuses another site's token —
+ refreshToken (30-day continuity, NEVER leaves the server),
usid, dwsid
else session fresh and dwsid matches
R-->>APP: existing identity — NO SLAS call at all
end
APP-->>B: GET BACK: JSON isLoggedIn, customerId, visitorId,
basketItemCount + Set-Cookie pnd_session, cc-at_x split at 4KB,
cc-nx-g_x, usid, dwsid, dwanonymous
Note over B: SAME SLAS endpoint, called server-side.
Token lives in Redis, not browser JS.
pnd_session cookie points at Redis — Redis IS the session.
dwsid arrives directly from SLAS — no OCAPI
session-bridge call, same as PWA Kit today
Note over R: stored fields shown are CURRENT state.
Roadmap target (vault, not mirror) — tokens + expiries
+ dwsid only. Identity decoded from the JWT,
basket/wishlist asked fresh. See roadmap section
Note over B,DOL: 3. Hearts light up
Note over B: useWishlist dedupes — 25 tiles share ONE request
and a module cache keyed on customerId or visitorId.
No getTokenWhenReady queue — the server owns the token
B->>APP: GET /api/ecom-plp/wishlist/
SEND: pnd_session cookie only — no Bearer in the browser,
no LD flags, no React Query cache key mimicry needed
APP->>DOL: GET customers/{customer_id}/product-lists
server attaches Bearer from Redis + sfdc_dwsid — always on, no flag
DOL-->>APP: GET BACK: product ids 791726PCZ, 798087EN
APP-->>B: wishlist product ids
Note over B: list creation is owned by the server route — the browser
never sees list ids, only productIds in/out.
Tiles check the list, matching hearts FILL
Note over B,DOL: 4. Guest taps an empty heart
B->>B: optimistic — heart FILLS immediately before the network call
B->>APP: POST /api/ecom-plp/wishlist/ with productId 792015CZ
SEND: pnd_session cookie — no client-side mutation override,
the server route attaches the Bearer
APP->>DOL: POST product-lists add item
DOL-->>APP: saved
APP-->>B: ok — module cache updates every tile
Note over B: GAP TODAY: on a 401 the tile just un-fills the heart.
No automatic re-auth retry in the client yet — planned Fix 2.
Login-caused 401s are fixed server-side by the dwsid
rotation guard (shipped in @pandora/auth 1.9.2)
Note over B,DOL: 5. Guest taps Add to Bag — useDolBasketMutation ported to Spark
B->>APP: add to bag request
SEND: pnd_session cookie + productId payload
Note over APP: NO pre-refetch via getCustomerBaskets needed —
Redis already knows basketId (current state — roadmap
target asks the basket fresh), and guest-session rotation
is handled by the session bridge, not the client
alt no basket exists yet
APP->>DOL: POST carts — ensureBasketId
auth + channel headers owned by the dol-api client,
sfdc_dwsid always attached — no LD flags
DOL-->>APP: new basket id
end
APP->>DOL: POST carts/{basketId}/items with productId payload
DOL-->>APP: updated cart WITH line items in the response
APP-->>B: GET BACK: cart id + item count from the SAME response
Note over B: no 500ms propagation wait, no background basket refetch,
no LD flags — count read straight from the addItem response
via customer.setBasket, minicart count updates,
add-to-cart confirmation sheet shows
Note over B,DOL: 6. Any NEW page load or MFE navigation — UI rehydration sequence
Note over B: the SERVER remembers everything — Redis replays identity
B->>APP: a. GET /api/ecom-plp/session/customer — always ONE call.
If token fresh and dwsid matches: Redis only, NO SLAS call.
If expired: refresh with the stored refreshToken.
If dwsid rotated: full re-reconcile against SLAS
APP->>R: read identity, token, basketId
APP-->>B: same customer_id back — identity continuity restored
PLUS isLoggedIn and basketItemCount in the SAME response
Note over B: identity resolved — fewer parallel hooks needed because
the one response already carries login state AND basket count
B->>APP: b. HEARTS — useWishlist (client-side, waits for shopperKey)
GET /api/ecom-plp/wishlist/ — one deduped call,
skipped if the module cache is still warm (SPA navigation)
APP-->>B: list items — every tile compares productId, matching hearts FILL
Note over B: c. MINICART — NO separate call.
basketItemCount already arrived in step a from session/customer.
PWA Kit needs useCustomerBaskets + pageshow listener here
Note over B: d. ACCOUNT LINKS — NO separate call.
isLoggedIn already arrived in step a — actions-bar renders
sign-in link (guest) OR my-account menu + logout (registered).
PWA Kit needs useCurrentCustomer here
opt newsletter-signup block is on the page
Note over B: e. SUBSCRIPTION — NOTHING TODAY in Spark.
No newsletter component exists in the Spark apps yet
(only locale strings in ecom-home). PWA Kit does
POST /find + PATCH opt-in here. Straightforward gap — to be ported
end
Note over B: all widgets converge from ONE identity answer
keyed to the same customer_id — Redis is the continuity
The same guest journey as S4, but in today's PWA Kit — for comparison when someone asks "how does it work now?". Identity lives in the browser's cookie jar and every widget re-authenticates itself; in Spark the same journey is one server-side session/customer answer.
sequenceDiagram
autonumber
participant B as Browser - PWA Kit app
participant CDN as CloudFront CDN
participant MRT as PWA Kit server MRT
participant SLAS as SLAS
participant SFCC as SFCC SCAPI
participant DOL as DOL API
Note over B,MRT: 1. Guest opens a product listing page
B->>CDN: GET pandora.net/en/charms/
alt edge cache HIT (TTL from Cache-Control set by MRT render)
CDN-->>B: cached page served from edge — MRT never invoked
else cache MISS or non-cacheable request
CDN->>MRT: forward to SSR
MRT-->>CDN: rendered page + Cache-Control headers
CDN-->>B: page (now cached at edge for next shopper)
end
Note over B: cached HTML is anonymous and shared — hearts empty,
no personal data in it. ALL personalization is layered on
client-side in steps 2-6, which is why page caching is safe
Note over B,SFCC: 2. The browser sorts out its own identity via Auth.ready
Note over B: commerce-sdk-react checks the cookie jar itself:
valid token? refreshable? nothing at all?
B->>MRT: POST /mobify/proxy/api/shopper/auth/v1/.../oauth2/token
SEND: grant_type=client_credentials, channel_id=en-ZA,
usid if one exists, dnt preference
Note over MRT: proxy injects Authorization Basic clientId:secret
the browser bundle only holds a placeholder
MRT->>SLAS: forwards to the SLAS tenant endpoint
SLAS-->>B: GET BACK: access_token JWT, 30 min + refresh_token
+ guest customer_id + usid
+ Set-Cookie dwsid and dwanonymous via Hybrid Auth
Note over B: SDK writes the tokens into browser cookies:
cc-at_x split at 4KB, cc-nx-g_x refresh.
No server-side session, no Redis.
The cookie jar IS the session.
dwsid arrives directly from SLAS — no OCAPI
session-bridge call is made anymore
Note over B,DOL: 3. Hearts light up
Note over B: API calls were queued behind getTokenWhenReady,
now the token exists so they fire
B->>DOL: GET dol-proxy/customers/customer_id/product-lists
SEND: Authorization Bearer + optional x-environment and
DOL Q3 channel headers via LD flags.
React Query cache key deliberately mimics the SCAPI key
so add/remove mutations invalidate it
DOL-->>B: GET BACK: product ids 791726PCZ, 798087EN
Note over B: if no wish_list exists yet, one is auto-created via
the custom shopper-customers mutation (also DOL proxy).
Tiles check the list, matching hearts FILL
Note over B,DOL: 4. Guest taps an empty heart
B->>DOL: POST dol-proxy/customers/customer_id/product-lists/listId/items
with productId 792015CZ — custom useShopperCustomersMutation
override attaches the Bearer itself
DOL-->>B: saved — shared cache key updates every tile, heart fills
Note over B,DOL: 5. Guest taps Add to Bag — useDolBasketMutation
B->>SFCC: refetch current basket via SCAPI getCustomerBaskets
fresh basket avoids Invalid Customer after guest-session rotation
alt no basket exists yet
B->>DOL: POST dol-proxy/carts
SEND: Bearer from getTokenWhenReady, x-environment,
channel headers (channelType online, or Q3 names via LD flag)
+ sfdc_dwsid from dwsid cookie if LD flag
enable-dwsid-header-for-shopper-requests is on
DOL-->>B: new basket id
end
B->>DOL: POST dol-proxy/carts/basketId/items with productId payload
same Bearer + headers
DOL-->>B: item added
Note over B: wait ~500ms for DOL to SCAPI propagation
B->>SFCC: refetch basket (background fire-and-forget if LD flag
enable-global-atb-background-basket-refetch is on)
SFCC-->>B: updated basket, minicart count updates,
add-to-cart confirmation sheet shows
Note over B,DOL: 6. Any NEW page load or MFE navigation — UI rehydration sequence
Note over B: server remembers nothing — the cookie jar replays identity first
B->>MRT: a. Auth.ready — if cc-at still valid, NO network call.
If expired: POST /oauth2/token grant_type=refresh_token
(cc-nx-g cookie) via MRT proxy
MRT->>SLAS: refresh if needed
SLAS-->>B: same customer_id back — identity continuity restored
Note over B: identity resolved — queued React Query hooks fire in parallel,
ONE request per data domain, shared by all widgets via cache key,
skipped entirely if the query cache is still warm (SPA navigation)
B->>DOL: b. HEARTS — useCustomerProductLists (client-side, waits for customerId)
GET dol-proxy/customers/customer_id/product-lists (Bearer)
DOL-->>B: list items — every tile compares productId, matching hearts FILL
B->>SFCC: c. MINICART — useCurrentBasket → useCustomerBaskets
GET customers/customer_id/baskets (Bearer)
SFCC-->>B: basket — totalItems derived from item quantities,
BagCountBadge shows count, popup data ready.
pageshow listener refetches on back/forward cache restore
B->>SFCC: d. ACCOUNT LINKS — useCurrentCustomer
GET customers/customer_id (Bearer)
SFCC-->>B: customer.isRegistered — actions-bar renders
sign-in link (guest) OR my-account menu + logout (registered)
opt newsletter-signup block is on the page
B->>DOL: e. SUBSCRIPTION — POST proxy/find with emailAddress (Bearer)
DOL-->>B: consents — if email consent optIn === true, status
ALREADY_SUBSCRIBED and form fields are replaced with
acknowledgement. If optIn false: PATCH /customers/id opts them in
end
Note over B: all widgets converge independently from fresh authenticated
fetches keyed to the same customer_id — the cookies ARE the continuity
Quality — the SDET transition (Playwright)
2 minThe SDET way of working — engineers own tests; QE specialists validate — carries over unchanged from the existing setup. What changes with Spark 2.0 is the tooling: migrating from Cypress to Playwright as the E2E framework.
tests/ in every vertical app, market/env picked via tests/config/market-configs.jsonCookie & Redis roadmap
2 minKeep all cookies exactly as they are while SFRA runs; Redis is only the token vault. The day the last SFRA page is gone, clean up in one verified move.
Phase 1 — while SFRA is still alive
Redis holds exactly two kinds of entries — nothing else:
app:slas_token:{org}:{client}:{siteId} TTL ~25 min (shared "shop key" for identity-free pages;
{ accessToken, expiresAt } env baked into the key — dev/test can't collide)
session:<random-id> TTL 30 days, sliding
{
accessToken, // 30-min SLAS JWT — identity is INSIDE it
accessExpiresAt,
refreshToken, // 30-day credential — NEVER leaves the server
refreshExpiresAt,
dwsid // ⏳ TEMPORARY — SFRA tripwire, deleted in Phase 2
}
Phase 2 — when SFRA retires (per market)
- Introduce
pnd_shopper(HttpOnly, ~180 days) — the new home for long-term shopper continuity (Einstein, analytics). Can start late Phase 1. - Run old + new in parallel one quarter; prove continuity in the dashboards.
- Before stopping the cookie relay: derive the Redis record TTL from the SLAS refresh-token expiry (30 d guest / 90 d registered, re-armed on activity) — today's fixed 24 h housekeeping TTL is safe only because a lapsed record can be rebuilt from the relayed SLAS cookies. Once Redis is the sole holder of the refresh token, a fixed 24 h would silently log shoppers out.
- Turn off Hybrid Auth in Business Manager; stop Spark's cookie relay.
- Drop
dwsidfrom the session → final four-field vault:{ accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }. - Retire the Salesforce cookies through the retirement register (reversible one quarter), then lock down
pnd_sessionfully.
End state — three script-proof cookies: pnd_session (30-day vault ticket) · pnd_shopper (~180-day long memory) · x-pandora-app (routing). Everything real lives in Redis and Salesforce.