toga-ai 1.0.870 → 1.0.871
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +2 -1
- package/knowledge/2.0/apps/toga2-commerce/features/catalog-cache-freshness.md +17 -6
- package/knowledge/2.0/apps/toga2-commerce/features/sso-redirect-and-session-gating.md +55 -42
- package/knowledge/2.0/apps/toga2-commerce/features/view-as-session-tokens.md +8 -4
- package/knowledge/2.0/apps/toga2-commerce/workflows/unit-testing-vitest.md +38 -0
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
| [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | How the hostname resolves the tenant and fans out to API base URL, theme (CSS variables), and fields; open when adding a tenant or debugging wrong-theme/wrong-A |
|
|
19
19
|
| [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | The item sync must finish before the header sync on order submit, or concurrent line deletes race a depth-3 response into an uncaught api2 500; open when touchi |
|
|
20
20
|
| [Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)](features/shipping-cost-waiver-gating.md) | Config-driven cost waiver making Standard Ground free for a bare computer kit (Compass USA); open when touching shipping cost gates or the Compass free-freight |
|
|
21
|
-
| [SSO
|
|
21
|
+
| [SSO sign-in, session gating & token refresh identity (hasSession / useClientSsoUrl)](features/sso-redirect-and-session-gating.md) | How commerce decides whether a visitor is signed in, shows the SSO panel or the credential form, and keeps a token refresh bound to one identity. |
|
|
22
22
|
| [Storefront persona gating — the Home page's kit / category / item counts, and the persona switcher](features/storefront-persona-gating-and-home-counts.md) | What a storefront user sees on the Home page is decided **in the front end**, from `user._personaIds`, not by the API's ACL. |
|
|
23
23
|
| [View-as another user — parked admin tokens & return-to-self](features/view-as-session-tokens.md) | How an admin impersonates another user and gets back: the delegated token pair, the parked admin pair in localStorage, and the return path. |
|
|
24
24
|
| [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How toga2-commerce builds/deploys on AWS Amplify (one dynamic amplify.yml, branch-per-env, per-env blox channel); open to deploy, stand up an env, resolve a blo |
|
|
25
25
|
| [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | Cypress e2e conventions + the Cart harness (cartV2.cy.ts) for toga2-commerce; read before adding any spec here, and note it doubles as the config-cart refactor |
|
|
26
26
|
| [Diagnosing ERR_HTTP2_PROTOCOL_ERROR (one client fails, everyone else is fine)](workflows/http2-protocol-error-diagnosis.md) | Diagnose ERR_HTTP2_PROTOCOL_ERROR when one client/network fails a togacommerce tenant but everyone else works (usually the client's TLS-inspection proxy, not ou |
|
|
27
|
+
| [Unit tests in toga2-commerce — vitest harness & the build-mode trap](workflows/unit-testing-vitest.md) | How to run and write unit tests in commerce (vitest + jsdom), and how to verify a change actually compiles without tripping the build-mode trap. |
|
|
@@ -6,10 +6,11 @@ project: TOGa Commerce
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-22
|
|
10
10
|
owners: ["bala", "apeterson", "tcox"]
|
|
11
11
|
files:
|
|
12
12
|
- toga2-commerce/src/App.tsx
|
|
13
|
+
- toga2-commerce/src/lib/queryCacheRestoration.ts
|
|
13
14
|
- toga2-commerce/src/contexts/AuthContext.tsx
|
|
14
15
|
- toga2-commerce/src/api/axiosInstance.ts
|
|
15
16
|
- toga2-commerce/src/hooks/useAuthenticationFlow.ts
|
|
@@ -28,7 +29,9 @@ The 24h persisted React Query cache that serves stale catalog/copy, how to opt a
|
|
|
28
29
|
|
|
29
30
|
## Summary
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
> **Status 2026-09-22 (TRUE-81991, `e546db3d`):** causes 1, 2 and 3 are **fixed**; cause 4 (the core-parameters force-logout) is still open. Read the cause sections for what the fix was — the history below is kept because it explains *why* each knob is set the way it is.
|
|
33
|
+
|
|
34
|
+
TOGa Commerce ran a **single `QueryClient` with a 24-hour default `staleTime`**, persisted to **`localStorage["commerce"]`** via `PersistQueryClientProvider`. That combination served **catalogue state up to 24 hours out of date, across full page reloads** — in **both directions**: after an item is switched off, a user who had already viewed it keeps seeing the live product page with a working Add to Cart; after it is switched back on, it stays hidden.
|
|
32
35
|
|
|
33
36
|
Any query whose answer **gates an action** (can this be bought? is this still available?) must be opted out of *both* mechanisms. Opting out of one is not enough.
|
|
34
37
|
|
|
@@ -75,7 +78,7 @@ The default branch (`query.state.status === "success"`) preserves TanStack's nor
|
|
|
75
78
|
|
|
76
79
|
Verified 2026-09-03 on branch `TRUE-81610`. Read-only; none of these are fixed.
|
|
77
80
|
|
|
78
|
-
**1. App-wide 24h `staleTime` + persisted cache with nothing excluded → daily stale data.** `
|
|
81
|
+
**1. App-wide 24h `staleTime` + persisted cache with nothing excluded → daily stale data.** ✅ **Fixed 2026-09-22.** App default is now `staleTime: 0` with `refetchOnWindowFocus: false`; the heavy Home/BundleView hooks took `staleTime: 30_000` + `gcTime: 1h` (the agreed shape below). Yesterday's catalog, pricing **and FIELDS copy** were previously served with no network call — daily, not only after a deploy. The 24h value was **also hardcoded per-hook**, so changing only the default was not enough:
|
|
79
82
|
- `src/pages/BundleView/hooks/useBundleQueryData.ts:26,40`
|
|
80
83
|
- `src/pages/Home/viewModel/useHomeViewModel.ts:150,173,197`
|
|
81
84
|
|
|
@@ -87,7 +90,13 @@ Verified 2026-09-03 on branch `TRUE-81610`. Read-only; none of these are fixed.
|
|
|
87
90
|
| `src/components/NavIcons/NavIconList.tsx:162-170` | view-as / persona switch (language-specific data) |
|
|
88
91
|
| `src/api/axiosInstance.ts:46` | 401 forced logout |
|
|
89
92
|
|
|
90
|
-
This is the "it's not successfully clearing their caches" report.
|
|
93
|
+
This is the "it's not successfully clearing their caches" report. ✅ **Fixed 2026-09-22:** every clear now goes through **`clearPersistedQueryCache()` in `src/lib/queryCacheRestoration.ts`**, which
|
|
94
|
+
|
|
95
|
+
1. waits for restoration (capped at **1.5s**, so a logout redirect can never hang),
|
|
96
|
+
2. **cancels in-flight queries** — otherwise they settle after the clear and are persisted again,
|
|
97
|
+
3. clears; an **epoch counter** makes a superseded clear a no-op.
|
|
98
|
+
|
|
99
|
+
The persister is also set to **`throttleTime: 0`**: the 1000ms default meant a queued write landed a second *after* the clear. Supply's equivalent is an `awaitRestoration()` promise from `useIsRestoring()` (`toga25-supply/src/contexts/AuthContext.tsx:56-77`).
|
|
91
100
|
|
|
92
101
|
**3. Two auth gates read two different localStorage keys → blank page / stuck login.** ✅ **Fixed on `TRUE-82172` (2026-09-21) — not yet merged.** Moved to its own doc: [SSO redirect & session gating](./sso-redirect-and-session-gating.md). It is an auth-gating defect, not a cache defect — clearing site data only masked it.
|
|
93
102
|
|
|
@@ -95,7 +104,7 @@ This is the "it's not successfully clearing their caches" report. Supply already
|
|
|
95
104
|
- On **any** core-parameter change it calls `logout()` + `window.location.href = "/"` with **no notice** to the user. On an SSO client `/` re-triggers the IdP.
|
|
96
105
|
- It **monkey-patches `window.history.pushState` and `replaceState`** (`:216-226`) to refetch on every navigation. react-router uses those same methods.
|
|
97
106
|
|
|
98
|
-
Separately, commerce's `login()`
|
|
107
|
+
Separately, commerce's `login()` **never cleared the cache**, so signing in as a different user inherited the prior user's data. ✅ **That half is fixed 2026-09-22** — `resetClientState()` runs on **both** login and logout (see [SSO & session gating](./sso-redirect-and-session-gating.md)). The force-logout aggression and the history monkey-patch are **still open**.
|
|
99
108
|
|
|
100
109
|
### Decision — `gcTime` is the speed knob, `staleTime` is the correctness knob
|
|
101
110
|
|
|
@@ -126,12 +135,14 @@ Keep the **app default at `0`**, like supply.
|
|
|
126
135
|
- **⚠ `gcTime: 0` does not stop persistence.** Active queries are still dehydrated. Exclude the key via `dehydrateOptions.shouldDehydrateQuery` as well.
|
|
127
136
|
- **⚠ Nothing is excluded from persistence on `_production`** — the `shouldDehydrateQuery` allowlist lives only on `_beta`/`#sprint84`. Check the branch.
|
|
128
137
|
- **⚠ The core-parameters watcher is not a targeted invalidation** — any Core parameter change force-logs-out every user on the environment. Use per-query opt-out or `queryClient.invalidateQueries` for anything narrower.
|
|
129
|
-
- **⚠ A `queryClient.clear()` that is not awaited behind cache restoration is undone.**
|
|
138
|
+
- **⚠ A `queryClient.clear()` that is not awaited behind cache restoration is undone.** Always clear via `clearPersistedQueryCache()`; never call `queryClient.clear()` directly. Cancelling in-flight queries is part of the clear, not an extra.
|
|
139
|
+
- **⚠ A persister `throttleTime` above 0 can write back after a clear.** Keep it at `0`.
|
|
130
140
|
- **⚠ Gate auth on `user`, never `accessToken`** — `fetchPublicToken` writes a public token into `accessToken`. See [SSO redirect & session gating](./sso-redirect-and-session-gating.md).
|
|
131
141
|
- **A `persistOptions.buster` keyed on the build version** would fix stale data at deploy time. Commerce has a committed `amplify.yml`, so injecting `AWS_COMMIT_ID` as a build id is straightforward here (`package.json` `version` is `0.0.0` and `vite.config.ts` has no `define` block, so the id must be created first). Mechanics and the scope limit — a buster runs inside the app and can never fix a browser-cached JS bundle — are written up in [supply's persisted query cache](../../toga25-supply/features/persisted-query-cache.md#the-buster-gap). Still **not implemented**.
|
|
132
142
|
- **`AuthLayout` never remounts**, so `refetchOnMount` fires once per session for queries it owns — a route-change refresh has to be wired explicitly. That interacts with everything above: a query can be both stale *and* never refetched during a session.
|
|
133
143
|
|
|
134
144
|
## Change history
|
|
145
|
+
- 2026-09-22 — Causes 1 and 2 fixed: app default `staleTime: 0` + `refetchOnWindowFocus: false`, heavy hooks on 30s/1h, and all clears routed through `clearPersistedQueryCache()` with `throttleTime: 0`. Login now resets client state. TRUE-81991 (`e546db3d`). (apeterson)
|
|
135
146
|
- 2026-09-21 — Cause 3 (public-token auth gate) fixed on `TRUE-82172` and moved to its own doc; causes 1, 2, 4 still open. (tcox)
|
|
136
147
|
- 2026-09-03 — Four stacked causes pinned to `file:line`; `gcTime`-vs-`staleTime` decision recorded. (bala, apeterson)
|
|
137
148
|
|
|
@@ -1,74 +1,87 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: SSO
|
|
2
|
+
title: SSO sign-in, session gating & token refresh identity (hasSession / useClientSsoUrl)
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: toga2-commerce
|
|
5
5
|
project: TOGa Commerce
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [tcox]
|
|
9
|
+
updated: 2026-09-22
|
|
10
|
+
owners: [tcox, apeterson]
|
|
11
11
|
files:
|
|
12
|
+
- toga2-commerce/src/lib/session.ts
|
|
12
13
|
- toga2-commerce/src/hooks/useAuthenticationFlow.ts
|
|
13
|
-
- toga2-commerce/src/
|
|
14
|
-
- toga2-commerce/src/
|
|
14
|
+
- toga2-commerce/src/hooks/useClientSsoUrl.ts
|
|
15
|
+
- toga2-commerce/src/pages/Login/view/SsoSignIn.tsx
|
|
15
16
|
- toga2-commerce/src/api/axiosInstance.ts
|
|
17
|
+
- toga2-commerce/src/contexts/AuthContext.tsx
|
|
18
|
+
- toga2-commerce/src/lib/authStorage.ts
|
|
19
|
+
- toga2-commerce/src/utils/postAuthRedirect.ts
|
|
16
20
|
related:
|
|
17
21
|
- ./catalog-cache-freshness.md
|
|
18
22
|
- ./view-as-session-tokens.md
|
|
23
|
+
- ../workflows/unit-testing-vitest.md
|
|
19
24
|
- ../architecture.md
|
|
20
25
|
- ../../toga25-supply/features/sso-redirect-and-session-gating.md
|
|
21
26
|
- ../../../standards/frontend.md
|
|
22
27
|
---
|
|
23
28
|
|
|
24
|
-
How commerce decides
|
|
29
|
+
How commerce decides whether a visitor is signed in, shows the SSO panel or the credential form, and keeps a token refresh bound to one identity. Open for "blank page at /", "SSO never fires", intermittent forced logout, or cross-user residue.
|
|
25
30
|
|
|
26
31
|
## Summary
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
One predicate answers "is the user signed in?": **`hasSession()` in `src/lib/session.ts`**. It reads `localStorage.user` and requires a parsed `uuid`. Four sites used to decide this independently off three keys (`user`, `zu-user`, `accessToken`) with three predicates — **the disagreements were the bugs**.
|
|
29
34
|
|
|
30
|
-
|
|
31
|
-
|---|---|---|
|
|
32
|
-
| axios request interceptor | `src/api/axiosInstance.ts:105-125` | Bearer a real token or a **public** one? |
|
|
33
|
-
| `useAuthenticationFlow` | `src/hooks/useAuthenticationFlow.ts:185-215` | Already signed in → do nothing; else SSO or `/login` |
|
|
34
|
-
| `AuthRedirect` (route `/`) | `src/routes.tsx` | Render `/home`, or a spinner while the flow runs |
|
|
35
|
+
`hasSession()` is a **routing/UI signal only, never an authorization check**. It reads storage inside a `try` because it runs in the axios request interceptor; a browser that blocks storage would otherwise reject every request.
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
| Piece | File | Question |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| axios request interceptor | `src/api/axiosInstance.ts` | user Bearer or public token? |
|
|
40
|
+
| `useAuthenticationFlow` | `src/hooks/useAuthenticationFlow.ts` | signed in → nothing; else `/login` |
|
|
41
|
+
| `useClientSsoUrl` | `src/hooks/useClientSsoUrl.ts` | is this tenant SSO, and what URL? |
|
|
42
|
+
| `LoginPage` | `src/pages/Login/LoginPage.tsx` | render `SsoSignIn` or the credential form |
|
|
37
43
|
|
|
38
44
|
## How it works
|
|
39
45
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`chrome.exe --headless=new --disable-extensions --user-data-dir=<temp> --virtual-time-budget=25000 --dump-dom <url>` and assert `input[name="userEmail"]` exists.
|
|
60
|
-
- Old code renders no login form (blank page); fixed code renders `/login`.
|
|
61
|
-
|
|
62
|
-
**Local and prod end differently for compasscanada — that is data, not a bug.** Beta has `Core.Domains` id 588 with **no** `ClientAuthentications` row, so the domains list is empty and the flow lands on `/login`; prod has `Core.Domains` id 600 → `ClientAuthentications` id 8 (type SSO, created 2026-04-06) and ends in an IdP redirect. Beta `/auth/public` also needs an allowed `Origin` header or it 401s `EN-6 "domain not permitted"` (`http://compasscanada.togacommerce:5173` is allowed).
|
|
46
|
+
### The public token never touches `accessToken`
|
|
47
|
+
|
|
48
|
+
`fetchPublicToken()` keeps the anonymous token in a **module variable in `axiosInstance.ts`** and stores nothing; its refresh token is discarded. `localStorage.accessToken` therefore means exactly one thing: **a signed-in user**.
|
|
49
|
+
|
|
50
|
+
### SSO detection and the sign-in screen
|
|
51
|
+
|
|
52
|
+
`useClientSsoUrl` is a React Query (so the auth flow and the login page share one cached answer): `GET /domains` joined to `ClientAuthentications` + `Environments`, matching `Domains.domain = "<protocol>//<hostname>"` and `Environments.slug` = build `MODE` uppercased, requiring `ClientAuthentications.type === "SSO"`.
|
|
53
|
+
|
|
54
|
+
Unauthenticated visitors always go to **`/login`**. `/login` renders `SsoSignIn` for an SSO tenant, else the credential form. Copy comes from `Login/viewModel/FIELDS/LOGINDUMMYFIELDS.json`. SSO tenants **no longer auto-jump to the IdP** — the user sees a page and clicks. Every logout path already lands on `/login`, so sign-out needs no extra SSO code.
|
|
55
|
+
|
|
56
|
+
### Single sign-out state: `AUTH_STORAGE_KEYS` + `resetClientState()`
|
|
57
|
+
|
|
58
|
+
`src/lib/authStorage.ts` holds the one key list and `resetClientState()`, run on **both login and logout**. The two logout paths (user sign-out, and the 401 handler) previously cleared *different* key sets, so what survived depended on how the session ended.
|
|
59
|
+
|
|
60
|
+
Clearing a persisted key is **not enough** — the zustand store must also be reset in memory, or the next `set()` re-persists the previous user's state under the cleared key (`useSettingsModalStore` had no reset action; one was added).
|
|
61
|
+
|
|
62
|
+
### Refresh is single-flight and identity-bound
|
|
63
|
+
|
|
64
|
+
api2 **rotates** the refresh token. Concurrent 401s each spent a token the other had already used, and the last response to land wrote a dead one — an intermittent forced logout with nothing in the logs. `refreshAuthTokens` is now one shared module promise, plus an **identity epoch** (`src/lib/session.ts`) bumped on sign-in, sign-out and view-as: a refresh whose epoch moved discards its result, and a second guard compares the stored refresh token. Without it, an older identity's token could overwrite a newer one — on the view-as path that restores the **higher-privilege** token.
|
|
63
65
|
|
|
64
66
|
## Gotchas
|
|
65
67
|
|
|
66
|
-
- **⚠ A token in `localStorage` is not a session.** Gating on `accessToken` made any visitor who had touched the API look authenticated
|
|
67
|
-
- **⚠
|
|
68
|
-
- **⚠
|
|
69
|
-
- **⚠
|
|
70
|
-
-
|
|
71
|
-
-
|
|
68
|
+
- **⚠ A token in `localStorage` is not a session.** Use `hasSession()`. Gating on `accessToken` made any visitor who had touched the API look authenticated, skipping **both** SSO and `/login` → permanently blank `/`.
|
|
69
|
+
- **⚠ Never gate logout on `zu-user`.** It is a zustand store that clears itself independently; with the key gone but the session live, the 401 handler skipped logout and the app looped on 401 looking signed in.
|
|
70
|
+
- **⚠ Only ever store a single-slash same-origin path as the post-auth redirect.** `savePostAuthRedirect` stored raw `location.pathname`; `https://<tenant>//evil.com` yields `//evil.com`, which the browser treats as protocol-relative — a **successful** login then navigated to another origin with a live session (`src/utils/postAuthRedirect.ts`).
|
|
71
|
+
- **⚠ Validate the SSO URL scheme before navigating.** `singleSignOnServiceUrl` is tenant data; a `javascript:` value executes on click and any other host is phishing on our own login page. Must be `https:`, checked where fetched **and** at the click; a bad value means "not SSO" → credential form.
|
|
72
|
+
- **⚠ Optional-chain the whole token path.** The view-as swap stored `response?.data.tokens.access`; a malformed reply wrote the literal string `"undefined"` (truthy), so every call sent `Bearer undefined`.
|
|
73
|
+
- **⚠ Clearing a persisted key without resetting the store in memory does nothing** — the next `set()` writes the old user's state straight back.
|
|
74
|
+
- **⚠ `zu-cart-store` is not a real key.** The cart persists under **`zu-cart`** (`src/stores/useCartStoreZu.ts`). The wrong name in the clear list left the previous user's cart alive through a 401 logout.
|
|
75
|
+
- **⚠ Open — Compass Canada SSO is broken in production.** `https://compasscanada.togacommerce.com/home` shows the credential form though the client is SSO; the lookup returns no match. Candidates in order: no `Domains` row matching the exact `https://compasscanada.togacommerce.com` string; no `ClientAuthentications` row for that clientId+environmentId; `type` not the literal `SSO`; or the PUBLIC role lacking `AclFieldPermissions` to read `ClientAuthentications.type` (the call runs pre-login on the public token, so a null field fails silently). Diagnostic SQL handed over, **not yet run**. The production build is plain `vite build`, so `MODE` is `production` and the query asks for `Environments.slug` `PRODUCTION`.
|
|
76
|
+
- **⚠ Open — toga-blox has the same public-token bug.** `toga-blox-npm/src/api/auth.ts` `fetchPublicToken` writes the anonymous token to `accessToken`; toga25-supply works around it in three places instead of fixing it. Needs its own ticket.
|
|
77
|
+
- **⚠ Open — the SSO URL's host is not constrained** to the tenant's configured IdP domain (scheme only). No allowlist source exists today.
|
|
78
|
+
- **⚠ Open — `AuthContext` still monkey-patches `window.history` `pushState`/`replaceState`.** Sentry patches history too, so the cleanup can clobber it; supply's poll-on-interval + focus is the replacement. Also `login()` still swallows errors after tokens are written.
|
|
79
|
+
|
|
80
|
+
## Decisions
|
|
81
|
+
|
|
82
|
+
- **Unify the predicate, not the storage.** Collapsing `user` and `zu-user` into one store (~15 files, no user-visible gain) was **rejected** on CTO advice.
|
|
83
|
+
- **No per-session key prefix, and tokens stay in `localStorage`.** Renaming every zustand `persist` name silently orphans existing users' state on deploy and touches every call site; supply's cheaper answer is plain `localStorage.clear()` on logout since every key commerce writes is session state. httpOnly cookies would need backend work plus CSRF protection that does not exist — api2 is Bearer-only. Matches `2.0/standards/frontend.md` §12.
|
|
72
84
|
|
|
73
85
|
## Change history
|
|
86
|
+
- 2026-09-22 — Public token moved out of `localStorage`; `hasSession()` + identity epoch + `AUTH_STORAGE_KEYS` added; SSO sign-in screen replaces the auto IdP jump; open-redirect and SSO-URL-scheme holes closed; single-flight refresh. TRUE-81991 (`e546db3d`). (apeterson)
|
|
74
87
|
- 2026-09-21 — Blank page at `/` fixed: gate on stored `user.uuid`, `AuthRedirect` renders the spinner instead of `null`. Branch `TRUE-82172` (`1eebdd96`, `3dc53d9a`), **not merged or deployed**. (tcox)
|
|
@@ -6,8 +6,8 @@ project: TOGa Commerce
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [tcox, bala]
|
|
9
|
+
updated: 2026-09-22
|
|
10
|
+
owners: [tcox, bala, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- src/api/sessionTokens.ts
|
|
13
13
|
- src/api/apiBaseUrl.ts
|
|
@@ -56,7 +56,9 @@ All token plumbing lives in **`src/api/sessionTokens.ts`** (new). `src/api/apiBa
|
|
|
56
56
|
|
|
57
57
|
### Refresh safety (`axiosInstance.ts`)
|
|
58
58
|
|
|
59
|
-
The 401 refresh is **single-flight** (one module-level promise shared by concurrent 401s) and **identity-bound**:
|
|
59
|
+
The 401 refresh is **single-flight** (one module-level promise shared by concurrent 401s) and **identity-bound**: it returns `null` if `localStorage.refreshToken` changed while the refresh was in flight. That stops an in-flight admin refresh from overwriting a just-minted delegated pair (or the reverse).
|
|
60
|
+
|
|
61
|
+
Since 2026-09-22 the binding is an **identity epoch** in `src/lib/session.ts`, bumped on sign-in, sign-out **and the view-as swap**, checked alongside the refresh-token comparison. The token comparison alone was not enough: on the view-as path an older refresh finishing late restored the **higher-privilege** admin token under the delegated identity. See [SSO & session gating](./sso-redirect-and-session-gating.md).
|
|
60
62
|
|
|
61
63
|
### Decisions (CSO review — SHIP WITH FIXES)
|
|
62
64
|
|
|
@@ -71,7 +73,8 @@ Seeds a long-lived `ADMIN_ACCESS_JWT`; stubs `/auth/delegator` → `mock-delegat
|
|
|
71
73
|
- **Never re-mint the admin's tokens on return.** The old `/auth/encrypted` path was a privileged call made from the browser. Restore the parked pair, or log out — those are the only two outcomes.
|
|
72
74
|
- **Always remove `originalAuthTokens`, even on a failed restore.** Leaving a stale parked pair blocks the next view-as (`beginDelegation` refuses to overwrite) and keeps an old admin refresh token on disk.
|
|
73
75
|
- **Clear `localStorage["commerce"]` on return.** The persisted query cache is shared; without the clear, the impersonated user's data rehydrates under the admin identity (see the 24h persisted-cache gotcha in [architecture](../architecture.md)).
|
|
74
|
-
- **Keep the refresh identity-bound.** A refresh that resolves after the identity changed must return `null`, not write tokens.
|
|
76
|
+
- **Keep the refresh identity-bound.** A refresh that resolves after the identity changed must return `null`, not write tokens. Bump the epoch on every identity change, including the view-as swap.
|
|
77
|
+
- **⚠ Optional-chain the whole token path on the swap.** `response?.data.tokens.access` wrote the literal string `"undefined"` on a malformed reply — truthy, so every later call sent `Bearer undefined`.
|
|
75
78
|
- **A view-as test must stub `/v2/pages/meta?page=home-page&app=commerce`.** Unstubbed, the real call fails and triggers the enter-view-as rollback, so the spec sees a return-to-admin it did not ask for.
|
|
76
79
|
|
|
77
80
|
## Open items (out of ticket, not fixed)
|
|
@@ -82,6 +85,7 @@ Seeds a long-lived `ADMIN_ACCESS_JWT`; stubs `/auth/delegator` → `mock-delegat
|
|
|
82
85
|
|
|
83
86
|
## Change history
|
|
84
87
|
|
|
88
|
+
- 2026-09-22 — Refresh identity binding upgraded to an epoch bumped on the view-as swap; `Bearer undefined` swap bug fixed. TRUE-81991 (`e546db3d`). (apeterson)
|
|
85
89
|
- 2026-09-21 — `/auth/delegator` is now gated server side by the Order For delegation check; noted that Order For will share the single parked-token slot. (bala)
|
|
86
90
|
- 2026-09-17 — Created. Admin pair parked in `originalAuthTokens` on delegation and restored on return; `/auth/encrypted` re-mint removed; single-flight identity-bound refresh (TRUE-81852, commit a8c3a537). (tcox)
|
|
87
91
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Unit tests in toga2-commerce — vitest harness & the build-mode trap
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: workflow
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-22
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-commerce/vitest.config.ts
|
|
13
|
+
- toga2-commerce/package.json
|
|
14
|
+
- toga2-commerce/src/lib/session.test.ts
|
|
15
|
+
- toga2-commerce/src/api/refreshAuthTokens.test.ts
|
|
16
|
+
related:
|
|
17
|
+
- ./cypress-testing.md
|
|
18
|
+
- ../features/sso-redirect-and-session-gating.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
How to run and write unit tests in commerce (vitest + jsdom), and how to verify a change actually compiles without tripping the build-mode trap.
|
|
22
|
+
|
|
23
|
+
## Steps
|
|
24
|
+
|
|
25
|
+
1. Run the suite: `npm test` (watch: `npm test:watch`). Config is `vitest.config.ts`, environment `jsdom`.
|
|
26
|
+
2. Name the file next to its subject: `src/lib/session.test.ts`, `src/api/refreshAuthTokens.test.ts`.
|
|
27
|
+
3. **Prove the test fails on the pre-fix code first.** Reverting `hasSession` turned 5 red; removing the single-flight and identity guards turned 3 red. A regression test that was never seen red proves nothing.
|
|
28
|
+
4. Test a race with a **held-open promise you resolve by hand**, never real timing — that is what makes the refresh tests deterministic.
|
|
29
|
+
5. Verify a build compiles with `npx vite build --mode development` (cheap), then read the trap below before trusting any `DEV`-gated path.
|
|
30
|
+
|
|
31
|
+
## Gotchas
|
|
32
|
+
|
|
33
|
+
- **⚠ `import.meta.env.DEV` comes from `NODE_ENV`, not `--mode`.** `vite build` pins `NODE_ENV=production`, so a `DEV`-gated code path is **unreachable in every deployed mode** — including `--mode development`. Never gate behaviour a deployed build needs on `DEV`.
|
|
34
|
+
- **⚠ Commerce's production build script is plain `vite build` with no `--mode`,** so `MODE` is `"production"`. Anything keyed on `MODE` (e.g. the SSO `Environments.slug` lookup) resolves to `PRODUCTION` there.
|
|
35
|
+
- Commerce had **no** unit harness before 2026-09-22 — Cypress only ([cypress-testing](./cypress-testing.md)). Put browser/route behaviour in Cypress; put pure logic and races in vitest.
|
|
36
|
+
|
|
37
|
+
## Change history
|
|
38
|
+
- 2026-09-22 — Created. vitest + jsdom added to commerce with 19 tests over `hasSession` and the token refresh. TRUE-81991 (`e546db3d`). (apeterson)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -32,7 +32,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
32
32
|
- **talos-backend** (TOGa IQ Backend) — 1 doc(s) → [2.0/apps/talos-backend/INDEX.md](2.0/apps/talos-backend/INDEX.md)
|
|
33
33
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
34
34
|
- **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
35
|
-
- **toga2-commerce** (TOGa Commerce) —
|
|
35
|
+
- **toga2-commerce** (TOGa Commerce) — 26 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
36
36
|
- **toga25-supply** (TOGa 2.5 Supply) — 21 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
37
37
|
- **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
38
38
|
- **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
|
package/package.json
CHANGED