toga-ai 1.0.832 → 1.0.834

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.
@@ -61,5 +61,5 @@
61
61
  | [USPS DPV Deliverability Verdict (is this address actually insurable/shippable?)](features/usps-dpv-deliverability.md) | **USPS returning HTTP 200 with a populated address is NOT evidence that the address is deliverable.** The authoritative signal is USPS's **DPV (Delivery Point V |
62
62
  | [Refreshing a Local Dev Database from Beta (dev-sandbox)](workflows/local-db-refresh-from-beta.md) | How to reset a local 2.0 dev database from the **beta / dev-sandbox** environment: dump each schema (`Core`, `Client_<Id>`, `Logs_<Id>`, …) from the beta host, |
63
63
  | [Deleting a shared branch does not remove bad commits — a stale local clone merges them back](workflows/recreated-shared-branch-stale-local-remerge.md) | **Deleting and recreating a shared environment branch removes only the *ref*.** Every teammate who still has that branch checked out locally keeps the full pre- |
64
- | [Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)](workflows/rotating-public-tls-certificates.md) | How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, t |
64
+ | [Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk, API Gateway)](workflows/rotating-public-tls-certificates.md) | How to replace the public TLS certificates that terminate HTTPS for TOGa front-end domains (togahub, togacommerce, togadesk, togaretail, togasupply, togaview, t |
65
65
  | [Running a 2.0 App Locally (browser, end-to-end via api2)](workflows/running-a-2.0-app-locally.md) | The full dependency chain required to run a 2.0 client app **through the browser**, end-to-end, against a **local `api2`** (e.g. |
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk)
2
+ title: Rotating Public TLS Certificates (ACM, CloudFront, ALB, Elastic Beanstalk, API Gateway)
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-17
10
10
  owners: ["rgirish"]
11
11
  files: []
12
12
  related:
@@ -27,13 +27,15 @@ This runbook was written from the 2026-09-16 rotation in AWS account **654654170
27
27
  one IMPORTED multi-domain cert (14 SANs, copied into us-east-1, us-west-2 and eu-west-1) served
28
28
  4 CloudFront distributions and 12 ALB listeners and expired the same day.
29
29
 
30
- **The three things that bite you:**
30
+ **The four things that bite you:**
31
31
  1. **Elastic Beanstalk keeps its own saved cert setting**, separate from the ALB listener. Fixing
32
32
  the listener is not enough — the next deploy puts the old cert straight back.
33
33
  2. **CloudFront holds exactly ONE cert per distribution.** There is no staging. The swap *is* the
34
34
  cutover, and it takes 5-15 minutes.
35
35
  3. **An ACM wildcard matches exactly one label.** `*.togasupply.com` covers
36
36
  `elite.togasupply.com` but **not** `compass.beta.togasupply.com`.
37
+ 4. **API Gateway custom domains hold their own cert** and are invisible to a CloudFront/ALB/EB
38
+ sweep. This is what the 2026-09-16 rotation missed — see step 6b.
37
39
 
38
40
  **Root cause to avoid repeating:** an **IMPORTED** ACM cert never auto-renews. Always replace with
39
41
  **Amazon-issued, DNS-validated** ACM certs, which renew themselves.
@@ -123,6 +125,60 @@ one), and every environment stayed Green/Ok through the update — no traffic di
123
125
  **Gotcha:** an EB environment whose CloudFormation stack is in `DELETE_FAILED` cannot be updated at
124
126
  all — `update-environment` is refused. That needs separate stack cleanup.
125
127
 
128
+ ## Step 6b — Audit API Gateway custom domains (the 2026-09-16 miss)
129
+
130
+ **API Gateway is a FOURTH resource type.** The 2026-09-16 rotation swept CloudFront, ALB listeners
131
+ and EB saved configs, and reported "0 endpoints at risk". The next morning `webhook.togahub.com`
132
+ was hard-down — it is an API Gateway custom domain, so none of those three sweeps could ever have
133
+ found it. Proof of the failure, not just a stale cert:
134
+
135
+ ```
136
+ $ curl -sS -o /dev/null -w "http=%{http_code} ssl=%{ssl_verify_result}\n" https://webhook.togahub.com/
137
+ curl: (60) SSL certificate problem: certificate has expired
138
+ http=000 ssl=10
139
+ ```
140
+
141
+ **Find them from DNS — an API Gateway domain CNAMEs to `*.execute-api.<region>.amazonaws.com`:**
142
+
143
+ ```
144
+ aws route53 list-resource-record-sets --hosted-zone-id <zid> --max-items 400 \
145
+ --query "ResourceRecordSets[?Type=='CNAME'].[Name,ResourceRecords[0].Value]" --output text \
146
+ | grep -i execute-api
147
+ ```
148
+
149
+ Across all seven TOGa zones this found exactly one: `webhook.togahub.com` →
150
+ `d-yiy4od2ade.execute-api.us-east-1.amazonaws.com`.
151
+
152
+ **An unknown account ID in `InUseBy` can be AWS itself.** The expired cert's `InUseBy` listed 3
153
+ ALBs in account **250044486744**, which no one has a profile for. That is AWS-managed edge
154
+ infrastructure — an edge-optimized API Gateway domain terminates TLS on AWS's own load balancers.
155
+ Treat it as a pointer to API Gateway, **not** as a rogue account to go hunt credentials for.
156
+
157
+ **Permissions block this from the CLI.** The `GoAgilant-Developers` SSO role has **no**
158
+ `apigateway:GET` in any of the three accounts (654654170868, 502614707982, 975050298201) —
159
+ `get-domain-names`, `get-domain-name` and the `apigatewayv2` equivalents all return
160
+ `AccessDeniedException`. The domain cannot be listed, read or fixed with the standard developer
161
+ role. Use the Console, or get `apigateway:GET` + `apigateway:PATCH` added first.
162
+
163
+ **Check the endpoint type FIRST — it decides which field you patch.** `get-domain-name` returns
164
+ `endpointConfiguration.types`. An **EDGE** domain reads its cert from `us-east-1` and uses
165
+ `/certificateArn`; a **REGIONAL** domain reads from its own region and uses
166
+ `/regionalCertificateArn`. Patching the wrong field silently does nothing useful.
167
+ `webhook.togahub.com` turned out to be **REGIONAL** (us-east-1), despite `InUseBy` showing
168
+ AWS-managed edge ALBs — so do not infer the type from `InUseBy`.
169
+
170
+ ```
171
+ aws apigateway get-domain-name --domain-name webhook.togahub.com \
172
+ --query 'endpointConfiguration.types'
173
+
174
+ aws apigateway update-domain-name --domain-name webhook.togahub.com \
175
+ --patch-operations op=replace,path=/regionalCertificateArn,value=<new-arn>
176
+ ```
177
+
178
+ The domain goes `UPDATING` and takes a few minutes to return to `AVAILABLE` (~4 min observed).
179
+ Poll `domainNameStatus` before verifying — checking too early shows the old cert and looks like a
180
+ failed patch. No downtime beyond the already-broken TLS; base path mappings are untouched.
181
+
126
182
  ## Step 7 — Check alias coverage before any CloudFront swap
127
183
 
128
184
  CloudFront **rejects** an update if any alias on the distribution is not covered by the new cert.
@@ -155,11 +211,22 @@ echo | openssl s_client -servername HOST -connect HOST:443 2>/dev/null \
155
211
  | openssl x509 -noout -issuer -enddate
156
212
  ```
157
213
 
214
+ `openssl` shows you the cert, but it does **not** prove a client can connect. Add a real verify —
215
+ and treat a known-bad host as the control that proves your check actually fails:
216
+
217
+ ```
218
+ curl -sS -o /dev/null -w "http=%{http_code} ssl=%{ssl_verify_result}\n" https://HOST/
219
+ ```
220
+
221
+ A live expired cert returns `http=000` with `SSL certificate problem: certificate has expired`.
222
+
158
223
  Also run it **without** `-servername` to check the no-SNI / default-cert path — that path can still
159
224
  be serving the old cert after everything else looks fine.
160
225
 
161
- Finish by re-running the step 1 audit: every CloudFront distribution, every ALB listener, and every
162
- EB saved config should name a cert with a future `NotAfter`.
226
+ Finish by re-running the step 1 audit: every CloudFront distribution, every ALB listener, every
227
+ EB saved config **and every API Gateway custom domain** should name a cert with a future
228
+ `NotAfter`. A sweep that omits any one of those four resource types is not a clean result — it is
229
+ an untested one.
163
230
 
164
231
  ## Gotchas
165
232
 
@@ -170,9 +237,28 @@ EB saved config should name a cert with a future `NotAfter`.
170
237
  - **CloudFront takes one cert; ALBs take many.** Two completely different risk profiles in the same
171
238
  rotation — plan them separately.
172
239
  - **Wildcards match one label only.** Verify alias coverage with code, not by eye.
240
+ - **API Gateway custom domains are a fourth resource type.** A CloudFront + ALB + EB sweep misses
241
+ them completely. Find them by `execute-api` CNAMEs in Route 53. See step 6b.
242
+ - **An unknown account ID in `InUseBy` may be AWS itself.** `250044486744` is AWS-managed edge
243
+ infrastructure for API Gateway, not a rogue account. See step 6b.
244
+ - **The `GoAgilant-Developers` role needs `apigateway:GET` + `apigateway:PATCH`** on
245
+ `arn:aws:apigateway:*::/domainnames*`. Added to the permission set 2026-09-17; it was missing
246
+ during the rotation. Granted via IAM Identity Center, then **provisioned** to the account — the
247
+ provision step is what actually applies it. See step 6b.
248
+ - **Patch the field that matches the endpoint type.** REGIONAL uses `/regionalCertificateArn`,
249
+ EDGE uses `/certificateArn`. See step 6b.
250
+ - **`openssl` is not proof of health.** It prints the cert even when clients cannot connect. Use
251
+ `curl` and check for `http=000`. See step 9.
173
252
  - **Expired certs hide.** Sweep the whole account; do not trust `InUseBy`.
174
253
 
175
254
  ## Change history
255
+ - 2026-09-17 — Added step 6b (API Gateway custom domains) after `webhook.togahub.com` was found
256
+ serving an expired cert and failing TLS the day after the rotation reported all-clear; added the
257
+ `curl` proof step, the AWS-owned-account `InUseBy` tell, and the missing `apigateway` permission.
258
+ **Fixed the same day:** `apigateway:GET`/`PATCH` added to the permission set, domain confirmed
259
+ REGIONAL, `regionalCertificateArn` swapped to the Amazon-issued `*.togahub.com` cert
260
+ (`a53d1ff6-...`, expires 2027-03-31). Verified `http=404 ssl=0` (was `http=000 ssl=10`), with
261
+ `expired.badssl.com` as the control that still fails. (rgirish)
176
262
  - 2026-09-16 — Created from the account 654654170868 rotation: replaced an expiring 14-SAN IMPORTED
177
263
  cert with 6 per-domain Amazon-issued DNS-validated certs, relinked 12 ALB listeners, 6 CloudFront
178
264
  distributions and 11 EB environment configs; documented the EB saved-config trap. (rgirish)
@@ -2,22 +2,23 @@
2
2
 
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
- | [TOGa Commerce (toga2-commerce / commerce2-react) Architecture](architecture.md) | `toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the customer-facing **B2B commerce storefront** of the 2.0 platform |
5
+ | [TOGa Commerce (toga2-commerce / commerce2-react) Architecture](architecture.md) | Whole-repo map of the toga2-commerce React storefront (bootstrap, routing, MVVM pages, state, API, multi-tenant resolution); open when working anywhere in this |
6
6
  | [Bundle item visibility & selectability (the three flags, and why they are enforced nowhere but the client)](features/bundle-item-visibility-and-selectability.md) | Which components of a kit a shopper can see and choose is decided **entirely in the browser**. |
7
7
  | [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every |
8
- | [Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. |
8
+ | [Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention](features/cart-notification-emails.md) | Cart Notifications section: CC-email duplicate prevention + auto-add/remove of order-for-user & delegate-manager emails; open when touching notification emails |
9
9
  | [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | The Cart summary section (subtotal / shipping / tax / total) is **data-driven** from `cartData`. |
10
10
  | [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. |
11
11
  | [Catalog cache freshness — the 24h persisted query cache, and how to opt a query out of it](features/catalog-cache-freshness.md) | TOGa Commerce runs a **single `QueryClient` with a 24-hour default `staleTime`**, and persists it to **`localStorage["commerce"]`** through `PersistQueryClientP |
12
12
  | [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of item tiles on a storefront category page is driven by exactly one |
13
- | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. |
13
+ | [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
14
14
  | [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | On the toga2-commerce **Cart** page, expedited shipping options (**"2nd Day EOB"** and **"Next Day Air"**) are only offered in the *Shipping Method* dropdown wh |
15
15
  | [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | The storefront has **two distinct search entry points that render the same card component through completely different code paths and different FIELDS files**. |
16
+ | [Header "Order for <Name>" dropdown + the pending-pick hand-off to the cart](features/header-order-for-dropdown.md) | Header pill that lets a buyer pick who they are ordering for from any page, and the in-memory store that hands that pick to the cart's order-for cascade; open b |
16
17
  | [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, or orderable **as a standalone line** on the storefront — but the **same flag is |
17
18
  | [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. |
18
19
  | [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | Submitting an order from the cart fires **two independent sync routines** — one for the sales-order header (`syncSalesOrderData`) and one for the line items (`s |
19
20
  | [Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)](features/shipping-cost-waiver-gating.md) | On the toga2-commerce **Cart** page, a shipping option's **cost** can be waived by config using the same `PrimaryItemShippingRule` vocabulary that drives expedi |
20
21
  | [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. |
21
22
  | [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. |
22
- | [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | The Cypress **e2e** convention set for `toga2-commerce`, and the first **active** e2e coverage for the **Cart** page (`cartV2.cy.ts`, slice 1 — 12 tests, verifi |
23
+ | [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 |
23
24
  | [Diagnosing ERR_HTTP2_PROTOCOL_ERROR (one client fails, everyone else is fine)](workflows/http2-protocol-error-diagnosis.md) | When a Chromium browser (Chrome / Edge) shows **`ERR_HTTP2_PROTOCOL_ERROR`** loading a `*.togacommerce.com` tenant for **one client/network but works for the TO |
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-09-17
10
10
  owners: ["apeterson", "tcox"]
11
11
  files:
12
12
  - src/main.tsx
@@ -29,22 +29,15 @@ related:
29
29
  - 2.0/apps/toga2-commerce/features/filter-search-results-page.md
30
30
  ---
31
31
 
32
+ Whole-repo map of the toga2-commerce React storefront (bootstrap, routing, MVVM pages, state, API, multi-tenant resolution); open when working anywhere in this repo.
33
+
32
34
  ## Summary
33
35
 
34
- `toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the
35
- customer-facing **B2B commerce storefront** of the 2.0 platform. It is a **React 18 + TypeScript +
36
- Vite 5 single-page app** styled with **Tailwind**, and it is the React front end in the 2.0
37
- "decoupled" architecture: it talks to **`api2`** (the TOGa v2 JSON API) for all data, and renders
38
- nothing server-side.
36
+ `toga2-commerce` (npm package **`commerce2-react`**, product **TOGa Commerce**) is the customer-facing **B2B commerce storefront** of the 2.0 platform: a **React 18 + TypeScript + Vite 5 SPA** styled with **Tailwind**. It is the React front end in the 2.0 "decoupled" architecture — talks to **`api2`** (the TOGa v2 JSON API) for all data, renders nothing server-side. It is a 2.0 *app with no PHP*: registry `dependsOn` is `api2`; it consumes `_underscore`/`api2` over HTTP but contains no framework PHP classes.
39
37
 
40
- It is **multi-tenant**: a single codebase serves several clients (currently **COMPASS**,
41
- **COMPASSCANADA**, **QUAD**, plus a **DEFAULT** fallback). The tenant is resolved at runtime from
42
- the **hostname** and drives three independent systems — the API base URL, the theme, and the
43
- per-tenant field/content config. See [multi-tenant-theming](features/multi-tenant-theming.md) and
44
- [client-fields](features/client-fields.md) for those subsystems in depth.
38
+ **Multi-tenant:** one codebase serves several clients (**COMPASS**, **COMPASSCANADA**, **QUAD**, plus a **DEFAULT** fallback). The tenant is resolved at runtime from the **hostname** and drives three independent systems — API base URL, theme, per-tenant field/content config. See [multi-tenant-theming](features/multi-tenant-theming.md) and [client-fields](features/client-fields.md).
45
39
 
46
- > Note: this is a **2.0 *app* with no PHP**. Its registry `dependsOn` is `api2`; it consumes the
47
- > `_underscore`/`api2` backend over HTTP but contains no framework PHP classes itself.
40
+ **Critical rules:** Tenant is derived from hostname only (`hostname.toUpperCase().split(".")[0]`) — wrong host = wrong tenant, and `localhost` falls back (fields→COMPASS, theme→DEFAULT). The single `QueryClient` uses a **24h default `staleTime`** and persists the whole cache to **`localStorage["commerce"]`**, so catalog AND config/copy (FIELDS) can be up to 24h stale for a returning user — verify any data/copy change after `localStorage.removeItem('commerce')` or logout, never on a warm browser. `AuthLayout` never remounts, so `refetchOnMount` fires once per session — route-change refreshes must be wired explicitly. The registry French language key is **`fr-CA`**, not `fr` — branch on `startsWith("fr")`. api2 returns the standard `{success,data,errors}` envelope; do not break the shape.
48
41
 
49
42
  ## Tech stack (verified versions, package.json)
50
43
 
@@ -67,10 +60,8 @@ E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
67
60
 
68
61
  ## Bootstrap & provider stack
69
62
 
70
- - `src/main.tsx` — initializes **Sentry** (browser tracing + replay) and mounts `<App/>` in React
71
- `StrictMode`; imports global CSS.
72
- - `src/App.tsx` — creates a single `QueryClient` (default `staleTime: 24h`) and a
73
- `createSyncStoragePersister` (localStorage key **`"commerce"`**). Provider nesting, **outermost → innermost**:
63
+ - `src/main.tsx` — initializes **Sentry** (browser tracing + replay), mounts `<App/>` in React `StrictMode`, imports global CSS.
64
+ - `src/App.tsx` — creates a single `QueryClient` (default `staleTime: 24h`) and a `createSyncStoragePersister` (localStorage key **`"commerce"`**). Provider nesting, **outermost → innermost**:
74
65
 
75
66
  ```
76
67
  PersistQueryClientProvider (client=queryClient, persister → localStorage "commerce")
@@ -81,13 +72,8 @@ E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
81
72
  + ReactQueryDevtools (initialIsOpen=false)
82
73
  ```
83
74
 
84
- `App` also runs a mount effect managing **edit-order mode**: it reads a `localStorage.synced`
85
- flag and calls `exitEditOrderModeGlobalSyncReset()` when an edit-order session was abandoned
86
- outside `/cart`, and subscribes to `useReturnOriginalUserFromViewAsStore` (the "view-as" feature).
87
-
88
- > The cache is **persisted**: query results survive refreshes via `PersistQueryClientProvider`.
89
- > Combined with the 24h default `staleTime`, catalog data is aggressively cached — invalidate
90
- > deliberately with `queryClient.invalidateQueries(...)` when freshness matters.
75
+ - `App` runs a mount effect managing **edit-order mode**: reads a `localStorage.synced` flag and calls `exitEditOrderModeGlobalSyncReset()` when an edit-order session was abandoned outside `/cart`; subscribes to `useReturnOriginalUserFromViewAsStore` (the "view-as" feature).
76
+ - The cache is **persisted**: query results survive refreshes. Combined with the 24h default `staleTime`, catalog data is aggressively cached — invalidate deliberately with `queryClient.invalidateQueries(...)` when freshness matters.
91
77
 
92
78
  ## Routing (`src/routes.tsx`, createBrowserRouter)
93
79
 
@@ -97,17 +83,11 @@ Public:
97
83
  - `/reset-password` → `ResetPasswordPage`
98
84
 
99
85
  Authenticated (wrapped by `PrivateRoute` → `AuthLayout`):
100
- - `/home`, `/bundle-view`, `/filter`, `/cart`, `/get-support`, `/account`, `/item-view`,
101
- `/order-details`, `/privacy-policy`, `/terms-conditions`; unmatched → `Navigate to="/home"`.
86
+ - `/home`, `/bundle-view`, `/filter`, `/cart`, `/get-support`, `/account`, `/item-view`, `/order-details`, `/privacy-policy`, `/terms-conditions`; unmatched → `Navigate to="/home"`.
102
87
 
103
88
  Mechanics:
104
- - **`PrivateRoute`** gates on `useAuth().isAuthenticated` + `useAuthenticationFlow().isLoading`;
105
- shows an `AuthLoading` spinner while loading, redirects to `/login` if unauthenticated.
106
- - **`AuthLayout`** wraps every authenticated route and **stays mounted across route changes**
107
- (header, footer, nav, notifications live here). Its `errorElement` renders `<ErrorMessage errorType={500}/>`.
108
- ⚠️ Because it never remounts, React Query `refetchOnMount` only fires **once per session** for
109
- queries owned by AuthLayout — route-change refreshes must be wired explicitly (e.g. the
110
- notifications query invalidates on route change keyed on `user?.uuid`).
89
+ - **`PrivateRoute`** gates on `useAuth().isAuthenticated` + `useAuthenticationFlow().isLoading`; shows an `AuthLoading` spinner while loading, redirects to `/login` if unauthenticated.
90
+ - **`AuthLayout`** wraps every authenticated route and **stays mounted across route changes** (header, footer, nav, notifications live here). Its `errorElement` renders `<ErrorMessage errorType={500}/>`. Because it never remounts, `refetchOnMount` fires **once per session** for queries owned by AuthLayout — route-change refreshes must be wired explicitly (e.g. the notifications query invalidates on route change keyed on `user?.uuid`).
111
91
  - Routes are **statically imported** (no `React.lazy`); Vite handles chunking at build time.
112
92
 
113
93
  ## Page architecture — MVVM convention
@@ -128,10 +108,7 @@ src/pages/<Page>/
128
108
  └── helpers/ # pure compute/format helpers
129
109
  ```
130
110
 
131
- Pages: `Account, BundleView, Cart, Filter, GetSupport, Home, ItemsView, Login, OrderDetails,
132
- PrivacyPolicy, ResetPassword, TermsConditions`. The **ViewModel hook is the seam**: it calls
133
- `useAssignClientFields(fieldKey, language, user)` to load the right tenant/role/language content,
134
- runs the page's queries, and returns ready-to-render data to the View. (See client-fields doc.)
111
+ Pages: `Account, BundleView, Cart, Filter, GetSupport, Home, ItemsView, Login, OrderDetails, PrivacyPolicy, ResetPassword, TermsConditions`. The **ViewModel hook is the seam**: it calls `useAssignClientFields(fieldKey, language, user)` to load the right tenant/role/language content, runs the page's queries, and returns ready-to-render data to the View. (See client-fields doc.)
135
112
 
136
113
  ## State management
137
114
 
@@ -147,6 +124,7 @@ Most use the `persist` middleware (localStorage). Verified files:
147
124
  | `useEditOrderZu` | "Edit existing order" mode flag + original snapshot |
148
125
  | `useEditOrderUuidStore` | UUID of the order being edited |
149
126
  | `useSelectedUserZu` | "Order for" user selected in cart |
127
+ | `useOrderForZu` | Header "order for" **pending pick** handed to the cart cascade — **in memory only, never `persist`ed** |
150
128
  | `useReturnOriginalUserFromViewAsStore` | "View as another user" return-to-self tracking |
151
129
  | `useEmailOptionsStore` | Notification CC email options (see cart-notification-emails) |
152
130
  | `useBundleBuilderZu` | Bundle-builder wizard selections |
@@ -159,59 +137,35 @@ Most use the `persist` middleware (localStorage). Verified files:
159
137
  - Cache persisted to localStorage (`"commerce"`); rehydrated on load.
160
138
  - Page queries are typically `enabled` on `isAuthenticated && user?.uuid && !settingsModalEnabled`.
161
139
 
162
- **Two-layer state model:** React Query owns *server* state (catalog, orders, user); Zustand owns
163
- *local* state (cart, selected user, edit mode, tenant/language). The cart is synced to the backend
164
- sales order via `src/api/syncSalesOrder*.ts` helpers.
140
+ **Two-layer state model:** React Query owns *server* state (catalog, orders, user); Zustand owns *local* state (cart, selected user, edit mode, tenant/language). The cart is synced to the backend sales order via `src/api/syncSalesOrder*.ts` helpers.
165
141
 
166
142
  ## API layer (`src/api/`)
167
143
 
168
- `src/api/axiosInstance.ts` is the shared client. Key behaviors (all verified):
169
- - **Base URL by tenant:** `host = "VITE_API_" + window.location.hostname.toUpperCase().split(".")[0]`;
170
- `baseURL = import.meta.env[host] || import.meta.env.VITE_API`. So `compass.togacommerce` →
171
- `VITE_API_COMPASS`, falling back to `VITE_API`. `timeout: 180000`.
144
+ `src/api/axiosInstance.ts` is the shared client. Verified behaviors:
145
+ - **Base URL by tenant:** `host = "VITE_API_" + window.location.hostname.toUpperCase().split(".")[0]`; `baseURL = import.meta.env[host] || import.meta.env.VITE_API`. So `compass.togacommerce` → `VITE_API_COMPASS`, falling back to `VITE_API`. `timeout: 180000`.
172
146
  - **Transaction id:** a request interceptor stamps every call with `params.transactionId = uuidv4()`.
173
- - **Auth token:** a second request interceptor attaches `Authorization: Bearer <token>` — using the
174
- stored `accessToken` if a `user` exists in localStorage, otherwise fetching a **public token** via
175
- `POST {baseURL}/auth/public`.
147
+ - **Auth token:** a second request interceptor attaches `Authorization: Bearer <token>` — using the stored `accessToken` if a `user` exists in localStorage, otherwise fetching a **public token** via `POST {baseURL}/auth/public`.
176
148
  - **1 ms delay interceptor:** a third request interceptor `await delay(1)` before sending.
177
- - **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh
178
- token) once (`_retry`), retries the original request on success, else calls `performLogout()`
179
- (clears all auth + cart + `commerce` cache keys and redirects to `/`).
149
+ - **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh token) once (`_retry`), retries the original request on success, else calls `performLogout()` (clears all auth + cart + `commerce` cache keys and redirects to `/`).
180
150
  - Failures are reported to **Sentry** with `errorType: "API"` tags.
181
151
 
182
- The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors`; see api2 arch);
183
- generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `saveData`, `updateData`,
184
- `deleteData`, optional `?depth=-1` for nested responses).
152
+ The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors`; see api2 arch); generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `saveData`, `updateData`, `deleteData`, optional `?depth=-1` for nested responses).
185
153
 
186
154
  ## Authentication & roles
187
155
 
188
- - `src/contexts/AuthContext.tsx` derives `host` from the hostname (`hostname.toUpperCase().split(".")[0]`),
189
- loads `getClientLoginFields(host)` (tenant config), decides persona-switcher visibility
190
- (`determineShouldShowPersonaSwitcher` — QUAD special-cased), then runs
191
- `getLoginSettings(...)` which writes `useFieldsStore.fieldKey = host` and the language, and
192
- hydrates `useUserStore`.
193
- - `src/hooks/useAuthenticationFlow.ts` orchestrates the post-login fetch sequence
194
- (user data → persona → contact → settings).
156
+ - `src/contexts/AuthContext.tsx` derives `host` from the hostname (`hostname.toUpperCase().split(".")[0]`), loads `getClientLoginFields(host)` (tenant config), decides persona-switcher visibility (`determineShouldShowPersonaSwitcher` — QUAD special-cased), then runs `getLoginSettings(...)` which writes `useFieldsStore.fieldKey = host` and the language, and hydrates `useUserStore`.
157
+ - `src/hooks/useAuthenticationFlow.ts` orchestrates the post-login fetch sequence (user data → persona → contact → settings).
195
158
  - **Role flags** on the user object are tenant-specific:
196
- - COMPASS / COMPASSCANADA → `ADMIN` (`_isAdmin`), `SUPERUSER` (`_isSuperAdmin`),
197
- `MANAGER` (`_isSupervisor`), else `USER`.
159
+ - COMPASS / COMPASSCANADA → `ADMIN` (`_isAdmin`), `SUPERUSER` (`_isSuperAdmin`), `MANAGER` (`_isSupervisor`), else `USER`.
198
160
  - QUAD → `GLOBALADMIN` (`_isGlobalAdmin`), `BUYER` (`_isBuyer`), `ITSHOPPER` (`_isItShopper`), else `USER`.
199
- - **Personas:** `user._personaIds` is a colon-delimited string; `getLoginSettings` has special
200
- handling collapsing persona-24 cases for non-QUAD tenants.
161
+ - **Personas:** `user._personaIds` is a colon-delimited string; `getLoginSettings` has special handling collapsing persona-24 cases for non-QUAD tenants.
201
162
 
202
163
  ## Build & deploy
203
164
 
204
- - **Per-tenant dev/build scripts** (`package.json`): `compass`, `compasscanada`, `quad`,
205
- `togacommerce` run `vite --mode development --host <tenant>.togacommerce`; production builds are
206
- `build` / `buildAlpha` / `buildBeta` / `buildGamma` / `buildQcSecurity` (each installs the matching
207
- `@agilant/toga-blox` npm channel, then `tsc` + `vite build --mode <env>`).
208
- - `vite.config.ts`: React plugin + Sentry Vite plugin (sourcemap upload); dev `server.allowedHosts`
209
- = `compass.togacommerce`, `compasscanada.togacommerce`, `quad.togacommerce`; PostCSS via
210
- `postcss.config.cjs`; `optimizeDeps.include: ["react-router-dom"]`.
211
- - Local dev requires `*.togacommerce` hostnames to resolve to localhost (hosts file) so tenant
212
- resolution works.
213
- - Deployment runs on **AWS Amplify** — see [amplify-build-and-deploy](workflows/amplify-build-and-deploy.md).
214
- A `Dockerfile`/`docker-compose.yml` also exist for containerized dev.
165
+ - **Per-tenant dev/build scripts** (`package.json`): `compass`, `compasscanada`, `quad`, `togacommerce` run `vite --mode development --host <tenant>.togacommerce`; production builds are `build` / `buildAlpha` / `buildBeta` / `buildGamma` / `buildQcSecurity` (each installs the matching `@agilant/toga-blox` npm channel, then `tsc` + `vite build --mode <env>`).
166
+ - `vite.config.ts`: React plugin + Sentry Vite plugin (sourcemap upload); dev `server.allowedHosts` = `compass.togacommerce`, `compasscanada.togacommerce`, `quad.togacommerce`; PostCSS via `postcss.config.cjs`; `optimizeDeps.include: ["react-router-dom"]`.
167
+ - Local dev requires `*.togacommerce` hostnames to resolve to localhost (hosts file) so tenant resolution works.
168
+ - Deployment runs on **AWS Amplify** — see [amplify-build-and-deploy](workflows/amplify-build-and-deploy.md). A `Dockerfile`/`docker-compose.yml` also exist for containerized dev.
215
169
 
216
170
  ## Directory map (`src/`)
217
171
 
@@ -234,26 +188,8 @@ generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `save
234
188
 
235
189
  ## Gotchas
236
190
 
237
- - **`AuthLayout` never remounts** → `refetchOnMount` fires once per session; wire explicit
238
- invalidation for data that must refresh on navigation.
239
- - **24h `staleTime` + persisted cache** → users can see stale catalog/pricing; invalidate on the
240
- events that should bust it. **This also masks config/copy deploys:** `FIELDS` is served *through*
241
- the `["clientFields", …]` query, so a returning user rehydrates the old labels from
242
- `localStorage["commerce"]` and sees stale copy for up to 24h after a build that contains the new
243
- JSON. Verify any FIELDS change after `localStorage.removeItem('commerce')` or a logout, never on a
244
- warm browser. A build-version `buster` in `persistOptions` would fix this at deploy time but
245
- invalidates every persisted query app-wide — **open team decision, not implemented.**
246
- - **Tenant resolution depends on the hostname.** On `localhost` with no `*.togacommerce` host the
247
- first DNS label won't match a tenant, so config falls back (`getClientLoginFields` → COMPASS,
248
- theme → DEFAULT). Use the per-tenant dev scripts.
249
- - **Theme vs. fields language keys differ** — theme/field *folder* names are uppercase
250
- (`COMPASSCANADA`, `ENGLISH`/`FRENCH`) but the runtime `FIELDS` registry keys language as
251
- `en` / **`fr-CA`** (not `fr`). Any language branch must use `startsWith("fr")` — a `=== "fr"`
252
- comparison silently never matches. See the client-fields doc.
253
-
254
- ## Change history
255
- - 2026-07-28 — Gotchas: recorded that the 24h persisted React Query cache also masks `FIELDS`
256
- config/copy deploys (verify after clearing `localStorage["commerce"]`; `persistOptions.buster` is
257
- an open team decision), and corrected the runtime language key to `fr-CA` requiring
258
- `startsWith("fr")` (tcox)
259
- </content>
191
+ - **`AuthLayout` never remounts** → `refetchOnMount` fires once per session; wire explicit invalidation for data that must refresh on navigation.
192
+ - **24h `staleTime` + persisted cache** → users can see stale catalog/pricing; invalidate on the events that should bust it. **This also masks config/copy deploys:** `FIELDS` is served *through* the `["clientFields", …]` query, so a returning user rehydrates the old labels from `localStorage["commerce"]` and sees stale copy for up to 24h after a build that contains the new JSON. Verify any FIELDS change after `localStorage.removeItem('commerce')` or a logout, never on a warm browser. A build-version `buster` in `persistOptions` would fix this at deploy time but invalidates every persisted query app-wide — **open team decision, not implemented.**
193
+ - **Tenant resolution depends on the hostname.** On `localhost` with no `*.togacommerce` host the first DNS label won't match a tenant, so config falls back (`getClientLoginFields` → COMPASS, theme → DEFAULT). Use the per-tenant dev scripts.
194
+ - **Theme vs. fields language keys differ** — theme/field *folder* names are uppercase (`COMPASSCANADA`, `ENGLISH`/`FRENCH`) but the runtime `FIELDS` registry keys language as `en` / **`fr-CA`** (not `fr`). Any language branch must use `startsWith("fr")` — a `=== "fr"` comparison silently never matches. See the client-fields doc.
195
+ - **Tailwind: not every `<n>px` name is a token.** `max-h-300px` does not exist (300px is only on the height/width scales) — write `max-h-[300px]`. `w-345px`, `h-300px`, `border-1` and `shadow-subMenu` do exist. A non-existent class silently renders unstyled.