toga-ai 1.0.853 → 1.0.855

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.
@@ -18,6 +18,7 @@
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 redirect & public-vs-user session gating (useAuthenticationFlow)](features/sso-redirect-and-session-gating.md) | How commerce decides on "/" whether a visitor is signed in, goes to SSO, or goes to /login; open for "blank page at /" or "SSO never fires" reports on any tenan |
21
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. |
22
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. |
23
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 |
@@ -80,7 +80,7 @@ E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
80
80
  ## Routing (`src/routes.tsx`, createBrowserRouter)
81
81
 
82
82
  Public:
83
- - `/` → `AuthRedirect` (→ `/home` if authenticated, else renders nothing while the auth flow runs)
83
+ - `/` → `AuthRedirect` (→ `/home` if authenticated, else an `AuthLoading` spinner while the auth flow redirects to SSO or `/login` — never `null`, which is what made `/` blank)
84
84
  - `/login` → `LoginRoute` (→ `/home` if already authenticated, else `<Login/>`)
85
85
  - `/reset-password` → `ResetPasswordPage`
86
86
 
@@ -196,3 +196,4 @@ The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors
196
196
  - **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.
197
197
  - **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.
198
198
  - **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.
199
+ - **⚠ A token in `localStorage` is not a session.** Any logged-out request mints a **public** `accessToken` via `fetchPublicToken` (`src/api/axiosInstance.ts`). Gating auth on `accessToken` made `/` render blank until the user cleared site data, on every tenant. Gate on a stored `user` JSON with a `uuid`. See [SSO redirect & session gating](features/sso-redirect-and-session-gating.md).
@@ -6,8 +6,8 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala", "apeterson"]
9
+ updated: 2026-09-21
10
+ owners: ["bala", "apeterson", "tcox"]
11
11
  files:
12
12
  - toga2-commerce/src/App.tsx
13
13
  - toga2-commerce/src/contexts/AuthContext.tsx
@@ -89,11 +89,7 @@ Verified 2026-09-03 on branch `TRUE-81610`. Read-only; none of these are fixed.
89
89
 
90
90
  This is the "it's not successfully clearing their caches" report. Supply already solved it with an `awaitRestoration()` promise built from `useIsRestoring()` (`toga25-supply/src/contexts/AuthContext.tsx:56-77`) and gates every clear behind it.
91
91
 
92
- **3. Two auth gates read two different localStorage keys → stuck on a broken login page.**
93
- - The axios request interceptor gates on **`user`** (`src/api/axiosInstance.ts:112-118`). With no `user` it calls `fetchPublicToken()`, which POSTs `/auth/public` and **writes the public token back as `accessToken`** (`src/api/axiosInstance.ts:53-64`).
94
- - But `src/hooks/useAuthenticationFlow.ts:213-217` gates on **`accessToken`** and returns early if it exists.
95
-
96
- So after a forced logout: the first API call writes a *public* `accessToken`, the auth flow sees a token, skips **both** the SSO redirect and `/login`, and the user is logged out while the app believes they are not. Supply gates on `user` — with an explicit comment at `toga25-supply/src/contexts/AuthContext.tsx:47-51`. This is the app-side twin of the blox `fetchPublicToken` gotcha already recorded in `2.0/standards/frontend.md` §22.
92
+ **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.
97
93
 
98
94
  **4. The forced logout is too aggressive, and login never clears the cache.** `src/contexts/AuthContext.tsx:192-252`:
99
95
  - 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.
@@ -131,13 +127,18 @@ Keep the **app default at `0`**, like supply.
131
127
  - **⚠ Nothing is excluded from persistence on `_production`** — the `shouldDehydrateQuery` allowlist lives only on `_beta`/`#sprint84`. Check the branch.
132
128
  - **⚠ 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.
133
129
  - **⚠ A `queryClient.clear()` that is not awaited behind cache restoration is undone.** All three of commerce's clear sites have this bug.
134
- - **⚠ Gate auth on `user`, never `accessToken`** — `fetchPublicToken` writes a public token into `accessToken`.
130
+ - **⚠ Gate auth on `user`, never `accessToken`** — `fetchPublicToken` writes a public token into `accessToken`. See [SSO redirect & session gating](./sso-redirect-and-session-gating.md).
135
131
  - **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**.
136
132
  - **`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.
137
133
 
134
+ ## Change history
135
+ - 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
+ - 2026-09-03 — Four stacked causes pinned to `file:line`; `gcTime`-vs-`staleTime` decision recorded. (bala, apeterson)
137
+
138
138
  ## Related
139
139
 
140
140
  - [inactive-item-purchase-gating](./inactive-item-purchase-gating.md)
141
+ - [sso-redirect-and-session-gating](./sso-redirect-and-session-gating.md)
141
142
  - [architecture](../architecture.md)
142
143
  - [supply persisted-query-cache](../../toga25-supply/features/persisted-query-cache.md)
143
144
  - [supply force-logout-on-deployment](../../toga25-supply/features/force-logout-on-deployment.md)
@@ -0,0 +1,74 @@
1
+ ---
2
+ title: SSO redirect & public-vs-user session gating (useAuthenticationFlow)
3
+ framework: "2.0"
4
+ repo: toga2-commerce
5
+ project: TOGa Commerce
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-21
10
+ owners: [tcox]
11
+ files:
12
+ - toga2-commerce/src/hooks/useAuthenticationFlow.ts
13
+ - toga2-commerce/src/routes.tsx
14
+ - toga2-commerce/src/contexts/AuthContext.tsx
15
+ - toga2-commerce/src/api/axiosInstance.ts
16
+ related:
17
+ - ./catalog-cache-freshness.md
18
+ - ./view-as-session-tokens.md
19
+ - ../architecture.md
20
+ - ../../toga25-supply/features/sso-redirect-and-session-gating.md
21
+ - ../../../standards/frontend.md
22
+ ---
23
+
24
+ How commerce decides on "/" whether a visitor is signed in, goes to SSO, or goes to /login; open for "blank page at /" or "SSO never fires" reports on any tenant.
25
+
26
+ ## Summary
27
+
28
+ Three pieces decide it, and they must all read the same signal:
29
+
30
+ | Piece | File | Question it answers |
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
+
36
+ **The only valid "is this visitor signed in?" test is a stored `user` JSON with a `uuid`** — never `localStorage.accessToken`. Shared code, so every tenant (compass, compasscanada, quad) behaves the same.
37
+
38
+ ## How it works
39
+
40
+ 1. **Logged-out requests mint a public token into `accessToken`.** `fetchPublicToken` (`axiosInstance.ts:52-64`) POSTs `/auth/public` and writes **both** `accessToken` and `refreshToken` to `localStorage`. The interceptor calls it whenever `localStorage.user` is absent — the SSO domain lookup on `/`, the login page's `/domains` and `/languages` calls, a failed SSO return. So a token in storage proves nothing.
41
+ 2. **The flow gates on the user, parsed safely** (`useAuthenticationFlow.ts:193-204`):
42
+ ```ts
43
+ const hasUserInStorage = (() => {
44
+ try {
45
+ const storedUser = localStorage.getItem("user");
46
+ return Boolean(storedUser && JSON.parse(storedUser)?.uuid);
47
+ } catch (error) { console.error("Could not read stored user:", error); return false; }
48
+ })();
49
+ if (isAuthenticated || hasUserInStorage) return;
50
+ ```
51
+ Missing, invalid, or unparsable storage counts as logged out. Same test as `AuthProvider`'s initial `isAuthenticated` (`AuthContext.tsx:73-85`) and as toga25-supply.
52
+ 3. **Otherwise it saves the post-auth redirect and runs `checkSsoAuth`** — a `singleSignOnServiceUrl` for the domain → `window.location` to the IdP; none → `/login`.
53
+ 4. **`AuthRedirect` at `/` renders `<AuthLoading/>`, never `null`,** in the not-authenticated case. A `<Navigate to="/login"/>` there was **rejected**: it fires before the SSO lookup resolves and cancels the IdP redirect for a first-time visitor.
54
+
55
+ ### Reproducing it without Cypress
56
+
57
+ - **Prod:** log in, then DevTools `localStorage.removeItem('user')`, load `/`.
58
+ - **Local:** `npm run compasscanada`; a temp page under `public/` sets `localStorage.accessToken='stale'`, removes `user`, `location.replace('/')`; dump with
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).
63
+
64
+ ## Gotchas
65
+
66
+ - **⚠ A token in `localStorage` is not a session.** Gating on `accessToken` made any visitor who had touched the API look authenticated → the flow skipped **both** the SSO redirect and `/login`, `AuthRedirect` returned `null`, and `/` was **permanently blank** until the user cleared site data. Per browser profile, so Chrome and Edge fail independently. It reads as an HTTP-caching bug and is not one — prod headers are correct (HTML `no-cache/no-store`, hashed immutable JS/CSS, missing chunk 404s, no service worker). Introduced 2026-02-12 by the `hasTokenInStorage` "Fix logout" change; fixed on `TRUE-82172`.
67
+ - **⚠ Don't `Navigate` to `/login` from `AuthRedirect`.** It races the SSO lookup and kills the IdP redirect for first-time visitors. Show the spinner.
68
+ - **⚠ Open: the cross-tab `storage` handler still gates on the token.** `AuthContext.tsx:254-262` does `setIsAuthenticated(Boolean(localStorage.getItem("accessToken")))`, so a logged-out tab minting a public token can flip another logged-out tab to "authenticated" → 401s. Derive it from `user` too. **Not fixed.**
69
+ - **⚠ Open: a malformed `user` crashes the whole tree.** `AuthProvider`'s initial state does `JSON.parse(localStorage.user)` in a `try`/**`finally` with no `catch`** (`AuthContext.tsx:73-85`). **Not fixed.**
70
+ - **A 403 on `GET /notifications` during the manual repro is expected** — after `user` is removed the interceptor swaps the still-mounted app onto a public token.
71
+ - **Cypress can't cover this on a broken binary cache.** A regression spec exists untracked at `cypress/e2e/AuthFlow/staleTokenRedirect.cy.ts` (stubs `**/auth/public` + `**/domains?*`, asserts `/login`). Local Cypress 15.19.0 fails with `bad option: --smoke-test` → `npx cypress install --force`. Machine-local, not a repo problem.
72
+
73
+ ## Change history
74
+ - 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)
@@ -31,7 +31,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
31
31
  - **talos-backend** (TOGa IQ Backend) — 1 doc(s) → [2.0/apps/talos-backend/INDEX.md](2.0/apps/talos-backend/INDEX.md)
32
32
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
33
33
  - **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
34
- - **toga2-commerce** (TOGa Commerce) — 24 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
34
+ - **toga2-commerce** (TOGa Commerce) — 25 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
35
35
  - **toga25-supply** (TOGa 2.5 Supply) — 21 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
36
36
  - **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
37
37
  - **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
@@ -17,7 +17,7 @@ project: _Underscore
17
17
  client: compass-canada
18
18
  type: profile
19
19
  status: active
20
- updated: 2026-09-17
20
+ updated: 2026-09-21
21
21
  owners: [jcardinal, bala, tcox, apeterson, ajean]
22
22
  files: []
23
23
  related:
@@ -35,6 +35,7 @@ related:
35
35
  - features/order-fulfillment-status-per-line.md
36
36
  - features/item-fulfillment-tableviews.md
37
37
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
38
+ - ../../2.0/apps/toga2-commerce/features/sso-redirect-and-session-gating.md
38
39
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
39
40
  - ../../2.0/apps/_underscore/features/surface-resolver.md
40
41
  ---
@@ -117,6 +118,15 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
117
118
  instead of **12 / 8 / 33**. Removing the hack gives them *more* access; whether Canada should get
118
119
  it is an **open decision**. See
119
120
  [Storefront persona gating](../../2.0/apps/toga2-commerce/features/storefront-persona-gating-and-home-counts.md).
121
+ - **⚠ "Blank page at `https://compasscanada.togacommerce.com/`" (reported by Compass Canada IT,
122
+ Sept 2026) is a shared auth-gating bug, not a Canada or caching problem.** A stale **public**
123
+ `accessToken` made the app think the visitor was signed in, so `/` rendered nothing until the
124
+ user cleared site data. Canada was hit hardest because the "Tech Hub is now available in Canada"
125
+ memo points everyone at `/`, and anyone who visited before the Canada SSO row existed
126
+ (`ClientAuthentications` id 8, created 2026-04-06) or landed on `/login` once already carried that
127
+ token. Same code path on Compass USA and Quad. Fixed on `TRUE-82172` (TRUE-82172 branch off
128
+ `_production`) — **not merged or deployed**. See
129
+ [SSO redirect & session gating](../../2.0/apps/toga2-commerce/features/sso-redirect-and-session-gating.md).
120
130
  - **No persona switcher here** (that is Quad-only), but Canada **is** the tenant with
121
131
  `languageSwitcher.isEnabled = true` (`fieldsConfig/clientFields/COMPASSCANADA.json`).
122
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.853",
3
+ "version": "1.0.855",
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",
@@ -0,0 +1,209 @@
1
+ ---
2
+ name: order-for
3
+ description: Everything about the Compass "Order For" feature, where user A places an order on behalf of user B. Covers the Users_DelegatorUsers table, the permission gate in _Model_Client_User and V2.php, the originalUserId audit column, the delegation-list GET call in toga2-commerce, the delegated token switch through /auth/delegator, and the ACL rows for Compass and Compass Canada. Use this skill whenever the user asks about "order for", "order on behalf of", delegation, delegator users, Users_DelegatorUsers, originalUserId, c_orderSubmittedById, the Order For dropdown, or why a delegation is being refused. Load it before changing any of those files.
4
+ ---
5
+
6
+ # Compass "Order For"
7
+
8
+ User A places an order for user B. B must be on A's list. The list is
9
+ `Client_<Tenant>.Users_DelegatorUsers`. The server is the gate, never the UI.
10
+
11
+ Built and verified on local 2026-09-21 against Client_Compass. Ticket TRUE-81848.
12
+
13
+ ---
14
+
15
+ ## The data
16
+
17
+ `Users_DelegatorUsers` lives in **every** client database
18
+ (`dbchanges2/Client/2026-09-18a - UsersDelegatorUsersTable.sql`):
19
+
20
+ | column | meaning |
21
+ |---|---|
22
+ | `userId` | the person doing the ordering |
23
+ | `delegatorUserId` | the person being ordered for |
24
+
25
+ Read it as "userId may order for delegatorUserId". Unique key on the pair, so a
26
+ row cannot be added twice. Model: `_Model_Client_Users_DelegatorUser`.
27
+
28
+ **The table ships empty.** There is no admin screen and no import. Until rows are
29
+ inserted, every dropdown is empty and every delegation is refused. That is correct
30
+ behaviour, not a bug.
31
+
32
+ Core metadata (`dbchanges2/Core/2026-09-18a`): record **362**, route
33
+ `user-delegator-users`, `aclDatabase = CLIENT`, RecordFields **2604** id, **2605**
34
+ uuid, **2606** userId, **2607** delegatorUserId. Registered as an inherent child of
35
+ record 2 (Users), which is what makes `userDelegatorUsers` readable nested under a
36
+ user.
37
+
38
+ ## The gate
39
+
40
+ Two checks, both server side, both against the same table.
41
+
42
+ **1. Minting the encrypted pair.** `_Model_Client_User::encryptedUser()` calls
43
+ `canUserOrderForOtherUser()`. Self is always allowed so switching back works. Anyone
44
+ not on the list gets a 403 and no pair.
45
+
46
+ **2. Issuing the token.** `api2/Component/Api/V2/V2.php`, `case 'delegator'` sets
47
+ `$isDelegatorAuthRequest = true` as its first statement, then falls through into
48
+ `case 'encrypted'` where the pair is checked again. This second check matters because
49
+ the encrypted pair has **no expiry and no MAC** (`_String::encryptWithKey`, AES-256-CBC
50
+ keyed on `Core.Parameters.API_SECRET_REFRESH_TOKEN`), so a captured pair could
51
+ otherwise be replayed forever.
52
+
53
+ **3. Refresh.** `/auth/refresh` re-checks an active delegation, so deleting a row or
54
+ deactivating either account ends the session instead of only blocking new switches.
55
+
56
+ The flag defaults to **false**, not true. A forged or expired bearer leaves
57
+ `$this->delegator` null, and a fail-open default would have minted a token.
58
+
59
+ ## The audit trail
60
+
61
+ `Logs_<Client>.Api.originalUserId` (`dbchanges2/Logs_Client/2026-09-18a`), nullable,
62
+ sits after `userId`. `V2.php` fills it at all three places it writes a client log row,
63
+ plus the CloudWatch entry.
64
+
65
+ - `userId` = who the token is for (the person being ordered for)
66
+ - `originalUserId` = who actually signed in
67
+
68
+ NULL means no delegation. Verified on order **SA136321**: `createdByUserId` Dawn
69
+ Anderson, log row `userId 124256` / `originalUserId 127046` (Dev Team).
70
+
71
+ Outbound logs (`_ApiRequest`, `_SoapRequest`, `direction = OUT`) never set either
72
+ column. There is no signed-in user on a call TOGa makes out to NetSuite or UPS.
73
+
74
+ ## Front end
75
+
76
+ ### Getting the list
77
+
78
+ `toga2-commerce/src/pages/Cart/api/CartApi.ts`:
79
+
80
+ ```ts
81
+ const people = await fetchDelegationList(user.uuid);
82
+ // [{ uuid, name, email }, ...]
83
+ ```
84
+
85
+ Pass the **signed-in user's own uuid**. One call, no paging, the whole list arrives at
86
+ once. Type `DelegationOption` is in `src/pages/Cart/types.ts`.
87
+
88
+ Two rules that are easy to get wrong:
89
+
90
+ 1. **Call it before switching the token.** Once the token belongs to the other person,
91
+ the signed-in user's own record reads as a 403. Cache the list you already loaded.
92
+ 2. **Never request `id`.** Asking for `id` makes the whole GET fail with a 403 for
93
+ these roles. Use `uuid`.
94
+
95
+ ### Switching the token
96
+
97
+ Order For is only real if the token changes. The steps, same as View As in
98
+ `src/components/NavIcons/NavIconList.tsx`:
99
+
100
+ 1. `GET /users/encryptedUser?uuid=<target>` returns `{client, user}` ciphertext.
101
+ 2. `POST /auth/delegator` with that pair returns the delegated token pair.
102
+ 3. `beginDelegation(tokens, originalUserUuid)` from `src/api/sessionTokens.ts` parks
103
+ the signed-in person's own pair and makes the delegated pair active.
104
+ 4. `restoreOriginalTokens(originalUserUuid)` puts them back, no new login needed.
105
+
106
+ In the JWT, `id.client.user` is the target and `id.delegator` is the real human.
107
+
108
+ **If you skip the switch and only keep the pick as a form value, none of the backend
109
+ work runs.** `$api->delegator` is never set, `originalUserId` stays NULL, the
110
+ `Users_DelegatorUsers` check never fires, and the user browses their own catalog and
111
+ prices while "ordering for" someone else.
112
+
113
+ There is one parked-token slot shared with View As, so the two features cannot be
114
+ nested. Guard whichever one you are building against the other.
115
+
116
+ ## ACL
117
+
118
+ Read only on purpose. A role that can write its own row here can self-grant the right
119
+ to order for anyone in the tenant, which defeats the whole gate.
120
+
121
+ | | Compass Base | Manager |
122
+ |---|---|---|
123
+ | US `Client_Compass` | role 7 | role 8 |
124
+ | Canada `Client_CompassCanada` | role **8** | role **9** |
125
+
126
+ **Canada role ids are different from US.** Do not copy the US file and rename it.
127
+
128
+ `appId` 5 is TOGa Commerce. The row expression is scoped to the caller, not `1`:
129
+
130
+ ```sql
131
+ CASE WHEN Users_DelegatorUsers.userId = AclUser.id THEN 1 ELSE 0 END
132
+ ```
133
+
134
+ `user-delegator-users` is a real route, so a plain `1` would let any user call
135
+ `GET /v2/user-delegator-users` and list who every colleague can order for. `AclUser`
136
+ is joined by `_Model_True::isPermitted` on the logged-in user.
137
+
138
+ Canada also needed `Core.RecordScripts` id **16** (`encryptedUser`) granted to its
139
+ roles 8 and 9. Canada shipped with only role 1 (Base), so a normal Canada user got a
140
+ 403 on every pick. US Compass already had it.
141
+
142
+ ## Traps that cost real time
143
+
144
+ - **`empty()` and `isset()` always lie on a `_Model` field.** `_Model::initialize()`
145
+ unsets every declared property and `_Model` has no `__isset`, so `empty($api->user->id)`
146
+ is always true. Use `(int)$x->id > 0`. This silently made the gate refuse everyone,
147
+ including a user acting as themself, and both reviewers and `php -l` passed it.
148
+ - **An ACL permission with no logic group denies everything.** `buildSqlExpression()`
149
+ returns `(0)`. `allowRead = 1` on its own grants nothing. You need the
150
+ `AclRecordExpressions` row, an `AclLogicGroups` row, and the link row.
151
+ - **Core rows are ignored for a CLIENT record.** Record 362 is `aclDatabase = CLIENT`,
152
+ and every lookup in `V2.php` (3236, 4057, 6502, 7307) switches on that and reads only
153
+ the matching bucket. Anything you put in `Core.AclRecordPermissions` for 362 is dead.
154
+ - **Core cannot name an api2 class.** Use `$api->response::DEFINED_MESSAGE_ERROR_UNAUTHORIZED`
155
+ from inside `_underscore`, not `\api\_Component_Api_V2_Response`.
156
+ - **Never join Core and `Client_<Tenant>` in one query.** Separate prod clusters.
157
+
158
+ ## Still open
159
+
160
+ - **Nothing seeds `Users_DelegatorUsers`.** No admin screen, no import.
161
+ - **`c_orderSubmittedById` is not stamped.** The column, `CustomRecordFields` id 40 on
162
+ record 14, and `_Model_Compass_SalesOrder:51` all already exist from Compass 1.0. It
163
+ is a plain `FIELD_INTEGER`, not a foreign key, so api2 will not resolve a `{uuid}` and
164
+ the client cannot set it. It belongs in `_Model_Compass_SalesOrder::prePost()` (line
165
+ 445, interceptor id 24, already active as PRE on POST for record 14), read from
166
+ `$api->delegator->user`. Leave NULL when there is no delegation, matching 1.0's
167
+ `convertcarttoorder.php`. Note the old data is dirty: of 112,096 Compass orders 8,118
168
+ have the field set and 7,174 of those equal `createdByUserId`, because the 1.0 guard
169
+ compared a manager id against a contact id.
170
+ - **Role 3 (API) has no grant on record 362 in any `Client_*` database**, only the dead
171
+ Core one, so no integration can read or write the table through api2.
172
+ - **`/auth/encrypted` still accepts a delegation pair with no bearer** and records no
173
+ delegator. Narrowed because Commerce no longer calls it; toga2-desk still does for
174
+ client switching.
175
+
176
+ ## Files
177
+
178
+ | repo | file |
179
+ |---|---|
180
+ | `_underscore` | `Model/Client/Users/DelegatorUser.php` |
181
+ | `_underscore` | `Model/Client/User.php` (`canUserOrderForOtherUser`, `getRequestingUser`, `encryptedUser`) |
182
+ | `_underscore` | `Model/Client/Logs/Api.php` (`originalUserId`) |
183
+ | `api2` | `Component/Api/V2/V2.php` |
184
+ | `dbchanges2` | `Client/2026-09-18a - UsersDelegatorUsersTable.sql` |
185
+ | `dbchanges2` | `Core/2026-09-18a - UserDelegatorUserRecords.sql` |
186
+ | `dbchanges2` | `Client_Compass/2026-09-18a - UserDelegatorUsersReadAcl.sql` |
187
+ | `dbchanges2` | `Client_CompassCanada/2026-09-18a - UserDelegatorUsersReadAcl.sql` |
188
+ | `dbchanges2` | `Logs_Client/2026-09-18a - ApiOriginalUserId.sql` |
189
+ | `toga2-commerce` | `src/pages/Cart/api/CartApi.ts` (`fetchDelegationList`) |
190
+ | `toga2-commerce` | `src/pages/Cart/types.ts` (`DelegationOption`) |
191
+ | `toga2-commerce` | `src/components/OrderForDropdown/` (the UI) |
192
+ | `toga2-commerce` | `src/api/sessionTokens.ts` (token parking) |
193
+
194
+ ## Checking a refusal
195
+
196
+ ```sql
197
+ SELECT d.id, u.id AS orderingUserId, CONCAT(u.firstName,' ',u.lastName) AS orderingUser,
198
+ du.id AS forId, CONCAT(du.firstName,' ',du.lastName) AS orderedFor
199
+ FROM Users_DelegatorUsers d
200
+ JOIN Users u ON u.id = d.userId
201
+ JOIN Users du ON du.id = d.delegatorUserId;
202
+ ```
203
+
204
+ Then confirm the log stamped both people:
205
+
206
+ ```sql
207
+ SELECT id, dtStamp, method, route, responseCode, userId, originalUserId
208
+ FROM Logs_Compass.Api ORDER BY id DESC LIMIT 20;
209
+ ```