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-
|
|
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
|
|
215
|
-
`CONCAT_WS` skips NULL but **not** an empty string, so
|
|
216
|
-
`451 Clarkson Ave , Brooklyn`; every part is `NULLIF`'d before
|
|
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