toga-ai 1.0.874 → 1.0.876

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.
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: nychh
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-18
10
- owners: ["bala"]
9
+ updated: 2026-09-23
10
+ owners: ["bala", "rgirish"]
11
11
  files:
12
12
  - worker2/Worker/Client/Nychh/TransferOrderEmails.php
13
13
  - _underscore/Model/Nychh/TransferOrder.php
@@ -211,9 +211,23 @@ Cost of the split, stated so nobody is surprised later:
211
211
  templates and the tracking line are Plus Jakarta Sans. Outlook gets Arial inside an
212
212
  `<!--[if mso]-->` block because it cannot load web fonts.
213
213
  - **Detail box:** *Site Contact* falls back to the person who raised the order — 3 of the 6 supply
214
- orders in prod have no contact, and that is the same fallback the greeting already used. And
215
- `CONCAT_WS` skips NULL but **not** an empty string, so a blank address line 2 produced
216
- `451 Clarkson Ave , Brooklyn`; every part is `NULLIF`'d before joining.
214
+ orders in prod have no contact. (The greeting used to share that fallback; since 2026-09-23 it
215
+ runs the other way round — see below.) And `CONCAT_WS` skips NULL but **not** an empty string, so
216
+ a blank address line 2 produced `451 Clarkson Ave , Brooklyn`; every part is `NULLIF`'d before
217
+ joining.
218
+ - **The greeting addresses the SUBMITTER, the detail box shows the CONTACT.** They are two different
219
+ people and the email shows both:
220
+
221
+ | Slot | Source column | Shape |
222
+ |---|---|---|
223
+ | `greetingName` ("Hi …,") | `CreatedByUsers.firstName` → `submittedByFirstName`, falling back to `Contacts.firstName` | **first name** |
224
+ | *Site Contact* row | `Contacts.firstName`+`lastName` → `siteContactName`, falling back to `submittedByName` | full name |
225
+ | *Submitted by* row | `CreatedByUsers.firstName`+`lastName` → `submittedByName` | full name |
226
+
227
+ Both greeting sources must be **`firstName` columns**. The original fallback was
228
+ `contactFirstName ?: submittedByName`, and `submittedByName` is the `CONCAT_WS` **full** name — so
229
+ an order with no contact greeted *"Hi Carol Ann Rosado,"*. Never fall back from a first-name slot
230
+ to a `…Name` full-name alias.
217
231
 
218
232
  ## Gotchas / known issues
219
233
 
@@ -247,6 +261,15 @@ unescaped values and both detail-box defects were all found there, not in produc
247
261
 
248
262
  ## Change history
249
263
 
264
+ - 2026-09-23 — **The greeting now addresses whoever raised the order, not the site contact.**
265
+ `readTransferOrderForEmail()` selects `CreatedByUsers.firstName AS submittedByFirstName`, and
266
+ `greetingName` flipped from `contactFirstName ?: submittedByName` to
267
+ `submittedByFirstName ?: contactFirstName`. Found live: an order raised by Carol Ann Rosado
268
+ opened *"Hi Shehu,"* — the site contact. Fixing the order of preference also fixed a second
269
+ latent bug: the old fallback reached for `submittedByName`, the **full** name, so it could render
270
+ *"Hi Carol Ann Rosado,"*; both sides of the fallback are now first-name columns. *Site Contact*
271
+ and *Submitted by* in the details box are unchanged. (worker2 `ed6d566` on `_production`)
272
+ (rgirish)
250
273
  - 2026-09-18 — **Moved the whole feature out of `_underscore` into worker2.** Deleted
251
274
  `_Trait_Nychh_TransferOrderEmail`; `worker2/Worker/Client/Nychh/TransferOrderEmails.php` now holds
252
275
  the queued `SendSubmitted` / `SendFulfillment` entry points, the `SendPending` cron, every query
@@ -0,0 +1,212 @@
1
+ ---
2
+ type: session
3
+ slug: commerce-sso-and-cache
4
+ title: Commerce persisted-cache, session-gating and SSO sign-in screen
5
+ author: apeterson
6
+ repos: [toga2-commerce]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-09-23
11
+ updated: 2026-09-23
12
+ ---
13
+
14
+ # Session: commerce-sso-and-cache
15
+ **Date:** 2026-09-23
16
+ **Project/Repo:** toga2-commerce (2.0), branch `TRUE-81991`
17
+ **Task:** Implement the 2026-09-03 cache-diagnosis plan in toga2-commerce, then build an SSO sign-in screen for SSO tenants and consolidate how the app decides someone is signed in.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+
23
+ **The 2026-09-03 plan, implemented and verified.**
24
+ - App default `staleTime` 24h → `0` plus `refetchOnWindowFocus: false` (`src/App.tsx`); heavy hooks
25
+ got `staleTime: 30_000` + `gcTime: 1h` (`src/pages/BundleView/hooks/useBundleQueryData.ts:26,42`,
26
+ `src/pages/Home/viewModel/useHomeViewModel.ts:150,175,201`). Evidence: `npx tsc --noEmit -p
27
+ tsconfig.app.json` exit 0 and `npx vite build` succeeded after each change.
28
+ - `src/lib/queryCacheRestoration.ts` (new) — every cache clear now waits for restoration, cancels
29
+ in-flight queries, then clears; capped at 1.5s; an epoch counter makes a superseded clear no-op.
30
+ Persister set to `throttleTime: 0` (the 1000ms default let a queued write land AFTER the clear).
31
+ - `src/lib/authStorage.ts` (new) — one `AUTH_STORAGE_KEYS` list used by BOTH logout paths, and by
32
+ `resetClientState()` which now runs on login as well as logout.
33
+ - Build-id `buster` via `__BUILD_ID__` (`vite.config.ts` `define`, declared in `src/vite-env.d.ts`).
34
+ Verified in the bundle: 0 unresolved `__BUILD_ID__` occurrences, the `"dev"` literal present once.
35
+
36
+ **Real bugs found and fixed (each confirmed in source before changing anything).**
37
+ - `AUTH_STORAGE_KEYS` contained `"zu-cart-store"`, which is not a real key — the cart persists under
38
+ `"zu-cart"` (`src/stores/useCartStoreZu.ts:956`). The previous user's cart survived a 401 logout.
39
+ - Clearing a persisted key is not enough: the zustand store must ALSO be reset in memory or the next
40
+ `set()` re-persists the old user's state. Added a `clearSettingsModal` action (that store had none)
41
+ and wired `useBundleBuilderZu` / `useUserStore` resets into `resetClientState()`.
42
+ - `fetchPublicToken()` wrote the ANONYMOUS token to `localStorage` under `"accessToken"` — the same
43
+ key a real login writes — so every "is there an accessToken?" check was true for a logged-out
44
+ visitor. Root-caused: the public token is now a module variable in `src/api/axiosInstance.ts`,
45
+ never stored, and its refresh token is not stored at all.
46
+ - The 401 handler gated logout on `"zu-user"`, a zustand store that clears itself independently. With
47
+ that key gone but the session live, logout was skipped and the app sat looping on 401.
48
+
49
+ **Security fixes (all found by the `cso` reviewer, all verified in source).**
50
+ - Open redirect on the post-auth return path (`src/utils/postAuthRedirect.ts`). Visiting
51
+ `https://<tenant>//evil.com` gives `pathname === "//evil.com"`, which the browser treats as
52
+ protocol-relative — so after a SUCCESSFUL login the user went to another origin with a live
53
+ session. Now only a single-slash same-origin path is stored or restored.
54
+ - `window.location.href = ssoUrl` with no scheme check. Now must be `https:`, checked where fetched
55
+ AND at the click; a bad value means "not SSO" so the tenant falls back to the credential form.
56
+ - Token refresh could run under the wrong identity. Made single-flight, then identity-scoped with an
57
+ epoch in `src/lib/session.ts` bumped on sign-in, sign-out and view-as, plus a second guard
58
+ comparing the stored refresh token.
59
+ - View-as swap stored `response?.data.tokens.access` with only `response` optional-chained — a
60
+ malformed reply wrote the literal string `"undefined"` (truthy), so the app sent `Bearer undefined`
61
+ on every call. Now both tokens are validated and the session is left untouched on a bad reply.
62
+
63
+ **SSO sign-in screen built.** Detection already existed inside `useAuthenticationFlow` as a mutation
64
+ that jumped straight to the IdP — the user never saw a page. Extracted to `src/hooks/useClientSsoUrl.ts`
65
+ (React Query, so the auth flow and the login page share one cached answer). `/login` now renders
66
+ `src/pages/Login/view/SsoSignIn.tsx` for SSO tenants, else the credential form. Design came from the
67
+ Claude Design project "TOGa Supply - SSO Sign In", rebuilt with commerce's own logo, theme tokens and
68
+ per-tenant FIELDS copy. Every logout path already lands on `/login`, so SSO users get the SSO screen
69
+ on sign-out with no extra code — verified by reading `src/routes.tsx`, NOT by running it.
70
+
71
+ **First unit-test harness in the repo.** vitest + jsdom, `npm test` / `npm test:watch`,
72
+ `vitest.config.ts`. 19 tests pass. Each was proven to fail on the pre-fix code first: reverting
73
+ `hasSession` turned **5 red**; removing the single-flight and identity guards turned **3 red**
74
+ (including both race tests). Race tests use a held-open promise, not real timing, so no flake.
75
+
76
+ ---
77
+
78
+ ## What did NOT work — DO NOT RETRY THESE
79
+
80
+ - **Falling back to the anonymous token when a session exists but `accessToken` is missing.** I wrote
81
+ this thinking it fixed "a 401 with no explanation". It is backwards — the 401 IS the point. The
82
+ `cso` agent confirmed against api2 (`Component/Api/V2/V2.php:578-598`): `/auth/public` mints a
83
+ FULLY VALID JWT carrying `KEY_PUBLIC_ROLE`, and its client comes from the **Domains row, not the
84
+ user**. So the request would NOT 401 — it would return 200 with public-scoped rows (or `EZ-1`),
85
+ the refresh/logout path would never fire, and the tab would stay silently anonymous under a real
86
+ session. On a domain whose `clientId` differs from the signed-in user's, a write could land in the
87
+ wrong tenant's database. **Reverted.** A live session with no `accessToken` must send NO
88
+ `Authorization` header so it 401s.
89
+ - **`hasSession()` as `Boolean(localStorage.getItem("user"))`.** Too loose — `"{}"`, `"null"` or a
90
+ half-written value booted the app into the signed-in state with no identity behind it. It must
91
+ parse and require a `uuid`. (This is one of the 5 tests that goes red on the loose version.)
92
+ - **Anchoring the SSO footer inside the form column.** It only worked by accident of the nearest
93
+ positioned ancestor, and escaped the `xl:w-96` width. The footer belongs to the left PANEL — it is
94
+ now `SsoSignInFooter`, rendered as a sibling of the form column.
95
+ - **Prefixing every per-session localStorage key so logout can clear by prefix.** My idea; the `cto`
96
+ agent rejected it with a concrete reason: renaming every zustand `persist` name silently orphans
97
+ every existing user's persisted state on deploy, and touches every `localStorage.getItem("zu-user")`
98
+ call site scattered through `src/api/`. Cheaper answer, which supply already uses: plain
99
+ `localStorage.clear()` on logout, since every key commerce writes is session state.
100
+ - **`grep --include=*.ts` under zsh** fails with "no matches found" when the glob is unquoted —
101
+ quote it (`--include="*.ts"`). Cost two wasted tool calls.
102
+ - **Python string-replace edits against this repo assuming space indentation.** Source is TABS, and
103
+ one line in `axiosInstance.ts` had a trailing-space blank line. Two `AssertionError`s before I
104
+ matched the real bytes. Check with `sed 's/\t/<TAB>/g'` first.
105
+
106
+ ---
107
+
108
+ ## Not tried yet (candidates for next session)
109
+
110
+ 1. **Compass Canada production SSO** — `https://compasscanada.togacommerce.com/home` sends users to
111
+ the credential form though the client uses SSO. Diagnostic SQL was handed over, NOT run. Candidate
112
+ causes in order: no `Domains` row matching the exact `https://compasscanada.togacommerce.com`
113
+ string; no `ClientAuthentications` row for that `clientId`+`environmentId`; `type` not the literal
114
+ `'SSO'`; or the PUBLIC role lacking `AclFieldPermissions` to read `ClientAuthentications.type`
115
+ (the call runs pre-login on the public token, so a null field fails silently). Note the production
116
+ build is plain `vite build` with NO `--mode`, so `MODE` is `production` and the query asks for
117
+ `Environments.slug LIKE 'PRODUCTION'`.
118
+ 2. **blox `fetchPublicToken` defect** (`toga-blox-npm/src/api/auth.ts:48-60`) — same bug commerce just
119
+ fixed locally; supply works around it in three places. Needs its own ticket; do NOT bundle it.
120
+ 3. **CTO-ranked, not done:** replace the `window.history.pushState`/`replaceState` monkey-patch in
121
+ `AuthContext.tsx` with supply's poll-on-interval + focus (Sentry patches history too, so the
122
+ cleanup can clobber it); make `login()` fail loudly instead of swallowing errors after tokens are
123
+ already written.
124
+ 4. **Narrow the core-parameters forced logout** to `META_LAST_REFRESH_DATETIME` only (it currently
125
+ compares the WHOLE `/core-parameters` blob, so any parameter edit logs out every user) and add
126
+ supply's notice toast.
127
+ 5. **Constrain the SSO URL's HOST**, not just its scheme, to the tenant's configured IdP domain. No
128
+ allowlist source exists today.
129
+ 6. **Unanswered since 2026-09-03:** do commerce bundle/item payloads carry prices or stock counts? If
130
+ they do, those queries need their spinner on `isFetching`, not `isLoading`.
131
+ 7. Point `performLogout` and the core-parameters logout at `/login` directly instead of `/`, to remove
132
+ the brief blank screen while `AuthRedirect` renders nothing.
133
+
134
+ ---
135
+
136
+ ## Current file state
137
+
138
+ All work is on branch `TRUE-81991` in `toga2-commerce`, as three commits.
139
+ `e546db3d` is pushed; **`93616f20 "Save"` and `fd92de42 "Save"` are LOCAL ONLY** and were made by the
140
+ developer, not by Claude. Working tree otherwise clean (only `.env.development` modified, unrelated).
141
+
142
+ | File | Status | Notes |
143
+ |------|--------|-------|
144
+ | `src/lib/session.ts` | new | `hasSession()` (parses `user`, requires a uuid, storage read inside the try) + the identity epoch |
145
+ | `src/lib/session.test.ts` | new | 11 tests; 5 go red if `hasSession` is loosened |
146
+ | `src/lib/authStorage.ts` | new | `AUTH_STORAGE_KEYS` + `clearAuthStorage()`, shared by both logout paths |
147
+ | `src/lib/queryCacheRestoration.ts` | new | restoration gate, `clearPersistedQueryCache()`, epoch guard, 1.5s cap |
148
+ | `src/hooks/useClientSsoUrl.ts` | new | `/domains` + `singleSignOnServiceUrl` lookup, `isSafeSsoUrl` https check |
149
+ | `src/pages/Login/view/SsoSignIn.tsx` | new | SSO panel + `SsoSignInFooter` (footer is a sibling of the form column) |
150
+ | `src/api/refreshAuthTokens.test.ts` | new | 8 tests; 3 go red without the single-flight + identity guards |
151
+ | `vitest.config.ts` | new | jsdom, `src/**/*.test.ts` |
152
+ | `src/api/axiosInstance.ts` | modified | in-memory public token, single-flight + identity-scoped refresh, `hasSession()` gates, `clearPublicToken()` |
153
+ | `src/contexts/AuthContext.tsx` | modified | `resetClientState()` on login AND logout, async `logout()`, storage listener uses `hasSession()` only |
154
+ | `src/hooks/useAuthenticationFlow.ts` | modified | SSO lookup removed; navigates to `/login`; gates on `hasSession()` |
155
+ | `src/pages/Login/LoginPage.tsx` | modified | branches to the SSO panel; left panel is now `relative` |
156
+ | `src/pages/Cart/api/CartApi.ts` | modified | view-as token validation + epoch bump + `clearPublicToken()` |
157
+ | `src/utils/postAuthRedirect.ts` | modified | `isSafeDestination()` blocks the protocol-relative open redirect |
158
+ | `src/stores/useSettingsModalStore.ts` | modified | added `clearSettingsModal` |
159
+ | `src/App.tsx` | modified | `staleTime: 0`, `refetchOnWindowFocus: false`, `buster`, `throttleTime: 0` |
160
+ | `vite.config.ts` / `src/vite-env.d.ts` | modified | `__BUILD_ID__` define + declaration |
161
+ | `src/pages/Login/viewModel/FIELDS/LOGINDUMMYFIELDS.json` | modified | `SSOSECTIONFIELDS` (button, loading, copyright) |
162
+ | `package.json` / `package-lock.json` | modified | vitest + jsdom devDeps, `test` scripts. Blox pin untouched at `1.1.1-production.139` |
163
+
164
+ ---
165
+
166
+ ## Decisions made
167
+
168
+ - **`gcTime`, not `staleTime`, is the speed knob.** React Query paints cached data instantly whatever
169
+ `staleTime` says, and commerce gates every spinner on `isLoading` (false when cached data exists) —
170
+ so lowering `staleTime` costs no spinner. Rejected: keeping the 24h `staleTime` "for speed".
171
+ - **One `hasSession()` predicate.** Four sites decided this off three different keys with three
172
+ different predicates, and the disagreements WERE the bugs. Rejected (CTO advice): collapsing `user`
173
+ and `zu-user` into one store — ~15 files, no user-visible gain. Unify the predicate, not the storage.
174
+ - **`hasSession()` is a routing/UI signal only, never an authorization check.** The value is
175
+ user-writable; api2 enforces every permission server-side. Noted in the file so it does not drift.
176
+ - **Keep tokens in `localStorage`.** api2 is Bearer-only with no cookie session; switching means
177
+ backend work plus CSRF protection that does not exist. Matches `2.0/standards/frontend.md` §12.
178
+ - **SSO tenants land on `/login` and click through, rather than auto-redirecting.** Developer's call.
179
+ `/login` IS the SSO page — the same URL decides which screen to show.
180
+ - **Commerce keeps its own FIELDS copy and theme**, not the Supply wording/art from the design.
181
+ Developer's call. Deviations from the mockup were listed rather than silently absorbed.
182
+ - **Fix the public-token bug app-local, not in blox.** Per the app-level-over-core rule; the blox fix
183
+ changes behavior for every consumer and needs its own review.
184
+ - **Dropped the `?ssoPreview=1` dev switch** at the developer's request after using it to verify the
185
+ screen. The `isSafeSsoUrl` https check stayed — that is a security fix, not test code.
186
+
187
+ ---
188
+
189
+ ## Blockers
190
+
191
+ - **Compass Canada production SSO is broken and the cause is unconfirmed.** The diagnostic SQL has
192
+ not been run. Until the `/domains` lookup returns SSO for that tenant, the new screen cannot be
193
+ seen there — and it cannot be seen locally either, since the dev preview switch was removed.
194
+ - **`93616f20` and `fd92de42` are unpushed local commits** with generic "Save" messages. They need
195
+ real commit messages (or a squash) before a PR.
196
+ - The mid-session checkout moved to `_production` and back; the work was never lost, but confirm
197
+ `git branch --show-current` is `TRUE-81991` before doing anything.
198
+
199
+ ---
200
+
201
+ ## Exact next step
202
+
203
+ > Run the Compass Canada diagnostic against the **Core production** database — the `SELECT` joining
204
+ > `Core.Domains` → `Core.Environments` → `Core.Clients` → `Core.ClientAuthentications` filtered on
205
+ > `d.domain LIKE '%compasscanada%'`. Read the result against four causes in order: no row / wrong
206
+ > `domain` string, `authType` NULL (no `ClientAuthentications` row for that clientId+environmentId),
207
+ > `authType` not the literal `'SSO'`, or — if the data is right — the PUBLIC role missing
208
+ > `AclFieldPermissions` on `ClientAuthentications.type`, which you check by opening the site in a
209
+ > private window and looking at the `/v2/domains` response in the Network tab.
210
+
211
+ ---
212
+ _Saved by /session-save on 2026-09-23_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.874",
3
+ "version": "1.0.876",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",