toga-ai 1.0.854 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.854",
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
+ ```