From 5f422509716e58439912f10d19c47c9acdde3045 Mon Sep 17 00:00:00 2001 From: Vitor Date: Wed, 12 Aug 2026 09:13:40 -0300 Subject: [PATCH 1/5] fix(storefront): Fix checkout blocked by censored profile cached on browser Profiles identified by e-mail and document only used to be returned with censored fields: "***" surname, "000XX" phone and address holding just zip and province. Sessions persisted at that time are still cached on customer browsers and get resubmitted on every checkout, where they overwrite the saved profile and produce an incomplete shipping address that payment gateways reject. Detect those fields when loading the persisted session and drop the cached profile, so it is fetched again once authenticated, and filter them out on fetchCustomer so a profile already stored censored is not reused as valid. Co-Authored-By: Claude Opus 5 --- .../src/lib/state/customer-session.ts | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/storefront/src/lib/state/customer-session.ts b/packages/storefront/src/lib/state/customer-session.ts index f1b43cdd1..009fb26e9 100644 --- a/packages/storefront/src/lib/state/customer-session.ts +++ b/packages/storefront/src/lib/state/customer-session.ts @@ -25,6 +25,47 @@ const session = useStorage<{ }, }>(storageKey, emptySession); +/* + * Profiles identified by e-mail and document only (without verified login) used to be + * returned with censored fields: '***' surname, '000XX' phone, address holding just zip + * and province. Sessions persisted back then are still cached on customer browsers and + * get submitted on checkout, where they overwrite the saved profile and produce an + * incomplete shipping address that payment gateways reject. + */ +const CENSORED = '***'; +type CustomerAddress = NonNullable[number]; +const isCensoredPhone = (number?: string) => !!number && /^0{3}\d{2}$/.test(number); +const isCensoredAddress = ({ name, line_address: lineAddress }: CustomerAddress) => { + return name === CENSORED || !!lineAddress?.includes(CENSORED); +}; +const hasCensoredFields = ({ name, phones, addresses }: Partial) => { + if (name?.family_name === CENSORED) return true; + if (phones?.some(({ number }) => isCensoredPhone(number))) return true; + return !!addresses?.some(isCensoredAddress); +}; +const withoutCensoredFields = (customerData: Partial) => { + const { + name, + phones, + addresses, + ...safeCustomer + } = customerData; + const cleanCustomer: Partial = safeCustomer; + if (name && name.family_name !== CENSORED) cleanCustomer.name = name; + const cleanPhones = phones?.filter(({ number }) => !isCensoredPhone(number)); + if (cleanPhones?.length) cleanCustomer.phones = cleanPhones; + const cleanAddresses = addresses?.filter((address) => !isCensoredAddress(address)); + if (cleanAddresses?.length) cleanCustomer.addresses = cleanAddresses; + return cleanCustomer; +}; +if (hasCensoredFields(session.customer)) { + // Drop the cached profile entirely to force `fetchCustomer` once authenticated again. + session.customer = { + display_name: session.customer.display_name || '', + main_email: session.customer.main_email || '', + }; +} + const isAuthenticated = computed(() => { const { auth } = session; return auth && new Date(auth.expires).getTime() - Date.now() > 1000 * 10; @@ -85,8 +126,9 @@ const fetchCustomer = async () => { const { data } = await api.get(`customers/${auth.customer_id}`, { accessToken, }); - session.customer = data; - return data; + // Profiles already persisted with censored fields must not be reused as valid values. + session.customer = withoutCensoredFields(data); + return session.customer; }; const isAuthReady = ref(false); From 20788eaa2a4afdb112c8aec967db098c09ac819f Mon Sep 17 00:00:00 2001 From: Vitor Date: Wed, 12 Aug 2026 11:44:24 -0300 Subject: [PATCH 2/5] fix(storefront): Keep valid customer data when clearing a censored cached session Previously the whole cached profile was dropped when a censored field was detected, forcing the customer to re-enter name, phone and address even when those fields were still valid. That extra friction hit exactly the customers whose sessions were already affected. Reuse withoutCensoredFields on load so only the censored fields are removed while valid ones are kept, and drop doc_number so fetchCustomer still runs to refresh from the server once the customer is authenticated. Both the load and fetch paths now handle censored data the same way. Co-Authored-By: Claude Opus 4.8 --- .../storefront/src/lib/state/customer-session.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/storefront/src/lib/state/customer-session.ts b/packages/storefront/src/lib/state/customer-session.ts index 009fb26e9..b5bec0dbe 100644 --- a/packages/storefront/src/lib/state/customer-session.ts +++ b/packages/storefront/src/lib/state/customer-session.ts @@ -59,11 +59,13 @@ const withoutCensoredFields = (customerData: Partial) => { return cleanCustomer; }; if (hasCensoredFields(session.customer)) { - // Drop the cached profile entirely to force `fetchCustomer` once authenticated again. - session.customer = { - display_name: session.customer.display_name || '', - main_email: session.customer.main_email || '', - }; + // Keep any still-valid cached fields, but drop `doc_number` so `fetchCustomer` + // runs again once the customer is authenticated. + const cleanCustomer = withoutCensoredFields(session.customer); + delete cleanCustomer.doc_number; + cleanCustomer.display_name = cleanCustomer.display_name || ''; + cleanCustomer.main_email = cleanCustomer.main_email || ''; + session.customer = cleanCustomer; } const isAuthenticated = computed(() => { From 93826f6874fc50cfaff4522ed65aa26e463a7396 Mon Sep 17 00:00:00 2001 From: Vitor Date: Thu, 13 Aug 2026 19:59:15 -0300 Subject: [PATCH 3/5] fix(storefront): Keep customer name and unlock auth state when clearing censored session Address review points from PR #808: - `name` is no longer dropped when clearing a censored cached session: it's required on `@checkout` contract, `given_name` is preserved valid by the mask, and the '***' marker is what triggers server side restore from the saved profile on checkout.ts; - `authenticate()`/`fetchCustomer()` failures on auth state change no longer keep `isAuthReady` stuck false, which was locking login submit and the account page load; - Censored fields predicates aligned with server side checks (`.includes('***')` and `/^0{3,}\d{1,4}$/` as checkout.ts); - Session purge on module boot guarded with `!import.meta.env.SSR` as sibling states do. Co-Authored-By: Claude Fable 5 --- .../src/lib/state/customer-session.ts | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/packages/storefront/src/lib/state/customer-session.ts b/packages/storefront/src/lib/state/customer-session.ts index b5bec0dbe..224fd8d8a 100644 --- a/packages/storefront/src/lib/state/customer-session.ts +++ b/packages/storefront/src/lib/state/customer-session.ts @@ -34,31 +34,29 @@ const session = useStorage<{ */ const CENSORED = '***'; type CustomerAddress = NonNullable[number]; -const isCensoredPhone = (number?: string) => !!number && /^0{3}\d{2}$/.test(number); +// Predicates must match server side checks on `@cloudcommerce/modules` checkout.ts +const isCensoredPhone = (number?: string) => !!number && /^0{3,}\d{1,4}$/.test(number); const isCensoredAddress = ({ name, line_address: lineAddress }: CustomerAddress) => { - return name === CENSORED || !!lineAddress?.includes(CENSORED); + return !!name?.includes(CENSORED) || !!lineAddress?.includes(CENSORED); }; const hasCensoredFields = ({ name, phones, addresses }: Partial) => { - if (name?.family_name === CENSORED) return true; + if (name?.family_name?.includes(CENSORED)) return true; if (phones?.some(({ number }) => isCensoredPhone(number))) return true; return !!addresses?.some(isCensoredAddress); }; const withoutCensoredFields = (customerData: Partial) => { - const { - name, - phones, - addresses, - ...safeCustomer - } = customerData; + const { phones, addresses, ...safeCustomer } = customerData; const cleanCustomer: Partial = safeCustomer; - if (name && name.family_name !== CENSORED) cleanCustomer.name = name; + /* `name` is intentionally kept even when `family_name` is censored: it's required + on `@checkout` contract, `given_name` is preserved valid by the mask, and the + '***' marker triggers server side restore from the saved profile (checkout.ts). */ const cleanPhones = phones?.filter(({ number }) => !isCensoredPhone(number)); if (cleanPhones?.length) cleanCustomer.phones = cleanPhones; const cleanAddresses = addresses?.filter((address) => !isCensoredAddress(address)); if (cleanAddresses?.length) cleanCustomer.addresses = cleanAddresses; return cleanCustomer; }; -if (hasCensoredFields(session.customer)) { +if (!import.meta.env.SSR && hasCensoredFields(session.customer)) { // Keep any still-valid cached fields, but drop `doc_number` so `fetchCustomer` // runs again once the customer is authenticated. const cleanCustomer = withoutCensoredFields(session.customer); @@ -128,7 +126,8 @@ const fetchCustomer = async () => { const { data } = await api.get(`customers/${auth.customer_id}`, { accessToken, }); - // Profiles already persisted with censored fields must not be reused as valid values. + /* Saved profiles may still hold censored fields persisted by old checkouts, + they must not be cached back as valid values. */ session.customer = withoutCensoredFields(data); return session.customer; }; @@ -163,12 +162,17 @@ const initializeFirebaseAuth = (canWaitIdle?: boolean) => { session.customer.main_email = user.email; } if (user.emailVerified) { - const isEmailChanged = user.email !== customerEmail.value; - if (isEmailChanged || !isAuthenticated.value) { - await authenticate(); - } - if (isEmailChanged || !session.customer.doc_number) { - await fetchCustomer(); + try { + const isEmailChanged = user.email !== customerEmail.value; + if (isEmailChanged || !isAuthenticated.value) { + await authenticate(); + } + if (isEmailChanged || !session.customer.doc_number) { + await fetchCustomer(); + } + } catch (err) { + // `isAuthReady` must be set anyway to unlock watching consumers + console.error(err); } } } From 5109cfe36f0865f80e3b03315b29dcae690c6ea2 Mon Sep 17 00:00:00 2001 From: Vitor Date: Thu, 13 Aug 2026 21:08:46 -0300 Subject: [PATCH 4/5] fix(storefront): Make logout always clear the session and keep customer keys consistent Remaining review points from PR #808: - `emptySession` shared mutable constant replaced by a factory: `useStorage` may keep the initial value as the live reactive state, so the `logout()` reset was a self-assignment no-op (token surviving and UI still logged) and later writes polluted the "empty" constant with the previous customer's data; - `fetchCustomer` now keeps the `display_name`/`main_email` always-set invariant that consumers assume, as the boot purge already did; - Censored session helpers extracted to `state/customer-session/` per directory convention (as `state/shopping-cart/`). Co-Authored-By: Claude Fable 5 --- .../src/lib/state/customer-session.ts | 66 +++++++------------ .../customer-session/censored-session.ts | 37 +++++++++++ 2 files changed, 61 insertions(+), 42 deletions(-) create mode 100644 packages/storefront/src/lib/state/customer-session/censored-session.ts diff --git a/packages/storefront/src/lib/state/customer-session.ts b/packages/storefront/src/lib/state/customer-session.ts index 224fd8d8a..462fc4a5e 100644 --- a/packages/storefront/src/lib/state/customer-session.ts +++ b/packages/storefront/src/lib/state/customer-session.ts @@ -5,57 +5,34 @@ import { nickname as getNickname } from '@ecomplus/utils'; import { ref, computed, watch } from 'vue'; import { requestIdleCallback } from '@@sf/sf-lib'; import useStorage from '@@sf/state/use-storage'; +import { + hasCensoredFields, + withoutCensoredFields, +} from '@@sf/state/customer-session/censored-session'; export const EMAIL_STORAGE_KEY = 'emailForSignIn'; const storageKey = 'ecomSession'; -const emptySession = { +type SessionAuth = null | { + access_token: string, + expires: string, + customer_id: Customers['_id'], +}; +/* Always a fresh object: `useStorage` may keep the initial value as the live +reactive state, so a shared constant would be mutated by session writes and +turn the `logout()` reset into a self-assignment no-op. */ +const getEmptySession = () => ({ customer: { display_name: '', main_email: '', - }, - auth: null, -}; + } as Partial, + auth: null as SessionAuth, +}); const session = useStorage<{ customer: Partial, - auth: null | { - access_token: string, - expires: string, - customer_id: Customers['_id'], - }, -}>(storageKey, emptySession); + auth: SessionAuth, +}>(storageKey, getEmptySession()); -/* - * Profiles identified by e-mail and document only (without verified login) used to be - * returned with censored fields: '***' surname, '000XX' phone, address holding just zip - * and province. Sessions persisted back then are still cached on customer browsers and - * get submitted on checkout, where they overwrite the saved profile and produce an - * incomplete shipping address that payment gateways reject. - */ -const CENSORED = '***'; -type CustomerAddress = NonNullable[number]; -// Predicates must match server side checks on `@cloudcommerce/modules` checkout.ts -const isCensoredPhone = (number?: string) => !!number && /^0{3,}\d{1,4}$/.test(number); -const isCensoredAddress = ({ name, line_address: lineAddress }: CustomerAddress) => { - return !!name?.includes(CENSORED) || !!lineAddress?.includes(CENSORED); -}; -const hasCensoredFields = ({ name, phones, addresses }: Partial) => { - if (name?.family_name?.includes(CENSORED)) return true; - if (phones?.some(({ number }) => isCensoredPhone(number))) return true; - return !!addresses?.some(isCensoredAddress); -}; -const withoutCensoredFields = (customerData: Partial) => { - const { phones, addresses, ...safeCustomer } = customerData; - const cleanCustomer: Partial = safeCustomer; - /* `name` is intentionally kept even when `family_name` is censored: it's required - on `@checkout` contract, `given_name` is preserved valid by the mask, and the - '***' marker triggers server side restore from the saved profile (checkout.ts). */ - const cleanPhones = phones?.filter(({ number }) => !isCensoredPhone(number)); - if (cleanPhones?.length) cleanCustomer.phones = cleanPhones; - const cleanAddresses = addresses?.filter((address) => !isCensoredAddress(address)); - if (cleanAddresses?.length) cleanCustomer.addresses = cleanAddresses; - return cleanCustomer; -}; if (!import.meta.env.SSR && hasCensoredFields(session.customer)) { // Keep any still-valid cached fields, but drop `doc_number` so `fetchCustomer` // runs again once the customer is authenticated. @@ -128,7 +105,11 @@ const fetchCustomer = async () => { }); /* Saved profiles may still hold censored fields persisted by old checkouts, they must not be cached back as valid values. */ - session.customer = withoutCensoredFields(data); + const cleanCustomer = withoutCensoredFields(data); + // Consumers assume these keys always set on `session.customer` + cleanCustomer.display_name = cleanCustomer.display_name || ''; + cleanCustomer.main_email = cleanCustomer.main_email || ''; + session.customer = cleanCustomer; return session.customer; }; @@ -206,6 +187,7 @@ const logout = () => { return; } firebaseAuth.signOut().then(() => { + const emptySession = getEmptySession(); session.auth = emptySession.auth; session.customer = emptySession.customer; localStorage.removeItem(storageKey); diff --git a/packages/storefront/src/lib/state/customer-session/censored-session.ts b/packages/storefront/src/lib/state/customer-session/censored-session.ts new file mode 100644 index 000000000..f03fbbf6d --- /dev/null +++ b/packages/storefront/src/lib/state/customer-session/censored-session.ts @@ -0,0 +1,37 @@ +import type { Customers } from '@cloudcommerce/api/types'; + +/* + * Profiles identified by e-mail and document only (without verified login) used to be + * returned with censored fields: '***' surname, '000XX' phone, address holding just zip + * and province. Sessions persisted back then are still cached on customer browsers and + * get submitted on checkout, where they overwrite the saved profile and produce an + * incomplete shipping address that payment gateways reject. + */ +const CENSORED = '***'; + +type CustomerAddress = NonNullable[number]; + +// Predicates must match server side checks on `@cloudcommerce/modules` checkout.ts +const isCensoredPhone = (number?: string) => !!number && /^0{3,}\d{1,4}$/.test(number); +const isCensoredAddress = ({ name, line_address: lineAddress }: CustomerAddress) => { + return !!name?.includes(CENSORED) || !!lineAddress?.includes(CENSORED); +}; + +export const hasCensoredFields = ({ name, phones, addresses }: Partial) => { + if (name?.family_name?.includes(CENSORED)) return true; + if (phones?.some(({ number }) => isCensoredPhone(number))) return true; + return !!addresses?.some(isCensoredAddress); +}; + +export const withoutCensoredFields = (customerData: Partial) => { + const { phones, addresses, ...safeCustomer } = customerData; + const cleanCustomer: Partial = safeCustomer; + /* `name` is intentionally kept even when `family_name` is censored: it's required + on `@checkout` contract, `given_name` is preserved valid by the mask, and the + '***' marker triggers server side restore from the saved profile (checkout.ts). */ + const cleanPhones = phones?.filter(({ number }) => !isCensoredPhone(number)); + if (cleanPhones?.length) cleanCustomer.phones = cleanPhones; + const cleanAddresses = addresses?.filter((address) => !isCensoredAddress(address)); + if (cleanAddresses?.length) cleanCustomer.addresses = cleanAddresses; + return cleanCustomer; +}; From f93d478be04c568187de43ffbefd3936f7aa9d59 Mon Sep 17 00:00:00 2001 From: Leonardo Matos Date: Fri, 14 Aug 2026 13:11:29 -0300 Subject: [PATCH 5/5] fix(storefront): Stop refetching the customer profile on every page load `hasCensoredFields` was testing `name.family_name`, but `withoutCensoredFields` deliberately keeps `name` so the `@checkout` contract stays satisfied and the '***' marker still triggers the server side restore. The marker therefore never left the session, keeping the check true forever: every page load dropped `doc_number` and made `onAuthStateChanged` refetch `customers/{id}`. It only settled for profiles clean on the server, so it stuck exactly on the corrupted ones the fix targets, and silently, since the new try/catch swallows the failure. The check now covers only what the cleanup actually removes, censored phones and addresses, so it runs once per contaminated session and turns false afterwards. A profile censored only on the surname stops being purged at all, which is correct: there is nothing to remove and checkout.ts restores it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013J3PpxE1ScfqPwmG9rEBUZ --- packages/storefront/src/lib/state/customer-session.ts | 5 +++-- .../src/lib/state/customer-session/censored-session.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/storefront/src/lib/state/customer-session.ts b/packages/storefront/src/lib/state/customer-session.ts index 462fc4a5e..879ad456b 100644 --- a/packages/storefront/src/lib/state/customer-session.ts +++ b/packages/storefront/src/lib/state/customer-session.ts @@ -34,8 +34,9 @@ const session = useStorage<{ }>(storageKey, getEmptySession()); if (!import.meta.env.SSR && hasCensoredFields(session.customer)) { - // Keep any still-valid cached fields, but drop `doc_number` so `fetchCustomer` - // runs again once the customer is authenticated. + /* Keep any still-valid cached fields, but drop `doc_number` so `fetchCustomer` runs + again once the customer is authenticated. Runs once per contaminated session: the + censored phones and addresses are gone afterwards, so the check turns false. */ const cleanCustomer = withoutCensoredFields(session.customer); delete cleanCustomer.doc_number; cleanCustomer.display_name = cleanCustomer.display_name || ''; diff --git a/packages/storefront/src/lib/state/customer-session/censored-session.ts b/packages/storefront/src/lib/state/customer-session/censored-session.ts index f03fbbf6d..ef6a2ebfa 100644 --- a/packages/storefront/src/lib/state/customer-session/censored-session.ts +++ b/packages/storefront/src/lib/state/customer-session/censored-session.ts @@ -17,8 +17,10 @@ const isCensoredAddress = ({ name, line_address: lineAddress }: CustomerAddress) return !!name?.includes(CENSORED) || !!lineAddress?.includes(CENSORED); }; -export const hasCensoredFields = ({ name, phones, addresses }: Partial) => { - if (name?.family_name?.includes(CENSORED)) return true; +/* Only fields `withoutCensoredFields` actually removes may be tested here: `name` is +deliberately kept, so testing it would keep this `true` forever on a profile censored +server side, dropping `doc_number` and refetching the customer on every page load. */ +export const hasCensoredFields = ({ phones, addresses }: Partial) => { if (phones?.some(({ number }) => isCensoredPhone(number))) return true; return !!addresses?.some(isCensoredAddress); };