PANDORA

Spark 2.0 Overall Architecture

Infrastructure · Edge · Session · Environments · Roadmap — Kuben · Oct 2026 · 15 min + Q&A

🏗️Infravertical apps
🌐CDNhow requests route
📦Cachewhat's stored
👤Sessionwho the shopper is
🍪Cookieswhat the browser holds
💡

Why Spark 2.0 — one unified UI framework

2 min

Pandora'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.

1️⃣

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.

01

Infrastructure

2 min

Vertical 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 min

Each vertical team interacts with four repos: its own app, the shared packages, and two config repos that hold environment variables per market.

RepoWhat lives thereWhen you touch it
Own app
Spark/ecom-home (etc.)
The vertical Next.js app — pages, BFF routes, its pipeline definition Daily feature work; deploys independently
Shared packages
Spark/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 config
Spark/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 config
Spark/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 min

Everything 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 min

Three regional stacks, each a straight vertical slice: markets → production swim lane → regional DOL → regional Spark origin → SFCC realm. A request never crosses a region.

Production
NAMLATAM EMEALite & 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
Pre-prod
NAMLATAM EMEALite & 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… → Staging
PT · pt-ecom-*.spark.ui… → Production
1 Cluster
Dev · dev-ecom-*.spark.ui… → Development
Test · test-ecom-*.spark.ui… → Staging
1 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.io for Spark origins, <region>.dol.api.pandoradigital.io for DOL — adding an app or region does not invent new conventions.
02

CDN — Cloudflare as code

3 min

All 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.

03

Cache Management

4 min · screenshot

Documents are identity-free and shared-cacheable; everything personal is excluded by rule and by header.

1
Cache Rule per zone — respect origin TTL
s-maxage
the app decides lifetimes, not the edge
HIT
verified: cf-cache-status + CloudFront hits
no-store
every personal API response
🚫

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.

🐢
Known gap — the next perf leverRSC navigation payloads (?_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.

Layer
What's stored
Maintained by
Lifetime
🧑 Browser
Documents: never reused locally — max-age=0, must-revalidate forces revalidation at the edge on every view.
App (response headers)
0 always revalidate
☁️ Cloudflare edge
Anonymous documents (home, PLP, PDP…). One cache rule per zone, "respect origin TTL" — the app's s-maxage decides. After the 15 min a stale copy may be served while revalidating — and up to a day if the origin errors.
App sets TTL · Cloudflare stores
15 min s-maxage=900 · +SWR 900 · stale-if-error 86400
⚙️ BFF / personal APIs
Nothing. Session, wishlist, basket, promotional prices → cache-control: no-store, plus the edge exclusion list (/checkout*, /account-*…).
App (BFF handlers)
never cached
🗄 Redis session
What the 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 🗺.)
App (@pandora/auth)
shopper: continuous follows the SLAS refresh token (30 d guest / 90 d registered) — record TTL 24 h housekeeping, re-coupled to the refresh token in 🗺 Phase 2
🔑 SLAS access token
The Bearer used on SCAPI calls. Expiry here is what triggered the wishlist incident — and what the 401 self-heal absorbs.
SLAS (auth server)
~30–60 min refreshed server-side
🔑 SLAS refresh token
Long-lived re-auth: how long a shopper stays recognized without logging in again.
SLAS (auth server)
30 d guest · 90 d registered
04

Session Management

4 min · identity doc

Pages 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.

1️⃣

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 SLASCompare dwsid first (chosen)
Correctness✅ always fresh✅ equally fresh — a mismatch forces the same SLAS call
SLAS callsone per page landing, per shopper — millions/dayonly when something actually changed — a tiny fraction
Speed of session/customer+ one SLAS round-trip (~100–200 ms) every timeone string comparison (~0 ms) in the normal case
SLAS rate limitsreal risk — SLAS throttles bursts (which is why the single-flight guard exists around token minting in our code)safe
05

Cookies

2 min

Few cookies, each with one job. Nothing personal is readable client-side.

⇄

Sequences

speak over these

Infra + 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 min

The 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.

🕸 Before
⚡ After (SDET)
Who writes tests
Developer writes code → hands off to QA → QA writes and runs tests
Engineer writes code and tests together, in the same PR
How tests run
Manual + separate QA cycles after the hand-off
Agentic workflow runs the Playwright suite as part of delivery
Who signs off
QA signs off → release
QE Specialist reviews & validates → release
Tooling (the Spark 2.0 change)
Cypress E2E suites
Playwright — tests/ in every vertical app, market/env picked via tests/config/market-configs.json
Effect
Quality is a stage at the end — hand-offs, queues, late surprises
Quality is built in — shorter feedback loop, QE focuses on risk & coverage, not authoring every test
🗺

Cookie & Redis roadmap

2 min

Keep 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)

  1. Introduce pnd_shopper (HttpOnly, ~180 days) — the new home for long-term shopper continuity (Einstein, analytics). Can start late Phase 1.
  2. Run old + new in parallel one quarter; prove continuity in the dashboards.
  3. 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.
  4. Turn off Hybrid Auth in Business Manager; stop Spark's cookie relay.
  5. Drop dwsid from the session → final four-field vault: { accessToken, accessExpiresAt, refreshToken, refreshExpiresAt }.
  6. Retire the Salesforce cookies through the retirement register (reversible one quarter), then lock down pnd_session fully.

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.