toga-ai 1.0.869 → 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/api2/architecture.md +4 -2
- 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/2.0/apps/worker2/features/elastic-beanstalk-health-monitor.md +12 -4
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-22
|
|
10
10
|
owners: [jcardinal, bala, mhammontree, dfranks]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Controller/Index.php
|
|
@@ -134,7 +134,9 @@ api2's EB target group health-checks **`/health`** (interval 15 s, timeout 5 s,
|
|
|
134
134
|
|
|
135
135
|
**A fourth cause (deploy-triggered) — a burst of ELB `502`s when both instances restart at once.** Confirmed on `api-production-1` (2026-08-28 23:51 UTC deploy: **43 × 502 in one minute**, the only ELB 5xx in a 7-hour window). Under the old `AllAtOnce`/100% policy, every deploy to a 2-instance env restarts Apache/PHP-FPM on **both** instances simultaneously, so in-flight requests have their connections dropped mid-response → a burst of **ELB-generated HTTP 502**. EB flags "X% of requests to the ELB are failing with HTTP 5xx" `Degraded` ~3–4 min later and recovers in ~5 min. Fingerprint vs. a real app failure: it is **502** (dropped backend connection), **not 503** (no healthy backend) or **504** (backend timeout); **`HTTPCode_Target_5XX_Count` (app 5xx) = 0**; **`HealthyHostCount` never drops below 2**; and there is **NOTHING in `Logs.Api`** — api2 logs a request only *after* the response, and a 502 never completes one. Diagnosis path: CloudWatch `HTTPCode_ELB_502/503/504_Count` vs `HTTPCode_Target_5XX_Count`, plus EB `describe-events` (a version change) and CodePipeline `list-pipeline-executions` — because a push to `_production` auto-deploys all three prod tiers, "we didn't deploy" can be false (see [CodePipeline deploy workflow](workflows/codepipeline-codeconnections-deploy.md)). **Rule:** an EB "% 5xx" `Degraded` with an **empty `Logs.Api` and 0 target-5xx** right after a deploy is a deploy-restart 502 artifact, not an application bug. **Fixed 2026-08-28:** all three prod envs moved to **Rolling / batch-size 1** — config + tradeoffs in the [deploy workflow](workflows/codepipeline-codeconnections-deploy.md).
|
|
136
136
|
|
|
137
|
-
**A fifth cause — a brief, self-healing "no-cause TargetGroup reduced health" flip on a LOW-TRAFFIC, IDLE 2-instance env is EB Enhanced Health FLAPPINESS, not worker starvation.** Distinct from the third cause (the *busy* env under Compass ASN saturation). This is a single missed ALB `/health` check — a network/agent transient. Fingerprint (verified `api-production-3`, eu-west-1, 2026-08-29 13:31 UTC `Severe`, static-`/health` build deployed): self-heals in ~2–5 min; throughout, `HealthyHostCount` stays 2, **0** target/ELB 5xx, sub-second latency, idle CPU, near-zero traffic
|
|
137
|
+
**A fifth cause — a brief, self-healing "no-cause TargetGroup reduced health" flip on a LOW-TRAFFIC, IDLE 2-instance env is EB Enhanced Health FLAPPINESS, not worker starvation.** Distinct from the third cause (the *busy* env under Compass ASN saturation). This is a single missed ALB `/health` check — a network/agent transient. Fingerprint (verified `api-production-3`, eu-west-1, 2026-08-29 13:31 UTC `Severe`, static-`/health` build deployed; **recurred 2026-09-22** — three ~5-min Ok→Warning→Ok cycles, no cause cited): self-heals in ~2–5 min; throughout, `HealthyHostCount` stays 2, **0** target/ELB 5xx, sub-second latency (or NO DATA on a fully idle env), idle CPU, near-zero traffic, empty ALB `RequestCount`; ALB target health already reads "healthy" by the time you check. On an idle env there is no worker starvation, so **no server-side app fix applies** — do not chase it.
|
|
138
|
+
|
|
139
|
+
Two paging facts confirmed 2026-09-22 on `api-production-3`: (1) **EB native notifications are OFF** (`aws:elasticbeanstalk:sns:topics` endpoint/topic all null) — EB itself never emails on a transition; the **only** paging path is the worker2 → OneUptime cron `Infrastructure/CloudWatch/ElasticBeanstalkHealth`. (2) That monitor pages only on `Degraded`/`Severe`, so a **`Warning`-only flip is below its alarm threshold and never pages** — the 2026-09-22 Warning flaps did not page. Mitigation is alert de-sensitization: the monitor's non-5xx persistence gate (`nonFivexxPersistMinutes`) was **raised from the default 5 to 8** on the API + API-Proxy monitors (dbchanges2 `2026-09-22d`), clearing the ~5-min idle flaps with margin; the 2026-09-01 4xx-rule disable (#6) and Rolling/batch-1 (#4) remain in place. See [worker2 EB health monitor](../worker2/features/elastic-beanstalk-health-monitor.md).
|
|
138
140
|
|
|
139
141
|
**A sixth cause — a 4xx BURST flips the env to `Degraded`/`Severe` while the ALB stays fully healthy, because EB Enhanced Health counts application/ELB 4xx toward environment health.** Not a deploy (#4), not worker starvation (#3), not idle flappiness (#5) — EB reacting to *client* 4xx responses working as designed. EB Enhanced Health's `ConfigDocument` (namespace `aws:elasticbeanstalk:healthreporting:system`) ships with two rules **Enabled by default**: `Rules.Environment.Application.ApplicationRequests4xx` and `Rules.Environment.ELB.ELBRequests4xx`. So a short burst of client 4xx (e.g. a client polling with an expired bearer token) flips the env to `Degraded`/`Severe` even though the ALB never marked a target unhealthy. Verified live (AWS CLI + CloudWatch + prod `Logs.Api`, acct `654654170868`):
|
|
140
142
|
- `api-production-1` (us-east-1) flip 2026-09-01 01:39–01:44 UTC, no cause cited, **not** during a deploy. Throughout: `HealthyHostCount=2`, `UnHealthyHostCount=0`, **0** target 5xx, **0** ELB 5xx, **0** connection errors.
|
|
@@ -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)
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-22
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Infrastructure/CloudWatch.php
|
|
@@ -65,8 +65,8 @@ Removed this iteration: `shouldPageForHealth()` and the old `HTTP_5XX_ALARM_RATI
|
|
|
65
65
|
|
|
66
66
|
A single `ElasticBeanstalkHealth` cron watches environments of **very different scale**, so thresholds are tuned per monitor. Measured 3h baselines: api-production-1 ~922–1737 req/5min, apiproxy ~400–1650, but eu-west-1 envs only ~40–90 req/5min; 5xx was 0 across all.
|
|
67
67
|
|
|
68
|
-
- **Worker + worker 1.0 monitors** keep the **method defaults** (`100 / 20% / 20`) — the low absolute floor suits ~150 req/5min traffic.
|
|
69
|
-
- **API and API-Proxy monitors** (each also including the low-traffic eu-west-1 env) are overridden to **`windowMinutes=5, minRequests=30, min5xxPercent=20, min5xxAbsolute=100`** — the lower request floor keeps the small env on the share gate,
|
|
68
|
+
- **Worker + worker 1.0 monitors** keep the **method defaults** (`100 / 20% / 20`, and `nonFivexxPersistMinutes` default 5) — the low absolute floor suits ~150 req/5min traffic.
|
|
69
|
+
- **API and API-Proxy monitors** (each also including the low-traffic eu-west-1 env) are overridden to **`windowMinutes=5, minRequests=30, min5xxPercent=20, min5xxAbsolute=100, nonFivexxPersistMinutes=8`** — the lower request floor keeps the small env on the share gate, the higher absolute floor stops the busy envs paging on minor blips (20 scattered 500s on ~1300 req = <2%), and the **8-minute** non-5xx persistence raises the gate above the observed ~5-min idle-flap length (the default 5 sat at the flap boundary). The overrides are applied by the two dbchanges2 migrations below (`2026-08-25a` = the four rate keys; `2026-09-22d` = `nonFivexxPersistMinutes=8`).
|
|
70
70
|
|
|
71
71
|
#### Fail-safe: an unmeasurable solely-5xx event still pages
|
|
72
72
|
|
|
@@ -78,7 +78,7 @@ A CloudWatch read *failure* (as opposed to empty data) sets **`probe=DEGRADED`**
|
|
|
78
78
|
|
|
79
79
|
A non-5xx `Degraded`/`Severe` pages only after the **current non-Ok episode has persisted ≥ `nonFivexxPersistMinutes`** (default 5). This exists because the brief "no-cause TargetGroup reduced health" flips on low-traffic 2-instance envs are **EB Enhanced Health flappiness** — a single missed ALB `/health` check (a network/agent transient), not a real app problem — and they self-heal in ~2–5 min. A persistence delay suppresses those while a genuinely sustained non-5xx degradation still pages, delayed only by the threshold. (The windowed 5xx-flood gate is **unchanged** — it has its own, independent decision.)
|
|
80
80
|
|
|
81
|
-
**Fingerprint of the idle-env flap** (verified `api-production-3`, eu-west-1, 2026-08-29 13:31 UTC `Severe`, static-`/health` build deployed): self-heals in ~2–5 min; throughout, `HealthyHostCount` stays 2 (1-min minimum), **0** target/ELB 5xx,
|
|
81
|
+
**Fingerprint of the idle-env flap** (verified `api-production-3`, eu-west-1, 2026-08-29 13:31 UTC `Severe`, static-`/health` build deployed; **recurred 2026-09-22** as three ~5-min Ok→Warning→Ok cycles on the same env, no cause cited): self-heals in ~2–5 min; throughout, `HealthyHostCount` stays 2 (1-min minimum), **0** target/ELB 5xx, latency/4xx/5xx = NO DATA (idle, zero traffic), ALB `RequestCount` empty; ALB target health already reads "healthy" by the time you look. The 2026-09-22 recurrence sat at the **~5-min boundary of the default `nonFivexxPersistMinutes=5`**, which is why the API + API-Proxy monitors were raised to **8** (migration `2026-09-22d`). Note the 2026-09-22 flips peaked at `Warning`, which is **below the monitor's alarm threshold** (the EB-health path pages only on `Degraded`/`Severe`), so they never paged; the gate raise is **preventive** for the `Severe`-flavored idle flap (e.g. 2026-08-29). On an idle env there is **no worker starvation**, so nothing to fix server-side — the mitigation is this monitor persistence-delay gate, not more app code. **This is DISTINCT from the PHP-`/health`-starvation cause** (that one is the *busy* env, `api-production-1`, under ASN worker saturation, fixed by the static-`/health` file) — see [api2 architecture — EB "Degraded" health](../../api2/architecture.md).
|
|
82
82
|
|
|
83
83
|
**Measuring episode age — `fetchNonOkDepartureAgeSeconds()`** reads EB `describeEvents`:
|
|
84
84
|
- The most recent **"transitioned from Ok to …"** event marks the current episode's start. Once non-Ok, later transitions stay between non-Ok states until a to-Ok event; because `describeEvents` returns **newest-first**, the first "from Ok to …" match wins.
|
|
@@ -129,6 +129,8 @@ WHERE action = 'Infrastructure/CloudWatch/ElasticBeanstalkHealth'
|
|
|
129
129
|
|
|
130
130
|
`Core.CronJobs.parameters` is confirmed a native `JSON` column, so `JSON_SET` merges rather than overwrites.
|
|
131
131
|
|
|
132
|
+
**`dbchanges2/Core/2026-09-22d - Beanstalk Health NonFivexx Persist Gate.sql`** (same pattern) adds only `$.nonFivexxPersistMinutes = 8` on the same two rows (matched by `action` + `name IN (API-Production, API Proxy-Production)`), raising the non-5xx persistence gate above the ~5-min idle-flap length seen in the 2026-09-22 `api-production-3` recurrence. Existing threshold keys are `JSON_TYPE=INTEGER`, so a bare `8` lands as an integer. Same **code-before-params deploy order** applies: worker2's `nonFivexxPersistMinutes` support (added 2026-08-29) must be live on `_production` first, or the monitor throws "Unknown named parameter" and goes dark. Ran on prod Core 2026-09-22.
|
|
133
|
+
|
|
132
134
|
### OneUptime monitor config (external, not code)
|
|
133
135
|
|
|
134
136
|
Paging depends on the OneUptime Incoming-Request monitor's **string-match** criteria on the POSTed body — `Contains "alarm":"HIGH"` pages.
|
|
@@ -146,6 +148,12 @@ Paging depends on the OneUptime Incoming-Request monitor's **string-match** crit
|
|
|
146
148
|
- **Deploy fails without degrading health** — still not caught (accepted residual).
|
|
147
149
|
- **`ElasticBeanstalkHealth()` is long** and grew with the persistence gate — php-reviewer (0 critical) flagged it as a decomposition candidate; deferred pending the missing harness.
|
|
148
150
|
|
|
151
|
+
## Change history
|
|
152
|
+
|
|
153
|
+
- 2026-09-22 — API + API-Proxy monitors set `nonFivexxPersistMinutes=8` (was default 5) via dbchanges2 `2026-09-22d`, after the `api-production-3` idle-flap recurred at the ~5-min default boundary; default 5 kept on Worker/Worker-1.0. (jcardinal)
|
|
154
|
+
- 2026-08-29 — added the non-5xx persistence gate (`nonFivexxPersistMinutes`, default 5) to suppress brief self-healing idle-env EB flaps. (jcardinal)
|
|
155
|
+
- 2026-08-25 — 5xx page moved to a CloudWatch windowed request-rate gate; API/API-Proxy override applied via dbchanges2 `2026-08-25a`. (jcardinal)
|
|
156
|
+
|
|
149
157
|
## Related
|
|
150
158
|
|
|
151
159
|
- [Cross-account AWS access](./cross-account-aws-access.md)
|
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